Java 共享变量的内存可见性问题

Java 共享变量的内存可见性问题

Java 共享变量的内存可见性问题

共享变量的内存可见性是多线程编程场景下的核心内存一致性问题,具体指:当一个线程对存储在主内存中的共享变量执行写操作后,该修改能否被其他访问此变量的线程及时、准确地感知到 ------ 若其他线程无法立即读取到最新值,仍获取到修改前的过期值,则称存在内存可见性问题;反之,若修改能被所有线程实时感知,则称该变量具备内存可见性。

为什么会出现共享变量的内存可见性问题

内存可见性问题的产生核心原因包括Java 内存模型的抽象设计(理论基础)和 CPU 的高速缓存性能优化机制(硬件落地支撑),前者定义了线程操作共享变量的规则边界,后者放大了数据不一致的问题,两者共同导致了可见性问题的出现。

一、理论基础:Java 内存模型的抽象设计

Java 内存模型(Java Memory Model,JMM)是一套抽象的内存访问规范,并非实际存在的内存区域。它的核心目的是屏蔽不同硬件、操作系统的内存访问差异,保证 Java 程序在不同平台上具有一致的内存行为,同时为多线程并发操作提供基础的内存语义约束。

JMM 的核心抽象是将内存划分为主内存和工作内存,两者的职责、存储内容和交互规则,是可见性问题产生的根本理论根源。

主内存(Main Memory):所有线程的共享数据存储区

  • 内存区域:包括 JVM 堆内存和方法区;
  • 存储内容:所有共享变量均存储在主内存中,具体包括类的实例变量、静态变量、数组元素;
  • 核心特性:全局共享、线程可访问,但线程无法直接操作主内存中的共享变量,所有对共享变量的操作必须通过工作内存中转完成。

工作内存(Working Memory):单个线程的私有操作区

  • 存储内容:线程私有数据以及该线程需要操作的共享变量副本(从主内存中加载的快照);
  • 对应 JVM 实际内存区域:工作内存对应 JVM 虚拟机栈和程序计数器,其中共享变量副本的存储逻辑会映射到 CPU 缓存 / 寄存器;
  • 核心特性与规则:
    私有性:每个线程都有独立的工作内存,其他线程无法直接访问或修改当前线程工作内存中的数据;
    操作独占性:线程对共享变量的所有读写操作,必须在自身的工作内存中进行,禁止直接操作主内存的共享变量;
    中转通信性:线程间的共享变量值传递,必须通过主内存完成中转;
    私有变量无可见性问题:方法内的局部变量、方法参数、异常参数等,仅存储在当前线程的虚拟机栈中,不进入主内存,也不存在跨线程共享的属性,因此不会产生内存可见性问题。

JMM 明确了线程操作共享变量的两个核心步骤,且对写回主内存的时机未做强制即时约束,这是可见性问题的关键:

  • 读取共享变量:线程在操作共享变量前,必须先从主内存中将该变量的最新值加载到自身工作内存,创建一份私有副本,后续所有操作均基于该副本,不再主动同步主内存的变化(除非触发重新加载)。
  • 修改共享变量:线程修改工作内存中的共享变量副本后,仅会先更新该副本的值,再在某个不确定的时机将修改后的副本写回主内存,更新主内存中的原始值。

这里的核心关键点是 JMM 未要求线程修改共享变量副本后立即写回主内存,这就导致了两个层面的数据不一致:

主内存中的共享变量原始值,与修改线程工作内存中的副本值不一致(未及时写回);

其他线程工作内存中的共享变量副本,与主内存中的最新值不一致(未及时重新加载);

最终表现为当一个线程修改了共享变量,其他线程无法及时感知到该修改,依然读取到旧值,即出现内存可见性问题。

二、硬件支撑:CPU 高速缓存优化(可见性问题的实际落地与放大)

JMM 中的工作内存是抽象概念,其实际硬件载体是 CPU 的高速缓存和寄存器。现代 CPU 为了弥补主内存读写速度与 CPU 运算速度的巨大差距,引入了高速缓存优化机制,这一机制既是 JMM 工作内存的落地实现,也进一步放大了内存可见性问题。

高速缓存的分级结构与 JMM 的映射关系

如图所示,典型的多核 CPU 架构中,高速缓存分为 3 级(L1、L2、L3),各级缓存的性能、容量和共享范围存在明确差异,且与 JMM 的内存模型形成精准映射:

缓存级别 所属范围 访问速度 存储容量 与 JMM 的映射关系
L1 缓存 单个 CPU 核心私有 最快 最小(几 KB 到几十 KB) 主要映射 JMM 工作内存,存储线程频繁操作的共享变量副本
L2 缓存 单个 CPU 核心私有 次快 中等(几十 KB 到几百 KB) 辅助映射 JMM 工作内存,作为 L1 缓存与 L3 缓存的缓冲
L3 缓存 所有 CPU 核心共享 较慢 较大(几 MB 到几十 MB) 介于主内存与 L2 缓存之间,减少多核核心对主内存的直接访问

高速缓存机制如何放大内存可见性问题:

  • 优先读写缓存,弱化主内存交互:CPU 执行线程指令时,会优先从 L1→L2→L3 缓存中查找所需数据,只有在缓存未命中时,才会从主内存中加载数据并写入缓存;同理,CPU 修改缓存中的数据后,不会立即将其同步回主内存;
  • 缓存同步的触发条件苛刻:缓存中的数据只有在满足特定条件时,才会被同步回主内存,常见触发条件包括:对应缓存行被淘汰(缓存空间不足,按照 LRU 等算法淘汰不常用数据),触发缓存一致性协议的同步规则,CPU 执行内存屏障指令;
  • 多核架构下的缓存孤岛问题:在多核 CPU 架构中,每个核心都有私有 L1/L2 缓存。当多个线程分别运行在不同核心上,且操作同一个共享变量时,该变量会被加载到多个核心的私有缓存中,形成缓存孤岛。其中一个核心修改了缓存中的变量值并同步回主内存后,其他核心的私有缓存中依然保留着旧值,且不会主动刷新,直到该核心的缓存行被淘汰或触发同步规则,这就进一步加剧了数据不一致的问题。

补充:缓存一致性协议的局限性

为了解决多核缓存的孤岛问题,硬件层面引入了缓存一致性协议,其核心作用是:当某个核心修改了缓存中的共享变量值并同步回主内存时,会通知其他核心失效对应缓存行中的旧值,迫使其他核心重新从主内存加载最新值。

但需要注意的是,缓存一致性协议仅能解决缓存与主内存的数据同步问题,无法解决 JMM 层面的延迟写回问题等。因此,即使有缓存一致性协议,依然无法从根本上消除内存可见性问题,还需要依赖 Java 层面的关键字或同步机制来补充约束。

代码示例:

java 复制代码
public class Test {

    public static Boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        ThreadA threadA = new ThreadA();
        ThreadB threadB = new ThreadB();

        new Thread(threadA, "threadA").start();
        Thread.sleep(1000l);//为了保证threadA比threadB先启动
        new Thread(threadB, "threadB").start();
    }

    static class ThreadA implements Runnable {
        public void run() {
            while (true) {
                if (flag) {
                    System.out.println(Thread.currentThread().getName() + " : flag is " + flag);
                    break;
                }

            }
        }
    }

    static class ThreadB implements Runnable {
        public void run() {
            flag = true;
            System.out.println(Thread.currentThread().getName() + " : flag is " + flag);
        }
    }
}

线程 B 成功将flag修改为true并打印日志,但线程 A 会无限循环,永远不会停止,也不会打印自身的日志。

这段代码出现了内存一致性问题。代码中定义了静态共享变量flag,线程 A 先启动并进入无限循环,持续检测flag是否为true,线程 B 延迟 1 秒启动后将flag修改为true并打印日志,但运行后会发现,线程 B 能正常完成修改和日志打印,而线程 A 会一直无限循环无法停止,也不会打印自身的日志,这就是内存一致性问题的直观体现;底层原因是按照 Java 内存模型的规定,线程 A 启动后会从主内存加载flag的初始值false到自身私有工作内存,之后的循环中仅读取工作内存中的该旧副本,不会主动去主内存刷新最新值,而线程 B 修改flag为true后,虽会将修改后的值写回主内存,但该修改结果对线程 A 的工作内存是不可见的,线程 A 无法感知到flag已被修改为true,依然持续读取工作内存中的旧值false,这种因共享变量修改无法被其他线程及时感知、导致多线程数据不一致的情况,就是典型的内存一致性问题中的内存可见性问题。

如何解决共享变量的内存可见性问题

synchronized和volatile都可以解决上面代码的内存可见性问题,但两者的解决原理、实现方式和轻量化程度有显著区别:

  • volatile是专门针对可见性问题的轻量化解决方案,直接修饰共享变量flag,通过 CPU 内存屏障指令强制修改后的值立即刷新回主内存,并通知其他线程失效旧副本,同时强制读取时从主内存加载最新值,以此保证可见性,修改方式简单且无额外锁开销,非常贴合这段代码仅需解决可见性的场景;
  • 而synchronized是通过锁机制实现的重量级解决方案,需要将线程 A 中读取flag的判断逻辑、线程 B 中修改flag的逻辑都包裹在同一个共享锁对象的同步代码块中,利用线程获取锁时清空工作内存旧副本、从主内存加载最新值,释放锁时将修改后的值强制写回主内存的附带内存语义,间接保证可见性,同时synchronized还能保证原子性和有序性,但对于这段仅需解决可见性的简单场景来说,存在功能冗余且并发性能略低于volatile。

这两个 JVM 关键字会在我后面的博客里详细介绍。

相关推荐
会游泳的石头2 小时前
深入剖析 Java 长连接:SSE 与 WebSocket 的实战陷阱与优化策略
java·开发语言·websocket
yutian06062 小时前
TI-C2000 系列 TMS320F2837X 控制律加速器(CLA)应用
开发语言·ti·ti c2000
夕阳之后的黑夜2 小时前
Python脚本:为PDF批量添加水印
开发语言·python·pdf
lllljz2 小时前
blenderGIS出现too large extent错误
java·服务器·前端
女王大人万岁2 小时前
Go标准库 path 详解
服务器·开发语言·后端·golang
qq_12498707532 小时前
基于spring boot的调查问卷系统的设计与实现(源码+论文+部署+安装)
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
一路往蓝-Anbo2 小时前
第 2 篇:单例模式 (Singleton) 与 懒汉式硬件初始化
开发语言·数据结构·stm32·单片机·嵌入式硬件·链表·单例模式
321.。2 小时前
从 0 到 1 实现 Linux 下的线程安全阻塞队列:基于 RAII 与条件变量
linux·开发语言·c++·学习·中间件
疯狂的喵2 小时前
实时信号处理库
开发语言·c++·算法