之前字节面试问到过Java内存模型,回答的是内存结构。重新学习了一下整理出来。
1、前置知识
通信是指线程之间如何交换信息,主要有两种机制:共享内存 和 **消息传递,**共享内存指的是多个线程共享的数据区,A 线程写,B 线程读,这就是一次简单的线程通信;在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。
同步是指程序控制不同线程之间操作发生相对顺序的机制。
- 在共享内存并发模型里,同步是显示进行的。
- 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。
2、Java 内存模型的抽象☆
在 Java 中,所有实例域、静态域、和数组元素存储在堆内存中,堆内存存在线程之前共享。局部变量,方法定义参数和异常处理器参数不会在线程之间共享,它们不会有可见性的问题,就是不受内存模型的影响
Java 线程之间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见
JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每一个线程都有一个私有的本地内存,本地内存中存储了该线程以读 / 写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它包含了缓存、写缓存区、寄存器以及其他的硬件和编译器优化。
Java 内存模型的抽象示意图
从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
- 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
通过示意图来说明这两个步骤:
如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证
3、重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
4、happen-before 规则
基于重排序既能提升程序性能,另一外面会导致程序出问题,我们既要保留它的优点还要禁止出现问题的重排序指令,这个事情交给 JMM 来处理。
JMM 定义了一套规则,叫做 happen-before 规则,这套规则一方面给程序员的要求,一方面是对编译器和处理器的约束。JMM 承诺程序员基于这套编程规则,即便不理解重排序,程序也不会因为发生了重排序出问题。
本质目的就是:保留不打破规则的重排序,禁止打破规则的重排序
happen-before 规则(文心一言生成)
在Java中,happen-before规则是一组定义了操作执行顺序的规则,用于解决并发问题。这些规则确保了线程之间的可见性和有序性。以下是happen-before规则的详细内容:
- 程序次序原则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。这是最基础的规则,它确定了在一个线程内部的执行顺序。
- 监视器锁规则:对一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须指同一个锁,后面指的是时间上的先后顺序。这个规则确保了对共享资源的同步访问,防止数据竞争。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。这里的后面同样指时间上的先后顺序。volatile关键字用于确保变量的读写操作具有原子性,通过内存屏障来实现。
- 传递性原则:如果A操作happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。这是传递性原则,确保了操作之间的顺序关系具有传递性。
- 线程启动规则:同一个线程的start()方法happen-before此线程的其它方法。这意味着线程的启动顺序决定了方法的执行顺序。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的检测到中断时间的发生。这个规则确保了线程中断的正确处理。
- 线程终结规则:线程中的所有操作都happen-before线程的终止检测。这个规则确保了在线程结束之前,所有操作都已经完成。
- 对象创建规则:一个对象的初始化完成先行发生于他的finalize()方法调用。这个规则确保了在对象被回收之前,初始化已经完成。
这些happen-before规则为Java程序员提供了一种方式来理解和预测并发程序的行为,从而编写正确的并发代码。它们有助于确保操作的可见性和有序性,从而避免了数据不一致和其他并发问题。