上一篇讲解了线程安全问题与JMM的核心原理以及8大原子操作,本文继续学习JMM中的Happens-before8大规则,8大原子操作从文字上理解可能不够深刻,我们从代码的角度直观的解读8大原子操作,进一步深入剖析其中的的运作机制,分析JMM是如何保证线程安全的。
Happens-before 8大规则
在分析之前,我们先来了解下JMM中的Happens-before 规则,字面意思即先行发生规则。
Two actions can be ordered by a happens-before relationship. If one action
happens-before another, then the first is visible to and ordered before the second.
Happens-before 规则是 Java 内存模型(JMM)的核心规则,用于定义多线程环境下操作的可见性与执行顺序。它并非要求实际执行顺序,而是保证:若一个操作 A “happens-before” 另一个操作 B,则操作 A 的结果对 操作B 可见,且操作 A 逻辑上排序在 操作B 之前。
If we have two actions x and y, we write hb(x, y) to indicate that x happens-before y.
• If x and y are actions of the same thread and x comes before y in program order,
then hb(x, y).
• There is a happens-before edge from the end of a constructor of an object to the
start of a finalizer (§12.6) for that object.
• If an action x synchronizes-with a following action y, then we also have hb(x, y).
• If hb(x, y) and hb(y, z), then hb(x, z).
...
• An unlock on a monitor happens-before every subsequent lock on that monitor.
• A write to a volatile field (§8.3.1.4) happens-before every subsequent read of
that field.
• A call to start() on a thread happens-before any actions in the started thread.
• All actions in a thread happen-before any other thread successfully returns from
a join() on that thread.
• The default initialization of any object happens-before any other actions (other
than default-writes) of a program.
参考官方文档说明,Happens-before 核心规则总结如下:
1、程序顺序规则
同一线程内,代码顺序前面的操作happens-before后续任意操作。
2、监视器锁规则
对锁的解锁操作happens-before后续同一锁的加锁操作。
3、volatile变量规则
对volatile变量的写操作happens-before后续任意线程对该变量的读操作。
4、线程启动规则
线程A调用线程B的start()方法happens-before线程B的任何操作。
5、线程终止规则
线程中的所有操作happens-before其他线程检测到该线程终止(通过join()或isAlive())。
6、传递性规则
若A happens-before B,且B happens-before C,则A happens-before C。
7、对象终结规则
对象构造函数结束happens-before该对象的finalize()方法开始。
8、中断规则
线程T1的中断操作happens-before其他线程检测到T1被中断。
了解了8大原子操作和Happens-before 规则之后,下面我们从代码的角度分析JMM是如何保证线程安全的。
双重检查加锁单例模式
下面以经典的双重检查加锁单例模式代码示例来分析8大原子操作的运行过程。
class Singleton {
// 主内存共享变量
private volatile static Singleton instance;
public static Singleton getInstance() {
// Step1: read(instance) + load + use(第一次检查)
if (instance == null) {
// Step2: lock(Singleton.class)
synchronized (Singleton.class) {
//Step3:read(instance) + load + use(第二次检查)
if (instance == null) {
// Step4: assign(instance) + store + write
instance = new Singleton();
}
}// Step5: unlock(Singleton.class)
}
return instance;
}
}
上面的例子主要功能是在多线程的环境中获取单例对象,例子中定义了一个用volatile关键字修饰的静态的类变量instance,这是一个共享变量,多个线程获取该变量时,返回的都是同一个实例。为了全面直观了解8大操作过程,我们用javap -c命令将该代码对应的class文件反编译成字节码指令。
C:\Program Files\Java\jdk-21\bin>javap -c Singleton.class
Compiled from "Singleton.java"
class com.demo.history.Singleton {
com.demo.history.Singleton();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static Singleton getInstance();
Code:
0: getstatic #2 // 读取静态字段instance (read+load)
3: ifnonnull 37 // if (instance != null) 跳转
6: ldc #3 // 加载Singleton.class对象 (lock准备)
8: dup
9: astore_0 // 存储锁对象到局部变量表
10: monitorenter // 进入同步块 (lock)
11: getstatic #2 // 再次读取instance (双重检查)
14: ifnonnull 27
17: new #4 // 创建Singleton对象
20: dup
21: invokespecial #5 // 调用构造方法
24: putstatic #2 // 赋值给instance (assign+store+write)
27: aload_0
28: monitorexit // 退出同步块 (unlock)
29: goto 37
32: astore_1 // 异常处理开始
33: aload_0
34: monitorexit // 确保锁释放
35: aload_1
36: athrow
37: getstatic #2 // 返回instance (read+load)
40: areturn
Exception table:
from to target type
11 29 32 any
32 35 32 any
}
步骤一:
java代码中的Step1:首次判断instance是否为空,此时instance可能还未初始化,从前文我们已经知道,共享变量存储在主内存当中,线程调用getInstance()方法时,先是从主内存读取instance的值到自己的工作内存中。那怎么知道执行了read + load + use操作的?又是怎么知道从主内存读取到工作内存的?
我们看字节码指令中的第一步的getstatic指令。getstatic是Java字节码指令,用于从类的静态字段(共享变量)获取值并压入操作数栈,而操作数栈主要用于方法执行过程中的临时计算的结果,是线程私有的工作区。所以这个指令的执行就是从主内存读取变量的值到线程的工作内存中。
ifnonnull指令用于判断对象引用是否非空并控制程序流程,该指令的的执行就是对变量的使用过程。所以getstatic和ifnonnull两个指令相当于执行了read + load + use操作。
步骤二:
java代码中的Step2:使用synchronized关键字对代码块进行加锁,锁对象是类的字节码对象,所以这是一个类锁,保证所有线程抢到的都是同一把锁。加锁的代码块里面是实例的创建过程,保证只有一个线程能创建实例。
从字节码指令中可以看出,synchronized底层加锁是基于monitorenter指令实现的,释放锁则基于monitorexit指令实现。所以这里执行了lock+unlock操作。
步骤三:
java代码中的Step3:进入synchronized加锁的代码块后,再次通过getstatic和ifnonnull指令判断instance变量是否为空,这里是第二次检查了。
步骤四:
java代码中的Step4:instance变量不为空时,则通过new关键字创建对象,并把新创建的对象赋值给instance。在字节码指令中使用putstatic指令进行赋值操作。putstatic是Java字节码中用于操作静态变量的关键指令,其作用是将操作数栈顶的值存储到类的静态字段中,对应Java代码中的静态变量赋值操作,这一过程相当于将线程工作内存中的变量写到主内存中。所以putstatic指令相当于执行了assign + store + write操作。
步骤五:
java代码中的Step5是synchronized同步块的结束语句,退出同步块的时候执行释放锁的操作,从字节码指令中可以看出,底层通过monitorexit指令实现,相当于执行了unlock操作。
线程安全实现分析
从上面的步骤分析中可以看出,**双重检查加锁单例模式保证线程安全(只有一个实例)的核心在于对变量进行volatile修饰以及加锁操作,进入同步块后触发加锁(lock操作),加锁后执行创建对象的过程,退出同步块前释放锁(unlock操作)前必须将变量同步回主内存,修改后及时对其他线程可见。**用8大原子操作注释代码如下:
class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {//1.lock
if (instance == null) {//2.read,3.load,4.use
instance = new Singleton();//5.assign,6.store,7.write
}
}//8.unlock
}
return instance;
}
}
这个例子也回答了一个在面试中经常问到的一个问题”通过new关键字创建对象时底层执行哪几步操作“,这下子我们可以大大方方的说出答案,那就是assign,store,write三步。
原子性保证
lock/unlock 将多个操作(new Singleton())绑定为不可分割的单元,例子中synchronized代码块内的assign-store-write操作作为一个整体执行,三步要么成功,要么失败,不允许出现部分执行成功,部分执行失败的情况。
可见性保证
volatile关键字修饰符变量,主要作用是保证一个线程对变量修改后,其他线程能立即可见。例子中assign-store-write操作作为一个整体执行后,确保对其他线程立即可见。volatile修饰变量后,强制线程每次读取变量时直接跳过工作内存缓存从主内存获取最新值。
有序性保证(防止指令重排)
volatile关键字还有另一个作用,禁止 JVM 对 instance = new Singleton() 进行指令重排序。原始步骤(可能重排序):分配内存空间、初始化对象、将引用指向内存地址。 重排序后可能导致第三步先执行,就会导致其他线程拿到未初始化的对象。
其次,通过happens-before规则约束操作顺序。根据程序顺序规则,对同一线程操作按程序顺序执行;根据监视器锁规则unlock操作先于后续的lock操作;根据volatile变量规则,对volatile变量的写操作happens-before后续任意线程对该变量的读操作。例子中,instance变量被关键字volatile修饰后,线程抢到锁后,对该变量的赋值操作(写操作)都会先行发生于其他线程对该变量的读。
总结
本文先是讲解了JMM的8大Happens-before 规则,然后结合之前讲解过的8大原子操作,用经典的双重检查加锁单例模式代码实现来分析底层运行过程以及保证线程安全的方法,希望通过具体化的实例讲解加深对线程安全以及JMM相关知识的理解。