目录
JVM内存结构
方法区、java堆为线程共享区域。
java栈、本地方法栈、程序计数器属于线程私有区域。
JMM (Java Memory Model)
JMM定义了共享内存系统中多线程程序读写操作行为的规范,来屏蔽各种硬件和操作系统的内存访问差异,来实现 Java 程序在各个平台下都能达到一致的内存访问效果。
JMM的主要目标是定义程序中各个变量的访问规则,也就是在虚拟机中将变量存储到内存以及从内存中取出变量这类的底层细节(这里的变量,指的是共享变量,也就是实例对象、静态字段、数组对象等存储在堆内存中的变量。而对于局部变量这类的,属于线程私有,不会被共享)。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。
它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了 CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的可见性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
主内存和工作内存
在JMM内存模型中,分为主内存和工作内存。
主内存是所有线程共享的,工作内存是每个线程独有的。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量。并且不同的线程之间无法访问对方工作内存中的变量,线程间的变量值的传递都需要通过主内存来完成。
总的来说,JMM 是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。
JMM怎么解决原子性、可见性、有序性的问题?
在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final。
我们可以直接使用synchronized等关键词来控制并发,使得我们不需要关心底层的编译器优化、缓存一致性的问题了,所以在Java内存模型中,除了定义了一套规范,还提供了开放的指令在底层进行封装后,提供给开发人员使用。
原子性保障
在Java中提供了两个高级的字节码指令monitorenter和monitorexit,保证synchronized修饰区域的操作是原子的。
可见性
Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。
除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。
有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。
实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。
volatile如何保证可见性?
volatile变量修饰的共享变量,在进行写操作的时候会多出一个lock前缀的汇编指令,当对其进行写操作时,JVM就会向处理器发送一条Lock前缀的指令,把这个变量所在的缓存行的数据写回到系统内存。然后处理器会根据MESI缓存一致性协议来保证多CPU下的各个高速缓存中的数据的一致性。
什么是指令重排序?
指令重排的目的是为了最大化的提高CPU利用率以及性能,CPU的乱序执行优化在单核时代并不影响正确性,但是在多核时代的多线程能够在不同的核心上实现真正的并行,一旦线程之间共享数据,就可能会出现一些不可预料的问题。
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("x=" + x + "->y=" + y);
}
如果不考虑编译器重排序和缓存可见性问题,上面这段代码可能会出现的结果是 x=0,y=1; x=1,y=0; x=1,y=1这三种结果,因为可能是先后执行t1/t2,也可能是反过来,还可能是t1/t2交替执行,但是这段代码的执行结果也有可能是x=0,y=0。
这就是在乱序执行的情况下会导致的一种结果,因为线程t1内部的两行代码之间不存在数据依赖,因此可以把x=b乱序到a=1之前;同时线程t2中的y=a也可以早于t1中的a=1执行,那么他们的执行顺序可能是 t1: x=b;t2:b=1;t2:y=a;t1:a=1。这就是重排序会导致可见性问题。
内存屏障
在JMM中把内存屏障指令分为4类,通过在不同的语义下使用不同的内存屏障来进制特定类型的处理器重排序,从而来保证内存的可见性。
LoadLoad Barriers
对每个volatile读操作前插入LoadLoad Barriers:load1 ; LoadLoad; load2,确保load1数据的装载优先于load2及所有后续装载指令的装载。
LoadStore Barriers
对每个volatile读操作后插入LoadStore Barriers: load1;loadstore;store2,确保load1数据装载优先于store2以及后续的存储指令刷新到内存。
StoreStore Barriers
对每个volatile写操作前插入StoreStore Barriers,store1; storestore;store2,确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储。
StoreLoad Barries
对每个volatile写操作后插入StoreLoad Barriers, store1; storeload;load2, 确保store1数据对其他处理器变得可见, 优先于load2及所有后续装载指令的装载。这条内存屏障指令是一个全能型的屏障,它同时具有其他3条屏障的效果。
happens-before原则
表示前一个操作的结果对于后续操作是可见的,所以它表达了多个线程之间对于内存的可见性。 所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作一定存在 happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程。
满足happens-before原则的场景:
- 一个线程中的任意操作,happens-before于该操作的后续操作。
- 对于volatile变量的写的操作, 一定happens-before后续的读操作。
- 如果ThreadA执行操作ThreadB.join()并成功返回,ThreadB中的任意操作happens-before于ThreadA的后续操作。
- 对一个锁的解锁,happens-before于随后对这个锁的加锁操作。
- 如果 1 happens-before 2,2 happens- before 3,那么1 happens-before 3。
volatile不能保证复合操作的原子性
如果num是个共享变量,那么num++不是原子性的操作,而是复合操作。这个操作分为三步:1.读取;2.加一;3.赋值。
因此,在多线程环境下,有可能线程A将num读取到本地内存中,此时其他线程可能已经将num增大了很多,线程A依然对过期的num进行自加,重新写到主存中,最终导致了num的结果小于预期。
volatile总结
volatile是一种轻量级的同步机制,它主要有两个特性:
- 保证共享变量对所有线程的可见性;
- 禁止指令重排序优化。
同时需要注意的是,在多线程场景下,如果是一写多读,volatile可以保证原子性,但是像num++这种复合操作,volatile无法保证其原子性。