背景与问题
并发问题最让人头疼的地方,不是“难”,而是“飘”。
有些 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++ 不是原子操作,它会拆成:
- 读取 count
- 加 1
- 写回 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;
}
}
排查思路
我一般这么查:
- 找所有被多个线程共享的变量
- 看更新逻辑是不是“读-改-写”
- 看是否有锁、原子类、CAS 保护
- 本地写个 100~1000 线程压测小程序复现
修复方案
- 简单计数:
AtomicInteger - 高并发热点计数:
LongAdder - 需要复合操作一致性:
synchronized或Lock
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++;
}
那就不是单纯原子自增问题,而是复合操作原子性问题。此时仅用 AtomicInteger 的 get() 再 incrementAndGet() 也可能出错,需要 compareAndSet 或加锁。
2. HashMap / ArrayList 在并发下数据异常
典型现象
- 集合大小不对
- 遍历时抛
ConcurrentModificationException - 缓存数据偶发丢失、覆盖
- 老项目里还可能出现 CPU 异常高
根因
HashMap、ArrayList 默认都不是线程安全的。
错误示例
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
jps找进程jstack pid > stack.log- 搜索
deadlock - 找到涉及的类、方法、锁对象
- 回代码里看是否存在锁顺序不一致
修复方案
- 统一加锁顺序
- 尽量避免嵌套锁
- 用
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();
}
如果这种模式层层嵌套,就很容易形成线程饥饿。
排查思路
看线程栈:
WAITINGTIMED_WAITINGparking to wait forFutureTask.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;
}
}
根因
对象创建可能发生指令重排:
- 分配内存
- 引用指向内存
- 执行构造方法
如果 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.locksFutureTask.getUnsafe.parksynchronized- HTTP/数据库客户端调用
- 自己的业务循环代码
止血方案
并发问题不一定能一次性优雅修好,线上先止血也很重要。
能快速止血的手段
-
降并发
- 限流
- 缩小消费线程数
- 关闭非核心异步任务
-
隔离资源
- 核心线程池和非核心线程池拆分
- 慢接口独立线程池
- 外部依赖设置超时和熔断
-
减少锁竞争
- 缩小同步块范围
- 用分段锁或并发容器
- 读写分离
-
快速修补
- 共享标志位补
volatile HashMap替换成ConcurrentHashMapThreadLocal使用后补remove()
- 共享标志位补
但要注意:止血方案不等于最终方案。
比如简单加大线程池,短期看似恢复,长期可能只是把问题从“超时”拖成“内存爆”。
安全/性能最佳实践
1. 不要把“线程安全”理解成“全都加锁”
锁能解决问题,但也可能制造性能瓶颈。
优先级建议是:
- 能无共享就无共享
- 能不可变就不可变
- 能用并发容器就别自己造锁
- 需要一致性再加锁
2. 共享状态越少,并发 Bug 越少
比如:
- 尽量让方法无状态
- 避免单例 Bean 持有可变成员
- 上下文尽量通过参数传递,而不是全局变量
3. 线程池必须可观测
至少监控:
- 核心线程数
- 活跃线程数
- 队列长度
- 已完成任务数
- 拒绝次数
- 平均耗时 / P99
4. 对外部依赖必须设超时
包括:
- HTTP
- RPC
- 数据库
- Redis
- MQ
否则并发一上来,线程会被慢依赖拖死。
5. 并发代码必须压测验证
不要只靠单元测试。
建议至少覆盖:
- 100~1000 并发下是否数据正确
- 长时间运行是否稳定
- 超时、拒绝、重试是否符合预期
- 线程池打满后的降级行为
6. 优先使用成熟工具类
常见替代关系:
HashMap→ConcurrentHashMapint++→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 并发问题最怕“凭感觉修”。真正有效的路径通常是:
- 先归类现象
- 再抓线程现场
- 确认共享资源
- 分析时序和内存语义
- 用合适工具修复并压测验证
这篇文章讲的 8 个常见问题,可以浓缩成一句话:
- 数据不准,看原子性
- 线程不退出,看可见性
- 偶发诡异,看有序性
- 服务变慢,看锁和线程池
- 请求卡住,看阻塞点和死锁
- 数据串用,看 ThreadLocal 和上下文清理
最后给几个我自己很常用的落地建议:
- 不要默认
HashMap、成员变量、单例对象是安全的 - 不要无脑开大线程池
- 不要在线程池里同步等待另一个慢任务
- 不要忘记
ThreadLocal.remove() - 并发代码改完后,一定要压测,不要只看“能跑”
如果你能把“共享资源、时序、线程状态”这三件事建立起排查习惯,大多数并发问题其实都能比较快地收敛。最难的不是语法,而是耐心把现场还原出来。