云计算百科
云计算领域专业知识百科平台

一个场景题去理解锁

Java 并发里最容易混淆的几个词是:锁、原子性、可见性、有序性、线程安全。很多问题表面上都像“加个锁就行”,但背后的问题可能完全不同:

  • 多个线程同时改一个变量,结果丢失,这是原子性问题;
  • 一个线程改了变量,另一个线程看不到,这是可见性问题;
  • 代码顺序看起来没问题,运行时因为重排序出现异常,这是有序性问题;
  • 多个线程同时访问一个不可并发的资源,这是互斥问题;
  • 读多写少时所有操作都互斥,吞吐差,这是并发粒度问题。

本文会围绕三条主线展开:

  • Java 常见锁的区别与实现原理;
  • volatile 能解决什么,不能解决什么;
  • count = count + 1 到底是不是原子操作;
  • 用一个音视频实例化对象同时访问共享资源的场景题,对比几种加锁实现方案。

1. 并发问题的四个基本概念

在讨论具体工具之前,先分清四个概念。

概念解决什么问题常见手段
原子性 一个操作不能被其他线程看到中间状态 synchronized、Lock、CAS、Atomic
可见性 一个线程写入后,其他线程能及时看到 volatile、锁、线程启动/结束规则
有序性 避免关键读写被错误重排序 volatile、锁、final、安全发布
互斥 同一时间只允许一个线程访问临界区 synchronized、ReentrantLock、Semaphore

很多初学者会把它们混成一句话:“线程不安全”。但定位问题时必须拆开。

例如:

volatile int count = 0;

void increase() {
count = count + 1;
}

volatile 可以让其他线程更快看到 count 的最新值,但不能让 count + 1 变成不可分割的原子操作。所以这段代码仍然不是线程安全的计数器。


2. Java 内存模型要理解什么

Java 并发不直接承诺“每次读写都马上访问主内存”。为了性能,线程可能使用寄存器、CPU 缓存、写缓冲和编译器优化。Java 内存模型定义的是一套抽象规则:在什么情况下,一个线程的写入对另一个线程可见。

可以简化理解为:

线程 A 写变量
-> 如果没有建立 happens-before 关系
-> 线程 B 不一定立刻看到,也不一定按你想象的顺序看到

常见 happens-before 关系包括:

  • 对同一把锁的 unlock happens-before 后续 lock;
  • 对 volatile 变量的写 happens-before 后续对同一变量的读;
  • 线程调用 start() 之前的操作 happens-before 新线程中的操作;
  • 一个线程中的操作按程序顺序 happens-before 后续操作;
  • final 字段在构造完成后的安全发布有特殊保证;
  • Thread.join() 返回后,可以看到目标线程结束前的操作。

这解释了为什么锁不只是“互斥”。锁还提供内存语义:

进入锁:刷新并读取可见数据
退出锁:把临界区内的修改发布出去


3. synchronized 的原理和作用

synchronized 是 Java 内置锁,也叫监视器锁。它可以修饰代码块和方法:

class Counter {
private int count;

public synchronized void increase() {
count++;
}

public synchronized int get() {
return count;
}
}

等价于锁住当前实例对象 this。如果写成静态同步方法,则锁住 Class 对象:

class GlobalCounter {
private static int count;

public static synchronized void increase() {
count++;
}
}

3.1 synchronized 锁的是什么

任何 Java 对象都可以作为锁:

private final Object lock = new Object();

void doWork() {
synchronized (lock) {
// 临界区
}
}

推荐使用私有锁对象,而不是锁 this:

private final Object lock = new Object();

原因是 this 可能被外部代码拿到并作为锁使用,扩大锁竞争范围,甚至引入死锁。

3.2 synchronized 解决什么

它同时解决三类问题:

  • 互斥:同一时间只有一个线程进入同一把锁保护的临界区;
  • 可见性:释放锁前的写入,对后续获取同一把锁的线程可见;
  • 有序性:锁边界限制关键读写的重排序。

示例:

class ConfigStore {
private final Object lock = new Object();
private Map<String, String> config = new HashMap<>();

public void update(Map<String, String> newConfig) {
synchronized (lock) {
config = new HashMap<>(newConfig);
}
}

public String get(String key) {
synchronized (lock) {
return config.get(key);
}
}
}

3.3 synchronized 的优点

  • 语法简单;
  • 自动释放锁,异常时也不会忘记 unlock;
  • JVM 深度优化;
  • 可重入;
  • 和 Java 内存模型结合紧密;
  • 适合保护短小临界区。

3.4 synchronized 的缺点

  • 不能尝试加锁失败后立即返回;
  • 不能设置超时;
  • 不能响应中断等待;
  • 不能像读写锁那样区分读锁和写锁;
  • 高竞争场景下可能影响吞吐;
  • 锁粒度设计不好时容易把无关操作串行化。

4. ReentrantLock 的原理和作用

ReentrantLock 是 java.util.concurrent.locks 包中的显式锁。

典型写法:

class Counter {
private final ReentrantLock lock = new ReentrantLock();
private int count;

public void increase() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}

finally 很关键。显式锁不会自动释放,如果忘记 unlock(),后续线程可能永远阻塞。

4.1 它为什么叫 Reentrant

Reentrant 表示可重入。同一个线程已经拿到锁后,可以再次拿同一把锁:

void outer() {
lock.lock();
try {
inner();
} finally {
lock.unlock();
}
}

void inner() {
lock.lock();
try {
// 同一个线程可以再次进入
} finally {
lock.unlock();
}
}

锁内部会记录持有线程和重入次数。加锁几次,就要释放几次。

4.2 ReentrantLock 比 synchronized 多什么能力

能力说明
tryLock() 尝试加锁,失败可以立即返回
tryLock(timeout) 在指定时间内等待锁
lockInterruptibly() 等锁期间可以响应中断
公平锁 可以按等待顺序获取锁,但吞吐可能下降
多个 Condition 一把锁可以有多个等待队列
显式释放 可以跨方法组织,但更容易出错

示例:避免长时间等待资源初始化锁。

if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
initCodec();
} finally {
lock.unlock();
}
} else {
throw new IllegalStateException("Codec resource is busy");
}

4.3 公平锁和非公平锁

创建公平锁:

private final ReentrantLock lock = new ReentrantLock(true);

公平锁尽量按等待顺序唤醒线程,减少饥饿。非公平锁允许后来线程插队,通常吞吐更高。

选择建议:

  • 大多数业务代码使用默认非公平锁;
  • 对等待顺序有强要求时才用公平锁;
  • 音视频实时场景通常更关注延迟和吞吐,不一定适合公平锁;
  • 公平锁不等于绝对公平,也不等于性能更好。

5. ReadWriteLock:读多写少场景

ReentrantReadWriteLock 把锁分成读锁和写锁:

class MediaConfigCache {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private Map<String, String> config = new HashMap<>();

public String get(String key) {
readLock.lock();
try {
return config.get(key);
} finally {
readLock.unlock();
}
}

public void update(Map<String, String> newConfig) {
writeLock.lock();
try {
config = new HashMap<>(newConfig);
} finally {
writeLock.unlock();
}
}
}

规则:

  • 多个读线程可以同时持有读锁;
  • 写线程持有写锁时,其他读写都阻塞;
  • 读锁和写锁保护的是同一份数据;
  • 适合读多写少,且读操作不是极短的场景。

5.1 优点

  • 读多写少时吞吐比互斥锁高;
  • API 明确表达读和写的意图;
  • 适合配置表、路由表、缓存快照。

5.2 缺点

  • 代码复杂度高于互斥锁;
  • 锁升级容易出问题;
  • 写操作可能被大量读操作影响;
  • 如果读操作很短,收益可能不明显;
  • 如果写操作频繁,可能比普通锁更差。

5.3 锁降级可以,锁升级要谨慎

锁降级是先持有写锁,再获取读锁,然后释放写锁:

writeLock.lock();
try {
updateCache();
readLock.lock();
} finally {
writeLock.unlock();
}

try {
readCache();
} finally {
readLock.unlock();
}

锁升级是持有读锁时再尝试获取写锁,容易死锁或长时间等待。不要随手这么写。


6. StampedLock:乐观读和版本戳

StampedLock 是更偏性能优化的锁,支持:

  • 写锁;
  • 悲观读锁;
  • 乐观读。

示例:

class Position {
private final StampedLock lock = new StampedLock();
private double x;
private double y;

double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.hypot(currentX, currentY);
}

void move(double newX, double newY) {
long stamp = lock.writeLock();
try {
x = newX;
y = newY;
} finally {
lock.unlockWrite(stamp);
}
}
}

乐观读的思想是:

先不阻塞写线程
读取一份数据
检查读取期间有没有写发生
如果没有,结果可用
如果有,退化成悲观读锁重新读

6.1 优点

  • 读多写少且读操作较频繁时性能很好;
  • 乐观读减少读写互斥;
  • 适合坐标、状态快照、统计信息等场景。

6.2 缺点

  • 不可重入;
  • 使用复杂;
  • stamp 用错会出 bug;
  • 不适合临界区里调用复杂业务逻辑;
  • 不适合新手默认使用;
  • 等待锁时对中断支持不如 ReentrantLock 直观。

StampedLock 不是 ReadWriteLock 的无脑替代,它是面向特定场景的性能工具。


7. volatile 的作用和边界

volatile 是 Java 关键字,用在字段上:

private volatile boolean released;

它主要提供两类语义:

  • 对该变量的写,对后续读该变量的线程可见;
  • 限制相关读写重排序,建立 happens-before 关系。

7.1 volatile 适合什么

适合一个线程写,其他线程读的状态标记:

class PlayerLoop {
private volatile boolean running = true;

public void stop() {
running = false;
}

public void loop() {
while (running) {
decodeOneFrame();
}
}
}

如果没有 volatile,循环线程可能长时间看不到 running = false。

也适合安全发布不可变对象引用:

private volatile MediaConfig config = MediaConfig.defaultConfig();

public MediaConfig getConfig() {
return config;
}

public void updateConfig(MediaConfig newConfig) {
config = newConfig;
}

前提是 MediaConfig 本身不可变,或者更新时整体替换引用,而不是多个线程同时修改内部字段。

7.2 volatile 不适合什么

不适合复合操作:

volatile int count = 0;

void increase() {
count++;
}

不适合保护多个变量的不变式:

volatile int width;
volatile int height;

void resize(int w, int h) {
width = w;
height = h;
}

读线程可能看到新 width 和旧 height 的混合状态。如果 width 和 height 必须成对一致,应使用锁或不可变对象整体替换:

record Size(int width, int height) {}

private volatile Size size = new Size(0, 0);

void resize(int width, int height) {
size = new Size(width, height);
}

7.3 volatile 和锁的区别

对比项volatile锁
互斥 不提供 提供
可见性 提供 提供
有序性 提供一部分关键保证 提供锁边界保证
原子性 只保证单次 volatile 读写原子,不保证复合操作 临界区整体原子
阻塞 不阻塞 可能阻塞
适合场景 状态标记、配置引用、安全发布 多变量一致性、复合更新、临界区

一句话:

volatile 是可见性工具,不是互斥工具。


8. count + 1 是不是原子操作

结论先说:

count = count + 1 不是原子操作
count++ 也不是原子操作
++count 也不是原子操作

它看起来是一行代码,但逻辑上至少包含三步:

1. 读取 count 当前值
2. 计算 count + 1
3. 把结果写回 count

两个线程同时执行时可能这样:

初始 count = 0

线程 A 读取 count = 0
线程 B 读取 count = 0
线程 A 计算 0 + 1 = 1
线程 B 计算 0 + 1 = 1
线程 A 写回 count = 1
线程 B 写回 count = 1

期望结果 2,实际结果 1

这就是典型的更新丢失。

8.1 volatile int count 是否能解决

不能。

private volatile int count;

public void increase() {
count = count + 1;
}

volatile 保证每次读到的值尽量是最新发布的值,但不能保证“读、加、写”三步之间不被其他线程插入。

8.2 用 synchronized 解决

class SafeCounter {
private int count;

public synchronized void increase() {
count++;
}

public synchronized int get() {
return count;
}
}

优点:

  • 简单;
  • 正确;
  • 同时保证原子性和可见性。

缺点:

  • 高竞争下可能阻塞;
  • 所有调用串行。

8.3 用 ReentrantLock 解决

class SafeCounter {
private final ReentrantLock lock = new ReentrantLock();
private int count;

public void increase() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}

public int get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}

优点:

  • 可以超时、可中断、可尝试获取锁;
  • 可扩展到 Condition。

缺点:

  • 代码更啰嗦;
  • 忘记 unlock 会造成严重问题。

8.4 用 AtomicInteger 解决

class SafeCounter {
private final AtomicInteger count = new AtomicInteger();

public void increase() {
count.incrementAndGet();
}

public int get() {
return count.get();
}
}

AtomicInteger 底层通过 CAS 思想实现原子更新。简化过程是:

读取旧值 old
计算新值 new = old + 1
尝试把内存中的 old 改成 new
如果期间没有其他线程改过,成功
如果失败,重新读取并重试

优点:

  • 无显式锁;
  • 计数、状态位、简单引用替换非常合适;
  • 低到中等竞争下性能好。

缺点:

  • 只能表达较简单的原子更新;
  • 高竞争下 CAS 自旋重试会消耗 CPU;
  • 多变量一致性不适合单个 Atomic;
  • 容易遇到 ABA 问题,需要额外设计。

8.5 用 LongAdder 解决高并发计数

高并发统计场景可以用:

class MetricsCounter {
private final LongAdder count = new LongAdder();

public void increase() {
count.increment();
}

public long sum() {
return count.sum();
}
}

LongAdder 通过分散热点减少竞争,适合高并发累加指标。

注意:

  • sum() 不是强一致瞬时快照;
  • 不适合余额、库存这类必须严格准确的同步决策;
  • 适合 QPS、播放帧数、丢帧统计等指标。

9. CAS、Atomic 和锁的区别

方案原理适合场景不适合场景
进入临界区前互斥 复杂状态、多变量一致性、资源访问 极高频简单计数
CAS / Atomic 比较并交换,失败重试 简单计数、状态切换、引用替换 复杂临界区、阻塞 IO
LongAdder 分散计数热点 高并发统计指标 需要强一致读取的业务值
volatile 可见性和有序性 状态标记、不可变引用发布 复合更新、互斥资源

如果一个操作包含以下特征,优先考虑锁:

  • 要同时修改多个字段;
  • 要检查后再执行;
  • 临界区内有资源生命周期;
  • 需要和外部系统交互;
  • 失败时需要回滚;
  • 要保证一组状态永远一致。

如果只是一个计数或状态位,优先考虑 Atomic。


10. 常见锁的完整对比

锁/工具是否互斥是否可重入是否可中断是否可超时典型用途
synchronized 等锁不可中断 简单临界区
ReentrantLock 支持 支持 复杂互斥、超时、Condition
ReentrantReadWriteLock 读读不互斥,写互斥 支持 支持 读多写少缓存
StampedLock 支持读写和乐观读 使用复杂 部分支持 高性能读多写少
AtomicInteger 不加锁 不适用 不适用 不适用 简单原子计数
LongAdder 不加锁 不适用 不适用 不适用 高并发统计
Semaphore 控制许可数量 不适用 支持 支持 限流、资源池
CountDownLatch 不是锁 不适用 await 可中断 支持 等待多个任务完成

CountDownLatch 经常被误认为锁。它不是用来保护共享资源的,而是用来协调线程等待:

CountDownLatch latch = new CountDownLatch(2);

new Thread(() -> {
loadAudio();
latch.countDown();
}).start();

new Thread(() -> {
loadVideo();
latch.countDown();
}).start();

latch.await();
startPlayback();

它解决的是“等两个任务完成再继续”,不是“多个线程同时修改共享资源”。


11. 场景题:音视频对象同时访问一个资源

11.1 题目

现在有一个音视频 SDK,需要同时初始化音频对象和视频对象:

AudioEngine 初始化时要读取/写入 MediaResource
VideoEngine 初始化时也要读取/写入 MediaResource
MediaResource 内部包含 native handle、配置表、缓存目录和设备状态
同一时间不能有两个线程同时执行 initNative()
初始化完成后,音频和视频都可以读取配置
释放时不能和初始化同时发生

简化代码:

class MediaResource {
private long nativeHandle;
private boolean initialized;
private MediaConfig config;

void initNative(MediaConfig config) {
// 加载 so、打开设备、创建 codec、申请 buffer
}

void releaseNative() {
// 释放 native handle、codec、buffer
}
}

两个线程可能同时调用:

new Thread(() -> audioEngine.init(sharedResource)).start();
new Thread(() -> videoEngine.init(sharedResource)).start();

请问有哪些加锁方案?各有什么优缺点?


12. 方案一:方法级 synchronized

最简单方式是把资源的关键方法都设为同步方法:

class MediaResource {
private long nativeHandle;
private boolean initialized;
private MediaConfig config;

public synchronized void initialize(MediaConfig newConfig) {
if (initialized) {
return;
}
config = newConfig;
nativeHandle = initNative(newConfig);
initialized = true;
}

public synchronized void release() {
if (!initialized) {
return;
}
releaseNative(nativeHandle);
nativeHandle = 0L;
initialized = false;
}

public synchronized MediaConfig getConfig() {
return config;
}
}

优点

  • 写法最简单;
  • 不容易忘记释放锁;
  • 原子性、可见性、互斥一次解决;
  • 适合初始化过程短、访问频率低的资源;
  • 对面试和小项目最容易讲清楚。

缺点

  • 锁住的是 this,外部代码也可能拿 resource 当锁;
  • 读配置也被初始化和释放阻塞;
  • 无法设置等待超时;
  • 等待锁期间不能响应中断;
  • 如果 initNative() 很慢,锁会持有很久;
  • synchronized 方法粒度容易越来越粗。

适用判断:

资源生命周期简单 + 初始化低频 + 团队需要优先保证正确性


13. 方案二:私有锁对象 + 缩小临界区

更推荐用私有锁对象:

class MediaResource {
private final Object lock = new Object();
private long nativeHandle;
private boolean initialized;
private MediaConfig config;

public void initialize(MediaConfig newConfig) {
synchronized (lock) {
if (initialized) {
return;
}
config = newConfig;
nativeHandle = initNative(newConfig);
initialized = true;
}
}

public void release() {
synchronized (lock) {
if (!initialized) {
return;
}
releaseNative(nativeHandle);
nativeHandle = 0L;
initialized = false;
}
}

public MediaConfig getConfig() {
synchronized (lock) {
return config;
}
}
}

优点

  • 锁对象不会暴露给外部;
  • 互斥范围更可控;
  • 语义比锁 this 更清楚;
  • 自动释放锁;
  • 适合绝大多数资源生命周期保护。

缺点

  • 仍然不能超时或中断等待;
  • 读写仍然互斥;
  • 如果 native 初始化很慢,其他读写都被阻塞;
  • 临界区里调用外部回调可能死锁。

重要原则:

临界区里只做必须互斥的状态读写,不要调用不可控外部回调。

如果 initNative() 很耗时,可以拆成两阶段,但要非常小心状态竞争。


14. 方案三:ReentrantLock + 超时失败

音视频资源常常对延迟敏感。如果锁被占用太久,与其无限等待,不如快速失败或降级。

class MediaResource {
private final ReentrantLock lock = new ReentrantLock();
private long nativeHandle;
private boolean initialized;
private MediaConfig config;

public void initialize(MediaConfig newConfig) throws InterruptedException {
if (!lock.tryLock(300, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("Media resource is busy");
}

try {
if (initialized) {
return;
}
config = newConfig;
nativeHandle = initNative(newConfig);
initialized = true;
} finally {
lock.unlock();
}
}

public void release() {
lock.lock();
try {
if (!initialized) {
return;
}
releaseNative(nativeHandle);
nativeHandle = 0L;
initialized = false;
} finally {
lock.unlock();
}
}
}

优点

  • 可以 tryLock(),避免无限等待;
  • 可以设置超时;
  • 可以响应中断;
  • 可选择公平锁;
  • 能配合 Condition 做复杂状态等待;
  • 适合播放器、编码器、设备资源等需要超时控制的场景。

缺点

  • 必须手动 unlock;
  • 代码比 synchronized 更繁琐;
  • 锁释放路径必须经过严格 review;
  • 公平锁可能降低吞吐;
  • 异常处理不当会导致资源状态半初始化。

适用判断:

资源初始化可能慢 + 调用方需要超时或取消 + 失败可以降级处理


15. 方案四:读写锁保护配置,互斥锁保护生命周期

如果初始化和释放很少,读取配置很多,可以拆成:

  • 生命周期操作用互斥锁;
  • 配置读取用读写锁;
  • 更新配置用写锁。

class MediaResource {
private final ReentrantReadWriteLock configLock = new ReentrantReadWriteLock();
private final Lock readLock = configLock.readLock();
private final Lock writeLock = configLock.writeLock();
private final Object lifecycleLock = new Object();

private long nativeHandle;
private boolean initialized;
private MediaConfig config = MediaConfig.defaultConfig();

public void initialize(MediaConfig newConfig) {
synchronized (lifecycleLock) {
if (initialized) {
return;
}

writeLock.lock();
try {
config = newConfig;
nativeHandle = initNative(newConfig);
initialized = true;
} finally {
writeLock.unlock();
}
}
}

public MediaConfig getConfig() {
readLock.lock();
try {
return config;
} finally {
readLock.unlock();
}
}
}

优点

  • 多个读取配置的线程可以并发;
  • 配置读取不会和其他配置读取互斥;
  • 适合播放过程中频繁读取只读配置;
  • 比所有方法 synchronized 吞吐更好。

缺点

  • 两把锁同时存在,锁顺序必须固定;
  • 容易引入死锁;
  • 代码复杂度明显上升;
  • 如果读取很短,读写锁收益不一定明显;
  • 如果生命周期和配置强绑定,拆锁反而危险。

锁顺序必须统一:

永远先拿 lifecycleLock,再拿 writeLock
不要在持有 readLock 时尝试 initialize/release

适用判断:

读配置非常频繁 + 写配置很少 + 团队能维护清楚锁顺序


16. 方案五:Atomic 状态机 + 锁保护资源

有些场景需要避免重复初始化,同时准确表达状态:

NEW -> INITIALIZING -> READY -> RELEASING -> RELEASED

可以用 AtomicReference 管理状态,再用锁保护真正的 native handle:

enum ResourceState {
NEW,
INITIALIZING,
READY,
RELEASING,
RELEASED
}

class MediaResource {
private final AtomicReference<ResourceState> state =
new AtomicReference<>(ResourceState.NEW);
private final Object nativeLock = new Object();
private long nativeHandle;

public boolean initialize(MediaConfig config) {
if (!state.compareAndSet(ResourceState.NEW, ResourceState.INITIALIZING)) {
return false;
}

try {
long handle = initNative(config);
synchronized (nativeLock) {
nativeHandle = handle;
}
state.set(ResourceState.READY);
return true;
} catch (RuntimeException error) {
state.set(ResourceState.NEW);
throw error;
}
}

public void release() {
if (!state.compareAndSet(ResourceState.READY, ResourceState.RELEASING)) {
return;
}

long handle;
synchronized (nativeLock) {
handle = nativeHandle;
nativeHandle = 0L;
}
releaseNative(handle);
state.set(ResourceState.RELEASED);
}
}

优点

  • 状态变化明确;
  • 可以快速拒绝重复初始化;
  • 不必所有线程都阻塞等待;
  • 适合播放器生命周期、SDK 初始化、连接状态管理;
  • 状态可以暴露给监控或日志。

缺点

  • 状态机设计复杂;
  • CAS 只保护状态,不自动保护资源对象;
  • 初始化失败和回滚路径必须严谨;
  • initNative() 和 releaseNative() 的并发边界要仔细设计;
  • 容易出现状态正确但资源不一致的问题。

适用判断:

生命周期状态复杂 + 需要快速失败 + 初始化/释放需要清晰状态流转


17. 方案六:单线程串行化资源访问

音视频资源有时最好的加锁方式是不显式加锁,而是把资源操作投递到同一个线程执行。

Android 中可以用 HandlerThread:

class MediaResourceDispatcher {
private final HandlerThread thread = new HandlerThread("MediaResourceThread");
private final Handler handler;
private final MediaResource resource = new MediaResource();

MediaResourceDispatcher() {
thread.start();
handler = new Handler(thread.getLooper());
}

public void initialize(MediaConfig config) {
handler.post(() -> resource.initialize(config));
}

public void release() {
handler.post(resource::release);
}

public void shutdown() {
handler.post(() -> {
resource.release();
thread.quitSafely();
});
}
}

也可以在纯 Java 中用单线程 ExecutorService:

class MediaResourceDispatcher {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final MediaResource resource = new MediaResource();

public Future<?> initialize(MediaConfig config) {
return executor.submit(() -> resource.initialize(config));
}

public Future<?> release() {
return executor.submit(resource::release);
}
}

优点

  • 资源操作天然串行;
  • 大幅降低锁复杂度;
  • 很适合音视频、相机、播放器这类有线程亲和性的资源;
  • 调用顺序清晰;
  • 避免多处随手加锁。

缺点

  • 所有操作排队,慢任务会阻塞后续任务;
  • 需要处理任务取消和生命周期;
  • 同步获取返回值需要 Future、callback 或协程封装;
  • 线程退出时要释放资源;
  • 如果调用方误以为方法同步完成,容易产生时序 bug。

适用判断:

资源强依赖调用顺序 + native 对线程敏感 + 操作可以异步排队

在音视频工程里,这通常是我更偏好的方案之一:把复杂并发问题变成明确的事件队列,调试起来也更有秩序。


18. 方案七:不可变快照 + volatile 引用

如果共享资源中只有配置需要被多个线程读取,可以把配置做成不可变对象,用 volatile 发布引用:

record MediaConfig(
int sampleRate,
int channelCount,
int width,
int height,
String codecName
) {}

class MediaConfigStore {
private volatile MediaConfig config =
new MediaConfig(44100, 2, 1920, 1080, "h264");

public MediaConfig getConfig() {
return config;
}

public void update(MediaConfig newConfig) {
config = newConfig;
}
}

优点

  • 读操作无锁;
  • 可见性好;
  • 代码简单;
  • 不会读到半更新对象;
  • 适合配置快照、开关、策略表引用。

缺点

  • 只适合不可变对象整体替换;
  • 不能保护 native handle;
  • 不能表达复杂初始化和释放;
  • 更新频繁且对象很大时会增加分配;
  • 多个资源之间的一致性仍然需要额外机制。

适用判断:

共享的是只读配置快照,而不是需要互斥访问的底层资源


19. 场景题推荐答案

如果这是一个面试题,可以这样回答:

先判断共享资源是什么。
如果只是状态标记,用 volatile 或 Atomic。
如果是 count + 1 这类复合更新,用 AtomicInteger 或锁。
如果是 native handle、codec、文件句柄这种真实资源,用锁或单线程串行化。
如果读多写少,可以考虑 ReadWriteLock。
如果资源生命周期复杂,可以用 Atomic 状态机表达状态,再用锁保护资源。
音视频场景通常还要考虑线程亲和性、初始化超时、释放顺序和回调死锁。

如果这是一个工程方案,我会优先推荐:

资源生命周期操作:单线程 dispatcher 或 ReentrantLock
配置读取:不可变快照 + volatile
计数统计:LongAdder 或 AtomicLong
复杂共享状态:私有锁对象保护

一个更完整的组合方案:

class MediaSession {
private final ExecutorService mediaExecutor =
Executors.newSingleThreadExecutor();
private final AtomicReference<ResourceState> state =
new AtomicReference<>(ResourceState.NEW);
private volatile MediaConfig config = MediaConfig.defaultConfig();
private final LongAdder droppedFrames = new LongAdder();

public Future<Boolean> initialize(MediaConfig newConfig) {
return mediaExecutor.submit(() -> {
if (!state.compareAndSet(ResourceState.NEW, ResourceState.INITIALIZING)) {
return false;
}
try {
config = newConfig;
initNative(newConfig);
state.set(ResourceState.READY);
return true;
} catch (RuntimeException error) {
state.set(ResourceState.NEW);
throw error;
}
});
}

public MediaConfig currentConfig() {
return config;
}

public void onFrameDropped() {
droppedFrames.increment();
}

public long droppedFrameCount() {
return droppedFrames.sum();
}
}

这不是“所有地方都不用锁”,而是按数据性质选择工具:

  • native 生命周期串行化;
  • 配置用不可变快照发布;
  • 状态用 Atomic;
  • 统计用 LongAdder。

20. 加锁时最容易犯的错误

20.1 锁对象不一致

错误:

synchronized (new Object()) {
count++;
}

每次都是新锁,等于没有锁。

正确:

private final Object lock = new Object();

synchronized (lock) {
count++;
}

20.2 只锁写不锁读

错误:

public void update(Config config) {
synchronized (lock) {
this.config = config;
}
}

public Config get() {
return config;
}

如果读写都要看到一致状态,要么读也加同一把锁,要么使用 volatile 发布不可变引用。

20.3 锁内调用外部回调

错误:

synchronized (lock) {
state = READY;
listener.onReady();
}

外部回调不可控,可能反过来调用当前对象,导致死锁或复杂重入。

更稳妥:

Listener callback;
synchronized (lock) {
state = READY;
callback = listener;
}
callback.onReady();

20.4 双重检查锁没有 volatile

错误:

class Singleton {
private static Singleton instance;

static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

正确:

class Singleton {
private static volatile Singleton instance;

static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

更简单的方式是使用静态内部类或 enum。

20.5 锁粒度过大

错误:

synchronized void decodeLoop() {
while (running) {
readPacket();
decode();
render();
}
}

这会让其他操作长时间无法进入。

更好的思路是只锁共享状态:

boolean shouldRun() {
synchronized (lock) {
return running;
}
}

真正耗时的解码、渲染不要随便放在大锁里。


21. 如何选择并发工具

可以按这个顺序判断:

1. 是否只是线程间通知一个状态?
-> volatile

2. 是否只是简单计数或状态切换?
-> AtomicInteger / AtomicReference

3. 是否是高并发统计指标?
-> LongAdder

4. 是否要保护多个字段或资源生命周期?
-> synchronized 或 ReentrantLock

5. 是否读多写少?
-> ReentrantReadWriteLock,必要时 StampedLock

6. 是否资源要求顺序执行或线程亲和?
-> 单线程 Executor / HandlerThread

7. 是否只是等待多个任务完成?
-> CountDownLatch / CompletableFuture,不要误用成锁

再结合工程约束:

约束更合适
代码要简单可靠 synchronized + 私有锁对象
需要超时、取消、可中断 ReentrantLock
读远多于写 ReadWriteLock
读极多且可接受重试 StampedLock
简单原子变量 Atomic*
高并发统计 LongAdder
音视频资源顺序敏感 单线程 dispatcher
生命周期状态复杂 Atomic 状态机 + 资源锁

22. 面试回答模板

22.1 问:Java 中锁有哪些区别?

可以这样答:

synchronized 是 JVM 内置监视器锁,语法简单,自动释放,适合短临界区。
ReentrantLock 是显式锁,支持 tryLock、超时、中断、公平锁和 Condition,但必须 finally unlock。
ReadWriteLock 区分读锁和写锁,适合读多写少。
StampedLock 支持乐观读,性能潜力高,但不可重入,使用复杂。
Atomic 不是锁,基于 CAS 做简单原子更新。
volatile 也不是锁,主要保证可见性和有序性,不保证复合操作原子性。

22.2 问:volatile 能不能保证线程安全?

可以这样答:

要看线程安全指什么。
volatile 能保证变量写入对其他线程可见,并限制相关重排序。
如果只是 running 这种状态标记,volatile 通常可以。
但如果是 count++、检查后执行、多字段一致性,volatile 不够,因为它不提供互斥,也不保证复合操作原子性。

22.3 问:count + 1 是不是原子性的?

可以这样答:

不是。它包含读 count、计算加一、写回 count 三步。
多个线程同时执行会出现更新丢失。
volatile int count 也不能解决这个问题。
可以用 synchronized、ReentrantLock、AtomicInteger.incrementAndGet(),高并发统计可以用 LongAdder。

22.4 问:音视频初始化共享资源怎么加锁?

可以这样答:

如果资源初始化必须互斥,最简单是 synchronized 或私有锁对象。
如果初始化可能很慢,调用方需要超时或取消,可以用 ReentrantLock.tryLock。
如果配置读多写少,可以用 ReadWriteLock 或不可变配置加 volatile 引用。
如果 native 资源有线程亲和性,更推荐单线程 Executor 或 HandlerThread 串行化资源访问。
如果生命周期状态复杂,可以用 AtomicReference 做状态机,再用锁保护 native handle。


23. 最终总结

Java 并发工具不是互相替代的关系,而是分别解决不同层次的问题:

  • volatile 解决可见性和有序性,不解决复合操作原子性;
  • count = count + 1 不是原子操作,即使用 volatile 修饰也不是;
  • synchronized 简单可靠,适合短临界区;
  • ReentrantLock 更灵活,适合超时、取消、复杂等待;
  • ReadWriteLock 适合读多写少;
  • StampedLock 适合更极致的乐观读性能优化;
  • AtomicInteger 适合简单原子更新;
  • LongAdder 适合高并发统计;
  • CountDownLatch 是线程协调工具,不是保护共享资源的锁;
  • 音视频资源常常更适合单线程串行化,因为它们有生命周期顺序和 native 资源约束。

最实用的判断方式是:

先判断你要保护的是一个变量、一个状态、一组字段,还是一个真实资源;再决定用 volatile、Atomic、锁、读写锁,还是单线程队列。

记住这句话,很多并发题都会清晰很多:可见性不等于原子性,原子变量不等于资源互斥,加锁也不等于设计合理。


参考资料

  • Java Language Specification: Threads and Locks
  • Oracle Java API: ReentrantLock
  • Oracle Java API: ReentrantReadWriteLock
  • Oracle Java API: StampedLock
  • Oracle Java API: AtomicInteger
  • Oracle Java API: LongAdder
  • Oracle Java API: CountDownLatch
赞(0)
未经允许不得转载:网硕互联帮助中心 » 一个场景题去理解锁
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!