一、JMM产生背景
因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。
需要注意的是JMM是一种JVM虚拟机规范(JSR-133),具体如何实现,由各个厂商决定。
二、JMM内存模型的内容
2.1 划分工作内存和主内存
JMM规定了内存主要划分为主内存和工作内存两种。
线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行,线程不能直接读写主内存中的变量。
不同的线程之间也无法访问对方工作内存中的变量。**线程之间变量值的传递均需要通过主内存来完成。**如果听起来抽象的话,我可以画张图给你看看,会直观一点:
每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主存。这是 Java 内存模型定义的线程基本工作方式。
2.2 定义内存操作及其使用规则
内存交互操作有8种,JMM规定虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外):
操作 | 含义 |
---|---|
lock/unlock | lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态 |
unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 | |
read/write | read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用 |
write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中 | |
load/store | load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中 |
store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用 | |
use/assign | use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令 |
assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中 |
使用规则
- 不允许 read、load、store、write 操作之一单独出现,也就是 read 操作后必须 load,store 操作后必须 write,只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的。
- 不允许线程丢弃他最近的 assign 操作,即工作内存中的变量数据改变了之后,必须告知主存。
- 不允许线程将没有 assign 的数据从工作内存同步到主内存。
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施 use、store 操作之前,必须经过 load 和 assign 操作。
- 一个变量同一时间只能有一个线程对其进行 lock 操作。多次 lock 之后,必须执行相同次数 unlock 才可以解锁。
- 如果对一个变量进行 lock 操作,会清空所有工作内存中此变量的值。在执行引擎使用这个变量前,必须重新 load 或 assign 操作初始化变量的值。
- 如果一个变量没有被 lock,就不能对其进行 unlock 操作。也不能 unlock 一个被其他线程锁住的变量。
- 一个线程对一个变量进行 unlock 操作之前,必须先把此变量同步回主内存。
2.3 指令重排序、内存可见性与Happens-Before原则
一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高并行度。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序,一般重排序可以分为如下三种:
上述1属于编译器重排序,而2和3统称为处理器重排序。
编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
内存操作的重排序:这里是指处理器的缓存,由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
对于单线程而言,重排序本身有自己的约束,那就是编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,在单线程中,不管怎么重排序,程序的执行结果不会被改变,这就是as-if-serial。
比如下面的例子:
double pi = 3.14 //1
double r = 1.0 //2
double area = pi * r * r //3
单线程下,上述代码中的步骤1和步骤2可以重排序,最终的结果是一致的。
那在多线程情况下,就得考虑重排序对执行结果的影响了,比如下面的例子:
线程1:
X=1
a=Y
线程2:
Y=1
b=X
假设X、Y是两个全局变量,初始的时候,X=0,Y=0。请问,这两个线程执行完毕之后,a、b的正确结果应该是什么?
很显然,线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的结果可能是:
(1)a=0,b=1
(2)a=1,b=0
(3)a=1,b=1
也就是不管谁先谁后,执行结果应该是这三种场景中的一种。但实际可能是a=0,b=0。
两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0,b=0。原因是线程1先执行X=1,后执行a=Y,但此时X=1还在自己的工作内存里面,没有及时写入主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。
这就是一个有意思的地方,虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来,a=Y和X=1顺序却是颠倒的。指令没有重排序(没有指令级的重排序),是写入内存的操作被延迟了,也就是内存被重排序了(内存操作的重排序),这也就造成内存可见性问题。
总结一下就是并发场景下的指令重排序会造成内存可见性的问题,导致执行结果不符合预期。
那么如何解决这种问题呢?JMM在JSR-133中提出了先行发生原则(Happen-Before),同样这是一种规范,并没有规定具体实现:
程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。
锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。
volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。
线程中断规则:对线程的interruption()调用,先于被调用的线程检测中断事件(Thread.interrupted())的发生
对象中止规则:一个对象的初始化方法先于一个方法执行Finalizer()方法
传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C
三、JMM三大特征:原子性、有序性和可见性
在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurrent包等解决原子性、有序性和可见性三大问题。
3.1 原子性
原子性指的是一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。2.2小节中的8个内存操作都是原子的,此外定义了synchronized关键字来保证代码块的原子性。
3.2 可见性
可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。Java 是利用 volatile 关键字来提供可见性的。 当变量被 volatile 修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。
除了 volatile 关键字之外,final 和 synchronized 也能实现可见性。
synchronized 的原理是,在执行完,进入释放锁之前,必须将共享变量同步到主内存中.
被final修饰的变量,相比普通变量,内存语义有一些不同:
- JMM禁止把final域的写重排序到构造器的外部,换句话说,写final变量一定在构造函数内。
- 在一个线程中,初次读该对象和读该对象下的final域,JMM禁止处理器重新排序这两个操作,换句话说。
用下面这个例子来说明:
public class FinalConstructor {
final int a;
int b;
static FinalConstructor finalConstructor;
public FinalConstructor() {
a = 1;
b = 2;
}
public static void write() {
finalConstructor = new FinalConstructor();
}
public static void read() {
FinalConstructor constructor = finalConstructor;
int A = constructor.a;
int B = constructor.b;
}
}
假设现在有线程A执行FinalConstructor.write()方法,线程B执行FinalConstructor.read()方法。
对应上述的Final的第一条规则,因为JMM禁止把Final域的写重排序到构造器的外部,而对普通变量没有这种限制,所以变量A=1,而变量B可能会等于2(构造完成),也有可能等于0(第11行代码被重排序到构造器的外部)。
对应上述的Final的第二条规则,如果constructor的引用不为null,A必然为1,要么constructor为null,抛出空指针异常。保证读final域之前,一定会先读该对象的引用。但是普通对象就没有这种规则。
补充一下,除了JMM规定的这几个关键字之外,我们还需要了解下工作内存同步到主内存的时机,因为这几个关键字是”规定“,但是除此以外,JVM在一些操作场景下也会主动同步主内存(这也意味着工作内存失效):
- 线程中释放锁时
- 线程切换时,比如sleep操作
- CPU有空闲时间时(比如线程休眠,IO操作(写文件、打印控制台等等))
3.2 有序性
在 Java 中,可以使用 synchronized 或者 volatile 保证多线程之间操作的有序性。实现原理有些区别:
volatile 关键字是使用内存屏障达到禁止指令重排序,以保证有序性。
synchronized 的原理是,一个线程 lock 之后,必须 unlock 后,其他线程才可以重新 lock(遵循内JMM的内存操作原则,可以回看2.2小结),使得被 synchronized 包住的代码块在多线程之间是串行执行的。
四、谈谈volatile的实现原理
为什么重点说volatile呢?因为面试经常问。
从3.2和3.3小节可以看出,volatile关键字起到了两个作用:保证内存可见性和禁止指令重排序,但是注意,它不能保证原子性。
需要注意一点,volatile针对的是那种会被多个线程操作的变量,对于不存在并发操作的变量而言,没有意义的。
4.1 保证内存可见性
对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
volatile写的内存语义如下:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义如下:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
4.2 禁止内存重排序
首先要讲一下内存屏障:
首先是硬件上面的内存屏障
- Load屏障,是x86上的”ifence“指令,在其他指令前插入ifence指令,可以让高速缓存中的数据失效,强制当前线程从主内存里面加载数据
- Store屏障,是x86的”sfence“指令,在其他指令后插入sfence指令,能让当前线程写入高速缓存中的最新数据更新写入主内存,让其他线程可见。
其次讲下Java里面的内存屏障:
在java里面有4种,就是 LoadLoad,StoreStore,LoadStore,StoreLoad,实际上也能看出来,这四种都是上面的两种的组合产生的
- LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore 屏障:对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
在每个volatile写操作前插入StoreStore屏障,这样就能让其他线程修改A变量后,把修改的值对当前线程可见,在写操作后插入StoreLoad屏障,这样就能让其他线程获取A变量的时候,能够获取到已经被当前线程修改的值
在每个volatile读操作前插入LoadLoad屏障,这样就能让当前线程获取A变量的时候,保证其他线程也都能获取到相同的值,这样所有的线程读取的数据就一样了,在读操作后插入LoadStore屏障;这样就能让当前线程在其他线程修改A变量的值之前,获取到主内存里面A变量的的值。