并发问题到底是哪些问题

2 阅读14分钟

📋 概述

一句话:多线程代码最可怕的不是出 bug,而是"同一个 bug,有时出现有时不出现"——这就是并发问题的本质:不可复现、难以调试、测试很难覆盖

你有没有遇到过这样的场景?

  • 本地跑了 100 次都没问题的代码,上线第一天就报了数据不一致
  • 接口偶发超时,但日志里找不到任何异常
  • 两个线程各自给 count 加 10000 次,结果有时是 20000,有时是 19847,有时又是 19923

这些"薛定谔的 bug"正是并发编程最让人头疼的地方。原因有三:

问题特征说明比喻
不可复现同样的代码,跑一万次可能只出一次鬼来电:你盯着它就不响,你一转身它就响
难以调试加断点会改变线程时序,bug 反而消失了测不准原理:观测行为改变了被观测对象
测试难覆盖单元测试几乎无法稳定复现竞态条件抽奖:想抽中"出错"那一次概率极低

本篇是"问题篇"的核心,回答一个根本问题:并发到底会带来哪些问题? 后续各篇(volatile / synchronized / 锁体系)讲怎么解决,本篇只讲问题本身 + 排查工具概览 + 问题到解决方案的导航地图。

💡 生活类比:交通问题

并发问题 = 交通问题。把线程想象成司机,共享资源想象成车道:

并发问题交通类比一句话说明
竞态条件两车同时抢一个车道谁先到谁走,但两车同时挤上去就撞了
死锁两车互相堵在窄路上,谁也不让A 等 B 让,B 等 A 让,永远卡住
活锁两人互相让路,结果左右晃了半天谁也没过去都在动,但都没前进
饥饿救护车被堵在车流里,永远到不了有急事的线程永远抢不到 CPU
可见性问题A 在黑板上改了数字,B 还在看自己手里的旧笔记改了但别人看不到
有序性问题你先穿袜子再穿鞋,但某天系统让你先穿鞋再穿袜子步骤被重排导致逻辑出错
原子性问题"先看余额再扣款"被插队:两人同时看余额都有 100 元,各扣 100,实际余额只减了 100复合操作被拆开执行

一句话记忆:路上的事故,本质上都是"抢道"引发的。并发问题的根源也是"多个线程抢同一份数据"。

🔍 7 大并发问题

1. 竞态条件(Race Condition)

问题本质:多个线程对共享数据的最终结果,取决于线程执行的时序——时序变了,结果就变了。

经典案例:count++ 为什么不对?

count++ 看起来是一步,实际上是三步:

mermaid-diagram-2026-08-04-083836.png

两个线程同时执行 count++,如果交错执行:

Thread-1 读 count=5
Thread-2 读 count=5      ← 也读到5
Thread-1 计算 5+1=6,写回 count=6
Thread-2 计算 5+1=6,写回 count=6  ← 丢失了一次更新!

触发条件:多个线程读写同一变量 + 操作非原子 + 无同步措施。

后果:数据不一致、计数器偏低、集合大小不对、业务逻辑跳过关键步骤。

2. 死锁(Deadlock)

问题本质:两个或多个线程互相持有对方需要的锁,导致所有线程都永远等待。

mermaid-diagram-2026-08-04-084350.png

触发条件:两把以上锁 + 多线程交叉获取 + 无固定顺序。

后果:程序完全卡死,CPU 使用率正常(线程都在等,不在跑),接口超时。

3. 活锁(Livelock)

问题本质:线程不断重试某个操作,但每次都因为对方的"配合"而失败。线程没有阻塞,但也没有进展。

生活类比:两人在走廊迎面相遇,同时让到左边,又同时让到右边,结果谁也过不去。

触发条件:线程在失败后立即重试 + 重试策略完全相同(如同时让步)。

后果:CPU 使用率飙高(线程在不断运行),但业务逻辑没有进展。

// 活锁示例:两人互相让路,永远走不过去
public class SimpleLivelock {
    private static volatile boolean aliceYielding = true;
    private static volatile boolean bobYielding = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5 && aliceYielding; i++) {
                System.out.println("Alice: 我让一下...");
                try { Thread.sleep(200); } catch (Exception e) {}
            }
            if (!aliceYielding) System.out.println("Alice 终于通过了!");
        }, "Alice");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5 && bobYielding; i++) {
                System.out.println("Bob: 我也让一下...");
                try { Thread.sleep(200); } catch (Exception e) {}
            }
            if (!bobYielding) System.out.println("Bob 终于通过了!");
        }, "Bob");

        t1.start(); t2.start();
        // 两人都在让路,循环结束时谁也没通过
        t1.join(); t2.join();
        System.out.println("Alice 让路次数: " + aliceYielding);
        System.out.println("Bob 让路次数: " + bobYielding);
    }
}

4. 饥饿(Starvation)

问题本质:某个线程长期得不到 CPU 时间片或锁资源,虽然系统整体在运行,但该线程的任务永远无法完成。

触发条件:线程优先级不合理 + 公平性缺失 + 长时间持有锁的线程。

后果:低优先级任务永远不完成、日志显示任务提交但无执行记录、系统整体吞吐下降。

5. 可见性问题

问题本质:一个线程修改了共享变量的值,但另一个线程读到的还是旧值。原因是 CPU 缓存和编译器优化导致每个线程看到的是"本地副本"。

触发条件:共享变量没有 volatile 修饰 + 没有 synchronized 保护 + 跨线程读写。

后果:线程陷入死循环(flag 改了但另一个线程看不到)、数据过期、条件判断失效。

6. 有序性问题

问题本质:JVM 和 CPU 为了优化性能会对指令进行重排序。单线程下重排不会出错(JMM 保证 as-if-serial),但多线程下重排可能破坏线程间的依赖关系。

触发条件:指令之间存在跨线程依赖 + 没有 volatilesynchronized 阻止重排。

后果:DCL 单例拿到半初始化的对象、初始化顺序错误导致 NPE。

7. 原子性问题

问题本质:一个操作看起来是一步,实际上被拆成了多步执行。多线程环境下,步骤之间可能被其他线程插入。

触发条件:复合操作(读-改-写)没有加锁或使用原子类。

后果:与竞态条件类似——计数器不准、余额计算错误、状态判断跳过关键步骤。

// 原子性问题:两个线程同时扣款,余额只扣了一次
public class AtomicityAccountDemo {
    private static int balance = 100;  // 初始余额 100 元

    public static void main(String[] args) throws Exception {
        Runnable withdraw = () -> {
            int temp = balance;     // ① 读余额
            if (temp >= 50) {       // ② 检查够不够
                try { Thread.sleep(10); } catch (Exception e) {} // 模拟处理
                balance = temp - 50; // ③ 扣款写回
            }
        };

        Thread t1 = new Thread(withdraw, "用户A");
        Thread t2 = new Thread(withdraw, "用户B");
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println("初始余额: 100, 实际余额: " + balance);
        System.out.println("两人都扣了 50,期望余额: 0, 实际: " + balance);
        // 预期:余额 = 50 而不是 0,两次扣款被"合并"了
    }
}

🔍 问题之间的关系

mermaid-diagram-2026-08-04-084432.png

关键因果关系

关系说明
竞态条件 = 原子性问题 + 可见性问题竞态条件是"果",原子性和可见性问题是"因"
有序性问题可能引发可见性问题指令重排导致写操作提前/延后,其他线程看到不一致的中间状态
活锁可能退化为 CPU 飙高线程不断重试但不阻塞,CPU 使用率 100% 但无业务进展
死锁和饥饿可能共存某些线程死锁等锁,另一些线程因死锁线程长期占资源而饥饿
原子性问题不一定是竞态条件单个原子变量的 CAS 操作虽然"复合"但由硬件保证原子性,不算竞态条件

🔍 排查工具速览

本篇只做概览,详细的排查步骤和命令见 03_问题与应对/03_并发问题排查实战

工具一句话用途最简命令适用问题
jstack导出线程栈快照,分析线程状态和锁持有关系jstack <pid> > dump.txt死锁、线程泄漏、BLOCKED 分析
jconsoleGUI 可视化监控,一键检测死锁jconsole <pid> → Thread → Detect Deadlock死锁、线程监控
arthas在线诊断神器,实时追踪线程和方法thread -b(死锁)/ thread -n 3(CPU 最高)全场景,尤其是 CPU 飙高和死锁
top + printf系统级定位 CPU 最高的线程top -Hp <pid>printf "%x" <tid>jstackCPU 飙高
jstatJVM 统计信息,看 GC 情况jstat -gcutil <pid> 1000GC 相关的 CPU 飙高
# 三板斧:定位 CPU 最高的线程(Linux)
top -Hp <pid>                        # 找 CPU 最高的线程 tid
printf "%x\n" <tid>                   # 转 16 进制
jstack <pid> | grep "<hex>" -A 30    # 在线程栈中定位
                                                    |

💻 可跑代码

以下 5 个 demo 都能直接 javac && java 跑,每个故意制造问题让你看到现象。

Demo 1:竞态条件(count++ 丢失更新)

public class RaceConditionDemo {
    private static int count = 0;

    public static void main(String[] args) throws Exception {
        Runnable r = () -> {
            for (int i = 0; i < 10000; i++) {
                count++;  // 非原子操作
            }
        };
        Thread t1 = new Thread(r, "Thread-A");
        Thread t2 = new Thread(r, "Thread-B");
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println("期望: 20000, 实际: " + count);
        // 预期输出:实际值 < 20000,如 "期望: 20000, 实际: 19847"
    }
}

Demo 2:死锁(交叉锁)

public class DeadlockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread-1: 持有 lockA,等待 lockB...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockB) {
                    System.out.println("Thread-1: 拿到 lockB,完成!");
                }
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("Thread-2: 持有 lockB,等待 lockA...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockA) {
                    System.out.println("Thread-2: 拿到 lockA,完成!");
                }
            }
        }, "Thread-2");

        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println("程序结束");
        // 预期输出:程序卡住,永远不会打印"程序结束"
    }
}

Demo 3:活锁(两人互相让路)

public class LivelockDemo {
    static class Person {
        private final String name;
        private boolean yielding;

        Person(String name, boolean yielding) {
            this.name = name;
            this.yielding = yielding;
        }

        String getName() { return name; }
        boolean isYielding() { return yielding; }
        void setYielding(boolean yielding) { this.yielding = yielding; }
    }

    public static void main(String[] args) throws InterruptedException {
        Person alice = new Person("Alice", true);
        Person bob = new Person("Bob", true);
        final int[] steps = {0};

        Runnable walk = () -> {
            for (int i = 0; i < 10; i++) {
                if (((Person) Thread.currentThread()).isYielding()) {
                    System.out.println(Thread.currentThread().getName() + " 让路...");
                    try { Thread.sleep(100); } catch (InterruptedException e) {}
                    continue;
                }
                System.out.println(Thread.currentThread().getName() + " 通过了!");
                steps[0]++;
            }
        };

        // 两人都在让路,谁也过不去
        alice.yielding = true;
        bob.yielding = true;
        Thread t1 = new Thread(() -> { for (int i = 0; i < 10; i++) { if (alice.yielding) { System.out.println("Alice 让路..."); try { Thread.sleep(100); } catch (Exception e) {} } else { System.out.println("Alice 通过!"); steps[0]++; break; } } }, "Alice");
        Thread t2 = new Thread(() -> { for (int i = 0; i < 10; i++) { if (bob.yielding) { System.out.println("Bob 让路..."); try { Thread.sleep(100); } catch (Exception e) {} } else { System.out.println("Bob 通过!"); steps[0]++; break; } } }, "Bob");
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println("步数: " + steps[0]);
        // 预期输出:Alice 和 Bob 交替打印"让路...",步数为 0
    }
}

Demo 4:可见性问题(flag 不加 volatile)

public class VisibilityDemo {
    private static boolean flag = false;  // 没有 volatile

    public static void main(String[] args) throws Exception {
        Thread reader = new Thread(() -> {
            while (!flag) {
                // 子线程可能永远看不到 flag 变为 true
            }
            System.out.println("子线程:看到 flag 变了!");
        }, "Reader");

        reader.start();
        Thread.sleep(1000);
        flag = true;  // 主线程修改
        System.out.println("主线程:flag 设为 true");

        reader.join(3000);
        if (reader.isAlive()) {
            System.out.println("⚠️ 子线程仍在死循环!flag 改了但它看不到");
            reader.interrupt();
        }
        // 预期输出:子线程可能永远不打印"看到 flag 变了"
    }
}

Demo 5:原子性问题(i++ 竞争)

public class AtomicityDemo {
    private static int counter = 0;

    public static void main(String[] args) throws Exception {
        int threadCount = 10;
        int incrementsPerThread = 100000;
        Thread[] threads = new Thread[threadCount];

        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < incrementsPerThread; j++) {
                    counter++;  // 非原子操作
                }
            }, "Worker-" + i);
            threads[i].start();
        }

        for (Thread t : threads) t.join();
        int expected = threadCount * incrementsPerThread;
        System.out.println("期望: " + expected + ", 实际: " + counter);
        System.out.println("丢失: " + (expected - counter) + " 次更新");
        // 预期输出:实际值远小于 1000000
    }
}

Demo 6:有序性问题(DCL 不加 volatile)

public class DCLUnsafeDemo {
    private static DCLUnsafeDemo instance;  // 没有 volatile

    static class DCLUnsafeDemo {
        public String data;
        DCLUnsafeDemo() {
            // 模拟耗时初始化
            data = "初始化完成";
            System.out.println("构造函数:对象初始化完毕");
        }
    }

    public static DCLUnsafeDemo getInstance() {
        if (instance == null) {                   // 第一次检查(无锁)
            synchronized (DCLUnsafeDemo.class) {
                if (instance == null) {           // 第二次检查(有锁)
                    instance = new DCLUnsafeDemo();  // ← 这不是原子的!
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) throws Exception {
        // 多线程同时获取实例
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                DCLUnsafeDemo obj = getInstance();
                if (obj.data == null) {
                    System.out.println("⚠️ 拿到半初始化对象!data == null");
                }
            }).start();
        }
        Thread.sleep(2000);
        System.out.println("单例 data: " + (instance != null ? instance.data : "null"));
        // 预期:可能出现 data == null(半初始化对象)
    }
}

⚠️ 常见问题

Q1:并发问题能完全避免吗?

不能完全避免,但可以大幅降低发生概率。 遵循三个原则:① 优先使用不可变对象(String、Integer、Record);② 优先使用高层并发工具(线程池、ConcurrentHashMap、CompletableFuture)而非手写锁;③ 最小化共享可变状态。做到这三点,大部分并发问题就不会出现。

Q2:测试能发现并发 bug 吗?

常规单元测试几乎不可能。 竞态条件的出现取决于线程调度时序,代码跑 100 次可能只出 1 次。如果要测试并发代码,需要:① 使用 CountDownLatch/CyclicBarrier 精确控制多线程启动时机;② 跑 10000+ 次循环增加复现概率;③ 使用 jcstress(Java Concurrency Stress Test)框架做压力测试。但即使如此,也只能"提高发现概率",不能"保证发现"。

Q3:什么时候不需要关心线程安全?

当以下任一条件满足时,通常不需要线程安全:① 只有一个线程在访问数据(没有共享);② 多个线程但都是只读(没有写操作);③ 数据是不可变的(String、Integer、Record 等 final 类);④ 使用了线程安全的并发容器(ConcurrentHashMapCopyOnWriteArrayList)。

Q4:并发问题和分布式问题有啥关系?

本质上是同一类问题在不同尺度上的体现。 并发问题是多个线程抢同一台机器上的数据;分布式问题是多个节点抢同一份数据。解决方案的思路也类似:并发用锁(synchronized/ReentrantLock),分布式用分布式锁(Redis/ZooKeeper);并发用 CAS,分布式用乐观锁(版本号)。理解并发问题是理解分布式系统的基石。

🎯 最佳实践

原则做法说明
优先不可变StringIntegerRecordfinal 字段不可变对象天然线程安全,无需加锁
优先高层工具ConcurrentHashMapAtomicInteger、线程池不要手写锁,框架已经处理好
最小化共享ThreadLocal、每个线程独立数据不共享就没有并发问题
用锁保护共享状态synchronized/ReentrantLock 保护临界区必须共享时,用锁串行化访问
按序加锁所有线程按固定顺序获取多把锁破坏死锁的循环等待条件
tryLock 超时ReentrantLock.tryLock(timeout)避免无限等待导致死锁
锁内不做慢操作网络/IO/sleep 移到锁外减少持锁时间,降低竞争概率
volatile 标志位volatile boolean flag 控制线程启停解决可见性问题,比 synchronized 轻量

💡 面试要点

  1. 7 大并发问题是哪些? → 竞态条件、死锁、活锁、饥饿、可见性问题、有序性问题、原子性问题。能说清每个问题的本质和触发条件。
  2. 死锁的 4 个必要条件? → 互斥、占有并等待、不可抢占、循环等待。破坏任一条件即可预防。最常见的方案是按序加锁(破坏循环等待)和 tryLock 超时(破坏不可抢占)。
  3. 竞态条件的本质是什么? → 是原子性问题 + 可见性问题的综合表现。count++ 不是原子操作(读-改-写三步),多线程交错执行就会丢失更新。
  4. 可见性问题的原因? → CPU 缓存(每个核有 L1/L2 缓存)+ 编译器/JVM 优化(把变量缓存到寄存器)。一个线程改了变量值,其他线程可能还在读自己的本地副本。解决方案:volatilesynchronized
  5. 如何预防并发问题? → 三个层次:① 设计层:不可变对象、最小化共享;② 代码层:优先用 ConcurrentHashMap 等并发容器;③ 锁层:synchronized/ReentrantLock 保护临界区。
  6. jstack 能检测所有并发问题吗? → 不能。jstack 只能检测 Java 级别的经典死锁。活锁、饥饿、可见性问题、有序性问题都需要通过代码分析或 arthas 等工具间接判断。
  7. 并发问题为什么测试难发现? → 因为取决于线程调度时序,具有不确定性。同一段代码跑 100 次可能只出 1 次。需要用 CountDownLatch 精确控制时序,或用 jcstress 框架做压力测试。

📝 总结

要点记住这一句
并发问题本质不可复现、难以调试、测试难覆盖
7 大问题竞态条件、死锁、活锁、饥饿、可见性、有序性、原子性
竞态条件非原子操作 + 多线程交错 = 结果取决于时序
死锁两把锁交叉持有 → 4 个必要条件 → 按序加锁预防
可见性CPU 缓存导致线程看不到最新值 → volatile 解决
排查三板斧top -Hpprintf "%x"jstack 定位代码行
预防原则不可变 > 高层工具 > 最小化共享 > 加锁保护