在Java并发编程的面试中,“synchronized 能否保证可见性”是一个经典问题。答案是肯定的:synchronized 关键字确实可以保证可见性。要理解这一点,我们需要从可见性的本质和 synchronized 的实现机制说起。
什么是可见性?
线程修改一个变量时,这个变量通常存储在主内存中。线程操作前,需要先将变量从主内存读取到线程私有的工作内存(如CPU寄存器、高速缓存)中。修改完成后,再写回主内存。如果某个线程修改了值但未能及时刷新到主内存,或者其他线程没有及时去主内存读取最新值,就会导致数据不一致的问题,这就是 数据的不可见性。保证可见性,就是要确保一个线程对共享变量的修改,能够被其他线程及时、准确地观察到。
可见性在底层如何保证?
从硬件层面看,可见性是通过 MESI缓存一致性协议 来保证的。该协议用于维护多个处理器(CPU)与主内存之间数据的一致性,从而在操作系统层面保证线程间的数据更新是可见的。
MESI协议主要通过两个关键机制来保证数据一致性:flush(刷新)和 refresh(重载)。

-
flush(刷新)
当处理器更新了某个变量,它首先会将新值刷新到自己的高速缓存。同时,它会向其他处理器发送一个 flush 消息,通知它们将该变量对应的缓存行标记为 无效(Invalid)。这确保了其他处理器后续不会读到该变量的过时版本。
-
refresh(重载)
当一个处理器中的线程需要读取某个变量时,如果发现该变量在其他处理器的高速缓存中已被更新(即自己的缓存行是无效状态),它必须从其他处理器的高速缓存或主内存中读取这个最新值,并更新到自己的高速缓存里。
从硬件到软件:内存屏障的作用
硬件层面的MESI协议是基础,但在上层编程语言(如Java)中,可见性是通过 内存屏障(Memory Barrier) 来保证的。内存屏障可以理解为一条指令,它会强制处理器执行 flush 和 refresh 操作,从而确保屏障前后的内存访问顺序和一致性。
synchronized 如何保证可见性?
synchronized 保证可见性的核心,正是通过编译器在指令中插入 内存屏障 来实现的。
当一个线程进入 synchronized 代码块时,会插入特定的内存屏障,强制线程执行 refresh 操作,从而确保它能读取到所有共享变量的最新值。而当线程退出 synchronized 代码块时,也会插入内存屏障,强制执行 flush 操作,确保它对共享变量的所有修改都能被刷新回主内存,对其他线程可见。
下面是一个示例,展示了在 synchronized 代码块前后插入内存屏障的示意:
int b = 0;
int c = 0;
synchronized (this) { // --> monitorenter
// --> Load 内存屏障
// --> Acquire 内存屏障
int a = b; // 读取操作
c = 1; // 写入操作
// --> Release 内存屏障
} // --> monitorexit
// --> Store 内存屏障
- Acquire 屏障(LoadLoad + LoadStore组合):确保一个线程在屏障之后的所有读写操作执行前,一定能看到其他线程在屏障之前的所有写操作结果。这对应了“进入时刷新”的语义。
- Release 屏障(LoadStore + StoreStore组合):确保一个线程在屏障之前的所有写操作完成后,其结果才能被其他线程在屏障之后看到。这对应了“退出时刷新”的语义。
扩展:JMM中的四种内存屏障
Java内存模型(JMM)定义了四种基本的内存屏障,了解它们有助于深入理解同步机制。记住,Load 代表从主内存读取数据,Store 代表将数据写回主内存。
- LoadLoad屏障:确保该屏障之前的
Load 操作,先于屏障之后的所有 Load 操作完成。对 Store 操作无约束。
- StoreStore屏障:确保该屏障之前的
Store 操作,先于屏障之后的所有 Store 操作完成。对 Load 操作无约束。
- LoadStore屏障:确保屏障之前的所有
Load 操作完成之后,才执行屏障之后的所有 Store 操作。
- StoreLoad屏障:这是一个全能型屏障。它确保屏障之前的所有内存访问(读和写)都完成之后,才执行屏障之后的内存访问。它能防止所有类型的指令重排序。
synchronized 使用的 Acquire 和 Release 屏障,正是上述基本屏障的组合。理解内存屏障,是深入理解 synchronized、volatile 等并发关键字背后原理的关键。如果你希望系统性地构建自己的Java并发知识体系,或者深入探讨其他计算机基础原理,可以到云栈社区与更多开发者交流学习。
|