1. 引言
本文总结了 Java 并发编程中最常遇到的几类问题,包括它们的成因、典型场景以及规避方案。这些坑看似简单,但在高并发场景下一旦踩中,轻则数据错乱,重则服务雪崩。
✅ 掌握这些内容,能帮你写出更健壮的多线程代码。
⚠️ 注意:本文面向有一定并发经验的开发者,基础概念不再赘述。
2. 使用线程安全的对象
2.1. 共享对象的风险
多线程环境下,线程之间主要通过共享对象来通信。但如果一个线程正在读取某个对象,另一个线程却在修改它,就可能导致读到脏数据;多个线程同时修改,更可能让对象进入不一致状态。
最根本的解决方案是使用不可变对象(Immutable Object),因为其状态无法被修改,天然避免了并发干扰。
但现实开发中,我们无法总是使用不可变对象。此时,必须确保可变对象是线程安全的。
2.2. 集合的线程安全化
集合类(如 HashMap
、ArrayList
)内部维护状态,多线程并发修改极易导致结构破坏或数据丢失。
一个简单粗暴的方式是使用 Collections.synchronizedXXX
包装:
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
List<Integer> list = Collections.synchronizedList(new ArrayList<>());
这类集合通过 synchronized 实现互斥访问,保证任意时刻只有一个线程能操作该集合,从而避免状态不一致。
2.3. 专用的并发集合
⚠️ 但 synchronized
集合有个致命问题:所有操作都串行化。即使只是多个线程同时读,也会互相阻塞,性能极差。
为此,Java 提供了专为高并发设计的集合类,比如:
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
Map<String, String> map = new ConcurrentHashMap<>();
CopyOnWriteArrayList
:写操作时复制底层数组,读操作无锁。适合读远多于写的场景(如监听器列表)。ConcurrentHashMap
:采用分段锁(JDK 8 后为 CAS + synchronized),不同 segment 可并行操作,性能远优于synchronizedMap
。
2.4. 非线程安全类型的正确使用
有些 JDK 内置类看似简单,实则暗藏陷阱。典型代表是 SimpleDateFormat
,它在 parse/format 时会修改内部状态,多线程共享使用会导致解析结果错乱。
如何安全使用?有三种方案:
- 每次使用都 new 一个实例(简单但可能频繁 GC)
- 使用
ThreadLocal<SimpleDateFormat>
,每个线程独享实例 - 对使用代码块加 synchronized
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 使用时
String dateStr = DATE_FORMAT.get().format(new Date());
⚠️ 类似的非线程安全类型还有 Random
(建议用 ThreadLocalRandom
)、StringBuilder
(多线程应改用 StringBuffer
)等。
3. 竞态条件(Race Condition)
3.1. 竞态条件示例
竞态条件指多个线程同时访问共享数据并试图修改它,最终结果依赖线程执行时序。
看一个经典例子:
class Counter {
private int counter = 0;
public void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
counter++
看似原子操作,实则分三步:
- 读取
counter
值 - 值 +1
- 写回
counter
当两个线程几乎同时执行时,可能都读到 0,各自 +1 后写回,最终结果是 1 而非 2。这就是典型的竞态。
3.2. 基于 synchronized 的解决方案
加锁是最直接的修复方式:
class SynchronizedCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public synchronized int getValue() {
return counter;
}
}
synchronized
保证了方法的互斥执行,从而确保操作的原子性。
3.3. 使用原子类(Atomic Classes)
JDK 提供了更高效的 java.util.concurrent.atomic
包,比如 AtomicInteger
:
AtomicInteger atomicInteger = new AtomicInteger(3);
atomicInteger.incrementAndGet(); // 原子自增,返回新值
✅ 优势:
- 无需加锁,基于 CAS(Compare-And-Swap)实现,性能更高
- API 丰富,支持 getAndIncrement、compareAndSet 等原子操作
- 是比手动同步更推荐的方案
4. 集合上的竞态条件
4.1. 问题:同步集合 ≠ 安全操作组合
很多人误以为 synchronizedList
能解决所有问题,但看这个代码:
List<String> list = Collections.synchronizedList(new ArrayList<>());
if(!list.contains("foo")) {
list.add("foo");
}
虽然 contains
和 add
各自是同步的,但整个 if 块不是原子的!两个线程可能同时通过 contains
判断,然后都执行 add
,导致重复添加。
4.2. 列表的解决方案
必须将多个操作包裹在同一个同步块中:
synchronized (list) {
if (!list.contains("foo")) {
list.add("foo");
}
}
⚠️ 关键点:必须使用集合对象本身作为锁(synchronized(list)
),这样才能保证所有访问该集合的代码块互斥。
4.3. ConcurrentHashMap 的原子操作
对于 Map,ConcurrentHashMap
提供了开箱即用的原子方法:
Map<String, String> map = new ConcurrentHashMap<>();
map.putIfAbsent("foo", "bar"); // 键不存在时才插入
或者动态计算值:
map.computeIfAbsent("foo", key -> key + "bar");
✅ 这些方法内部已实现原子性,无需额外同步,是处理“检查再插入”逻辑的首选。
5. 内存一致性问题
5.1. 问题:缓存导致的可见性
现代 CPU 有多级缓存(L1/L2/L3),线程可能将变量缓存在本地,导致修改对其他线程不可见。
回顾 Counter
示例:
class Counter {
private int counter = 0; // 普通变量
// ...
}
线程 A 在自己缓存中将 counter
改为 1,线程 B 仍可能从自己的缓存读到旧值 0。JVM 不保证一个线程的修改能立即被其他线程看到。
5.2. 解决方案:happens-before 与 volatile
要解决可见性问题,必须建立 happens-before 关系,确保一个操作的结果对后续操作可见。
常见手段:
- synchronized:退出同步块时,会将修改刷新到主存;进入时,会从主存重新加载变量。
- volatile:修饰的变量,写操作立即刷新到主存,读操作直接从主存读取。
用 volatile
修复 Counter
:
class SyncronizedCounter {
private volatile int counter = 0; // 保证可见性
public synchronized void increment() {
counter++; // 仍需 synchronized,因为 ++ 非原子
}
public int getValue() {
return counter; // 读取的是最新值
}
}
⚠️ 注意:volatile
只保证可见性和有序性,不保证原子性。所以 increment()
方法仍需加锁。
5.3. long 与 double 的非原子性
根据 JLS 规范,JVM 可能将 64 位的 long
和 double
操作拆分为两个 32 位操作。
这意味着:
- 读取一个非 volatile 的
long
变量时,可能读到“半个新值 + 半个旧值”,得到一个完全错误的随机数。 - 而 volatile 修饰的
long
/double
读写则是原子的。
✅ 结论:在并发场景下,所有共享的 long
/double
变量都应声明为 volatile
。
6. 同步机制的误用
6.1. 错误地同步 this
方法级 synchronized
(即 synchronized void foo()
)本质是 synchronized(this)
,以当前实例为锁。
这等价于:
public void foo() {
synchronized(this) {
//...
}
}
⚠️ 问题:
- 粒度太粗:整个对象被锁,影响并发性能。
- 锁暴露:外部代码可能意外获取
this
锁,导致死锁或性能瓶颈。
✅ 建议:优先使用私有锁对象:
private final Object lock = new Object();
public void foo() {
synchronized(lock) {
// 仅保护关键代码
}
}
6.2. 死锁(Deadlock)
死锁指多个线程互相等待对方持有的锁,导致所有线程永久阻塞。
经典案例:
public class DeadlockExample {
public static Object lock1 = new Object();
public static Object lock2 = new Object();
public static void main(String args[]) {
Thread threadA = new Thread(() -> {
synchronized (lock1) {
System.out.println("ThreadA: Holding lock 1...");
sleep(1000);
System.out.println("ThreadA: Waiting for lock 2...");
synchronized (lock2) {
System.out.println("ThreadA: Holding lock 1 & 2...");
}
}
});
Thread threadB = new Thread(() -> {
synchronized (lock2) {
System.out.println("ThreadB: Holding lock 2...");
sleep(1000);
System.out.println("ThreadB: Waiting for lock 1...");
synchronized (lock1) {
System.out.println("ThreadB: Holding lock 1 & 2...");
}
}
});
threadA.start();
threadB.start();
}
private static void sleep(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
执行结果:
ThreadA: Holding lock 1...
ThreadB: Holding lock 2...
ThreadA: Waiting for lock 2...
ThreadB: Waiting for lock 1...
// 此处永久阻塞
✅ 避免死锁的关键:
- 统一锁的获取顺序:所有线程按相同顺序(如先 lock1 后 lock2)申请锁。
- 使用
java.util.concurrent
中的超时锁(tryLock(timeout)
)或检测工具。
7. 总结
本文梳理了 Java 并发编程中的核心陷阱:
- ✅ 优先使用不可变对象或线程安全类(如
ConcurrentHashMap
、AtomicInteger
) - ✅ 警惕复合操作的竞态,必要时使用同步块或原子方法(如
putIfAbsent
) - ✅ 注意内存可见性,用
volatile
保证变量及时刷新 - ❌ **避免过度使用 synchronized(this)**,改用私有锁对象
- ❌ 预防死锁,规范锁的申请顺序
所有示例代码已整理至 GitHub 仓库:https://github.com/yourname/java-concurrency-examples