Java 内存模型
当 CPU 是单核心时,同一时间点只能执行一个任务,即只能同时运行一个进程或者说是线程。后来多核心、超线程(通过特殊的硬件指令将一个物理核心模拟成多个逻辑核心,每个逻辑核心的功能都与物理核心相似,但是由于逻辑核心共享一个物理核心的资源,所以有时会出现冲突,这时就需要某个逻辑核心让出占用。超线程能提高性能,但不等于同等数量的物理核心)出现,计算机在同一时间可以执行多个任务,即运行多个进程或多个线程。但是多线程的出现并不只是因为硬件的提升,一个重要的原因是计算机的运行速度和它的存储、通信子系统速度差距过大,大量的时间浪费在了磁盘 I/O、网络通信上,因此让计算机同时处理多个任务成为压榨计算机性能的重要手段。
硬件效率一致性
让计算机同时执行多个任务在硬件实现上也不是那么容易的。因为多数任务不是只靠计算就可以完成的,至少还需要内存参与其中,CPU 需要将数据从内存中读取或写入内存,但是 CPU 的速度要远远超过内存的速度,因此不得不在它们之间加入一层与 CPU 速度相匹配的高速缓存作为缓冲区,使 CPU 减少等待的时间消耗。
这样处理确实能够解决 CPU 与内存处理速度不匹配的问题,但是又会带来新的问题:缓存一致性。
多 CPU 系统中,每个 CPU 都有自己的高速缓存,这些缓存共享同一个主内存(Main Memory),当多个 CPU 的运算都涉及到同一块内存区域时,可能导致各自的高速缓存中的数据不一致。为了解决这个问题,需要各个高速缓存都遵循一些协议,依照这些协议来读写数据。
Java 内存模型抽象
Java 内存模型(JMM)主要是定义各种变量的访问规则,这里的变量主要包括实例字段(成员变量)、静态字段(类变量)和数组元素,不包括局部变量(如果局部变量是引用类型,它引用的对象在堆中被各线程共用,但是引用本身还是保存在 Java 栈的局部变量表中)和方法参数。前者是线程共享的,后者是线程私有的。
Java 内存模型规定所有的变量都存储在主内存中。每个线程都有自己的工作内存(Working Memory,是 JMM 的抽象概念,并不真实存在),类似于处理器的高速缓存,线程的工作内存保存了该线程使用到的在主内存的变量的一份拷贝。线程对变量的读写操作都必须在工作内存中进行,不同线程不能直接访问对方的工作内存,线程间变量值的传递均需要经过主内存来完成。
内存间交互操作
Java 内存模型定义了 8 种操作来完成主内存和工作内存之间的交互,虚拟机在实现这些操作时必须保证每一种操作都是原子的。
- lock(锁定)
作用于主内存的变量,它把一个变量标识为线程独占的状态。 - unlock(解锁)
作用于主内存的变量,它把一个处于锁定状态的变量释放。 - read(读取)
作用于主内存的变量,它把一个变量的值从主内存传递到线程的工作内存中。 - load(载入)
作用于工作内存的变量,它把 read 操作得到的变量值放入工作内存的变量的副本中。 - use(使用)
作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时就执行该操作。 - assign(赋值)
作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就执行该操作。 - store(存储)
作用于工作内存的变量,它把工作内存中的一个变量的值传递到主内存中。 - write(写入)
作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
Java 内存模型规定了一些执行这 8 种操作的规范,比如 read 和 load、store 和 write 必须顺序的、组合的出现,即有 read 必须有 load,且 read 必须在 load 前面,read 和 load 中间可以插入其他指令。利用这些规则可以确定 Java 程序中哪些内存访问操作是线程安全的,但是这些规则十分烦琐,有一种等效的判断原则——先行发生原则(Happens-Before)。
在 JSR133 中,已经放弃采用这 8 种操作去定义 Java 内存模型的访问协议了(仅仅是描述方式改变了,内存模型并没有改变)。
对于 volatile 型变量的特殊规则
- 在工作内存中,每次使用变量前都必须先从主内存刷新最新的值,用于保证能够看到其他线程对变量值的修改。
- 在工作内存中,每次修改变量的值都必须立刻同步回主内存中,用于保证其他线程可以看到该线程对变量值的修改。
- volatile 修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。
对于 long 和 double 型变量的特殊规则
原子性、可见性与有序性
Java 内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性来建立的。
原子性
可以大致认为基本类型的访问读写是原子性的(一种几乎不会发生的例外就是 long 和 double 的非原子协定)。如果需要更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作,尽管虚拟机没有把这两种操作直接开发给用户使用,却提供了更高层次的字节码指令 monitorenter
和 monitorexit
,反映到代码中就是 synchronized
关键字。
可见性
可见性保证当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。无论是普通变量还是 volatile
变量都是在变量修改后将新值同步回主内存,区别在于,volatile
保证变量修改后能够立刻同步回主内存、每次使用变量前都从主内存刷新。
除了 volatile
外,synchronized
和 final
也能够保证可见性。同步块能保证可见性是因为在执行 unlock 之前,会将变量的值同步回主内存中。被 final
修饰的字段在构造器中一旦初始化完成,并且构造器没有把 this
引用传递出去(this
引用逃逸,常出现在构造器中启动线程或注册监听器时,其他线程可以通过该引用访问到还未初始化结束的对象),那么在其他线程中就可以看到 final
字段的值。
有序性
如果在本线程内观察,所有的操作都是有序的、串行的;如果在一个线程内观察另一个线程,因为指令重排序和工作内存与主内存同步延迟的原因,所有的操作都是无序的。Java 提供了 volatile
和 synchronized
来保证线程之间操作的有序性,volatile
禁止了指令重排序,而 synchronized
使得一个变量在同一时刻只允许一条线程对其进行 lock 操作,即持有同一个锁的两个同步块只能串行的进入。
先行发生原则
如果 Java 内存模型中所有的有序性都仅仅依靠 volatile
和 synchronized
来完成,那么一些操作会变得很烦琐,但是我们在编写 Java 并发代码的时候并没有感觉到这一点,这是因为 Java 中有一个先行发生原则(Happens-Before),它是判断数据是否存在竞争、线程是否安全的主要依据。
- 程序次序规则
在一个线程内,按照代码的顺序,准确的说是控制流的顺序,书写在前面的操作先行发生于书写在后面的操作。 - 管程锁定规则
同一个锁,unlock
操作先行发生于lock
操作,即对于同一个锁,加锁之前必须解锁。 - volatile 变量规则
对一个volatile
变量的写操作先行发生于后面对这个变量的读操作。 - 线程启动规则
Thread 对象的start()
方法先行发生于此线程的每一个动作之前。 - 线程中断规则
线程中断方法interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()
方法检测是否有中断发生。 - 线程终止规则
线程中的所有操作都先行发生于对此线程的终止检测,可以使用Thread.join()
方法终止,使用Thread.isAlive()
方法检测是否已经终止。 - 对象终结规则
一个对象的初始化完成(构造器执行结束)先行发生于它的finalize()
方法的开始。 - 传递性
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,则操作 A 先行发生于操作 C。