写代码久了会发现,并发不像书上那样优雅。业务在催,线程在抢,日志里一片 NullPointer 和 ConcurrentModification。你会开始怀疑:到底该老老实实上锁,还是换一种"读多写少就 CopyOnWrite 吧"的思路?
一、问题从哪里来:共享可变状态的两条路
先把场景抽象清楚,否则很容易"见 API 不见问题"。
典型的并发问题模型只有一句话:
多个线程同时读写同一份可变数据。
解决它,本质上只有两条路线:
- 互斥修改 :同一时刻只允许一个线程改数据,其他人要么排队,要s么阻塞。
- Java 中典型代表就是
synchronized、ReentrantLock、synchronizedList这类。
- Java 中典型代表就是
- 复制修改(Copy-On-Write) :读写彻底分开,
- 读线程只看一个稳定的快照数组;
- 写线程在副本上改,改完之后一次性"切换指针"。
- 代表就是
CopyOnWriteArrayList、CopyOnWriteArraySet。
换句话说:
synchronized走的是"一个盘子,大家轮流用"的思路;- CopyOnWrite 走的是"每次要改,先复制一份新盘子"的思路。
理解这一点之后,后面所有设计、性能、面试题,基本都围绕这两条路线展开。
二、synchronized:互斥锁的底层机制和执行流程
1. 概念:语言级的内置互斥锁
synchronized 是 Java 提供的关键字,编译后在字节码层对应 monitorenter / monitorexit 指令。
它有三个核心特性:
- 互斥:同一时刻只允许一个线程持有某个对象的监视器锁。
- 可重入:同一个线程可以多次进入同一把锁,计数器加一,不会自己把自己死锁住。
- 具备内存语义:进入和退出同步块时,会建立 happens-before 关系,保证可见性与有序性。
注意:synchronized 控制的是"代码块的执行互斥",数据只是被"顺带"保护了。
2. 底层实现概要:对象头、Monitor 和锁升级
synchronized 和"对象头 + Monitor 结构"强绑定,这是面试高频点。
- 每个 Java 对象都有对象头(Object Header),其中一部分叫 Mark Word。
- Mark Word 里会存放锁标志位、偏向线程 ID、锁记录指针等。
- 当某段代码使用
synchronized(obj)时,JVM 会尝试把这个对象和当前线程建立锁关系。
为了提高性能,HotSpot 对锁做了多级优化,常说的"锁升级"就是这个过程:
- 无锁状态:对象刚创建,没有任何线程竞争。
- 偏向锁:当一把锁几乎总被同一个线程使用时,JVM 会"偏向"这个线程,再次进入时几乎零成本。
- 轻量级锁:出现多线程竞争但还不激烈时,使用 CAS 和自旋来获取锁,尽量不让线程进入内核态阻塞。
- 重量级锁:竞争激烈时,会膨胀为重量级锁,让竞争线程进入阻塞队列,依靠操作系统调度。
更细节的可以展开很多,但对面试来说,知道:
- synchronized 不是一上来就"系统调用 + 阻塞等待",而是通过偏向锁、轻量级锁尽量把锁控制在用户态;
- 只有竞争激烈时才退化成重量级锁。
就已经高于平均水平了。
3. 内存语义(happens-before)
很多人只记住"synchronized 可以防止指令重排、保证可见性",但面试的时候给个过程很加分。
- 进入同步块(monitorenter) :
- 相当于一个隐含的 LoadLoad + LoadStore 屏障,
- 保证当前线程读取到的是其他线程释放锁前的最新数据。
- 退出同步块(monitorexit) :
- 相当于一个 StoreStore + StoreLoad 屏障,
- 把本线程对共享变量的修改刷新到主内存,对之后获得锁的线程可见。
所以可以用一句话回答:
"同一个锁的释放,happens-before 后续对同一把锁的获取。"
4. 典型执行流程(可以直接复述)
用一个简单例子:
java
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}
假设两个线程 T1 和 T2 同时调用 increment,可以这样描述执行流程:
- T1 调用
increment(),JVM 生成的字节码中先执行monitorenter指令,对this尝试加锁。- 如果当前对象处于无锁或偏向 T1 状态,则 T1 直接获得锁,进入同步块。
- 如果已经有其他线程持有锁,T1 进入自旋等待或阻塞队列(根据当前锁状态,轻量级或重量级)。
- T1 成功获取锁后,进入临界区,执行
count++。- 读取主内存中
count值到线程工作内存。 - 在工作内存中执行加一操作。
- 写回主内存,但对其他线程是否立刻可见,要到释放锁时才能完全保证。
- 读取主内存中
- T2 在调用
increment()时,同样执行monitorenter:- 如果这时 T1 还没释放锁,T2 无法成功获取锁,会自旋或挂起阻塞。
- 只有当 T1 执行完同步块,执行
monitorexit释放锁后,T2 才能获取到锁。
- T1 执行到方法末尾或遇到异常时,会执行
monitorexit:- 把工作内存中对
count的修改刷新到主内存。 - 释放锁,唤醒阻塞在这把锁上的其他线程(例如 T2)。
- 把工作内存中对
- T2 被唤醒后再次尝试
monitorenter,获取到锁,然后重复上述过程。
面试的时候,你可以用"先获取锁、再读写、最后释放锁并刷新到主内存"的三段式来讲,同时顺便带出"锁升级"和"自旋 / 阻塞"。
5. synchronized 的优缺点和适用场景
优点:
- 语法简单,编译器和 JVM 支持好,不容易用错。
- 内置内存语义,写起来不用关心 volatile、内存屏障细节。
- 自 Java 6 起做了大量优化,性能并没有早期那么差。
缺点:
- 同一时刻只有一个线程能进入,读多写少场景会被严重拖慢。
- 竞争激烈时会导致大量上下文切换,吞吐量下降。
- 锁的粒度很容易写得过大,影响并发度。
典型适用场景:
- 写操作比例不低,需要强一致性;
- 业务上不允许读取到旧数据,例如扣款、库存、状态机流转。
三、CopyOnWrite:用"复制"换"读的无锁"
1. 概念:写时复制的读写分离思路
Copy-On-Write 的思路是:
读和写彻底分离:
读永远只看一个"稳定的快照";写在副本上改,改完后一次性交换引用。
在 Java 中最典型的就是 CopyOnWriteArrayList。它有几个关键特性:
- 内部用一个
volatile的数组保存数据。 - 所有"读操作"(包括
get、size、遍历)都不加锁,只读这个数组引用。 - 所有"写操作"(
add、remove等)都在synchronized保护下:- 先复制出一个新数组;
- 在新数组上修改;
- 修改完成后把
array指向新数组。
这意味着:
- 读线程永远不会被阻塞,极大提升读多场景的吞吐;
- 代价是写操作会变得非常重(需要复制数组)。
2. CopyOnWriteArrayList 的设计思路
看下核心字段(逻辑层面):
java
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private transient volatile Object[] array;
}
两个关键点:
- 存储结构是 数组;
- 数组引用是 volatile,保证发布和可见性。
再看一个典型写方法(简化版逻辑):
java
public boolean add(E e) {
synchronized (lock) {
Object[] oldArray = array;
int len = oldArray.length;
Object[] newArray = Arrays.copyOf(oldArray, len + 1);
newArray[len] = e;
array = newArray;
return true;
}
}
这里的重点:
synchronized是用来保护"写过程"的原子性;Arrays.copyOf做的是数组复制;array = newArray是一个 volatile 写,保证新数组对其他线程立刻可见。
读操作是这样的(本质上只有一步):
java
public E get(int index) {
return (E) array[index];
}
没有加锁,也不会复制。
3. 读写执行流程(面试复述版)
用 CopyOnWriteArrayList 的 add 和 get 举个场景。
3.1 写操作 add 的执行流程
假设线程 T1 调用 add("A"):
- T1 进入
add方法,获取lock这把内部锁(注意,这不是外部传入的对象锁,是容器内部封装的锁)。 - 把当前的
array引用保存到oldArray,假设长度为 N。 - 调用
Arrays.copyOf(oldArray, N + 1),创建一个长度为 N+1 的新数组newArray,复制原有元素。 - 在
newArray[N]位置写入新元素"A"。 - 把
array这个volatile字段指向newArray。 - 释放
lock锁,写操作结束。
整个过程中:
- 旧数组
oldArray从头到尾保持不变; - 所有正在迭代旧数组的读线程完全不受干扰;
- 写操作完成后,新来的读线程会看到
array引用的新数组。
3.2 读操作 get / 遍历的执行流程
假设另一线程 T2 调用 get(k) 或者通过 for (E e : list) {} 遍历时:
- 读取当前
array的引用(这是一个volatile读)。 - 在这次方法调用期间,直接使用这份数组,按索引访问或顺序遍历。
- 即使此时其他线程在进行写入,只要不改变这份数组本身,T2 的读操作不会受到影响。
所以可以总结成一句:
"读的是一个随时可能变更引用的快照,但在当前方法执行期间,这个快照本身不会变。"
3.3 迭代器的"弱一致性快照"
CopyOnWriteArrayList 的迭代器是基于快照的:
- 创建迭代器时,内部把当时的
array引用保存一份; - 后续迭代都只看这份快照;
- 中途别的线程对 list 的修改,对当前这个迭代器完全不可见;
这就导致两个行为:
- 不会抛
ConcurrentModificationException; - 你遍历时看到的是"旧数据",也就是说它是一种 弱一致性,不是实时一致。
面试时如果被问"为何迭代器不 fail-fast",可以直接说:
因为 CopyOnWrite 容器的设计目标就是"读时快照、写在副本",遍历过程不感知并发修改,自然不需要 fail-fast。
4. CopyOnWrite 的优缺点和适用场景
优点(本质就是用空间换时间):
- 读操作完全无锁,多个线程同时读不会互相阻塞,吞吐量很高。
- 迭代时无需考虑并发修改,不会抛
ConcurrentModificationException,代码简单。 - 读操作看到的数据永远是一个"自洽的快照",不会读到一半被改的中间状态。
缺点:
- 写操作代价非常高:每次写都需要复制整个数组。
- 内存开销大:写频繁时会不断产生新数组,触发更多 GC。
- 数据不是实时一致:读操作只能看到"某一时刻的快照",不适合对实时性要求高的场景。
- 容器越大,写一次的复制成本越高。
典型适用场景可以记成一句话:
读多写少,并且允许读到略旧的数据。
例如:
- 系统中的"配置白名单列表"、"黑名单列表",很少改,但到处有人读;
- 事件监听器列表(Listener List);
- 权限菜单、路由表,在运行期偶尔更新,但绝大多数时间都在被读取。
四、synchronized vs CopyOnWrite:从本质到选型
把刚才所有内容,收束到几个关键维度对比,这一段是面试里很容易问的地方。
1. 模型层面
- synchronized:
- 基于互斥锁,串行化"读写操作"。
- 保护的是某一段临界区代码的执行顺序和可见性。
- CopyOnWrite:
- 基于写时复制,只串行化"写操作",读操作完全不加锁。
- 保护的是"读到的数据快照始终自洽"。
一句话区别:
- synchronized:我只有一个盘子,只能让你们一个一个来动;
- CopyOnWrite:要动盘子的人请复制一份,读的你们随便看自己的那份。
2. 性能和开销
- synchronized:
- 优点:写操作成本相对稳定;
- 缺点:哪怕是纯读场景,也会被锁拖死。
- CopyOnWrite:
- 优点:读操作极快且无需加锁;
- 缺点:写操作成本随集合大小线性增长,写多就爆。
所以可以记一句判断:
读写比极度倾斜到读,比如 99% 读取、1% 写入时,CopyOnWrite 非常适合;
写稍微多一点,或者集合偏大,就要谨慎使用。
3. 一致性和可见性
- synchronized:
- 满足强一致性:持有同一把锁的线程之间,读到的是最新的写入结果。
- CopyOnWrite:
- 弱一致性:读到的是某一时刻的快照,可能略旧,但一定是自洽的(不会读到被部分修改的数组)。
选型上:
- 只要业务要求"改完必须立刻被所有读线程看到",不能接受读旧数据,就优先 synchronized / ReentrantLock / ReadWriteLock;
- 能接受"最多晚一个写操作",并且写比较少,就可以考虑 CopyOnWrite。
4. 内存和 GC
- synchronized:
- 不会额外复制数据,主要成本在锁竞争和上下文切换。
- CopyOnWrite:
- 写操作会不断新建数组,频繁 GC,尤其在大列表上很明显。
所以 CopyOnWrite 容器一般只用于中小规模列表,不适合保存海量数据。
5. 与其他并发工具的关系
容易被问的几个对比点:
CopyOnWriteArrayListvsCollections.synchronizedList:- 后者仍然是互斥锁,读写都需要抢锁;
- 前者读无锁,写复制。
CopyOnWriteArrayListvsConcurrentHashMap这类结构:- CopyOnWrite 是"通过复制避免读写冲突",适合列表型读多写少;
- ConcurrentHashMap 是通过分段锁 / CAS 细化互斥粒度。
五、面试经典问法与回答模板
这一节完全可以作为你准备面试的"即用版答案"。
问题 1:synchronized 底层是怎么实现的?和锁升级是什么关系?
答题思路:
- 先从语法层面讲 monitorenter/monitorexit;
- 再讲对象头 Mark Word 和 Monitor;
- 然后用"无锁 → 偏向锁 → 轻量级锁 → 重量级锁"的顺序描述锁升级;
- 最后补一句内存语义。
参考回答:
synchronized 是 Java 提供的内置锁,编译后在字节码层会变成 monitorenter 和 monitorexit 指令。
每个对象在 JVM 里都有对象头,其中的 Mark Word 记录了锁标志位、偏向线程 ID、锁记录指针等信息。线程在执行 synchronized 代码块时,会尝试把当前线程 ID 或锁记录写入对象头,从而把这个对象和自己绑定,相当于获得了这把锁。
为了减少锁带来的性能开销,HotSpot 对 synchronized 做了多级优化:
- 一开始是无锁状态;
- 如果始终只被一个线程反复加锁,就会进入偏向锁,获取锁几乎不需要 CAS;
- 出现多个线程竞争时,会升级为轻量级锁,通过自旋和 CAS 获取锁,尽量不进入内核态;
- 竞争再激烈时,自旋不再划算,锁会膨胀成重量级锁,线程会进入阻塞队列,交给操作系统调度。
synchronized 同时还提供了内存语义:释放锁之前,对共享变量的修改会刷回主内存;之后获取同一把锁的线程一定能看到这些修改,这就是经典的 happens-before 关系。
问题 2:CopyOnWriteArrayList 的实现原理是什么?为什么读操作不用加锁还能线程安全?
答题思路:
- 先说明存储结构和 volatile 数组;
- 再讲写操作的"复制 + 替换引用"流程;
- 解释读无锁的原因(快照 + 不修改原数组);
- 提到迭代器快照和弱一致性。
参考回答:
CopyOnWriteArrayList 的核心思路是"写时复制"。
内部用一个 volatile 的 Object[] 数组保存数据。所有写操作,比如 add、remove,都会在 synchronized 保护下做三件事:
第一,拷贝当前数组生成一个新数组;
第二,在新数组上做修改,比如把元素加在最后一个位置;
第三,把内部的 array 字段指向新数组,这是一个 volatile 写,能保证对其它线程可见。
在这个过程中,旧数组永远不被修改,所以读操作只要读当前 array 引用,并在这个数组上按索引访问,就不会有数据竞争,也不需要加锁。
它的迭代器也是基于快照实现的,创建迭代器时会把当时的数组引用保存下来,后续遍历只看这份快照,因此不会抛 ConcurrentModificationException,但是看到的可能是旧数据,也就是一种弱一致性语义。
问题 3:什么时候用 synchronized,什么时候用 CopyOnWriteArrayList?
答题思路:
- 从读写比例、实时性要求、数据规模三个维度来答;
- 最后给一两句落地的业务例子。
参考回答:
这两个方案本质上面向的场景不一样。
synchronized 是传统的互斥锁,读写都需要抢锁,适合写操作比较多、对一致性要求比较强的场景,例如库存扣减、订单状态流转这类逻辑,你希望写完就立刻对所有读线程可见。
CopyOnWriteArrayList 走的是写时复制路线,只对写操作加锁并复制数组,读操作完全无锁,适合典型的"读多写少、能接受读到旧数据"的场景。比如系统里的黑白名单、监听器列表,平时基本都是在读,很少有更新,而且就算延迟一个写操作被读到也没问题。
所以如果业务上要求实时一致、写频率不低,就用 synchronized 或者其他锁结构;如果读远远大于写,并且可以接受弱一致性,就可以考虑 CopyOnWriteArrayList。
问题 4:CopyOnWriteArrayList 有哪些缺点?为什么不推荐在大集合和写多场景使用?
答题思路:
- 强调写操作的 O(n) 复制成本;
- 强调 GC 和内存占用;
- 再补上弱一致性的限制。
参考回答:
CopyOnWriteArrayList 的主要问题在于写操作非常重,它每次写都会复制整个底层数组,所以写一次的复杂度是 O(n)。如果集合很大或者写操作频繁,就会导致大量 CPU 用在数组复制上,同时不断产生新数组,对 GC 压力也很大。
另外,它的读是基于快照的弱一致性,读线程看到的是创建快照时的那一版数据,不适合对实时性要求特别高的场景。
综合来说,它只适合中小规模的列表,并且读远远多于写的场合。如果写操作比较多或者数据量很大,更合适的选择还是基于锁分段、CAS 的并发容器,或者用 ReadWriteLock 这种读写锁来提升读性能。
问题 5:CopyOnWriteArrayList 和 Collections.synchronizedList 有什么区别?
答题思路:
- 明确一个是写时复制,一个是全程互斥;
- 对比读时是否加锁、迭代器行为。
参考回答:
Collections.synchronizedList 本质上就是在普通 List 外面包了一层互斥锁,读写操作都要先获取同一把锁,走的是传统的"大家排队访问"路线,它的迭代器是 fail-fast 的,并发修改时会抛 ConcurrentModificationException。
CopyOnWriteArrayList 则是写时复制的实现,读操作完全无锁,迭代基于快照,不会抛并发修改异常,但读到的是旧数据。
两者都保证线程安全,但适用场景不同:前者适合读写比例差不多或者写比较多;后者适合读远多于写、并且能接受弱一致性的场景。
六、小结:把这套逻辑在脑子里串起来
可以把整篇内容在脑子里压缩成下面这几句话,方便你在面试时连贯地讲出来:
- 并发读写共享可变状态,解决方案有两路:
- 一路是以 synchronized 为代表的互斥锁,串行化读写;
- 一路是以 CopyOnWrite 为代表的写时复制,用空间换取无锁读。
- synchronized 底层依赖对象头 Mark Word 和 Monitor,锁会经历无锁、偏向锁、轻量级锁、重量级锁等状态,释放锁和获取锁之间建立 happens-before,提供强一致性和可见性。
- CopyOnWriteArrayList 内部用 volatile 数组存数据,写操作在 synchronized 下复制数组并替换引用,读操作无锁直接读当前数组快照,迭代器基于快照,提供弱一致性。
- 选型时重点看三件事:读写比例、实时性要求、数据规模。
- 写多或要强一致,就用 synchronized / 其他锁结构;
- 读多写少且能接受读旧数据,且集合规模不大,可以用 CopyOnWrite 容器。
如果你能把这几个点按"问题 → 原理 → 执行流程 → 场景 → 对比 → 缺点"这一条线顺下来,synchronized vs CopyOnWrite 这个话题基本就拿捏住了。