1. 引言

本文总结了 Java 并发编程中最常遇到的几类问题,包括它们的成因、典型场景以及规避方案。这些坑看似简单,但在高并发场景下一旦踩中,轻则数据错乱,重则服务雪崩。

✅ 掌握这些内容,能帮你写出更健壮的多线程代码。
⚠️ 注意:本文面向有一定并发经验的开发者,基础概念不再赘述。

2. 使用线程安全的对象

2.1. 共享对象的风险

多线程环境下,线程之间主要通过共享对象来通信。但如果一个线程正在读取某个对象,另一个线程却在修改它,就可能导致读到脏数据;多个线程同时修改,更可能让对象进入不一致状态。

最根本的解决方案是使用不可变对象(Immutable Object),因为其状态无法被修改,天然避免了并发干扰。

但现实开发中,我们无法总是使用不可变对象。此时,必须确保可变对象是线程安全的。

2.2. 集合的线程安全化

集合类(如 HashMapArrayList)内部维护状态,多线程并发修改极易导致结构破坏或数据丢失。

一个简单粗暴的方式是使用 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++ 看似原子操作,实则分三步:

  1. 读取 counter
  2. 值 +1
  3. 写回 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");
}

虽然 containsadd 各自是同步的,但整个 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 位的 longdouble 操作拆分为两个 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 并发编程中的核心陷阱:

  1. 优先使用不可变对象或线程安全类(如 ConcurrentHashMapAtomicInteger
  2. 警惕复合操作的竞态,必要时使用同步块或原子方法(如 putIfAbsent
  3. 注意内存可见性,用 volatile 保证变量及时刷新
  4. ❌ **避免过度使用 synchronized(this)**,改用私有锁对象
  5. 预防死锁,规范锁的申请顺序

所有示例代码已整理至 GitHub 仓库:https://github.com/yourname/java-concurrency-examples


原始标题:Common Concurrency Pitfalls in Java