十、【并发基础】Java内存模型基础知识

1. 基本概念

1.1 程序

程序是一组计算机能识别和执行的指令,运行于电子计算机上,满足人们某种需求的信息化工具

1.2 进程

在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器

1.3 线程

是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

2. JVM与线程

2.1 JVM

Java虚拟机(英语:Java Virtual Machine,缩写为JVM),一种能够运行Java bytecode的虚拟机,以堆栈结构机器来进行实做。Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM作为一种编程语言的虚拟机,实际上不只是专用于Java语言,只要生成的编译文件符合JVM对加载编译文件格式要求,任何语言都可以由JVM编译运行。

JVM运行

JVM在程序编译好,正式启动后开始运行,在程序结束时停止运行。这里一个JVM是一个进程,里面包含了一个或多个线程。

3. JVM内存区域

JVM内存区域

3.1 堆区

JVM里的“堆”(Heap)特指用于存放Java对象的内存区域。所以根据这个定义,Java对象全部都在堆上,且堆中不存放基本类型和对象引用,只存放对象本身。一个JVM只有一个堆区,它被所有线程共享,类的对象都由某种自动内存管理机制所管理,这种机制通常叫做“垃圾回收”或者GC(Garbage Collection)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

3.2 方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

3.3 VM Stack、栈区

JVM里的“栈”(Stack)特指用于保存方法中的基础数据类型和自定义对象的引用(不是对象)的内存区域。每个线程包含一个栈区,栈区在线程创建时创建,它的生命期是跟随线程的生命期,线程结束时栈区释放。每个栈中的数据(原始类型和对象引用)都是私有的,其他栈不能访问。栈分为3个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常

3.4 Native Method Stacks、本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

PC

这里的PC不是指普通的电脑,而是程序计数器(Program Counter Register)的简称,它是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

4. Java内存模型(JMM)

4.1 JMM

Java内存模型(英语:Java Memory Model,缩写为JVM),一种虚拟机规范,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程何时、如何可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

JMM

4.2 主内存

又叫共享内存,Java内存模型规定所有的变量都存在主内存中。

4.3 工作内存

又叫私有内存,每条线程都有自己的工作内存,线程对变量的操作都要在工作内存中进行,不同线程之间无法直接访问对方工作内存中的变量。

4.4 主内存与工作内存的关系

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

此处的变量与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,因此,不会被共享,自然就不存在竞争问题。

每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值得传递均需要通过主内存来完成。

4.5 工作机制

  1. 修改私有数据

线程直接修改自己工作内存中的变量

  1. 修改共享数据

先把数据从主内存中拷贝到工作内存中,然后在工作内存中修改,修改完后,把数据再刷新到主内存中

4.6 内存间交互操作

关于主内存与工作内存之间具体的交换协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了一下八种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

操作 定义 概念
lock 锁定 作用于主内存的变量,它把一个变量标识为一条线程独占的状态
unlock 解锁 作用与主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read 读取 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load 载入 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
use 使用 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
assign 赋值 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作
store 存储 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
write 写入 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

其中,read load、store write必须按顺序执行,但不保证是连续执行。这些定义相当严谨但又十分繁琐,实践起来很麻烦,所以我们一个等效判断原则-------先行发生原则,用来确定一个访问在并发环境下是否安全。

5. 并发编程

5.1 并发

是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。

5.2 线程安全

在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

5.3 场景

现在有一个抢票系统,大家过节都想回家,但票只有那么多,所以早上十点开始售票的时候就有很多人去买票,假设总共有4张票,B要买2张票,C要买4张票,刚好他提交的数据同时到了服务器,由于服务器同时收到两个请求,所以现在创建了两个线程BB,CC来处理B和C的请求,两个线程同时从主内存中获取数据,那么两个线程都以为还剩4张票,BB认为满足B的需求,CC认为满足C的需求,所以两条线程都把票卖出去了,结果卖出去6张,那么B和C的票肯定是重复的,这就不符合实际的业务需求。

其中,B、C同时抢票这个概念称为并发情况,而在剩余的4张票里面抢票出现了抢出了6张票这种情况我们认为是线程不安全的。

5.4 并发编与JMM

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。

5.4.1 三大特性

  1. 原子性
    1. 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的。
  2. 可见性
    1. 可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
  3. 有序性
    1. 因为“线程内表现为串行的语义”,所以如果在本线程内观察,所有的操作都是有序的;
    2. 因为“指令重排序”现象和“工作内存与主内存同步延迟”现象,所以如果在一个线程中观察另一个线程,所有的操作都是无序的。

5.4.2 先行发生原则

先行发生原则是判断数据是否存在竞争、线程是否安全的主要依据,它是指 Java 内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到。

下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无需任何同步器协助就已经存在。

  1. 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。
  2. 管理锁定规则:一个 unlock 操作先行发生于后面对同一个锁的lock操作。这里必须强调是同一个锁,而“后面”是指时间上的先后顺序。
  3. volatile变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。“后面”是指时间上的先后顺序。
  4. 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  5. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。
  6. 线程中断规则:对线程 interrupt() 方法的调优先行发生于被中断线程的代码检测到中断事件的发生。
  7. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
  8. 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出结论操作A先生发生于操作C的结论。

6. Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式调度和抢占式调度。

6.1 协同式调度

如果使用协同式调度的多线程系统,线程的执行时间由线程本身控制,线程把自己的工作执行完了之后,就主动通知系统切换到另一个线程上。

协同式调度的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。

同时,它的坏处是:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告诉系统进行线程切换,那么程序就会一直阻塞在那里。

6.2 抢占式调度

如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield() 可以让出执行时间)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。例如 Windows ,当一个进程出现问题,我们还可以使用任务管理器把 这个进程“杀掉”。

虽然Java线程的调度室系统自动完成的,但是我们还可以“建议”系统给某些线程多分配一点执行时间,另外的一下线程则可以少分配一点-----这项操作可以通过设置线程优先级来完成。

在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。

不过线程游戏家也并不是太靠谱,原因是Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,在Windows系统存在一个成为“优先级推进器”的功能,它的大致作用就是当系统发现一个线程执行得特别“勤奋努力”的话,可能会越过线程优先级去为它分配执行时间。

7. 线程状态

Java语言定义了5种线程状态,在任意个时间点,一个线程只能有且只有其中的一种状态。

7.1 新建(New)

创建后尚未启动的线程处于这种状态。

7.2 运行(Runable)

包括了操作系统线程状态中的 Running 和 Ready,也就是处于次状态的线程有可能正在执行,也有可能正在等待着 CPU 为它分配执行时间。

7.3 无限期等待 (Waiting)

线程不会被分配CPU执行时间,它们要等待被其他线程显示地唤醒,以下方法会让线程陷入无限期的等待状态。

  1. 没有设置 Timeout 参数的 Object.wait() 方法。
  2. 没有设置 Timeout 参数的 Thread.join() 方法。
  3. LockSupport.park() 方法

7.4 有限期等待(Timed Waiting)

线程也不会被分配 CPU 执行时间,不过无需等待被其他线程显示的唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程陷入有限期的等待状态。

  1. Thread.sleep() 方法
  2. 设置 Timeout 参数的 Object.wait() 方法。
  3. 设置 Timeout 参数的 Thread.join() 方法。
  4. LockSupport.parkNanos()
  5. LockSupport.parkUnitl()

7.5 阻塞(Blocked)

线程被阻塞了,“阻塞状态”与“等待状态”的区别是:阻塞状态在等待着获取一个排它锁,这个时间将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生,在程序等待进入同步区域的时候,线程将进入这种状态。

7.6 结束

已终止的线程状态。

猜你喜欢

转载自juejin.im/post/5dd7e16af265da7e26473667