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 是可见性工具,不是互斥工具。
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
网硕互联帮助中心


评论前必须登录!
注册