文章目录
概述
在 Java 中,volatile 和 synchronized 是两种重要的并发控制机制,它们用于保证多线程环境下的数据一致性。volatile 用于声明一个变量的可见性和禁止指令重排,而 synchronized 用于确保代码块或方法的互斥访问。
本文将深入探讨这两个关键字的底层实现原理和技术细节。
volatile 的实现原理
基本概念
volatile 关键字用于声明一个变量在多线程环境下的可见性。当一个线程修改了一个被声明为 volatile 的变量后,这个修改对其他线程是立即可见的。此外,volatile 还禁止了编译器和处理器对这些变量的指令重排,从而确保了内存访问的顺序性。
作用
- 可见性(Visibility): 当一个线程修改了 volatile 变量的值时,这个变量的新值会立即被写回主内存,而其他线程会立即看到这个新值。这确保了所有线程对共享变量的修改都是可见的,避免了一个线程修改了变量值而其他线程不知道的情况。
- 禁止指令重排序: volatile 关键字禁止了指令重排序,确保了一些关键操作的执行顺序。在没有 volatile 的情况下,编译器和处理器可能会对指令进行重排序,导致多线程环境下的程序出现不可预期的错误。
- 保证原子性(Atomicity): 尽管 volatile 不能保证复合操作的原子性,但它确保了对单个变量的读/写操作是原子的。这意味着一个线程在写入 volatile 变量时,其他线程不能同时进行写操作,从而避免了竞态条件。
虽然 volatile 提供了一定程度上的线程安全性,但它并不能解决所有的并发问题。对于一些复合操作(例如检查-更新操作),仍然需要额外的同步手段,例如使用 synchronized 关键字或 java.util.concurrent 包提供的工具类。
底层实现细节
字节码层面
- 创建一个新的Java类,并声明一个volatile变量。
package org.hbin.bytecode;
/**
* @author Haley
* @version 1.0
* 2024/8/22
*/
public class VolatileTest {
volatile int num;
}
- 编译这个类
- 使用反汇编工具查看class文件内容
- 对照上述字段访问标志表和源码分析,我们可以得知字节码文件中的该变量访问标志为0x0040,即ACC_VOLATILE。
JVM层面
volatile的底层实现跟JMM息息相关,我们先了解下JMM是什么?
JMM
《Java虚拟机规范》中曾试图定义一种“Java内存模型”(Java Memory Model,简称JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
Java内存模型是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
简单来说:在JMM中,每个线程都有自己的工作内存(也称为本地内存),它存储着线程私有的数据副本,包括栈帧、程序计数器等。而主内存则是所有线程共享的内存区域,它存储着所有的共享变量。
当线程访问共享变量时,它首先会把共享变量从主内存中读取到自己的工作内存中进行操作,包括读取、修改和写入。然后,线程对变量的操作完成后,必须将结果刷新到主内存中,以便其他线程可以看到最新的值。这个过程可以看作是线程和主内存之间的数据同步。
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分的(对于double和long类型的变量有例外,别说。)
操作 | 名称 | 作用范围 | 用途 |
---|---|---|---|
lock | 锁定 | 主内存 | 把一个变量标识为线程独占状态 |
unlock | 解锁 | 主内存 | 把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
read | 读取 | 主内存 | 把一个变量的值从主内存传输到线程的工作内存,以便随后的load动作使用 |
write | 写入 | 主内存 | 把store操作从工作内存中得到的变量值放入主内存的变量中 |
load | 载入 | 工作内存 | 把read操作从主内存中得到的变量值放入工作内存的变量副本中 |
store | 存储 | 工作内存 | 把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用 |
use | 使用 | 工作内存 | 把工作内存中一个变量的值传递给执行引擎,当遇到一个需要使用到变量的值的虚拟机指令时将会执行这个操作 |
assign | 赋值 | 工作内存 | 把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作 |
上述操作相关的更多信息可以在JavaSE6的JVM规范章节Threads and Locks中查找。根据JSR-133文档,目前已经放弃采用这8种操作去定义Java内存模型的访问协议。(仅是描述方式变更,JMM并没改变)
根据上述JMM规范,更新一个变量的值需要经过哪些步骤呢?答案是:
- read:将主内存中的值读取出来
- load:将值副本写入到工作内存中
- use:当前线程将工作内存中的值拿出,再经过执行引擎运算
- assign:将运算后的值写回到工作内存
- store:线程将当前工作内存中新的值存储回主内存,注意此时主内存中的值还没有改变
- write:将store操作从工作内存中得到的变量值放入主内存的变量中,即刷新主内存变量值。
到此,更新一个变量值的流程就走完。
HotSpot VM中的内存屏障
JMM为了更好地简化Java开发者的开发过程,屏蔽一些底层细节,设计了volatile的方式来标记共享变量。JVM层为此设计了四种内存屏障来支持volatile。
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 保证Load1的读取操作在Load2及后续读取操作之前执行 |
StoreStore | Store1;StoreStore;Store2 | 在store2及其后的写操作执行前,保证Store1的写操作已经刷新到主内存 |
LoadStore | Load1;LoadStore;Store2 | 在Store2及其后的写操作执行前,保证Load1的读操作已经结束 |
StoreLoad | Store1;StoreLoad;Load2 | 保证Store1的写操作已经刷新到主内存后,Load2及其后的读操作才能执行 |
这四个屏障只是Java为了跨平台而设计出来的,实际上根据CPU的不同,对应 CPU 平台上的 JVM 可能可以优化掉一些 屏障,例如LoadLoad、LoadStore和StoreStore是x86上默认就有的行为。
总结
结合JMM和内存屏障的信息,在HotSpot JVM中volatile的底层实现主要依赖于内存屏障,在读取和更新volatile变量的前后添加相应的内存屏障来规范了变量的访问方式。
OS和硬件层面
在硬件层面,大多数现代处理器使用内存屏障(Memory Barrier 或 Memory Fence)来实现 volatile 语义。
内存屏障是一种硬件指令,用于确保内存操作的顺序和一致性。
- 写内存屏障(Store Memory Barrier):在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存中,让其他线程可见。强制写入主内存,这种显示调用,不会让CPU去进行指令重排序。也就是说当看到写内存屏障指令,就必须把该指令之前的所有写入指令执行完毕才能继续往下执行。
- 读内存屏障(Load Memory Barrier):在指令后插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存中加载最新数据,确保了读取到的是最新数据。也是不会让CPU去进行指令重排。
上述读写内存屏障只是一种规范,不同的硬件可能还有不同的内存屏障操作。
X86/64
x86/64系统架构提供了三种内存屏障指令:sfence、lfence、mfence。
- lfence:load fence,读屏障指令。在lfence指令前的读操作必须在lfence指令后的读操作前完成。即读串行化。
- sfence:save fence,写屏障指令。在sfence指令前的写操作必须在sfence指令后的写操作前完成。即写串行化。
- mfence:modify/mix fence,混合屏障指令,是一种全能型的屏障。在mfence指令前的读写操作必须在mfence指令后的读写操作前完成。即读写串行化。
// linux-6.9.1/arch/arm64/include/asm/barrier.h
#define __mb() asm volatile("mfence":::"memory")
#define __rmb() asm volatile("lfence":::"memory")
#define __wmb() asm volatile("sfence" ::: "memory")
arm64
arm64系统提供的下面几种内存屏障指令:
#define isb() asm volatile("isb" : : : "memory")
#define dmb(opt) asm volatile("dmb " #opt : : : "memory")
#define dsb(opt) asm volatile("dsb " #opt : : : "memory")
#define __smp_mb() dmb(ish)
#define __smp_rmb() dmb(ishld)
#define __smp_wmb() dmb(ishst)
#define __mb() dsb(sy)
#define __rmb() dsb(ld)
#define __wmb() dsb(st)
#define __dma_mb() dmb(osh)
#define __dma_rmb() dmb(oshld)
#define __dma_wmb() dmb(oshst)
以上两段代码参考Linux内存屏障
synchronized 的实现原理
基本概念
synchronized 关键字用于声明一个代码块或方法只能被一个线程同时访问。它通过获取和释放对象的监视器锁来实现互斥访问。
底层实现细节
字节码层面
声明方法
- 对象方法
package org.hbin.bytecode;
/**
* @author Haley
* @version 1.0
* 2024/8/22
*/
public class SynchronizedTest {
synchronized void test() {}
}
- 静态方法
package org.hbin.bytecode;
/**
* @author Haley
* @version 1.0
* 2024/8/22
*/
public class SynchronizedTest2 {
synchronized static void test() {
}
}
反编译class:
对照上述方法访问标志表和源码分析,我们可以得知字节码文件中的方法访问标志信息:
- 对象方法为0x0020,即ACC_SYNCHRONIZED
- 静态方法为0x0028,该值为0x0020和0x0008两个值通过或运算得到,即
0x0020|0x0008
。所以该方法的访问标志也包含ACC_SYNCHRONIZED。
声明代码块
package org.hbin.bytecode;
/**
* @author Haley
* @version 1.0
* 2024/8/22
*/
public class SynchronizedTest2 {
void test() {
synchronized (this) {
}
}
}
反编译class:
对照上述源码分析,留意如下几行:
3 monitorenter // 进入同步方法
...
5 monitorexit // 退出同步方法
...
11 monitorexit // 异常时退出同步方法
从字节码中可知,同步语句块的实现使用的是monitorenter 和 monitorexit 指令。
- monitorenter:指向同步代码块的开始位置
- monitorexit:指明同步代码块的结束位置
JVM层面
synchronized 的底层是通过监视器锁机制来实现的。每个 Java 对象都有一个与之对应的监视器锁,当一个线程获取了该对象的监视器锁,就可以执行 synchronized 代码块或方法。其他线程只能等待该线程释放锁,才能获取该对象的监视器锁并执行 synchronized 代码块或方法。
当一个线程进入 synchronized 代码块或方法时,它会尝试获取对象的监视器锁。如果该锁没有被其他线程占用,则该线程获取锁并继续执行 synchronized 代码块或方法;如果该锁已经被其他线程占用,则该线程会进入锁池(Lock Pool)等待,直到该锁被其他线程释放。
当一个线程释放对象的监视器锁时,它会唤醒锁池中的一个等待线程,让其获取锁并继续执行 synchronized 代码块或方法。如果锁池中有多个等待线程,那么唤醒哪个线程是不确定的,取决于 JVM 的实现。
Mark Word
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。其中,对象头又包含Mark Word、Class Pointer、Array Length三部分。
Mark Word存储了锁的状态信息,并且它的内容会随着锁的状态改变而改变。监视器锁机制通过变更锁标志位来维护线程同步信息。
锁的状态
是否偏向锁和锁标志位:
OS和硬件层面
- Mutex: 当监视器锁升级为重量级锁,JVM会使用操作系统提供的 Mutex 来实现互斥访问。Mutex 是操作系统提供的原语,用于实现资源的互斥访问。
- 原子操作: 某些处理器提供了原子操作指令,例如 cmpxchg(比较并交换)指令,用于实现锁的获取和释放过程中的原子操作。
- 内存屏障: 内存屏障是一种特殊的指令,它可以确保某些内存操作的顺序性。在 synchronized 的实现中,Java 虚拟机会利用内存屏障来保证锁的获取和释放过程中的内存可见性。
结论
volatile 和 synchronized 是 Java 中用于实现多线程并发控制的重要机制。volatile 通过内存屏障来确保变量的可见性和禁止指令重排,而 synchronized 通过锁的机制来确保代码块或方法的互斥访问。理解这些关键字的底层实现原理可以帮助开发者更有效地使用它们来编写高性能的并发代码。
参考
【JAVA】volatile 关键字的作用
Java中的内存屏障
操作系统——什么是内存屏障?
synchronized 的底层原理
JAVA基础篇–JVM–3对象结构