Java 17内存屏障:为什么需要,怎么用

年轻时,看《Thinking In Java》这本书时,总是会被里面简短的代码所吸引,代码虽然短,但是都能明确的引出一个问题,从而触发思考。思考后,问题就好理解了。

今天我们也一样,用一段简单的代码,引出问题,目的也是希望可以帮助你理解Java内存屏障这个概念。

例子很简单的,就是两个线程共享两个变量,线程A写数据,线程B读数据:

Java 复制代码
int data = 0;
boolean ready = false;
// 线程A
data = 100;
ready = true;
Java 复制代码
// 线程B
if (ready) {
    System.out.println(data);
}

你可以思考一下,线程B会打印什么?是不是100? 毕竟data = 100写在ready = true前面,线程B看到ready为true的时候,data肯定已经是100了。

实际上,线程B有可能打印0。

原因不是代码写错了,而是你的代码,CPU和编译器根本没有按你写的顺序来执行。

那CPU和编译器为什么要打乱你的代码?

我们可以从一个比喻开始。如果你自己做过饭,应该知道,做一道菜,需要切肉、炒肉、切葱、炒葱等。如果严格按顺序来,切完肉就炒肉,炒完肉再切葱,效率很低。更高效的做法是把所有切菜工作一次做完,再统一炒。

CPU和编译器干的是同样的事。编译器在编译期会调整指令顺序来更好地利用寄存器,这叫编译器重排序。CPU在执行时也会打乱指令顺序来填满流水线,这叫CPU乱序执行。再加上多级缓存的存在,CPU写入数据到L1缓存和最终刷回主存之间有时间差,其他CPU核心看到的数据可能不是最新的。

单线程下这些重排不会出问题。无论怎么调整顺序,最终结果和按原始顺序执行一样。多线程下情况就变了。线程A里的ready = true有可能被CPU重排到data = 100之前执行,线程B看到ready为true,去读data,读到的还是初始值0。

编译器重排、CPU乱序、缓存不一致,三个因素叠加在一起,让多线程代码的执行顺序变得不可预测。

这个时候,就需要内存屏障出场了

内存屏障是一条特殊的CPU指令,它的作用就一个:告诉CPU和编译器,这条线之前的操作必须全部完成,这条线之后的操作不能跑到前面去。

类比一下,内存屏障就像十字路口的红绿灯。不管每条路上有多少车急着要走,红灯一亮,所有车都得停下来,等到绿灯了,车辆才能走。

Java内存模型(JMM)定义了一套规则:在什么位置必须插入内存屏障,编译器和CPU必须遵守。开发者不需要自己决定在哪里放屏障,JMM已经规定好了。只要按照Java提供的同步机制写代码,屏障会被自动安放到正确的位置。

四种屏障和获取/释放语义

内存屏障有四种类型,按读(Load)和写(Store)的组合来区分:

屏障类型 组合方式 作用
LoadLoad 读 → 屏障 → 读 上一步的读必须完成,才能执行下一步的读
StoreStore 写 → 屏障 → 写 上一步的写必须对其他处理器可见,才能执行下一步的写
LoadStore 读 → 屏障 → 写 上一步的读必须完成,才能执行下一步的写
StoreLoad 写 → 屏障 → 读 上一步的写必须对所有处理器可见,才能执行下一步的读。四种里开销最大的一个

四种屏障看着是挺多的多,但在实际使用时不需要逐个去记的。你可以简单的使用这两个概念作为触发点去想就可以了:获取(Acquire)和释放(Release)

写数据的一端用Release语义:保证屏障之前的所有读写操作都完成,再执行当前的写操作。也即是前面的活都干完了,这个写操作才能生效。

读数据的一端用Acquire语义:保证屏障之后的所有读写操作不会提前执行。也即是后面的操作得等我读完再说。

写端Release,读端Acquire,两端配对使用,就能保证多线程下的数据可见性和顺序正确性。大部分并发同步机制,底层都遵循这个模式。

Java 17里怎么用

Java里获得内存屏障有两种方式:隐式的和显式的。

隐式方式:volatile。 把一个变量声明为volatile后,JIT编译器会在写这个变量时自动插入Release语义的屏障(包含StoreStore和StoreLoad),在读这个变量时自动插入Acquire语义的屏障(包含LoadLoad和LoadStore)。开发者什么都不用做,加个关键字就行。

回到开头的例子,把ready声明为volatile就够了:

Java 复制代码
volatile boolean ready = false;
// 写ready自带Release语义,data=100一定先于ready=true对其他线程可见
// 读ready自带Acquire语义,后续读data的操作不会被提前到读ready之前

显式方式:VarHandle。 Java 9引入了VarHandle API,可以对单个变量的读写指定内存语义,不用把整个变量标记为volatile

Java 复制代码
// 声明变量句柄
static final VarHandle VH = MethodHandles.lookup()
    .findVarHandle(Fence.class, "ready", boolean.class);
// 写端用Release语义
VH.setRelease(this, true);
// 读端用Acquire语义
boolean r = (boolean) VH.getAcquire(this);

效果跟volatile一样:setRelease保证data = 100ready写入之前对读线程可见,getAcquire保证读data的操作不会被重排到读ready之前。区别在于volatile对整个变量生效,VarHandle可以精确控制单次读写的内存语义。

VarHandle还提供了几个静态fence方法:releaseFence()acquireFence()fullFence(),可以直接在代码中插入独立的内存屏障,不绑定到某个具体变量上。实际业务开发中很少用到,更多出现在并发框架的内部实现里。

小结

理解了内存屏障后,就能明白volatile和并发容器为什么能工作。知道底层有屏障在保障顺序,用这些高层抽象时心里就有些底了。

99%的业务开发不需要直接碰内存屏障。volatileReentrantLockCompletableFuture、各种并发容器,内部已经把屏障处理好了。只有自己去写高性能框架(Disruptor、Netty这类项目)、阅读并发库源码、或者排查诡异的多线程bug时,这些知识才会派上用场。

补充说明:为了文章的流畅性和易懂性,有些技术表达,会特意简化,因此这里需要补充一些细节澄清:

第一:内存屏障是一条特殊的CPU指令。在某些架构下,的确是指令,但有些也不是。更加准确的描述,可以换成:内存屏障是一组CPU提供的同步原语。

第二:在某些架构上,JIT实际只会为volatile写,生成一个lock前缀指令(等效StoreLoad),不会额外插StoreStore,因为部分屏障由硬件去保证。

相关推荐
青山木1 小时前
Hot 100 --- 最长递增子序列
java·数据结构·算法·leetcode·动态规划
CC数分1 小时前
2026年金融数据分析岗位需要哪些工具?2027届秋招备考指南
面试·数据分析
小陈不好吃1 小时前
深入理解 JVM 内存模型:从堆栈结构到 GC 日志分析实战
java·jvm·java-ee·jdk
爱吃苹果的日记本1 小时前
JAVA十三(练习课)
java·学习
磐链科技1 小时前
交易所开发全流程指南:从需求分析到上线运维的完整路线图
java
無限進步D1 小时前
Java Web 前端 简介
java·开发语言·前端·css·html·css3·web
聪明蛋子哟1 小时前
从LangChain到LangGraph:Python与Java双栈Agent开发实战对比
java·ai·langchain
azhou的代码园2 小时前
景区游船预约服务系统
人工智能·spring boot·后端
三8442 小时前
Java 模板注入(FreeMarker)· 04 · 利用与绕过:payload 全集
java·web安全·freemarker·payload