Java并发安全:看这一篇就懂了!

前言:并发编程到底在解决什么问题?

编写正确的并发程序,本质上只做两件事:

  1. 保护共享数据的一致性------无论多少线程同时操作,数据始终正确。
  2. 协调线程的执行节奏------该等待的等待,该通知的通知,该限流的限流。

Java的并发工具虽然琳琅满目,但追根溯源,它们都在解决上述两个问题。只不过解决方式各有侧重:有的靠"堵"(互斥锁),有的靠"让"(自旋+重试),有的靠"管"(信号量限制并发数),有的靠"调度"(等待/通知机制)。

本文按照 "从问题到方案" 的逻辑展开,不堆砌API,只讲清每样工具为什么被发明出来解决了什么问题在什么场景下用


第一部分:问题的根源------数据为什么会被弄乱?

一切并发安全问题的起点,都指向多个线程同时访问同一份共享数据。但"同时"本身不是问题,"乱"才是。数据是怎么乱的?

假设你有一份银行账户余额,两个线程同时执行"取100元"的操作。流程无非三步:

  1. 从主内存读取当前余额到工作内存。
  2. 在工作内存中计算"余额 - 100"。
  3. 把新余额写回主内存。

如果两个线程完美地串行执行,一切正常。但现实中,这三步可能被穿插执行

  • 线程A读到余额1000。
  • 线程B也读到余额1000(A还没写回去)。
  • 线程A计算并写回900。
  • 线程B计算并写回900。

结果是:取了两次钱,余额只减了100。 数据丢了,乱了。

这个例子暴露了三个层次的问题:

一、原子性被破坏

"读-改-写"是一组不可分割的动作,但操作系统的时间片切换可以让它中间被打断。这就是原子性缺失------本该整体执行的操作被拆散了。

二、可见性失效

线程A把900写回主内存后,线程B可能还在用自己工作内存里缓存的1000。一个线程的修改,另一个线程看不见。这就是可见性问题。

三、指令重排序的干扰

编译器和CPU为了优化,可能把代码的顺序悄悄调换。在单线程环境下无关紧要,但多线程下可能产生诡异的结果------比如你明明先赋值了flag,后赋值了data,但另一个线程看到的却是data还没赋值、flag已经变了。


小结一下 :并发问题的根本原因,是CPU的缓存机制、时间片切换和指令重排这三件事,共同导致了原子性、可见性、有序性的缺失。而Java并发工具的所有设计,都围绕如何恢复这三性展开。


第二部分:如何恢复三大特性?

既然问题的根源在硬件和操作系统层面,Java不可能去修改底层。于是Java设计了一套 "约定" ------Java内存模型(JMM),并提供了多种工具来落实这套约定。

2.1 volatile:最轻量级的约定落实者

volatile 能干什么?

  • 每次读都从主内存拿,每次写都立刻刷回主内存------保证可见性
  • 禁止编译器对相关指令进行重排序------保证有序性

但它保证不了原子性volatile 只确保"读一个变量"这个动作本身是原子的,或者"写一个变量"是原子的。但对于 count++ 这种"读→改→写"三步曲,它管不了中间被打断。

所以 volatile 只适合一种场景 :一个线程写、多个线程读的状态标志。比如 volatile boolean shutdown = false,写线程把它改为true,所有读线程立刻感知。

2.2 synchronized:通用且自动的互斥锁

如果 volatile 解决不了原子性,那就用锁。synchronized 是Java最基础的锁机制,它能同时保证三性:

  • 原子性:锁住的代码块,同一时刻只有一个线程能执行,天然不可分割。
  • 可见性:解锁前强制把工作内存刷新到主内存,加锁时强制从主内存读------保证了修改对其他线程可见。
  • 有序性:锁内的代码不会被重排到锁外,锁外的也不会被重排进锁内。

它的底层怎么实现的?

每个Java对象都关联着一个Monitor(监视器) 。当线程进入 synchronized 块时,它要尝试获得该对象Monitor的所有权。成功则进入,失败则被挂起,进入_EntryList等待队列,等待操作系统唤醒。

但这里有个历史包袱:早期的synchronized太重了

为什么说它"重"?因为线程挂起和唤醒涉及操作系统层面的调度,要把线程从用户态切换到内核态,开销很大。所以早期Java里 synchronized 被诟病为"重量级锁"。

JDK 6 做了关键优化:锁升级。

对象头的Mark Word里记录了锁的状态。刚开始没有竞争时,锁处于"偏向锁"状态------Mark Word里直接记录当前线程ID,该线程再次进入时连CAS都不用做,直接放行。有第二个线程来竞争时,升级为"轻量级锁"------通过CAS自旋抢锁,不挂起线程。只有自旋超过一定次数或竞争线程太多时,才升级为"重量级锁",进入操作系统级的阻塞等待。

这个优化让 synchronized 在大多数低竞争场景下几乎零开销。 所以今天如果你问我"synchronized还能用吗",答案是:能用,且应该优先用,除非你有特殊需求。

synchronized 的局限

  • 锁不可中断:线程拿不到锁就死等,不能超时放弃,也不能响应中断。
  • 只有一个条件队列:wait()notify() 只能管一个等待集合,无法精确控制"队列满时唤醒生产者"和"队列空时唤醒消费者"分别处理。
  • 非公平:后来的线程可能插队先拿到锁(虽然公平锁在某些场景也不一定是好事)。

2.3 ReentrantLock:当synchronized不够用时

ReentrantLocksynchronized 的基础上,补充了三个关键能力:

  1. 可中断lockInterruptibly() 让线程在等待锁的过程中可以被外部打断,避免无限阻塞。
  2. 可超时tryLock(timeout) 尝试获取锁,拿不到就放弃,避免死锁僵局。
  3. 多个条件队列newCondition() 可以创建多个等待条件,精准控制哪些线程被唤醒。

它的底层是AQS

ReentrantLock 依赖的AQS(AbstractQueuedSynchronizer)本质上是一个 "状态 + 等待队列" 的框架:

  • 一个 volatile int state 表示锁的状态(0未锁定,>0被持有且可重入)。
  • 一个FIFO双向队列存放等待获取锁的线程。

获取锁失败时,线程被包装成节点加入队列尾部,然后通过 LockSupport.park() 阻塞自己。解锁时,唤醒队列头部的下一个线程。

什么时候必须用ReentrantLock?

  • 你需要响应中断或设置超时时间。
  • 你需要公平锁(虽然公平锁通常比非公平慢,但可防止线程饥饿)。
  • 你需要多个 Condition 来精确管理等待/唤醒逻辑。

其他时候,优先用 synchronized,更简洁、更省心。


第三部分:锁只能管"互斥",但问题不止是"互斥"

锁解决了"同一时刻只能一个人改数据"的问题。但有些场景的问题不是"能不能进",而是"能进几个"或者"该让谁先走"。

3.1 Semaphore:控制并发数量

问题场景:数据库连接池只有10个连接,你不想让100个线程同时去抢着创建连接。或者某个接口承受不了太高的并发,你想限流。

Semaphore 的机制 :内部有一个许可证计数器,同样基于AQS的共享模式实现。acquire() 尝试拿一个许可证,有就拿走并继续,没有就阻塞等待;release() 归还许可证并唤醒等待者。

它不关心具体是哪个资源,只关心 "同时最多N个人可以进来"

应用上非常直观:限流器、连接池、有界缓冲区的并发控制。

3.2 CountDownLatch:等待事件完成

问题场景:主线程需要启动,但前提是4个前置模块加载完毕。或者主线程要汇总结果,需要等10个子线程全部算完。

机制 :初始化时设定一个计数值,每调用一次 countDown() 计数值减1,当减到0时,所有在 await() 上等待的线程被唤醒。

一句话理解:等N件事做完,我就继续。

注意:计数器不可重置,用完就废,只能一次性使用。

3.3 CyclicBarrier:等待线程到齐

问题场景:一场比赛需要10个选手都到起跑线上,才能鸣枪开跑。或者一轮MapReduce需要所有mapper完成后,reducer才开始。

机制 :设定一个屏障点(parties),每个线程调用 await() 到达屏障时阻塞,直到所有线程都到达,屏障打开,所有人同时继续。它还可以传入一个Runnable,由最后一个到达的线程执行。

和CountDownLatch的区别

  • CountDownLatch等的是事件 (countDown被调用了N次),CyclicBarrier等的是线程(N个线程都调用了await)。
  • CyclicBarrier的计数器可以重置,适合多轮同步(比如循环赛的每一轮都重新集结)。

3.4 Exchanger:双线程交换数据

问题场景:两个线程各自完成了一半的数据,需要交换中间结果继续下一步处理。

机制 :两个线程都调用 exchange(),当它们都到达时,自动交换传入的数据对象。

定位:小众但精准的工具,用于双线程协作场景。

3.5 Phaser:灵活的阶段性同步

如果 CountDownLatch 是一次性的倒计时,CyclicBarrier 是可重复使用的栅栏,那么 Phaser 就是可动态增减参与者的多阶段栅栏

它把同步过程分成多个"阶段"(phase),每个阶段结束后自动进入下一阶段。参与者可以随时注册或注销,适合复杂的迭代计算或分阶段任务。


第四部分:如果连"阻塞"都不想用------无锁方案

锁的本质是 "让线程等" ------等不到就挂起或自旋。挂起有上下文切换开销,自旋空转CPU。于是有了另一种思路:不阻塞线程,用CAS(比较并交换)在硬件层面保证原子性。

4.1 CAS的工作原理

CAS有三个参数:内存地址V、期望值E、新值N。当且仅当V当前的值等于E时,才把V更新为N;否则什么都不做,并返回失败。

关键:这个"比较-交换"操作是原子性的(由CPU指令直接支持),所以即使在多线程下,也不会出现"读到一半被改掉"的情况。

4.2 原子类(AtomicXXX)

AtomicIntegerAtomicLongAtomicReference 等都是基于CAS实现的。它们适用于 "单个变量频繁自增/更新" 的场景------比如统计请求总数、生成ID序列。

效率优势:不加锁,不阻塞,线程失败后自己重试即可,没有内核态切换开销。

CAS的短板

  • ABA问题 :值从A变成B又变回A,CAS会认为没变过。解决方案是用 AtomicStampedReference 带上版本号。
  • 自旋消耗:高竞争下失败的线程会一直循环重试,占用CPU。适合竞争不激烈的场景。

第五部分:并发容器------把数据结构和并发控制打包

手动用锁保护集合太麻烦且容易出错,JUC直接提供了线程安全的容器实现。

5.1 ConcurrentHashMap

Java并发中最常用的容器。

JDK 7 采用"分段锁"(Segment数组),每个Segment独立加锁,把竞争分散到多个锁上。

JDK 8 改为 CAS + synchronized 锁桶 :插入时先CAS尝试放首节点,失败则用 synchronized 锁住该桶(一个数组下标对应的链表/红黑树根节点)。锁粒度从"一段"细化到"一个桶",并发度大幅提升。

5.2 CopyOnWriteArrayList

核心思想:修改时复制一份新的底层数组,修改完成后替换旧数组。读操作完全不加锁,因为读的是不可变的旧数组快照。

极致适合"读多写极少"的场景,比如配置列表、监听器列表。写操作虽然开销大(要复制整个数组),但读操作永远零阻塞。

5.3 阻塞队列(BlockingQueue)

ArrayBlockingQueueLinkedBlockingQueue 等实现了生产者-消费者模式的标准工具。put() 在队列满时阻塞,take() 在队列空时阻塞,省去了手写 wait/notify 的繁琐。


最终总结:如何选择并发工具?

把问题抽象成三层:

第一层:共享数据的一致性

  • 如果只是一个简单状态标志 → volatile
  • 如果需要互斥修改一组数据 → synchronized(优先)或 ReentrantLock(有特殊需求时)

第二层:并发流量的控制

  • 限制同时访问的最大线程数 → Semaphore
  • 等待N个线程准备就绪 → CyclicBarrier
  • 等待N个任务完成 → CountDownLatch

第三层:数据结构的线程安全

  • Key-Value存储 → ConcurrentHashMap
  • 读多写少集合 → CopyOnWriteArrayList
  • 生产者-消费者通道 → 阻塞队列(BlockingQueue)
  • 单变量计数器 → AtomicInteger(CAS)

下面用一张表收束全局,把每个工具对应到它解决的核心问题上:

核心问题 解决方案 核心原理
状态标志可见性 volatile 内存屏障(强制主存读写+禁止重排)
普通互斥 synchronized JVM Monitor + 锁升级(偏向→轻量→重量)
高级互斥(中断/超时/多条件/公平) ReentrantLock AQS(state状态 + FIFO等待队列 + LockSupport)
限制并发数量 Semaphore AQS共享模式(许可证增减)
等待N个事件完成 CountDownLatch 倒计时门闩(一次性)
等待N个线程到齐 CyclicBarrier 可重置栅栏(支持屏障动作)
双线程数据交换 Exchanger 槽位交换
多阶段动态同步 Phaser 灵活分阶段屏障
单变量原子更新 AtomicInteger(CAS) 无锁乐观锁
并发Key-Value存储 ConcurrentHashMap CAS + 细粒度锁桶
读多写少集合 CopyOnWriteArrayList 读写分离(复制数组)
生产者-消费者通道 BlockingQueue 阻塞式存取

根本原则:从问题的本质出发,选择最轻量、最匹配的工具。加锁不是唯一的手段,协调也不是万能的。真正理解每个工具解决什么问题、不解决什么问题,才能在复杂的并发场景中做出正确的决策。

相关推荐
早点睡9752 小时前
向量检索原理入门:从 ANN 到 HNSW
后端·面试
前端双越老师2 小时前
前端学习 Java 其实很容易:TS 和 Java 语法的 N 个相同点
java·全栈
Augustzero2 小时前
为什么高性能调度器都在“偷任务”?从无锁队列看懂工作窃取
c++·后端
众人皆醒我独醉2 小时前
Embedding:把词变成向量——AI 理解语义的唯一方式
人工智能·面试·llm
Leo2822 小时前
异步任务链路如何不断链:基于 OpenTelemetry 的 Trace 设计
后端
技术长镜头2 小时前
别再死记 Record、Gap、Next-Key:沿一条 SQL 看懂 InnoDB 锁
后端·mysql
晚安code2 小时前
Java四大函数式接口一篇讲透:配上Stream流式计算处理集合
后端
晴殇i2 小时前
最近在 Github 名字叫“马尾辫”,这个真的很有趣看到头像
前端·后端·开源
IT_陈寒3 小时前
Vite打包时静态资源404?加个斜杠就能解决
前端·人工智能·后端