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 这个话题基本就拿捏住了。

相关推荐
爱吃烤鸡翅的酸菜鱼3 分钟前
【Java】封装位运算通用工具类——用一个整数字段替代几十个布尔列,极致节省存储空间
java·开发语言·设计模式·工具类·位运算·合成复用原则
xinhuanjieyi5 分钟前
php给30支NBA球队添加logo图标,做好对应关系
android·开发语言·php
骑猪兜风2338 分钟前
如何设计一个面向Agent的后端开发平台
经验分享
菜菜小狗的学习笔记9 分钟前
八股(三)Java并发
java·开发语言
云烟成雨TD14 分钟前
Spring AI Alibaba 1.x 系列【10】ReactAgent 工具加载和执行流程
java·人工智能·spring
lee_curry14 分钟前
JUC第一章 java中基础概念和CompletableFuture
java·多线程·并发·juc
GISer_Jing17 分钟前
前端JS面试6大核心考点详解
前端·javascript·面试
其实秋天的枫19 分钟前
【26年最新】英语六级2015-2025年12月历年真题及答案PDF+六级核心词汇
经验分享·pdf
Trouvaille ~25 分钟前
【MySQL篇】内外连接:多表关联的完整指南
android·数据库·mysql·面试·后端开发·dql·内外连接
一晌小贪欢27 分钟前
PyQt5 开发一个 PDF 批量合并工具
开发语言·qt·pdf