前言:并发编程到底在解决什么问题?
编写正确的并发程序,本质上只做两件事:
- 保护共享数据的一致性------无论多少线程同时操作,数据始终正确。
- 协调线程的执行节奏------该等待的等待,该通知的通知,该限流的限流。
Java的并发工具虽然琳琅满目,但追根溯源,它们都在解决上述两个问题。只不过解决方式各有侧重:有的靠"堵"(互斥锁),有的靠"让"(自旋+重试),有的靠"管"(信号量限制并发数),有的靠"调度"(等待/通知机制)。
本文按照 "从问题到方案" 的逻辑展开,不堆砌API,只讲清每样工具为什么被发明出来 、解决了什么问题 、在什么场景下用。
第一部分:问题的根源------数据为什么会被弄乱?
一切并发安全问题的起点,都指向多个线程同时访问同一份共享数据。但"同时"本身不是问题,"乱"才是。数据是怎么乱的?
假设你有一份银行账户余额,两个线程同时执行"取100元"的操作。流程无非三步:
- 从主内存读取当前余额到工作内存。
- 在工作内存中计算"余额 - 100"。
- 把新余额写回主内存。
如果两个线程完美地串行执行,一切正常。但现实中,这三步可能被穿插执行:
- 线程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不够用时
ReentrantLock 在 synchronized 的基础上,补充了三个关键能力:
- 可中断 :
lockInterruptibly()让线程在等待锁的过程中可以被外部打断,避免无限阻塞。 - 可超时 :
tryLock(timeout)尝试获取锁,拿不到就放弃,避免死锁僵局。 - 多个条件队列 :
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)
AtomicInteger、AtomicLong、AtomicReference 等都是基于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)
ArrayBlockingQueue、LinkedBlockingQueue 等实现了生产者-消费者模式的标准工具。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 |
阻塞式存取 |
根本原则:从问题的本质出发,选择最轻量、最匹配的工具。加锁不是唯一的手段,协调也不是万能的。真正理解每个工具解决什么问题、不解决什么问题,才能在复杂的并发场景中做出正确的决策。