1.JMM概念
Java 内存模型(Java Memory Model 简称JMM)是一种抽象的概念,并不真实存在,指一组规则或规范,通过这组规范定义了程序中各个变量的访问方式。
因为在不同的硬件生产商和不同的操作系统下,内存的访问有一定的差异,所以会造成相同的代码运行在不同的系统上会出现各种问题。java内存模型(JMM)屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。
结构
JAVA内存模型
也就是说,在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,一般称之为共享变量。
所以,内存可见性针对的是堆中的共享变量。
内存可见性问题是如何发生的?
那可能就有小伙伴会问:既然堆是共享的,为什么在堆中会有内存不可见问题?
这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为 CPU 访问缓存区比访问内存要快得多。
什么是主内存?什么是本地内存?
- 主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
- 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
从图中可以看出:
- 所有的共享变量都存在主存中。
- 每个线程都保存了一份该线程使用到的共享变量的副本。
- 如果线程 A 与线程 B 之间要通信的话,必须经历下面 2 个步骤:
- 线程 A 将本地内存 A 中更新过的共享变量刷新到主存中去。
- 线程 B 到主存中去读取线程 A 之前已经更新过的共享变量。
所以,线程 A 无法直接访问线程 B 的工作内存,线程间通信必须经过主存。
注意,根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取。
所以线程 B 并不是直接去主存中读取共享变量的值,而是先在本地内存 B 中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存 B 去主存中读取这个共享变量的新值,并拷贝到本地内存 B 中,最后线程 B 再读取本地内存 B 中的新值。
2.Java 内存区域和 JMM 有何区别?
区别
两者是不同的概念。JMM 是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开。而 Java 运行时内存的划分是具体的,是 JVM 运行 Java 程序时必要的内存划分。
联系
都存在私有数据区域和共享数据区域。一般来说,JMM 中的主存属于共享数据区域,包含了堆和方法区;同样,JMM 中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。
总结:
- 方法区:存储了每一个类的结构信息,如运行时常量池、字段和方法数据、构造方法和普通方法的字节码内容。
- 堆:几乎所有的对象实例以及数组都在这里分配内存。这是 Java 内存管理的主要区域。
- 栈:每一个线程有一个私有的栈,每一次方法调用都会创建一个新的栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息。所有的栈帧都是在方法调用和方法执行完成之后创建和销毁的。
- 本地方法栈:与栈类似,不过本地方法栈为 JVM 使用到的 native方法服务。
- 程序计数器:每个线程都有一个独立的程序计数器,用于指示当前线程执行到了字节码的哪一行。
处理器重排序与内存屏障指令
前面提到了,JMM 定义了多线程之间如何互相交互的规则,主要目的是为了解决由于编译器优化、处理器优化和缓存系统等导致的可见性、原子性和有序性。
那我们接下来就来聊聊重排序以及它所带来的顺序问题。
为什么指令重排可以提高性能?
大家都知道,计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
那可能有小伙伴就要问:为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令 1 还没有执行完,就可以开始执行指令 2,而不用等到指令 1 执行结束后再执行指令 2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这段代码的执行情况:
a = b + c;
d = e - f ;
先加载 b、c(注意,有可能先加载 b,也有可能先加载 c),但是在执行 add(b,c)
的时候,需要等待 b、c 装载结束才能继续执行,也就是需要增加停顿,那么后面的指令(加载 e 和 f)也会有停顿,这就降低了计算机的执行效率。
为了减少停顿,我们可以在加载完 b 和 c 后把 e 和 f 也加载了,然后再去执行 add(b,c)
,这样做对程序(串行)是没有影响的,但却减少了停顿。
换句话说,既然 add(b,c)
需要停顿,那还不如去做一些有意义的事情(加载 e 和 f)。
综上所述,指令重排对于提高 CPU 性能十分必要,但也带来了乱序的问题。
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例:
// Processor A
a = 1; //A1
x = b; //A2
// Processor B
b = 2; //B1
y = a; //B2
// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。
重排序有哪几种?
指令重排一般分为以下三种:
编译器优化重排,编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
指令并行重排,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
内存系统重排,由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
代码举例:
编译器优化重排:
int a = 1; int b = 2; System.out.println(a); System.out.println(b);
尽管在代码中,我们首先赋值给a,然后赋值给b,最后打印这两个变量,但编译器可能会重排这些语句,因为它们之间没有数据依赖性。编译器可能会这样优化代码:
int b = 2; // 编译器可能首先执行这一行 int a = 1; // 然后执行这一行 System.out.println(b); // 打印b System.out.println(a); // 打印a
指令并行重排示例:
int x = 1; int y = x + 1; System.out.println(y);
在这个例子中,y的值依赖于x的值。然而,如果编译器认为x的值不会改变,它可能会将System.out.println(y)提前执行,因为y的计算可以与打印操作并行执行:
int x = 1; System.out.println(y); // 可能被提前执行 int y = x + 1; // 尽管y依赖x,但编译器可能认为可以并行执行
内存系统重排示例:
double pi = 3.14159; System.out.println(pi);
在这个例子中,我们首先将pi的值赋值为3.14159,然后打印它。然而,由于处理器使用缓存,pi的值可能首先被加载到寄存器或一级缓存中,然后才被打印。这个加载操作和打印操作可能会在不同的时间点执行,导致乱序执行:
// 假设pi的值首先被加载到缓存 double cachedPi = loadFromCache(pi); // 这可能在打印之前发生 // 然后执行打印操作 System.out.println(pi); // 这可能在加载操作之后发生
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
JMM与顺序一致性模型
当程序未正确同步的时候,就可能存在数据竞争。
数据竞争:在一个线程中写一个变量,在另一个线程读同一个变量,并且写和读没有通过同步来排序。
如果程序中包含了数据竞争,那么运行的结果往往充满了不确定性,比如读发生在了写之前,可能就会读到错误的值;如果一个线程能够正确同步,那么就不存在数据竞争。
Java 内存模型(JMM)对于正确同步多线程程序的内存一致性做了以下保证:如果程序是正确同步的,程序的执行将具有顺序一致性。即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。因为他要求更多的同步和内存保障,所以这种模型会浪费一些性能
这里的同步包括使用 volatile、final、synchronized 等关键字实现的同步。
如果我们开发者没有正确使用volatile
、final
、synchronized
等关键字,那么即便是使用了同步,JMM 也不会有内存可见性的保证,很可能会导致程序出错,并且不可重现,很难排查。
顺序一致性内存
顺序一致性模型是一个理想化的理论参考模型,它为程序提供了极强的内存可见性保证。顺序一致性模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序(即 Java 代码的顺序)来执行。
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见。
为了理解这两个特性,我们举个例子,假设有两个线程 A 和 B 并发执行,线程 A 有 3 个操作,他们在程序中的顺序是 A1->A2->A3,线程 B 也有 3 个操作,B1->B2->B3。
假设正确使用了同步,A 线程的 3 个操作执行后释放锁,B 线程获取同一个锁。那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上有序,并且两个线程都只能看到这个执行顺序。
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。
同步程序的顺序一致性效果
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() {
a = 1;
flag = true;
}
public synchronized void reader() {
if (flag) {
int i = a;
……
}
}
上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:
从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。
JMM和happens-before
Happens-Before原则是JMM中用于定义操作之间偏序关系的一个原则。根据这个原则,如果一个操作A happens-before另一个操作B,那么A的结果对B可见,即B能够看到A对共享变量所做的修改。Happens-Before原则是保证多线程程序内存一致性的关键机制之一。
happens-before 关系的定义如下:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
happens-before 关系本质上和 as-if-serial 语义是一回事。
as-if-serial 语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before 关系保证正确同步的多线程程序的执行结果不被重排序改变。
as-if-serial 语义示例
假设我们有一个简单的单线程程序,如下所示:
int a = 1; // A int b = 2; // B int c = a + b; // C
根据as-if-serial语义,即使编译器和处理器将A和B重排序,最终执行的结果应该与上述代码顺序执行的结果一致。例如,它们可以重排序为:
int a = 1; // A int c = 3; // C int b = 2; // B (尽管B被重排序到C之后,但这不影响最终结果)
因为最终`c`的值仍然是3,与原始代码顺序执行的结果一致。
happens-before 语义示例
class Counter { private volatile int count = 0; public void increment() { count++; // A } public int getCount() { return count; // B } } Counter counter = new Counter(); Thread t1 = new Thread(counter::increment); Thread t2 = new Thread(() -> { while (counter.getCount() == 0) ; // C }); t1.start(); t2.start();
- 操作A(`count++`)对操作B(`return count`)是可见的,因为`count`是`volatile`变量,根据`volatile`变量规则,对`volatile`变量的写操作happens-before于后续对该变量的读操作。
- 操作C(循环检查`count`)happens-before于操作B,因为C是一个循环,它在B之前执行,并且B依赖于C的结果来退出循环。
即使编译器和处理器可能会对A和C进行重排序,它们必须保证重排序后的执行结果与按照happens-before关系顺序执行的结果一致。例如,它们不能将C重排序到A之前,因为这将破坏内存一致性。
class SafeCounter { private int count = 0; private final Object lock = new Object(); public void increment() { synchronized (lock) { count++; // D } } public int getCount() { synchronized (lock) { return count; // E } } } SafeCounter counter = new SafeCounter(); Thread t1 = new Thread(counter::increment); Thread t2 = new Thread(() -> { int result = counter.getCount(); // F System.out.println(result); }); t1.start(); t2.start();
在这个例子中:
- 操作D(`count++`)在监视器锁的保护下,因此它happens-before于操作E(`return count`),因为它们被同一个锁`lock`保护。
- 即使编译器和处理器可能会对D和E进行重排序,它们必须保证重排序后的执行结果与按照happens-before关系顺序执行的结果一致,以保证内存一致性。
通过这些示例,我们可以看到`as-if-serial`和`happens-before`语义如何在Java程序中确保单线程和多线程环境下的执行结果的一致性。
总之,如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程。