synchronized vs CopyOnWrite 系列

写代码久了会发现,并发不像书上那样优雅。业务在催,线程在抢,日志里一片 NullPointer 和 ConcurrentModification。你会开始怀疑:到底该老老实实上锁,还是换一种"读多写少就 CopyOnWrite 吧"的思路?

一、问题从哪里来:共享可变状态的两条路

先把场景抽象清楚,否则很容易"见 API 不见问题"。

典型的并发问题模型只有一句话:
多个线程同时读写同一份可变数据。

解决它,本质上只有两条路线:

  1. 互斥修改 :同一时刻只允许一个线程改数据,其他人要么排队,要s么阻塞。
    • Java 中典型代表就是 synchronizedReentrantLocksynchronizedList 这类。
  2. 复制修改(Copy-On-Write) :读写彻底分开,
    • 读线程只看一个稳定的快照数组;
    • 写线程在副本上改,改完之后一次性"切换指针"。
    • 代表就是 CopyOnWriteArrayListCopyOnWriteArraySet

换句话说:

  • synchronized 走的是"一个盘子,大家轮流用"的思路;
  • CopyOnWrite 走的是"每次要改,先复制一份新盘子"的思路。

理解这一点之后,后面所有设计、性能、面试题,基本都围绕这两条路线展开。


二、synchronized:互斥锁的底层机制和执行流程

1. 概念:语言级的内置互斥锁

synchronized 是 Java 提供的关键字,编译后在字节码层对应 monitorenter / monitorexit 指令。

它有三个核心特性:

  1. 互斥:同一时刻只允许一个线程持有某个对象的监视器锁。
  2. 可重入:同一个线程可以多次进入同一把锁,计数器加一,不会自己把自己死锁住。
  3. 具备内存语义:进入和退出同步块时,会建立 happens-before 关系,保证可见性与有序性。

注意:synchronized 控制的是"代码块的执行互斥",数据只是被"顺带"保护了。

2. 底层实现概要:对象头、Monitor 和锁升级

synchronized 和"对象头 + Monitor 结构"强绑定,这是面试高频点。

  • 每个 Java 对象都有对象头(Object Header),其中一部分叫 Mark Word
  • Mark Word 里会存放锁标志位、偏向线程 ID、锁记录指针等。
  • 当某段代码使用 synchronized(obj) 时,JVM 会尝试把这个对象和当前线程建立锁关系。

为了提高性能,HotSpot 对锁做了多级优化,常说的"锁升级"就是这个过程:

  1. 无锁状态:对象刚创建,没有任何线程竞争。
  2. 偏向锁:当一把锁几乎总被同一个线程使用时,JVM 会"偏向"这个线程,再次进入时几乎零成本。
  3. 轻量级锁:出现多线程竞争但还不激烈时,使用 CAS 和自旋来获取锁,尽量不让线程进入内核态阻塞。
  4. 重量级锁:竞争激烈时,会膨胀为重量级锁,让竞争线程进入阻塞队列,依靠操作系统调度。

更细节的可以展开很多,但对面试来说,知道:

  • 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,可以这样描述执行流程:

  1. T1 调用 increment(),JVM 生成的字节码中先执行 monitorenter 指令,对 this 尝试加锁。
    1. 如果当前对象处于无锁或偏向 T1 状态,则 T1 直接获得锁,进入同步块。
    2. 如果已经有其他线程持有锁,T1 进入自旋等待或阻塞队列(根据当前锁状态,轻量级或重量级)。
  2. T1 成功获取锁后,进入临界区,执行 count++
    1. 读取主内存中 count 值到线程工作内存。
    2. 在工作内存中执行加一操作。
    3. 写回主内存,但对其他线程是否立刻可见,要到释放锁时才能完全保证。
  3. T2 在调用 increment() 时,同样执行 monitorenter
    • 如果这时 T1 还没释放锁,T2 无法成功获取锁,会自旋或挂起阻塞。
    • 只有当 T1 执行完同步块,执行 monitorexit 释放锁后,T2 才能获取到锁。
  4. T1 执行到方法末尾或遇到异常时,会执行 monitorexit
    1. 把工作内存中对 count 的修改刷新到主内存。
    2. 释放锁,唤醒阻塞在这把锁上的其他线程(例如 T2)。
  5. T2 被唤醒后再次尝试 monitorenter,获取到锁,然后重复上述过程。

面试的时候,你可以用"先获取锁、再读写、最后释放锁并刷新到主内存"的三段式来讲,同时顺便带出"锁升级"和"自旋 / 阻塞"。

5. synchronized 的优缺点和适用场景

优点:

  1. 语法简单,编译器和 JVM 支持好,不容易用错。
  2. 内置内存语义,写起来不用关心 volatile、内存屏障细节。
  3. 自 Java 6 起做了大量优化,性能并没有早期那么差。

缺点:

  1. 同一时刻只有一个线程能进入,读多写少场景会被严重拖慢。
  2. 竞争激烈时会导致大量上下文切换,吞吐量下降。
  3. 锁的粒度很容易写得过大,影响并发度。

典型适用场景:

  • 写操作比例不低,需要强一致性;
  • 业务上不允许读取到旧数据,例如扣款、库存、状态机流转。

三、CopyOnWrite:用"复制"换"读的无锁"

1. 概念:写时复制的读写分离思路

Copy-On-Write 的思路是:

读和写彻底分离:

读永远只看一个"稳定的快照";写在副本上改,改完后一次性交换引用。

在 Java 中最典型的就是 CopyOnWriteArrayList。它有几个关键特性:

  1. 内部用一个 volatile 的数组保存数据。
  2. 所有"读操作"(包括 getsize、遍历)都不加锁,只读这个数组引用。
  3. 所有"写操作"(addremove 等)都在 synchronized 保护下:
    • 先复制出一个新数组;
    • 在新数组上修改;
    • 修改完成后把 array 指向新数组。

这意味着:

  • 读线程永远不会被阻塞,极大提升读多场景的吞吐;
  • 代价是写操作会变得非常重(需要复制数组)。

2. CopyOnWriteArrayList 的设计思路

看下核心字段(逻辑层面):

java 复制代码
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    private transient volatile Object[] array;
}

两个关键点:

  1. 存储结构是 数组
  2. 数组引用是 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. 读写执行流程(面试复述版)

CopyOnWriteArrayListaddget 举个场景。

3.1 写操作 add 的执行流程

假设线程 T1 调用 add("A")

  1. T1 进入 add 方法,获取 lock 这把内部锁(注意,这不是外部传入的对象锁,是容器内部封装的锁)。
  2. 把当前的 array 引用保存到 oldArray,假设长度为 N。
  3. 调用 Arrays.copyOf(oldArray, N + 1),创建一个长度为 N+1 的新数组 newArray,复制原有元素。
  4. newArray[N] 位置写入新元素 "A"
  5. array 这个 volatile 字段指向 newArray
  6. 释放 lock 锁,写操作结束。

整个过程中:

  • 旧数组 oldArray 从头到尾保持不变;
  • 所有正在迭代旧数组的读线程完全不受干扰;
  • 写操作完成后,新来的读线程会看到 array 引用的新数组。
3.2 读操作 get / 遍历的执行流程

假设另一线程 T2 调用 get(k) 或者通过 for (E e : list) {} 遍历时:

  1. 读取当前 array 的引用(这是一个 volatile 读)。
  2. 在这次方法调用期间,直接使用这份数组,按索引访问或顺序遍历。
  3. 即使此时其他线程在进行写入,只要不改变这份数组本身,T2 的读操作不会受到影响。

所以可以总结成一句:
"读的是一个随时可能变更引用的快照,但在当前方法执行期间,这个快照本身不会变。"

3.3 迭代器的"弱一致性快照"

CopyOnWriteArrayList 的迭代器是基于快照的:

  1. 创建迭代器时,内部把当时的 array 引用保存一份;
  2. 后续迭代都只看这份快照;
  3. 中途别的线程对 list 的修改,对当前这个迭代器完全不可见;

这就导致两个行为:

  • 不会抛 ConcurrentModificationException
  • 你遍历时看到的是"旧数据",也就是说它是一种 弱一致性,不是实时一致。

面试时如果被问"为何迭代器不 fail-fast",可以直接说:

因为 CopyOnWrite 容器的设计目标就是"读时快照、写在副本",遍历过程不感知并发修改,自然不需要 fail-fast。

4. CopyOnWrite 的优缺点和适用场景

优点(本质就是用空间换时间):

  1. 读操作完全无锁,多个线程同时读不会互相阻塞,吞吐量很高。
  2. 迭代时无需考虑并发修改,不会抛 ConcurrentModificationException,代码简单。
  3. 读操作看到的数据永远是一个"自洽的快照",不会读到一半被改的中间状态。

缺点:

  1. 写操作代价非常高:每次写都需要复制整个数组。
  2. 内存开销大:写频繁时会不断产生新数组,触发更多 GC。
  3. 数据不是实时一致:读操作只能看到"某一时刻的快照",不适合对实时性要求高的场景。
  4. 容器越大,写一次的复制成本越高。

典型适用场景可以记成一句话:
读多写少,并且允许读到略旧的数据。

例如:

  • 系统中的"配置白名单列表"、"黑名单列表",很少改,但到处有人读;
  • 事件监听器列表(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. 与其他并发工具的关系

容易被问的几个对比点:

  • CopyOnWriteArrayList vs Collections.synchronizedList
    • 后者仍然是互斥锁,读写都需要抢锁;
    • 前者读无锁,写复制。
  • CopyOnWriteArrayList vs ConcurrentHashMap 这类结构:
    • CopyOnWrite 是"通过复制避免读写冲突",适合列表型读多写少;
    • ConcurrentHashMap 是通过分段锁 / CAS 细化互斥粒度。

五、面试经典问法与回答模板

这一节完全可以作为你准备面试的"即用版答案"。

问题 1:synchronized 底层是怎么实现的?和锁升级是什么关系?

答题思路:

  1. 先从语法层面讲 monitorenter/monitorexit;
  2. 再讲对象头 Mark Word 和 Monitor;
  3. 然后用"无锁 → 偏向锁 → 轻量级锁 → 重量级锁"的顺序描述锁升级;
  4. 最后补一句内存语义。

参考回答:

synchronized 是 Java 提供的内置锁,编译后在字节码层会变成 monitorenter 和 monitorexit 指令。

每个对象在 JVM 里都有对象头,其中的 Mark Word 记录了锁标志位、偏向线程 ID、锁记录指针等信息。线程在执行 synchronized 代码块时,会尝试把当前线程 ID 或锁记录写入对象头,从而把这个对象和自己绑定,相当于获得了这把锁。

为了减少锁带来的性能开销,HotSpot 对 synchronized 做了多级优化:

  • 一开始是无锁状态;
  • 如果始终只被一个线程反复加锁,就会进入偏向锁,获取锁几乎不需要 CAS;
  • 出现多个线程竞争时,会升级为轻量级锁,通过自旋和 CAS 获取锁,尽量不进入内核态;
  • 竞争再激烈时,自旋不再划算,锁会膨胀成重量级锁,线程会进入阻塞队列,交给操作系统调度。
    synchronized 同时还提供了内存语义:释放锁之前,对共享变量的修改会刷回主内存;之后获取同一把锁的线程一定能看到这些修改,这就是经典的 happens-before 关系。

问题 2:CopyOnWriteArrayList 的实现原理是什么?为什么读操作不用加锁还能线程安全?

答题思路:

  1. 先说明存储结构和 volatile 数组;
  2. 再讲写操作的"复制 + 替换引用"流程;
  3. 解释读无锁的原因(快照 + 不修改原数组);
  4. 提到迭代器快照和弱一致性。

参考回答:

CopyOnWriteArrayList 的核心思路是"写时复制"。

内部用一个 volatile 的 Object[] 数组保存数据。所有写操作,比如 add、remove,都会在 synchronized 保护下做三件事:

第一,拷贝当前数组生成一个新数组;

第二,在新数组上做修改,比如把元素加在最后一个位置;

第三,把内部的 array 字段指向新数组,这是一个 volatile 写,能保证对其它线程可见。

在这个过程中,旧数组永远不被修改,所以读操作只要读当前 array 引用,并在这个数组上按索引访问,就不会有数据竞争,也不需要加锁。

它的迭代器也是基于快照实现的,创建迭代器时会把当时的数组引用保存下来,后续遍历只看这份快照,因此不会抛 ConcurrentModificationException,但是看到的可能是旧数据,也就是一种弱一致性语义。


问题 3:什么时候用 synchronized,什么时候用 CopyOnWriteArrayList?

答题思路:

  1. 从读写比例、实时性要求、数据规模三个维度来答;
  2. 最后给一两句落地的业务例子。

参考回答:

这两个方案本质上面向的场景不一样。

synchronized 是传统的互斥锁,读写都需要抢锁,适合写操作比较多、对一致性要求比较强的场景,例如库存扣减、订单状态流转这类逻辑,你希望写完就立刻对所有读线程可见。

CopyOnWriteArrayList 走的是写时复制路线,只对写操作加锁并复制数组,读操作完全无锁,适合典型的"读多写少、能接受读到旧数据"的场景。比如系统里的黑白名单、监听器列表,平时基本都是在读,很少有更新,而且就算延迟一个写操作被读到也没问题。

所以如果业务上要求实时一致、写频率不低,就用 synchronized 或者其他锁结构;如果读远远大于写,并且可以接受弱一致性,就可以考虑 CopyOnWriteArrayList。


问题 4:CopyOnWriteArrayList 有哪些缺点?为什么不推荐在大集合和写多场景使用?

答题思路:

  1. 强调写操作的 O(n) 复制成本;
  2. 强调 GC 和内存占用;
  3. 再补上弱一致性的限制。

参考回答:

CopyOnWriteArrayList 的主要问题在于写操作非常重,它每次写都会复制整个底层数组,所以写一次的复杂度是 O(n)。如果集合很大或者写操作频繁,就会导致大量 CPU 用在数组复制上,同时不断产生新数组,对 GC 压力也很大。

另外,它的读是基于快照的弱一致性,读线程看到的是创建快照时的那一版数据,不适合对实时性要求特别高的场景。

综合来说,它只适合中小规模的列表,并且读远远多于写的场合。如果写操作比较多或者数据量很大,更合适的选择还是基于锁分段、CAS 的并发容器,或者用 ReadWriteLock 这种读写锁来提升读性能。


问题 5:CopyOnWriteArrayList 和 Collections.synchronizedList 有什么区别?

答题思路:

  1. 明确一个是写时复制,一个是全程互斥;
  2. 对比读时是否加锁、迭代器行为。

参考回答:

Collections.synchronizedList 本质上就是在普通 List 外面包了一层互斥锁,读写操作都要先获取同一把锁,走的是传统的"大家排队访问"路线,它的迭代器是 fail-fast 的,并发修改时会抛 ConcurrentModificationException。

CopyOnWriteArrayList 则是写时复制的实现,读操作完全无锁,迭代基于快照,不会抛并发修改异常,但读到的是旧数据。

两者都保证线程安全,但适用场景不同:前者适合读写比例差不多或者写比较多;后者适合读远多于写、并且能接受弱一致性的场景。


六、小结:把这套逻辑在脑子里串起来

可以把整篇内容在脑子里压缩成下面这几句话,方便你在面试时连贯地讲出来:

  1. 并发读写共享可变状态,解决方案有两路:
    • 一路是以 synchronized 为代表的互斥锁,串行化读写;
    • 一路是以 CopyOnWrite 为代表的写时复制,用空间换取无锁读。
  2. synchronized 底层依赖对象头 Mark Word 和 Monitor,锁会经历无锁、偏向锁、轻量级锁、重量级锁等状态,释放锁和获取锁之间建立 happens-before,提供强一致性和可见性。
  3. CopyOnWriteArrayList 内部用 volatile 数组存数据,写操作在 synchronized 下复制数组并替换引用,读操作无锁直接读当前数组快照,迭代器基于快照,提供弱一致性。
  4. 选型时重点看三件事:读写比例、实时性要求、数据规模。
    • 写多或要强一致,就用 synchronized / 其他锁结构;
    • 读多写少且能接受读旧数据,且集合规模不大,可以用 CopyOnWrite 容器。

如果你能把这几个点按"问题 → 原理 → 执行流程 → 场景 → 对比 → 缺点"这一条线顺下来,synchronized vs CopyOnWrite 这个话题基本就拿捏住了。

相关推荐
-森屿安年-1 小时前
LeetCode 11. 盛最多水的容器
开发语言·c++·算法·leetcode
用户0304805912631 小时前
前后端数据传输: 利用 Jackson 注解实现 Enum 与 int 的双向映射
java·后端
程序员小胖1 小时前
困扰我一整天的MyBatis"Invalid bound statement"问题,原来是因为这个不起眼的注解冲突!
面试·mybatis
ouliten1 小时前
C++笔记:std::stringbuf
开发语言·c++·笔记
Rhys..1 小时前
Jenkinsfile保存在项目根目录下的好处
java·开发语言
lly2024061 小时前
SQL LCASE() 函数详解
开发语言
0***K8922 小时前
PHP框架比较
开发语言·php
哟哟耶耶2 小时前
ts-属性修饰符,接口(约束数据结构),抽象类(约束与复用逻辑)
开发语言·前端·javascript
nvd112 小时前
Gidgethub 使用指南
开发语言·python