并发编程特性与volatile
共享性
- 数据共享性是线程安全的主要原因之一。
- 如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是我们在编程的时候经常不需要考虑线程安全的主要原因之一。
- 在多线程编程中,数据共享是不可避免的。
- 最典型的场景是数据库中的数据,为了保证数据的一致性,我们通常需要共享同一个数据库中的数据。
互斥性
- 资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。
- 我们通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。
- 所以我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。
- 如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全。
- 对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。
- 但是对共享数据的写操作,一般就需要保证互斥性。
可见性
- 线程只能操作自己工作空间中的数据。
- 每个工作线程都有自己的工作内存,所以当某个线程修改完某个变量之后,在其他的线程中,未必能观察到该变量已经被修改。
如何保证可见性
volatile
volatile
关键字要求被修改之后的变量要求立即更新到主内存,每次使用前从主内存处进行读取。- 当修饰引用类型的时候, 只能保证引用本身的可见性, 不能保证内部字段的可见性。
synchronized 加锁
synchronized 保证 unlock 之前必须先把变量刷新回主内存。
示例如下
/**
* @author BNTang
*/
public class VisibilityTest {
private static boolean running = true;
private static void method() {
System.out.println("start");
while (running) {
// 如果添加了打印语句, 变量不加 volatile 程序也会结束
// 因为在println当中触发了程序的可见性,使用 synchronized 修饰
System.out.println("hello");
}
System.out.println("end");
}
public static void main(String[] args) {
new Thread(VisibilityTest::method).start();
SleepTools.sleepSecond(1);
running = false;
}
}
/**
* @author BNTang
*/
public class VisibilityTest {
/**
* 成员变量是线程共享的
*/
private static volatile boolean running = true;
private static void method() {
System.out.println("start");
while (running) {
}
System.out.println("end");
}
public static void main(String[] args) {
// 创建一个线程 新线程
new Thread(VisibilityTest::method).start();
// 主线程 main
SleepTools.sleepSecond(1);
running = false;
}
}
/**
* @author BNTang
*/
public class VisibilityTest {
private static boolean running = true;
private static void method() {
System.out.println("start");
while (running) {
}
System.out.println("end");
}
public static void main(String[] args) {
// 程序一直结束不了
new Thread(VisibilityTest::method).start();
SleepTools.sleepSecond(1);
running = false;
}
}
原子性
- 原子性就是指对数据的操作是一个独立的、不可分割的整体。
- 换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行到一半的时候被其他线程所修改。
示例
1.X = 5
- 是一个写操作
- 具有原子性
2.Y = X
- 不具有原子性
- 先把数据 X 读取到工作空间
- 再把 X 的值写给 Y
- 是一个读写操作, 不具有原子性
3.i++
- 不具有原子性
- 读 i 到工作空间
- +1 后写给 i
- 刷新结果到内存
4.a = a + 1
- 不具有原子性
- 读 a 到工作空间
- +1
- 刷新结果到内存
如何保证原子性
- Synchronized
- JUC Lock 加锁
- 被 synchronized 关键字或其他锁包裹起来的操作也可以认为是原子的。
- 从一个线程观察另外一个线程的时候,看到的都是一个个原子性的操作。
有序性
为了提高性能,编译器和处理器可能会对指令做重排序。
编译器优化的重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序
- 现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。
- 如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序
由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
程序中的顺序不一定就是执行的顺序,编译时重排序,指令重排序。
如何保证有序性
通过 volatile 和 synchronized 可以保证程序的有序性。
数据依赖性
什么是数据依赖性
不同的程序指令之间的顺序是不允许进行交换的,即可称这些程序指令之间存在数据依赖性。
哪些指令不允许重排
写后读: a = 1;b = a;写一个变量之后,再读这个位置。
写后写: a = 1;a = 2;写一个变量之后,再写这个变量。
读后写: a = b;b = 1;读一个变量之后,再写这个变量。
发现这里每组指令中都有写操作,这个写操作的位置是不允许变化的,否则将带来不一样的执行结果。
多线程下的指令重排
TODO
as-if-serial
重新排序后的运行结果和之前的结果要保持一致。
Happen-Before
Happen-Before
- Happen-Before 被翻译成先行发生原则,意思就是当 A 操作先行发生于 B 操作。
- 则在发生 B 操作的时候,操作 A 产生的影响能被 B 观察到。
- “影响” 包括修改了内存中的共享变量的值、发送了消息、调用了方法等。
原则
- 程序次序规则(Program Order Rule)在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。
- 锁定规则(Monitor Lock Rule)一个 Unlock 的操作肯定先于下一次 Lock 的操作, 后一次加锁必须等前一次解锁, 这里必须是同一个锁。同理我们可以认为在 synchronized 同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。
- volatile 变量规则(volatile Variable Rule)对同一个 volatile 的变量,先行发生的写操作,肯定早于后续发生的读操作。
- 线程启动规则(Thread Start Rule)Thread 对象的 start() 方法先行发生于此线程的每一个动作。
- 线程中止规则(Thread Termination Rule)Thread 对象的中止检测(如:Thread.join())操作,必行晚于线程中所有操作。
- 线程中断规则(Thread Interruption Rule)对线程的 interruption() 调用,先于被调用的线程检测中断事件 (Thread.interrupted()) 的发生。
- 对象中止规则(Finalizer Rule)一个对象的初始化方法先于一个方法执行 Finalizer() 方法。
- 传递性(Transitivity)如果操作 A 先于操作 B、操作 B 先于操作 C, 则操作 A 先于操作 C。
volatile
内存模型
volatile 关键字的作用
- 当一个变量加上 volatile, 就是告诉虚拟机, 每一次要使用变量时, 总是要从主内存当中读取。
- 如果要修改修饰的变量, 一定要把修改完后的值, 刷回主内存,即不会出现数据脏读的现象。
- 被 volatile 修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
特点
- 保证共享性
- 保证有序性
- 不能保证原子性
不是线程安全的, 只能保存变量的可见性, 不能保证变量的原子性, 对共享变量的修改,其他的线程马上能感知到,不能保证原子性(读写, i++)。
volatile 的实现原理
/**
* @author BNTang
*/
public class Test {
public static volatile int counter = 1;
public static void main(String[] args) {
counter = 2;
System.out.println(counter);
}
}
字节码
查看字节码, 使用 JavaP 查看字节码, 找到字节码执行反编译操作
javap -v Test
使用 idea 外部工具
- Arguments:
-v $FileClass$
- Working directory:
$OutputPath$
结论
- 修饰 counter 字段的 public、static、volatile 关键字
- 在字节码层面分别是以下访问标志:
ACC_PUBLIC
,ACC_STATIC
,ACC_VOLATILE
- volatile 在字节码层面,就是使用访问标志:
ACC_VOLATILE
来表示- 供后续操作此变量时判断访问标志是否为
ACC_VOLATILE
,来决定是否遵循 volatile 的语义处理
如上是在 JVM 层面所看到的
反编译
HSDIS
HSDIS (HotSpot disassembler) 一个 Sun 官方推荐的 HotSpot 虚拟机 JIT 编译代码的反汇编插件,其实际上就是一个动态库。这里我们直接从网上下载与我们系统对应的编译后文件,然后直接将其放置到 JDK 的 bin 目录下即可
把下载好的 hsdis
解压放到 bin
目录当中 (jdk11 是放在 bin 目录当中,jdk8 是放在 jre\bin\server 目录当中)
在程序运行时添加参数
-server
-Xcomp
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=compileonly,*Test.main
以上内容只需要修改的地方就是修改类名,前面的 *
号不要去掉,然后再加上对应的方法名称即可,配置好了在次运行程序发现控制台输出结果如下
观察结果
volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令
lock 前缀指令实际上相当于是一个内存屏障(也称内存栅栏)内存屏障会提供 3 个功能
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
- 它会强制将对缓存的修改操作后的数据立即写入主存
- 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。对缓存行进行加锁处理
volatile 禁止指令重排原理
JMM 对于 volatile重排序的禁止规则
当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序
当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序
当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序
volatile 是通过编译器在生成字节码时,在指令序列中添加 “内存屏障” 来禁止指令重排序的
硬件层面的内存屏障
- sfence:写屏障 (Store Barrier) 在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,保证写入的数据立刻对其他线程可见
- lfence:读屏障 (Load Barrier) 读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,保证读取的是最新的数据
- mfence:即全能屏障 (modify / mix Barrier) 兼具 sfence 和 lfence 的功能
- lock 前缀:lock 不是内存屏障,而是一种锁
JMM 层面的内存屏障
- LoadLoad 屏障:像 Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕
- StoreStore 屏障:像 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见
- LoadStore 屏障:对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕
- StoreLoad 屏障: 对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见
JVM 的实现会在 volatile 读写前后均加上内存屏障,在一定程度上保证有序性
volatile
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
例如下方的几个栗子:
StoreStoreBarrier
volatile 写操作
StoreLoadBarrier
LoadLoadBarrier
volatile 读操作
LoadStoreBarrier
手动添加内存屏障
在 Java 提供的 Api 中有一个名称为 Unsafe
的类
/**
* @author BNTang
*/
public class Test {
public static void main(String[] args) {
Unsafe unsafe = Unsafe.getUnsafe();
// 添加读屏障
unsafe.loadFence();
// 添加写屏障
unsafe.storeFence();
// 读写屏障
unsafe.fullFence();
}
}
volatile 保证可见性原理
- 总线加锁
- 缓存一致性协议
MESI
MESI
MESI 中每个缓存行都有四个状态
- E(exclusive)独占状态
- S(shared)共享状态
- M(modified)修改状态
- I(invalid)失效状态
过程
- cpu 在启动的时候,会采用监听模式,一直会监听消息的传递
- 如果在读取一个变量时,发现被 lock 修饰时,其它 CPU 会监听到现在有人在读取数据
- 假设现在 cpu1 读取到一个变量 a = 1, 是第一次读,会把这个变量 a 标记成
E(独占状态)
- 如果此时,有另一个 cpu 也读取变量 a, 此时 cpu1 也会可监听到,并且会把状态更改为
S状态(共享状态)
在 cpu2 当中会也会标记成S状态(共享状态)
如果两个 cpu 都要对 a 变量进行修改
- 假设 cpu1 把 a 改为 2
- cpu2 要把 a 改成 3
- 会分别在 cpu 当中的缓存行中加锁,一旦加锁成功后,就可以来修改里面的内容,并且把状态标志成
M(已修改状态)
- 假设 cpu2 缓存行加锁成功,会向消息总线发送一个本地写缓存的消息
- 如果两个人同时加锁,发消息给消息总线
- 此时总线就要采取内部仲裁的方式来决定谁先成功
- 通过总线的高低电位来裁决
- 消息发成功后,会被 cpu1 捕捉到,cpu1 会把自己当中的变量置为
I(无效状态)
到内存当中再读取最新的数据- 在发出消息后,并不是立马就写入到内存当中,会先把写的数据放到一个
store buffer
当中,等 cpu1 把消息变为无效后,才会写到入到内当中- 当 cpu1 把消息设置会无效后,会把原来的数据 a = 1 放到一个 queue 队列当中,并且会发送一个消息通过已经置为无效
使用场景
一个线程写, 多个线程读
- 状态标志(开关模式)
- 双重检查锁定(double-checked-locking)DCL
/**
* 单例
*
* @author BNTang
*/
public class Singleton {
/**
* 实例
*/
private volatile static Singleton instance = null;
/**
* 单例
*/
private Singleton() {}
/**
* 获得实例
*
* @return {@link Singleton}
*/
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
- 需要利用顺序性
volatile 与 synchronized
- volatile 只能修饰变量,synchronized 只能修饰方法和语句块
- synchronized 可以保证
原子性
,volatile 不能保证原子性- 都可以保证
可见性
,但实现原理不同, volatile 对变量加了lock
synchronized 使用monitorEnter
和monitorexit monitor
- volatile 能保证
有序
,synchronized 可以保证有序性
,但是代价(重量级)并发退化到串行- synchronized 引起阻塞
- volatile 不会引起阻塞
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· winform 绘制太阳,地球,月球 运作规律
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· 写一个简单的SQL生成工具