首页/新闻资讯/正文详情

深入理解Java并发三大特性:原子性、可见性、有序性

发布时间:2026/9/24 22:25:39 来源:云帆数科 栏目:资讯中心
深入理解Java并发三大特性:原子性、可见性、有序性
1. 并发Bug为什么难排查先从内存模型说起做Java开发的早晚都会遇到并发问题。但我发现一个很有意思的现象很多写了三五年CRUD的工程师遇到线上偶发的数据错乱、死循环、查询结果不一致第一反应是服务器有问题数据库有问题很少有人会想到问题可能出在Java内存模型上。我刚工作那会儿也一样。有一次做一个库存扣减的接口单机部署一切正常上线后两台实例一起跑很快就出现超卖。当时的我很懵代码明明加了synchronized为什么还会超卖后来排查了整整一天才发现问题根本不在代码块上而在并发三大特性的理解上——我把可见性当成了原子性来用。所以这篇我把并发编程最底层的三件事一次讲透原子性、可见性、有序性。这三件事不搞懂synchronized、volatile、Lock这些工具你用起来始终是知其然不知其所以然面试被追问两句就露馅线上出了诡异bug也没法定位。先说这三件事是怎么来的。它们全部源自一个事实CPU处理数据的速度远快于内存读写速度。为了解决速度不匹配硬件和编译器搞了两件事一是在CPU和内存之间加了一层或多层高速缓存Cache二是允许指令重排序。这两件事极大提升了运行效率也把变量在不同地方读到的值可能不一样代码执行顺序可能和你写的不一样这两个并发隐患引了进来。Java为了屏蔽不同硬件平台的差异定义了一套自己的规范就是Java内存模型简称JMM。JMM规定所有变量存在主内存对应物理内存的概念每个线程有自己的工作内存对应寄存器或缓存的抽象概念线程不能直接操作主内存只能先读入工作内存、操作完再写回。这套抽象模型是所有并发知识的地基。有了这个地基三大特性的问题就变得具体了多个线程同时对同一个变量做读-改-写就涉及原子性不同线程的工作内存和主内存之间的同步时机就涉及可见性编译器和CPU对代码执行顺序的调整就涉及有序性。下面逐个拆。2. 原子性一个操作不可分割不只是加锁这么简单原子性的定义很好说一个或多个操作在CPU执行过程中不被中断的特性。但不被中断这个词很容易误导人初学者总觉得我写的一行代码就是原子操作这是最典型的认知误区。2.1 i为什么不是原子操作先看最经典的例子public class AtomicityDemo { private static int count 0; public static void main(String[] args) throws InterruptedException { Thread[] threads new Thread[100]; for (int i 0; i 100; i) { threads[i] new Thread(() - { for (int j 0; j 10000; j) { count; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(count count); } }这段代码开100个线程每个线程对count自增10000次。你直觉上觉得结果应该是1000000但跑出来的结果几乎每次都不一样通常是七十万、八十万左右。原因很简单count在字节码层面至少拆成三条指令——先读取count的当前值然后加1最后写回。三个线程完全可能同时读到count100各自加1再各自写回结果是101而不是103。这就是典型的读-改-写非原子操作。这里要注意一个区分赋值一个int变量的操作本身是原子的JVM规范保证了基本类型赋值和引用赋值的原子性但读取-计算-写回这个复合流程不是。很多人面试时被问i是不是线程安全的答不是就完了但面试官往往接着追问那把这个变量声明成volatile呢还安全吗——答案依然不安全因为volatile管的是可见性管不了原子性。2.2 解决原子性的三种工具怎么选保证原子性Java里可以用synchronized、Lock接口体系以及java.util.concurrent.atomic包下的Atomic系列类。synchronized是最朴素的方案它通过监视器锁Monitor保证同一时刻只有一个线程能进入临界区public synchronized void increment() { count; }JVM对synchronized的优化这些年做了很多从偏向锁到轻量级锁再到重量级锁的升级路径锁的粒度越收越细。早期版本中synchronized性能确实不如Lock但从JDK 6之后两者差距已经很小能优先用synchronized就优先用代码更简洁、不容易出错。Lock接口常用实现是ReentrantLock的优势在于支持尝试获取锁、超时获取锁、可中断获取锁以及多个条件变量。这些能力在复杂并发场景下有用但99%的业务代码用不到不必为了看起来高级而滥用。Atomic系列类走的是另一条路——无锁方案。底层依赖CASCompare And Swap操作。CAS的思路是比较当前内存值是否等于我预期的值如果是就更新为新值如果不是说明被别人改过了就重试。private static AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); }CAS适合竞争不激烈的场景因为竞争激烈时重试次数多CPU开销反而大。另外Atomic类无法解决包含多个独立变量更新的复合操作问题比如先扣款再减库存这种需要整体原子性的业务操作还是得加锁。2.3 一个常被忽略的点long和double的原子性问题JMM规范里有一条特殊说明对于64位的long和double类型如果未声明为volatileJVM允许把它的读写拆成两个32位操作执行。也就是说理论上一个线程可能在写入long变量时写了高32位、还没写低32位另一个线程就读到了这个写了一半的中间值。不过在主流64位JVM中long和double的读写实际都是原子的。只有早期32位JVM才容易出现这个问题。但面试官问到的时候你要能说出这条规范以及主流平台的实际情况这就比只背概念的人高一个层次。3. 可见性变量在另一个线程修改了为什么我这里看不到3.1 从一段永远停不下来的代码说起这个问题我用一个实际场景展开。先看这段代码很多面试八股文里都有类似例子public class VisibilityDemo { private static boolean flag true; public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (flag) { // 循环等待 } System.out.println(worker线程退出); }); worker.start(); Thread.sleep(1000); flag false; System.out.println(主线程已修改flag为false); } }你猜这段代码的输出是什么很多人在自己电脑上跑发现worker线程根本不会退出程序一直在运行。这背后的原因就是可见性问题主线程改了flag的值但worker线程的工作内存里还缓存着flag的旧值它看不到主线程的修改。为什么会出现这种情况JMM规定线程修改变量时不直接改主内存而是先改工作内存再由某个时机写回主内存。这个时机不受你控制。worker线程在循环里每次都从工作内存读flag读到的始终是旧值于是死循环。3.2 volatile的内存语义不仅仅是强制读主内存解决可见性最直接的工具是volatile。很多人对volatile的理解停留在加了volatile就去主内存读取这个说法不完全准确但用来理解可见性足够了。准确地说volatile做了三件事写volatile变量时JVM会把当前线程工作内存中该变量的值强制刷新到主内存读volatile变量时JVM会使当前线程工作内存中该变量的缓存失效必须从主内存重新读取写volatile变量时JMM还会通过内存屏障Memory Barrier禁止相关指令重排这也是volatile能保证禁止指令重排的关键机制。所以对于上面的死循环代码只要把flag声明为volatileworker线程立刻就能感知到主线程的修改循环退出private static volatile boolean flag true;但要强调volatile解决了可见性但没有解决原子性。回到前面的count例子即便count声明为volatile多线程自增依然会丢数据。因为volatile不管读取-计算-写回这三步的中间过程。这也是面试中最常见的连环坑之一。3.3 synchronized怎么保证可见性很多初学者以为synchronized只保证原子性这是另一个误区。synchronized同样保证可见性只是它的实现路径不同线程释放锁时会把工作内存中的共享变量刷新回主内存线程获取锁时会使工作内存中的相关缓存失效、从主内存重新加载。所以synchronized能同时保证进入临界区的线程看到的都是最新值。举个实际例子前面库存扣减的问题如果只用synchronized修饰了扣减方法但另一个查询库存的方法没有加锁就会存在扣减线程改了库存、查询线程看不到的情况。解决方式是查询路径也加同一把锁或者用volatile修饰库存变量前提是库存操作本身能原子化。3.4 可见性问题在生产中的经典表现在这几年的实际项目中可见性问题的经典场景有这么几类状态开关变量如服务降级开关、特征开关被多个线程读取但更新后其他线程感知不到——解决方式是volatile懒加载的单例对象多线程下拿到半初始化的实例——这同时涉及可见性和有序性下面详细展开日志开关、配置刷新场景配置中心下发新值后业务线程仍然用旧值——配置类属性一般直接做成volatile或者用AtomicReference持有配置对象。排查这类问题有个技巧尽量不要迷信本地复现。可见性问题跟CPU型号、JVM版本、线程调度时机强相关本地机器可能怎么跑都正常线上压测才出现。可以用jstack抓线程栈看线程当前停在哪里结合代码分析是否存在共享变量的读写路径。4. 有序性代码不一定按你写的顺序执行4.1 指令重排到底是什么有序性指的是程序执行的顺序按照代码的先后顺序执行。但在JMM中为了性能编译器和CPU允许对指令进行重排序。重排序有个铁律在单线程内重排序不能改变程序的运行结果但在多线程环境下一个线程的优化可能会对另一个线程产生意想不到的影响。指令重排有三个层面编译器优化重排Java编译器在不改变单线程语义的前提下调整语句执行顺序指令级并行重排现代CPU采用流水线技术多条指令重叠执行处理器会调整指令执行顺序内存系统重排由于CPU Cache的存在加载和存储操作看起来可能在乱序执行。这三个层面叠加问题就变得很隐蔽了。4.2 双重检查锁定的隐患来看一个非常著名的有序性问题——双重检查锁定Double-Checked LockingDCL单例public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); } } } return instance; } }这段代码初看没有问题第一次检查避免重复加锁第二次检查避免重复创建实例。但实际上它有一个严重隐患。instance new Singleton()这行代码不是原子操作在字节码层面主要做了三件事分配内存空间在内存空间上初始化Singleton对象构造方法执行将instance引用指向这块内存。问题在于步骤2和步骤3可能被重排。如果线程A先执行了步骤3instance已经不再为null但对象还没完成构造。此时线程B第一次检查看到instance不为null直接return instance取到的就是一个半初始化的对象。这时候如果这个对象的某个字段还没赋值B线程拿去用就会出现空指针或读到默认值0的诡异问题。解决方案有两个方向。一个是给instance加上volatile修饰利用volatile禁止重排序的内存语义保证引用赋值一定发生在对象构造完成之后private static volatile Singleton instance;这也是业内目前普遍推荐的做法。另一个方案是使用静态内部类Initialization-on-demand holder idiom利用JVM的类加载机制天然保证线程安全public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }JVM在类初始化阶段会加锁保证一个类的初始化在多线程环境下只执行一次而且其他线程会阻塞等待初始化完成。这个方案既延迟加载又不需要额外操心可见性和有序性是更优雅的选择。4.3 volatile如何禁止重排内存屏障机制volatile禁止重排的核心是内存屏障。JMM在volatile读写操作的前后插入了内存屏障规则在每个volatile写操作的前面插入StoreStore屏障禁止上面的普通写操作与下面的volatile写操作重排在每个volatile写操作的后面插入StoreLoad屏障禁止上面的volatile写操作与下面可能有的volatile读/写操作重排在每个volatile读操作的后面插入LoadLoad屏障和LoadStore屏障禁止上面的volatile读操作与下面的普通读/写操作重排。这里不需要死记硬背屏障名称关键是理解它的作用屏障两边的指令不能越过屏障交换顺序。打个比方内存屏障就像快递分拣中心的一条传送带包裹有严格的先后顺序前面的包裹不能被后面的包裹插队。在一个更宏观的层面上JMM把内存屏障按读-读、读-写、写-读、写-写四类来管理结合volatile和锁形成了一套有序性保证机制。日常写代码的时候不需要手动插入屏障但要知道volatile的约束边界在哪里volatile只能保证volatile变量本身的读写不会被重排序不能保证它周边所有操作的有序性。5. 从Happens-Before规则看三大特性的统一逻辑讲到这里三大特性看起来是三个独立的话题实际上它们都统一在Happens-Before规则之下。Happens-Before是JMM定义的一套偏序关系用来描述两个操作之间的内存可见性。如果操作A Happens-Before操作B那么A的执行结果对B是可见的且A的执行顺序排在B之前。JMM规定的Happens-Before规则主要有这么几条程序次序规则同一个线程中按代码顺序前面的操作Happens-Before后面的操作监视器锁规则对一个锁的解锁Happens-Before于后续对这个锁的加锁volatile变量规则对一个volatile变量的写Happens-Before于后续对这个volatile变量的读传递性规则如果A Happens-Before B且B Happens-Before C那么A Happens-Before C线程启动规则Thread.start()方法调用Happens-Before于该线程中的每一个动作线程终止规则线程中的所有操作Happens-Before于其他线程检测到该线程终止中断规则调用线程interrupt()方法Happens-Before于被中断线程检测到中断事件的发生。为什么要了解这些规则因为它们把何时能保证可见性和有序性这件事变成了可推导的逻辑。举个例子你写了一段代码想知道线程B修改的变量在线程A中能不能看到不需要猜只需要看两条操作之间是否满足某条Happens-Before规则。把Happens-Before和三大特性对照起来看逻辑就很清晰了。synchronized的加锁解锁通过监视器锁规则保证临界区的原子性和可见性同时通过加锁和解锁的顺序保证临界区代码的有序性。volatile通过volatile变量规则保证可见性通过内存屏障保证有序性但因为没有锁的存在无法保证复合操作的原子性。final关键字也有自己的语义在构造方法中写入final字段在构造方法外读取这个字段JMM保证之间有一次冻结操作所以final字段在正确发布的场景下天然具备可见性。这也就解释了为什么DCL单例必须加volatile才能彻底安全volatile给instance的赋值操作和对象的构造操作之间建立了一种屏障利用volatile变量规则确保读线程看到的instance要么是null要么是构造完全完成的对象不会看到中间状态的半成品。实际排查并发问题的时候Happens-Before规则的工程价值在于你可以快速判断某个变量到底需不需要加volatile、需不需要加锁。判断标准就是——两个线程访问同一个共享变量如果它们之间本来就有Happens-Before关系比如通过某个锁串联、通过start/join串联、通过volatile写读串联就不需要额外同步如果没有就需要显式加工具建立同步关系。学会了这一招你的并发代码设计思路会清晰很多不再是出了问题加锁的碰运气式编程。6. 面试怎么答才能让面试官觉得你真的懂最后聊聊面试。聊到Java多线程三大特性最怕的就是背书式回答——原子性就是操作不可分割可见性就是多线程访问同一变量时一个线程修改其他线程能立即看到有序性就是代码按顺序执行。这种答案背得再流畅面试官追问两三句就穿帮了。真正有深度的回答应该在哪几个细节上做出差异化第一一定要点出三大特性的由来是CPU缓存和指令重排。这能直接证明你不是死记硬背而是理解了为什么会有这些问题。可以顺带把JMM的主内存-工作内存模型画出来面试时用手画说清楚线程修改变量的完整路径。第二要能说出volatile和synchronized的边界差异。很多面试官的经典问法是volatile能保证原子性吗synchronized能保证可见性吗你不仅要回答能或不能还要给出场景——比如volatile修饰的状态开关为什么够用但volatile修饰计数器为什么不够用synchronized为什么能同时管住三件事。第三要能结合经典案例讲。DCL单例为什么需要volatile这里既能聊有序性指令重排导致半初始化对象又能聊可见性volatile的写读语义是性价比极高的案例。建议准备的时候把字节码层面的instance初始化过程也了解一下这样讲出来会非常有说服力。第四快速说出Happens-Before规则的本质以及如何用它推导同步需求。能讲清楚这一层说明你已经把三大特性从需要记的知识点升级成了可以推导的思维框架这是高级工程师和平庸工程师最明显的分水岭。把三大特性真正吃透之后你会发现后面学AQS、ConcurrentHashMap、ThreadLocal都会顺畅很多因为它们本质上都是在解决如何用最小的代价保证原子性、可见性、有序性这个问题。并发编程没有银弹所有工具都是在这三个约束条件下做取舍理解了这一点看源码时就不再是走马观花而是能看懂设计者为什么选择这个方案、放弃哪个特性。这可能才是学三大特性真正的价值所在。

相关推荐

谷物害虫目标检测数据集实践:清洗、标注转换与YOLOv8训练
谷物害虫目标检测数据集实践:清洗、标注转换与YOLOv8训练

简介:这是一份面向农业人工智能与粮食仓储管理的谷物害虫目标检测数据集,适合目标检测算法研究者、农业监测系统开发者以及相关专业学生使用。数据集聚焦单一“害虫”类别,收录687张真实谷物环境下的高清图片,每张均带有精确的YOL… · 2026/9/24 22:25:33

什么是 BarTender 开发调用?
什么是 BarTender 开发调用?

经常收到客户咨询:什么是BarTender开发调用?今天就用通俗易懂的内容统一为大家讲解清楚!Q什么是开发调用?简单来说就是把公司内部管理系统和BarTender打印软件连在一起。平时用来管生产、管仓库、管订单的系统(ERP/MES… · 2026/9/24 22:25:33

基于YOLOv5的口罩检测:从数据集制作到模型部署全流程
基于YOLOv5的口罩检测:从数据集制作到模型部署全流程

简介:基于YOLOV5的口罩检测项目资料包,面向计算机相关专业毕业设计、课程设计与期末大作业场景,提供从数据集、标注文件到训练模型与完整代码的一站式方案。项目经导师指导并以98分评审通过,适合需要快速搭建检测系统、完成论文实… · 2026/9/24 22:25:33

深度学习新闻分类推荐系统:从TextCNN到个性化推荐
深度学习新闻分类推荐系统:从TextCNN到个性化推荐

简介:这份基于深度学习的新闻分类推荐系统Python实现源码,是专为课程设计与期末大作业准备的高分项目,下载后无需修改即可运行,适用于需要快速交付完整课题的高校学生。系统涵盖新闻数据预处理、文本分类模型训练、推荐逻辑展示等… · 2026/9/24 23:59:53

汽车电子底层软件开发:AUTOSAR与CAN总线实战解析
汽车电子底层软件开发:AUTOSAR与CAN总线实战解析

1. 这门“汽车电子底层软件开发就业课”到底在教什么?——不是写个LED闪烁就能上岗的很多人看到“汽车电子底层软件开发就业课”这个标题,第一反应是:不就是嵌入式C语言单片机CAN通信?刷几道LeetCode、调通一个STM32 CAN收发例程&… · 2026/9/24 23:59:53

Vim基础操作全攻略:保存退出、模式切换与高频命令实战
Vim基础操作全攻略:保存退出、模式切换与高频命令实战

1. 项目概述1.1 核心需求解析今天聊聊Vim。写这个题目的原因是:几乎每个后端开发者、运维人员、数据工程师某天都会遇到一个场景——深夜加班,服务器登录界面只有黑底白字,编辑器只有vi/vim,你必须在五分钟内完成一次配置修改并保… · 2026/9/24 23:59:53

Python+CNN车牌识别实战:从数据预处理到模型训练与部署
Python+CNN车牌识别实战:从数据预处理到模型训练与部署

简介:基于Python与卷积神经网络的车牌识别项目,面向计算机视觉初学者及智能交通开发者,目标是帮助用户掌握从数据预处理、模型构建到实际部署的完整流程。压缩包共25个文件,包含jpg/png图像样本、py训练脚本、md说明文档、dat数据… · 2026/9/24 23:59:53

AI元人文:从工具使用到思维重构的深度探索
AI元人文:从工具使用到思维重构的深度探索

最近半年我一直在琢磨一件事:AI元人文到底是什么?说白了,就是“用元视角重新审视人与AI的关系”,也在“探索AI如何反向逼着我们发现自己的思考边界”。标题里的“元探索”,在我看就是一层套一层的追问——当你用AI解决… · 2026/9/24 23:59:53

《AI Agent 场景应用 - MobileOpenClaw》第5-9节:会话上下文细化处理实战指南
《AI Agent 场景应用 - MobileOpenClaw》第5-9节:会话上下文细化处理实战指南

文档教程后端 【免费下载链接】CodeGuide :books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总,旨在为大家提供一个清晰详细的学习教程,侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助,请给予支持(关注、… · 2026/9/24 23:59:47

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码