跳转到内容
123xiao | 无名键客

《Java开发踩坑实录:8个常见并发问题的排查思路与修复方案》

字数: 0 阅读时长: 1 分钟

背景与问题

并发问题最让人头疼的地方,不是“难”,而是“飘”。

有些 Bug 在测试环境死活复现不了,上线后却在高峰期稳定出现;有些问题看起来像数据库慢、像网络抖动,最后却发现是线程池打满、锁竞争严重;还有些问题更隐蔽——代码跑了半年没事,一次扩容后突然开始丢数据、重复执行、CPU 飙高。

我自己踩过不少坑,后来慢慢总结出一个规律:并发问题不要上来就改代码,先分清“症状”、再还原“时序”、最后确认“共享资源”。这篇文章就从排查视角出发,拆解 8 个 Java 开发里非常常见的并发问题,并给出可执行的修复方案。

先给一张总览图。

flowchart TD
    A[线上异常现象] --> B{表现类型}
    B --> C[数据错误/重复/丢失]
    B --> D[接口变慢/超时]
    B --> E[CPU飙高]
    B --> F[线程卡死]
    C --> G[检查共享变量/原子性/可见性]
    D --> H[检查锁竞争/线程池/阻塞队列]
    E --> I[检查自旋/死循环/CAS重试]
    F --> J[检查死锁/外部资源阻塞/Future.get]
    G --> K[定位代码路径]
    H --> K
    I --> K
    J --> K
    K --> L[复现+打点+jstack+jfr]
    L --> M[修复并压测验证]

核心原理

Java 并发问题,通常绕不开三个基础点:

1. 原子性

count++ 不是原子操作,它会拆成:

  1. 读取 count
  2. 加 1
  3. 写回 count

多个线程同时执行时,就可能发生覆盖写,导致结果比预期小。

2. 可见性

一个线程修改了共享变量,另一个线程不一定立刻可见。
如果没有 volatile、锁、原子类等内存语义保证,就可能读到旧值。

3. 有序性

编译器和 CPU 会做指令重排。
单线程看没问题,多线程下可能出问题,比如经典的双重检查锁单例若没加 volatile 就可能拿到“半初始化对象”。

可以用这张图快速记忆:

classDiagram
    class 并发三要素 {
      原子性
      可见性
      有序性
    }

    class 常见手段 {
      synchronized
      volatile
      Lock
      Atomic*
      ConcurrentHashMap
      ThreadPoolExecutor
    }

    并发三要素 --> 常见手段 : 通过这些工具保证

现象复现

先放一个可运行的小程序,它故意包含几个并发坑,方便你本地跑着看现象。

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class ConcurrentPitfallsDemo {

    // 坑1:非线程安全计数器
    static int unsafeCounter = 0;

    // 正确方式
    static AtomicInteger safeCounter = new AtomicInteger(0);

    // 坑2:线程不安全集合
    static Map<String, Integer> unsafeMap = new HashMap<>();
    static Map<String, Integer> safeMap = new ConcurrentHashMap<>();

    // 坑3:可见性问题
    static boolean stop = false;
    static volatile boolean volatileStop = false;

    public static void main(String[] args) throws Exception {
        testUnsafeCounter();
        testSafeCounter();
        testUnsafeMap();
        testVisibility();
        testThreadPoolReject();
    }

    static void testUnsafeCounter() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(8);
        CountDownLatch latch = new CountDownLatch(1000);
        for (int i = 0; i < 1000; i++) {
            pool.execute(() -> {
                for (int j = 0; j < 1000; j++) {
                    unsafeCounter++;
                }
                latch.countDown();
            });
        }
        latch.await();
        pool.shutdown();
        System.out.println("unsafeCounter = " + unsafeCounter);
        System.out.println("expected = " + 1000 * 1000);
    }

    static void testSafeCounter() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(8);
        CountDownLatch latch = new CountDownLatch(1000);
        for (int i = 0; i < 1000; i++) {
            pool.execute(() -> {
                for (int j = 0; j < 1000; j++) {
                    safeCounter.incrementAndGet();
                }
                latch.countDown();
            });
        }
        latch.await();
        pool.shutdown();
        System.out.println("safeCounter = " + safeCounter.get());
    }

    static void testUnsafeMap() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(8);
        CountDownLatch latch = new CountDownLatch(1000);
        for (int i = 0; i < 1000; i++) {
            int idx = i % 10;
            pool.execute(() -> {
                unsafeMap.put("k" + idx, idx);
                safeMap.put("k" + idx, idx);
                latch.countDown();
            });
        }
        latch.await();
        pool.shutdown();
        System.out.println("unsafeMap size = " + unsafeMap.size());
        System.out.println("safeMap size = " + safeMap.size());
    }

    static void testVisibility() throws Exception {
        Thread t1 = new Thread(() -> {
            while (!stop) {
                // busy wait
            }
            System.out.println("t1 stopped");
        });

        Thread t2 = new Thread(() -> {
            while (!volatileStop) {
                // busy wait
            }
            System.out.println("t2 stopped");
        });

        t1.start();
        t2.start();

        Thread.sleep(1000);
        stop = true;
        volatileStop = true;

        t1.join(1000);
        t2.join(1000);

        System.out.println("t1 alive = " + t1.isAlive());
        System.out.println("t2 alive = " + t2.isAlive());
    }

    static void testThreadPoolReject() {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2,
                2,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(2),
                new ThreadPoolExecutor.AbortPolicy()
        );

        for (int i = 0; i < 10; i++) {
            int taskId = i;
            try {
                executor.execute(() -> {
                    try {
                        Thread.sleep(3000);
                        System.out.println("task " + taskId + " done");
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            } catch (RejectedExecutionException e) {
                System.out.println("task " + taskId + " rejected");
            }
        }
        executor.shutdown();
    }
}

这段代码可以直接运行,能看到:

  • 计数结果不对
  • 可见性问题导致线程不退出
  • 线程池任务被拒绝

这几个现象,已经覆盖了很多线上故障的原型。


8 个常见并发问题的排查思路与修复方案


1. count++ 导致计数不准

典型现象

  • 订单量、点击数、库存扣减结果偶发不对
  • 并发压测下统计值明显偏小
  • 单线程测试完全正常

根因

count++ 不是原子操作,多线程下会发生写覆盖。

错误示例

public class CounterService {
    private int count = 0;

    public void incr() {
        count++;
    }

    public int get() {
        return count;
    }
}

排查思路

我一般这么查:

  1. 找所有被多个线程共享的变量
  2. 看更新逻辑是不是“读-改-写”
  3. 看是否有锁、原子类、CAS 保护
  4. 本地写个 100~1000 线程压测小程序复现

修复方案

  • 简单计数:AtomicInteger
  • 高并发热点计数:LongAdder
  • 需要复合操作一致性:synchronizedLock
import java.util.concurrent.atomic.AtomicInteger;

public class SafeCounterService {
    private final AtomicInteger count = new AtomicInteger();

    public void incr() {
        count.incrementAndGet();
    }

    public int get() {
        return count.get();
    }
}

边界条件

如果你的逻辑不是简单加一,而是:

if (count < 10) {
    count++;
}

那就不是单纯原子自增问题,而是复合操作原子性问题。此时仅用 AtomicIntegerget()incrementAndGet() 也可能出错,需要 compareAndSet 或加锁。


2. HashMap / ArrayList 在并发下数据异常

典型现象

  • 集合大小不对
  • 遍历时抛 ConcurrentModificationException
  • 缓存数据偶发丢失、覆盖
  • 老项目里还可能出现 CPU 异常高

根因

HashMapArrayList 默认都不是线程安全的。

错误示例

Map<String, Integer> cache = new HashMap<>();

public void put(String key, Integer value) {
    cache.put(key, value);
}

排查思路

重点看这三点:

  • 这个集合是不是成员变量、单例 Bean、缓存对象
  • 有没有被多个请求线程同时访问
  • 有没有“遍历 + 修改”的组合操作

修复方案

  • 读多写少缓存:ConcurrentHashMap
  • 简单同步:Collections.synchronizedMap
  • 写时复制场景:CopyOnWriteArrayList
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class SafeCacheService {
    private final Map<String, Integer> cache = new ConcurrentHashMap<>();

    public void put(String key, Integer value) {
        cache.put(key, value);
    }

    public Integer get(String key) {
        return cache.get(key);
    }
}

注意

ConcurrentHashMap 只保证单次操作线程安全。
如果你这样写:

if (!cache.containsKey(key)) {
    cache.put(key, value);
}

仍然可能有竞态条件。更合适的是:

cache.putIfAbsent(key, value);

3. 线程无法停止:可见性问题

典型现象

  • 服务停止很慢
  • 后台线程明明设置了退出标志,却一直不退出
  • CPU 长时间占用在某个循环里

根因

线程读到的是本地缓存里的旧值,没有看到其他线程对变量的更新。

错误示例

public class Worker {
    private boolean running = true;

    public void stop() {
        running = false;
    }

    public void work() {
        while (running) {
            // do work
        }
    }
}

修复方案

volatile 保证可见性:

public class Worker {
    private volatile boolean running = true;

    public void stop() {
        running = false;
    }

    public void work() {
        while (running) {
            // do work
        }
    }
}

排查经验

这个问题常被误判成“线程卡死”。
我会先看 jstack

  • 如果线程状态一直是 RUNNABLE
  • 栈顶在某个 while 循环
  • 没有阻塞、没有等待锁

那大概率就是可见性或者死循环逻辑问题。


4. 死锁:两个线程都在等对方

典型现象

  • 接口完全卡住
  • 线程数不高,但吞吐掉到接近 0
  • jstack 明确显示 Found one Java-level deadlock

错误示例

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

    public void method1() {
        synchronized (lockA) {
            sleep(100);
            synchronized (lockB) {
                System.out.println("method1");
            }
        }
    }

    public void method2() {
        synchronized (lockB) {
            sleep(100);
            synchronized (lockA) {
                System.out.println("method2");
            }
        }
    }

    private void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

定位路径

死锁问题非常适合按固定套路查:

sequenceDiagram
    participant T1 as 线程1
    participant A as 锁A
    participant B as 锁B
    participant T2 as 线程2

    T1->>A: 获取锁A
    T2->>B: 获取锁B
    T1->>B: 等待锁B
    T2->>A: 等待锁A
  1. jps 找进程
  2. jstack pid > stack.log
  3. 搜索 deadlock
  4. 找到涉及的类、方法、锁对象
  5. 回代码里看是否存在锁顺序不一致

修复方案

  • 统一加锁顺序
  • 尽量避免嵌套锁
  • tryLock + 超时 防止永久等待
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TryLockDemo {
    private final ReentrantLock lockA = new ReentrantLock();
    private final ReentrantLock lockB = new ReentrantLock();

    public void work() throws InterruptedException {
        if (lockA.tryLock(1, TimeUnit.SECONDS)) {
            try {
                if (lockB.tryLock(1, TimeUnit.SECONDS)) {
                    try {
                        System.out.println("do work");
                    } finally {
                        lockB.unlock();
                    }
                }
            } finally {
                lockA.unlock();
            }
        }
    }
}

5. 线程池用错,导致请求堆积或任务丢失

典型现象

  • 接口 RT 越来越高
  • 内存上涨
  • 拒绝任务异常 RejectedExecutionException
  • 日志线程、消息消费线程突然不工作

根因

常见误区有几个:

  • 直接用 Executors.newFixedThreadPool(),队列无界
  • 线程池参数拍脑袋设置
  • 没有自定义拒绝策略和监控
  • IO 密集和 CPU 密集任务混用一个池

错误示例

ExecutorService executor = Executors.newFixedThreadPool(100);

看起来没问题,但底层队列可能是无界的,任务积压时会把内存拖爆。

排查思路

我通常会先看:

  • 活跃线程数
  • 队列长度
  • 任务平均执行时长
  • 拒绝次数
  • 线程栈是否卡在 IO / DB / 锁等待上

修复方案

显式创建 ThreadPoolExecutor

import java.util.concurrent.*;

public class ThreadPoolConfig {
    public static ExecutorService newBizPool() {
        return new ThreadPoolExecutor(
                8,
                16,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(200),
                r -> {
                    Thread t = new Thread(r);
                    t.setName("biz-pool-" + t.getId());
                    return t;
                },
                new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }
}

建议

  • CPU 密集:线程数接近 CPU 核数
  • IO 密集:可适当放大,但一定要压测
  • 核心业务不要默默丢任务,拒绝策略要明确

6. Future.get() 或外部调用导致线程池“假死”

典型现象

  • 线程池线程都在忙,但业务几乎不处理
  • 大量线程阻塞在 FutureTask.get、HTTP 调用、数据库等待
  • 上游超时导致下游雪崩

根因

线程池不是只怕“任务多”,也怕“任务一直不结束”。
尤其是在线程池任务里同步等待另一个任务结果,或者发起慢 IO 调用,很容易把池子耗尽。

错误示例

ExecutorService pool = Executors.newFixedThreadPool(4);

public String handle() throws Exception {
    Future<String> future = pool.submit(() -> {
        Thread.sleep(5000);
        return "ok";
    });
    return future.get();
}

如果这种模式层层嵌套,就很容易形成线程饥饿。

排查思路

看线程栈:

  • WAITING
  • TIMED_WAITING
  • parking to wait for
  • FutureTask.get
  • 网络客户端阻塞调用

修复方案

  • get() 设置超时
  • 异步链路用 CompletableFuture
  • 外部依赖调用设置超时、隔离线程池
  • 避免线程池任务再提交同池任务并等待
import java.util.concurrent.*;

public class FutureTimeoutDemo {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(4);
        Future<String> future = pool.submit(() -> {
            Thread.sleep(3000);
            return "ok";
        });

        try {
            String result = future.get(1, TimeUnit.SECONDS);
            System.out.println(result);
        } catch (TimeoutException e) {
            System.out.println("timeout");
            future.cancel(true);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            pool.shutdown();
        }
    }
}

7. 双重检查锁单例写错,拿到半初始化对象

典型现象

  • 单例对象偶发 NullPointerException
  • 问题难复现,线上偶尔炸一次
  • 代码“看起来很标准”

错误示例

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

根因

对象创建可能发生指令重排:

  1. 分配内存
  2. 引用指向内存
  3. 执行构造方法

如果 2 和 3 重排,其他线程可能拿到未完全初始化的对象。

修复方案

volatile

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

更稳妥的写法

我更推荐静态内部类:

public class Singleton {
    private Singleton() {}

    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

8. ThreadLocal 使用不当,导致内存泄漏或数据串用

典型现象

  • 请求间数据污染
  • 用户 A 的上下文出现在用户 B 请求里
  • 长时间运行后内存持续上涨
  • 线程池复用下出现诡异脏数据

根因

ThreadLocal 的值跟着线程走,不是跟着请求走。
在线程池里线程会复用,如果不 remove(),旧值可能残留到下一次任务。

错误示例

public class UserContext {
    private static final ThreadLocal<String> currentUser = new ThreadLocal<>();

    public static void set(String user) {
        currentUser.set(user);
    }

    public static String get() {
        return currentUser.get();
    }
}

正确示例

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalDemo {
    private static final ThreadLocal<String> currentUser = new ThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(1);

        pool.execute(() -> {
            try {
                currentUser.set("userA");
                System.out.println("task1 user = " + currentUser.get());
            } finally {
                currentUser.remove();
            }
        });

        pool.execute(() -> {
            System.out.println("task2 user = " + currentUser.get());
        });

        pool.shutdown();
    }
}

排查建议

遇到“串数据”的问题,别只盯着缓存和数据库,
要反过来想:是不是线程上下文没清理干净


常见坑与排查

这一节我把排查路径再收拢一下,适合真实线上故障时快速使用。

1. 先看症状归类

  • 数据错:优先查共享变量、集合、缓存、幂等
  • 变慢:优先查锁、线程池、外部调用
  • 卡死:优先查死锁、无限循环、阻塞等待
  • CPU 高:优先查忙等、自旋、CAS 重试、频繁上下文切换
  • 内存涨:优先查任务堆积、ThreadLocal、无界队列

2. 再抓现场

常用命令:

jps -l
jstack <pid> > stack.log
jstat -gc <pid> 1000

如果线上允许,也可以配合:

  • JFR
  • Arthas
  • VisualVM
  • Async Profiler

3. 看线程状态

线程状态常见含义
RUNNABLE可能在跑计算,也可能在忙等
BLOCKED等待进入 synchronized
WAITING无期限等待,如 Object.wait() / Future.get()
TIMED_WAITING限时等待,如 sleep() / get(timeout)

4. 重点看哪些栈信息

  • java.util.concurrent.locks
  • FutureTask.get
  • Unsafe.park
  • synchronized
  • HTTP/数据库客户端调用
  • 自己的业务循环代码

止血方案

并发问题不一定能一次性优雅修好,线上先止血也很重要。

能快速止血的手段

  1. 降并发

    • 限流
    • 缩小消费线程数
    • 关闭非核心异步任务
  2. 隔离资源

    • 核心线程池和非核心线程池拆分
    • 慢接口独立线程池
    • 外部依赖设置超时和熔断
  3. 减少锁竞争

    • 缩小同步块范围
    • 用分段锁或并发容器
    • 读写分离
  4. 快速修补

    • 共享标志位补 volatile
    • HashMap 替换成 ConcurrentHashMap
    • ThreadLocal 使用后补 remove()

但要注意:止血方案不等于最终方案。
比如简单加大线程池,短期看似恢复,长期可能只是把问题从“超时”拖成“内存爆”。


安全/性能最佳实践

1. 不要把“线程安全”理解成“全都加锁”

锁能解决问题,但也可能制造性能瓶颈。
优先级建议是:

  1. 能无共享就无共享
  2. 能不可变就不可变
  3. 能用并发容器就别自己造锁
  4. 需要一致性再加锁

2. 共享状态越少,并发 Bug 越少

比如:

  • 尽量让方法无状态
  • 避免单例 Bean 持有可变成员
  • 上下文尽量通过参数传递,而不是全局变量

3. 线程池必须可观测

至少监控:

  • 核心线程数
  • 活跃线程数
  • 队列长度
  • 已完成任务数
  • 拒绝次数
  • 平均耗时 / P99

4. 对外部依赖必须设超时

包括:

  • HTTP
  • RPC
  • 数据库
  • Redis
  • MQ

否则并发一上来,线程会被慢依赖拖死。

5. 并发代码必须压测验证

不要只靠单元测试。
建议至少覆盖:

  • 100~1000 并发下是否数据正确
  • 长时间运行是否稳定
  • 超时、拒绝、重试是否符合预期
  • 线程池打满后的降级行为

6. 优先使用成熟工具类

常见替代关系:

  • HashMapConcurrentHashMap
  • int++AtomicInteger / LongAdder
  • 手写等待通知 → CountDownLatch / Semaphore / BlockingQueue
  • 裸线程创建 → ThreadPoolExecutor

实战代码:一个更像线上场景的修正版

下面这个例子模拟“并发统计 + 缓存写入 + 线程池提交”的安全实现方式。

import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;
import java.util.*;

public class SafeConcurrentService {

    private final LongAdder requestCounter = new LongAdder();
    private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();

    private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
            4,
            8,
            60,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(100),
            r -> {
                Thread t = new Thread(r);
                t.setName("safe-biz-" + t.getId());
                return t;
            },
            new ThreadPoolExecutor.CallerRunsPolicy()
    );

    public String process(String key, String value) {
        requestCounter.increment();

        return cache.computeIfAbsent(key, k -> {
            Future<String> future = executor.submit(() -> slowBuild(value));
            try {
                return future.get(2, TimeUnit.SECONDS);
            } catch (TimeoutException e) {
                future.cancel(true);
                return "fallback";
            } catch (Exception e) {
                return "error";
            }
        });
    }

    private String slowBuild(String value) {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "interrupted";
        }
        return "result:" + value;
    }

    public long totalRequests() {
        return requestCounter.sum();
    }

    public void shutdown() {
        executor.shutdown();
    }

    public static void main(String[] args) throws Exception {
        SafeConcurrentService service = new SafeConcurrentService();

        ExecutorService testPool = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(50);

        for (int i = 0; i < 50; i++) {
            int idx = i % 5;
            testPool.execute(() -> {
                try {
                    String result = service.process("k" + idx, "v" + idx);
                    System.out.println(Thread.currentThread().getName() + " -> " + result);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        testPool.shutdown();
        service.shutdown();

        System.out.println("totalRequests = " + service.totalRequests());
    }
}

这段代码里做了几件关键的事:

  • 计数用 LongAdder
  • 缓存用 ConcurrentHashMap
  • 原子地初始化缓存值用 computeIfAbsent
  • 线程池显式配置
  • Future.get() 设置超时
  • 中断后恢复中断标记

这才是更接近生产环境的写法。


总结

Java 并发问题最怕“凭感觉修”。真正有效的路径通常是:

  1. 先归类现象
  2. 再抓线程现场
  3. 确认共享资源
  4. 分析时序和内存语义
  5. 用合适工具修复并压测验证

这篇文章讲的 8 个常见问题,可以浓缩成一句话:

  • 数据不准,看原子性
  • 线程不退出,看可见性
  • 偶发诡异,看有序性
  • 服务变慢,看锁和线程池
  • 请求卡住,看阻塞点和死锁
  • 数据串用,看 ThreadLocal 和上下文清理

最后给几个我自己很常用的落地建议:

  • 不要默认 HashMap、成员变量、单例对象是安全的
  • 不要无脑开大线程池
  • 不要在线程池里同步等待另一个慢任务
  • 不要忘记 ThreadLocal.remove()
  • 并发代码改完后,一定要压测,不要只看“能跑”

如果你能把“共享资源、时序、线程状态”这三件事建立起排查习惯,大多数并发问题其实都能比较快地收敛。最难的不是语法,而是耐心把现场还原出来。


分享到:

上一篇
《AI 智能体实战:基于 RAG 与函数调用构建企业内部知识问答系统》
下一篇
《安卓逆向实战:从 Frida Hook 到 JNI 层跟踪,定位 App 登录签名生成逻辑》