【Java并发编程篇】JMM内存模型与volatile内存语义
JMM 内存模型
什么是 JMM 内存模型
Java 内存模型是 Java 虚拟机定义的一种多线程访问 Java 内存各个变量的访问规范,主要围绕如何解决并发过程中的原子性、可见性、有序性这三个问题来解决线程的安全问题。
Java 内存模型将内存分为了主内存和工作内存(也称为栈空间)。主内存存放所有的共享变量,所有线程都可以访问。每个线程都有自己的工作内存,存储了该线程使用到的变量的副本,线程对变量的所有操作都必须在自己的工作内存中完成,不能直接操作主存中的变量。操作时,首先将变量从主内存拷贝到自己的工作内存中,然后在自己的工作内存中对变量进行操作,操作完成后再将变量写回主存。不同的线程间也无法直接访问对方的工作内存的变量,线程间的变量值的传递必须通过主内存来完成。
(1)原子性:原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
(2)可见性:可见性指的是,当一个线程修改了某个共享变量的值,其他线程能够马上得知这个修改的值。
串行程序不存在可见性问题,因为在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个修改过的变量值。但在多线程环境中可就不一定了,因为线程对共享变量的操作都是拷贝到各自的工作内存中进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题。
(3)有序性:对于多线程环境,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,有可能出现乱序现象。
指令重排序:计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,在单线程条件下,指令重排序可以保证执行结果的一致性,但是在多线程条件下,这些重排优化可能会导致程序出现内存可见性问题,不能保证多线程间语义一致性。
原子性、可见性、有序性问题的解决措施
(1)原子性问题:除了 JVM 自身提供对基本数据类型读写操作的原子性外,对于方法级别或者代码级别的原子性操作,可以使用 synchronized 关键字或者重入锁 ReentrantLock 保证程序执行的原子性。
(2)可见性问题:工作内存与主内存同步延迟现象导致的可见性问题,可以使用 synchronized 关键字、Lock 或者 volatile 关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
(3)有序性问题:可以利用 volatile、synchronized 关键字解决。
JMM 的 as-if-serial 规则和 happens-before 规则
(1)as-if-serial规则:无论编译器和处理器如何进行重排序,单线程程序的执行结果不会改变。编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
(2)happens-before:在多线程程序开发中,如果仅靠 synchronized 和 volatile 关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,所以 Java 内存模型中提供了内置的 happens-before 规则来辅助处理前面提到的问题,它是判断数据是否存在竞争、线程是否安全的依据,从而保证线程安全。一个操作 happens-before 另一个操作,表示第一个的操作结果对第二个操作可见,并且第一个操作的执行顺序也在第二个操作之前。但这并不意味着 JVM 必须按照这个顺序来执行程序。如果重排序后的执行结果与按 happens-before 关系执行的结果一致,JVM 也允许重排序的发生。happens-before 原则内容如下:
- 程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
- 锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
- volatile 规则:volatile 变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile 变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
- 线程启动规则:线程的 start() 方法先于它的每一个动作,即如果线程A在执行线程B的 start() 方法之前修改了共享变量的值,那么当线程B执行 start() 方法时线程A对共享变量的修改对线程B可见。
- 传递性:A先于B ,B先于C,那么A必然先于C
- 线程终止规则:线程的所有操作先于线程的终结,Thread.join() 方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程从线程B的 join() 方法成功返回后,线程B对共享变量的修改将对线程A可见。
- 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测线程是否中断。
- 对象终结规则:对象的构造函数执行,结束先于 finalize() 方法。
volatile 内存语义
volatile 的作用
volatile 是 Java 虚拟机提供的轻量级同步机制,是线程不安全的,volatile 跟可见性和有序性有关,被 volatile 修饰的共享变量,具有以下两个作用:
(1)保证不同线程对该变量操作的内存可见性:当变量被 volatile 修饰时,那么对它的修改会立刻刷新到主存,同时其他线程操作 volatile 变量时,JMM 会把该线程对应的工作内存置为无效,那么该线程就只能从主存中重新读取共享变量,保证读取到最新值
通过 synchronized 和 Lock 也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存,但是相比于 volatile,synchronized 和 Lock 的开销都更大。
(2)禁止指令重排序
内存屏障
volatile 在内存中的语义是通过内存屏障实现,即可见性和禁止重排优化。把加入 volatile 关键字的代码和未加入 volatile 关键字的代码都生成汇编代码,会发现加入 volatile 关键字的代码会多出一个内存屏障指令,它是一个 CPU 指令。内存屏障提供了以下功能:
(1)告诉编译器和处理器,重排序时不能把后面的指令重排序到内存屏障之前的位置,从而避免多线程环境下出现乱序执行现象。
(2)保存某些变量的内存可见性,利用该特性实现 volatile 的内存可见性。
volatile 的原子性
volatile 的两点内存语义能保证可见性和有序性,但是不能保证原子性:对单个 volatile 变量的读/写具有原子性,但是对于类似 volatile++ 这样的复合操作就无能为力了,要想保证原子性,只能借助于 synchronized、Lock 或者并发包下的 atomic 的原子操作类了。
volatile使用场景
(1)状态量标记:这种对变量的读写操作,标记为 volatile 可以保证修改对线程立刻可见,效率也比 synchronized、Lock 有一定的提升。
(2)单例模式的实现,典型的双重检查锁定(DCL)
这是一种懒汉的单例模式,使用时才创建对象,而且为了避免初始化操作的指令重排序,给 instance 加上了 volatile。指令重排序会导致,当一条线程访问的 instance 不为 null 时,但是实际上 instance 实例未必已初始化完成,也就造成了线程安全问题。
参考: |