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

一个场景题去理解锁

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)

评论 抢沙发

评论前必须登录!