JVM(9):内存模型与线程
1. 内存模型
1.1 主内存与工作内存
Java
内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。在这里主要考虑的问题是多线程对同一块内存区域进行的操作,因此并不需要考虑一些线程私有的变量比如局部变量和方法参数。Java
内存模型规定了所有变量都存储在主内存 ( $Main\ \ Memory$ ) 中,每条线程允许拥有自己的工作内存 ( $Working\ \ Memory$ ),保存了主内存中要使用的变量的副本。对于引用对象,工作内存中并不会包含整个对象的副本,而是对象引用以及对象中要使用的字段的副本。通过工作内存,线程的所有的对变量的操作都会在工作内存中进行,即通过工作内存间接访问主内存。
1.1 内存间交互
主内存与工作内存之间的交互协议指的是一个变量如何从主内存拷贝到工作内存,并从工作内存同步至主内存的过程。Java
内存模型定义了 $8$ 种原子性操作:
- $lock$ :作用于主内存变量,将变量标识为线程独占状态;
- $unlock$ :作用于主内存变量,把释放一个处于线程独占状态的变量;
- $read$ :作用于主内存变量,将一个变量传输到工作内存中;
- $load$ :作用于工作内存变量,将一个主内存传输的变量载入工作内存的副本中;
- $use$ :作用于工作内存变量,每当虚拟机遇到一个需要使用变量的字节码指令时,将变量传递给执行引擎;
- $assign$ :作用于工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时,从执行引擎接收值并赋给变量;
- $store$ :作用于工作内存变量,将一个变量传送到主内存中;
- $write$ :作用于主内存变量,将一个工作内存传送的变量写入主内存的变量中。
如果要把一个变量从主内存拷贝到工作内存中,就需要顺序执行 $read$ 和 $load$ 操作;反过来,如果要把一个变量从工作内存拷贝到主内存中,就需要顺序执行 $store$ 和 $write$ 操作。Java
内存模型虽然规定了上述操作要顺序执行,但并没有要求连续执行,也就是说可以读取多个变量后再依次载入,或者存储多个变量后再依次写入。除此之外,还有其他规则:
- 不允许 $read$ 和 $load$ 、$store$ 和 $write$ 单独出现;
- 不允许线程丢弃 $assign$ 操作;
- 不允许线程在没有进行 $assign$ 操作时同步内存;
- 不允许在工作内存中直接 $use$ 一个未被初始化的变量;
- 一个变量在一个时刻只能被一个线程 $lock$ ,一个线程可以多次执行 $lock$ 操作,后续需要执行相应次数的 $unlock$ 才能解锁;
- 对一个变量执行 $lock$ 操作会清除变量值,需要重新执行 $load$ 或者 $assign$ 进行赋值;
- 不允许对一个没有被锁定的变量 $unlock$ ,也不允许 $unlock$ 其他线程独占的变量;
- $unlock$ 变量前需要先对其进行 $store$ 和 $write$ 。
1.2 volatile
$volatile$ 具有一些特殊的访问规则。当一个变量被定义为 $volatile$ 之后,它将具有两条性质:一是保证变量是所有线程可见的,即一个线程的修改可以在之后被另一个线程所发现;二是指令重排序优化会被禁止,以保证语句之间的相对执行顺序。虽然可见性使得线程间变量值的传递不再需要经过对主内存的读取和写入,但这并不意味读取到的变量值是正确的。因为 $volatile$ 允许多线程同时对变量进行写操作,这就意味着对于一些非原子性的操作,譬如Java
中的运算操作符(需要先将值读取到操作栈之后才能进行运算),$volatile$ 只能保证值被读取的时候是正确的,并不能保证在之后进行运算的过程中值不会发生改变。因此对于第一条性质,要保证运算结果并不依赖变量的当前值,或者只有一个线程会修改变量值。
保证 $volatile$ 可见性的关键在于 $lock$ 操作,它会清空变量值,并在之后对变量进行 $store$ 和 $write$ 操作,从而保证了对变量的修改可以被其他线程发现。同时,$lock$ 操作也充当着内存屏障的功能,即执行 $lock$ 操作代表之前的操作已经执行完毕,从而可以利用 $lock$ 操作保证指令之间的相对执行顺序。也因此,$volatile$ 相比于 $synchronized$ 或者 $java.util.concurrent$ ,读操作速度没有什么差别,但是写操作会慢上一些,因为需要插入许多内存屏障以保证执行顺序,当然总体上来讲还是要快于后两者的。
Java
内存模型也对 $volatile$ 变量定义了特殊规则,设 $V$ 和 $W$ 代表两个 $volatile$ 变量,则有:
- 只有前一条指令是 $load$ 时才能 $use$ ;只有后一条指令是 $use$ 时才能 $load$ ,即 $load$ 和 $use$ 必须连续一起出现;
- 只有前一条指令是 $assign$ 时才能 $store$ ;只有后一条指令是 $store$ 时才能 $assign$ ,即 $assign$ 和 $store$ 必须连续一起出现;
- 在一个线程中,如果对 $V$ 执行 $use$ 或者 $assign$ 操作优先于对 $W$ 执行 $use$ 或者 $assign$ 操作,那么对 $V$ 执行的 $read$ 或者 $write$ 操作必须优先于对 $W$ 执行的 $read$ 或者 $write$ 操作。
1.3 long
和double
$long$ 和 $double$ 是 $64$ 位的数据类型。对于 $64$ 位的数据类型,Java
内存模型定义了一条特殊的规则:允许将其划分为两次 $32$ 位的操作进行,即 “ $long$ 和 $double$ 非原子性协定 ” ( $Non-Atomic\ \ Treatment\ \ of\ \ double\ \ and\ \ long\ \ Variables$ )。如果有多个线程共享一个未声明 $volatile$ 的 $long$ 或者 $double$ 类型的变量,并且同时对它们进行读写操作,那么某些线程可能会读取到一个修改了一半的数值。当然这种情况很罕见,因为常用的 $64$ 位虚拟机中并不会出现非原子性访问行为。
1.4 原子性、可见性与有序性
1.4.1 原子性
Java
内存模型直接保证的原子性变量操作包括:$read$ 、$load$ 、$assign$ 、$use$ 、$store$ 和 $write$ 。如果需要一个更大范围的原子性保证,Java
内存模型还提供了 $lock$ 和 $unlock$ 操作,即 $synchronized$ 块之间的操作也具备原子性。
1.4.2 可见性
Java
内存模型通过在变量修改后将新值同步回主内存,并在变量被读取时从主内存刷新变量值的方式实现可见性。除了 $volatile$ 之外,还可以使用 $synchronized$ 和 $final$ 保证可见性。
1.4.3 有序性
有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。
1.5 先行发生原则
先行发生是Java
内存模型中定义的两项操作之间的偏序关系。如果操作 $A$ 先行发生于操作 $B$ ,指的就是操作 $B$ 能够观测到操作 $A$ 产生的影响,影响指的是修改变量值、发送消息、调用方法等。Java
内存模型之中存在一些天然的先行发生关系:
- 程序次序规则 ( $Program\ \ Order\ \ Rule$ ):线程内按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作;
- 管程锁定规则 ( $Monitor\ \ Lock\ \ Rule$ ):$unlock$ 操作先行发生于后续对同一个锁的 $lock$ 操作;
- $volatile$ 变量规则 ( $Volatile\ \ Variable\ \ Rule$ ):对一个 $volatile$ 变量的写操作先行发生于读操作;
- 线程启动规则 ( $Thread\ \ Start\ \ Rule$ ):$Thread$ 的 $start(\ )$ 方法先行发生于所有动作;
- 线程终止规则 ( $Thread\ \ Termination\ \ Rule$ ):$Thread$ 的所有操作都先行发生于对其的终止检测 ( $join(\ )$ 和 $isAlive(\ )$ 等 );
- 线程中断规则 ( $Thread\ \ Interruption\ \ Rule$ ):$Thread$ 的 $interrupt(\ )$ 方法先行发生于对其的中断事件检测 ( $interrupted(\ )$ );
- 对象终结规则 ( $Finalizer\ \ Rule$ ):对象的初始化先行发生于其 $finalize(\ )$ 方法的开始;
- 传递性 ( $Transitivity$ ):如果操作 $A$ 先行发生于操作 $B$ ,操作 $B$ 先行发生于操作 $C$ ,那么操作 $A$ 先行发生于操作 $C$ 。
对于没有出现在上述列表里的操作关系,或者不能由上述列表关系推导出来的操作关系,虚拟机可以随意对它们进行重排。
2. 线程
2.1 线程实现
虽然并发并不一定依赖于线程,但在Java
中基本都离不开线程。线程允许在共享进程资源的同时又可以分离资源分配和执行调度,是Java
里面进行处理器资源调度的最基本单位。
2.1.1 内核线程实现
内核线程 ( $Kernel-Level\ \ Thread$ ,$KLT$ ) 就是直接由操作系统内核支持的线程,即内核负责线程切换,通过操纵调度器 ( $Scheduler$ ) 对线程进行调度,并将线程处理的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,支持多线程的内核就称为多线程内核 ( $Multi-Threads\ \ Kernel$ )。程序一般不会直接使用内核线程,而是使用其高级接口轻量级进程 ( $Light\ \ Weight\ \ Process$ ,$LWP$ ),也就是我们通常讲的线程。一个系统能支持的轻量级进程的数量是有限的,取决于内核线程的数量。基于内核线程实现的线程的各种操作都是系统调用,而系统调用需要在用户态和内核态之间不断切换,从而会带来较高的代价。内核线程实现也称为 $1:1$ 实现。
2.1.2 用户线程实现
广义上来讲,一个线程只要不是内核线程,都可以称为用户线程 ( $User\ \ Thread$ ,$UT$ )。而狭义上的用户线程指的是完全建立在用户空间的线程库上,从而系统内核无法感知到的线程。用户线程的建立、调度和销毁完全可以在用户态下完成,而且如果建立得当,所有操作都可以在用户态下完成,从而拥有更高的效率。但是反过来,缺少了系统内核的支持,线程的创建、销毁、切换和调度就交给了用户,从而带来了一些更加复杂的问题。用户线程实现也称为 $1:N$ 实现。
2.1.3 混合实现
混合实现指的是同时使用内核线程和用户线程的方式,也称为 $N:M$ 实现。在这种方式下,用户线程还是建立在用户空间中,而 $LWP$ 充当了用户线程和内核线程之间的桥梁,一个 $LWP$ 可以对应多个用户线程。这样可以使用内核提供的线程调度功能和处理器映射功能,用户线程的系统调用也要通过 $LWP$ 完成,降低了阻塞风险。
2.1.4 Java
实现
从JDK 1.3
起,主流JVM
的线程模型普遍替换为基于操作系统原生线程模型的方式实现,即 $1:1$ 线程模型。HotSpot
虚拟机中,每个Java
线程都直接映射到一个操作系统的原生线程上,中间没有额外的间接结构,即把线程调度完全交给操作系统。
2.2 线程调度
线程调度主要有两种方式:协同式 ( $Cooperative\ \ Threads-Scheduling$ ) 和抢占式 ( $Preemptive\ \ Threads-Scheduling$ )。在协同式线程调度下,线程的执行时间由线程本身控制,线程在执行完成后会通知另一个线程;在抢占式线程调度下,线程的执行时间和切换由系统决定。虽然Java
的线程调度由系统完成,但是可以通过设置线程优先级的方式,建议系统优先执行某些线程。
2.3 状态转换
线程具有 $6$ 种状态:新建、运行、无限期等待、限期等待、阻塞和结束。