一.什么是JMM
JMM(Java Memory Model)是Java语言内存模型,是一种抽象的概念,并不真实存在,是一种规范,用于定义多线程程序如何与内存进行交互,规定了程序中变量在内存中的访问方式。jvm运行程序的实体是线程,而每个线程运行时,都会创建一个工作内存(也叫栈空间),来保存线程所有的私有变量。而JMM内存模型规范中规定所有的变量都存储在主内存中,而主内存中的变量是所有线程都可以共享的,对主内存中的变量进行操作时,必须在线程的工作内存中操作,首先将主内存的变量copy到工作内存,进行写操作后,再将变量刷回到主内存中。所有线程只有通过主内存来进行通信。简单来说它是规定程序中线程之间的抽象关系,也就是线程之间的共享变量的访问规则。
1.1 为什么会出现
- 程序的运行结果依赖于处理器,而不同的处理器规则都不一样,不同处理器差异是很大的,所以同段代码在处理器A运行正常,搬到处理器B运行结果是不一样的,所以为了兼容这种差异,推出了Java内存模型规范,简单的说CPU的运行并不是直接操作内存而是先把内存里面的数据读取到缓存,而内存的读和写操作的时候就会造成不一致的问题
- JVM规范中试图定义一种Java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。所以就保证了Java语言“一次书写,到处运行”
1.2 JMM核心概念
1.2.1 主内存与工作内存
- 主内存:所有线程创建的实例对象都存放在内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中,属于所有线程共享区域,所以存在线程之间安全问题。
- 工作内存:每个线程都有自己的工作内存,工作内存中保存了主内存中变量的副本。线程对变量的所有操作(读取、写入)都在工作内存中进行,对其他线程是不可见的,最后再将结果同步回主内存。
1.2.2 原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程给打断。
在java中,对基本的数据类型的操作都是原子性的操作,但是要注意的是对于32位系统的操作对于long、double类型的并不是原子性操作(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作)。因为对于32位的操作系统来说,每次读写都是32位,而doubel、long则是64位存储单位。就会导致一个线程操作完前面32位后,另一个线程刚好读到后面的32位,这样一来一个64位被两个线程分别读取。
1.2.3 可见性(volatile)
- 是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 ,JMM规定了所有的变量都存储在主内存中。Java中普通的共享变量不保证可见性,因为数据修改被写入内存的时机是不确定的,多线程并发下很可能出现"脏读",所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等 )都必需在线程自己的工作内存中进行,而不能够直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
- 对于串行执行的程序是不存在可见性,当一个线程修改了共享变量后,后续的线程都能感知到共享变量的变化,也能读取到最新的值,所以对于串行程序来讲是不存在可见性问题。
- 对于多线程程序,就不一定了,前面分析过对于共享变量的操作,线程都是将主内存的变量copy到工作内存进行操作后,在赋值到主内存中。这样就会导致,一个线程改了之后还未回写到主内存,其余线程就无法感知到变量的更新,线程之间的工作内存是不可见的。另外指令重排序以及编译器优化也会导致可见性的问题。
1.2.4 有序性
有序性是指对于单线程的代码,我们总是认为程序是按照代码的顺序进行执行,对于单线程的场景这样理解是没有问题,但是在多线程情况下, 程序就会可能发生乱序的情况,因为编译器为了提高性能,编译器和处理器通常会对指令序列进行重新排序,指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生"脏读",简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。所以编译器编译成机器码指令后,指令可能会被重排序,重排序的指令并不能保证与没有排序前的保持一致。
1.3 JMM存在的必要性
由于JVM运行时的实体是线程,每一个线程运行时都会创建一个工作内存,作为线程的私有空间,每个线程操作变量都在自己的工作内存,从主内存copy一份数据到工作内存,在工作内存中进行操作后,再将其刷新回主内存。如果是在多线程的情况下,则会出现线程安全问题。因此JMM规范规定了如下八种操作来完成数据的操作:
(1) lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
如果一个变量从主内存中复制到工作内存中,就需要按顺序的执行read、load指令,如果是工作内存刷新到主内存则需要按顺序的执行store、write操作。但JMM只保证了顺序执行,并没有保证连续执行。
1.3.1 同步规则分析
(1)不允许线程无原因的将变量从工作内存写回主内存(没有经过任何的assign)。
(2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量(load或者assign)的变量,就是对一个变量进行use和store操作之前,必须先自行load与assign操作。
(3)一个变量在同一时刻只能被一个线程lock,同一个线程能多次lock,必须对应的多次unlock,lock与unlock必须成对的出现。
(4)如果对一个变量执行lock操作,将会清除工作内存中此变量的值,在执行引擎使用这个变量时,必须重新load、assign操作。
(5)如果一个线程对一个变量没有进行lock操作,则不允许unlock,也不允许对其他线程进行unlock。
(6)对一个变量进行unlock时,必须先将变量刷新到主内存。
1.4 JMM如何解决原子性、可见性、有序性
1.4.1 原子性
除了jvm自身提供的对基本类型的原子性操作以外,可以通过synchronized和Lock实现原子性。synchronized与lock在同一时刻始终只会存在一个线程访问对应的代码块。
1.4.2 可见性
- Volatile关键字保证了可见性。当一个共享变量被volatile修饰时,它会保证共享变量修改的值立即被其他线程可见,即修改的值立即刷新到主内存,当其它线程去需要读取变量时,从主内存中读取。
- Synchronized和Lock也保证了可见性。因为同一时刻只有一个线程能访问同步代码块,所以是能保证可见性。
1.4.3 有序性
- 指令重排:即只要程序的最终结果与顺序执行的结果保持一致,则虚拟机就可以进行排序,此过程就叫指令重排序,为啥需要指令重排序?jvm根据处理器特性(cpu多级缓存、多核处理器等)适当的对机器指令进行排序,使机器指令能更符合CPU的执行特性,最大的限度发挥机器性能。
- JMM内存模型:每个线程都有自己的工作内存。线程对变量的操作只能在工作内存中进行操作,并且线程之前的工作内存是不可见的。java内粗模型具备一定的先天有序性,happens-before 原则。如果两个操作无法推断出happens-before 原则,则无法保证程序的有序性。虚拟机可以随意的将它们进行排序。
- volatile关键字保证了有序性,synchronized和Lock也保证了有序性(因为同一时刻只允许一个线程访问同步代码块,自然保证了线程之间在同步代码块的有序执行)。
- as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
1.5 happens-before原则
只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:
(1)程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
(2)锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
(3)volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
(4)线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
(5)传递性:A先于B ,B先于C 那么A必然先于C
(6)线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
(7)线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
(8)终结器规则:对象终结规则对象的构造函数执行,结束先于finalize()方法
总之,JMM通过规定线程之间的抽象关系和共享变量的访问规则,确保了Java程序在并发处理中的正确性和可靠性。
二.Volatile
2.1 什么是volatile
volatile 可以看做是轻量级的 synchronized,它只保证了共享变量的可见性,是Java虚拟机提供的轻量级的同步机制。在线程 A 修改被 volatile 修饰的共享变量之后,线程 B 能够读取到正确的值。Java 在多线程中操作共享变量的过程中,会存在指令重排序与共享变量工作内存缓存的问题。volatile 一共有三大特性,保证可见性,不保证原子性,禁止指令重排。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。
2.2 实现可见性
volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现:
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。
- 内存屏障,又称内存栅栏,是一个 CPU 指令。
- 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止+ 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
- 在volatile修饰的共享变量进行写操作的时候会多出lock前缀的指令
lock 前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
- 为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。
- 如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
- 为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
- 所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
- volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
lock指令:
在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。
缓存一致性:
缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。 LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议。 缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
2.3 实现有序性
happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
2.3.1 volatile禁止重排序
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 会针对编译器制定 volatile 重排序规则表。
" NO " 表示禁止重排序。
为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
2.4 不保证原子性
volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。先从如下两个问题来理解(后文再从内存屏障的角度理解):
2.4.1 i++为什么不能保证原子性?
对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。
2.4.2 共享的long和double变量的为什么要用volatile?
因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。
2.5 使用场景
使用 volatile 必须具备的条件
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
- 只有在状态真正独立于程序内其他内容时才能使用 volatile
2.5.1.状态标志
也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
2.5.2.一次性安全发布(one-time safe publication)
缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
2.5.3.双重检查
单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。
2.5.4 独立观察(independent observation)
安全使用 volatile 的另一种简单模式是定期发布观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
public class UserManager {
public volatile String lastUser;
public boolean authenticate(String user, String password) {
boolean valid = passwordIsValid(user, password);
if (valid) {
User u = new User();
activeUsers.add(u);
lastUser = user;
}
return valid;
}
}