Java 并发编程常见问题及解决方案
在 Java 开发中,并发编程是提升系统性能的重要手段,但同时也伴随着各种难以调试的问题。从线程安全到死锁,从资源竞争到性能损耗,每一个问题都可能让开发者头疼不已。本文将梳理 Java 并发编程中的八大常见问题,深入分析其产生原因,并提供经过实践验证的解决方案。
一、线程安全问题:共享变量的并发修改
线程安全是并发编程中最基础也最常见的问题。当多个线程同时操作共享变量时,若缺乏适当的同步机制,就会导致数据不一致的情况。
问题表现
两个线程同时对同一计数器进行累加操作,预期结果为 20000,但实际运行结果往往小于该值:
ini
public class CounterProblem {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 10000; i++) {
count++;
}
};
Thread t1 = new Thread(increment);
Thread t2 = new Thread(increment);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:" + count); // 通常小于20000
}
}
问题根源
count++操作并非原子操作,它包含三个步骤:读取当前值、增加值、写入新值。当两个线程交替执行这些步骤时,就会出现值被覆盖的情况。
解决方案
- 使用原子类:java.util.concurrent.atomic包提供了线程安全的原子操作类
java
private static AtomicInteger count = new AtomicInteger(0);
// 替换count++为
count.incrementAndGet();
- 加锁同步:使用synchronized关键字或Lock接口保证操作的原子性
java
private static int count = 0;
private static final Object lock = new Object();
// 在操作处添加同步块
synchronized (lock) {
count++;
}
- 使用线程封闭:避免共享变量,将变量限制在单个线程内使用
二、死锁:线程间的无限等待
死锁是指两个或多个线程相互持有对方所需的资源,又等待对方释放资源,从而陷入无限等待的状态。
问题表现
java
public class DeadlockExample {
private static final Object resource1 = new Object();
private static final Object resource2 = new Object();
public static void main(String[] args) {
// 线程1:持有resource1,等待resource2
new Thread(() -> {
synchronized (resource1) {
System.out.println("线程1获取到资源1");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (resource2) {
System.out.println("线程1获取到资源2");
}
}
}).start();
// 线程2:持有resource2,等待resource1
new Thread(() -> {
synchronized (resource2) {
System.out.println("线程2获取到资源2");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (resource1) {
System.out.println("线程2获取到资源1");
}
}
}).start();
}
}
程序运行后,两个线程会相互等待,永远无法完成执行。
问题根源
死锁产生需要满足四个条件:互斥条件、持有并等待、不可剥夺、循环等待。当这四个条件同时满足时,就会发生死锁。
解决方案
- 固定资源获取顺序:确保所有线程按相同的顺序获取资源
arduino
// 两个线程都先获取resource1,再获取resource2
- 使用 tryLock 设置超时:避免无限等待
java
Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
// 操作资源
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
- 使用定时释放机制:主动释放已持有的资源
- 使用 JDK 工具排查:通过 jstack 命令分析线程状态,定位死锁位置
三、线程池滥用:资源耗尽与性能下降
线程池是管理线程的重要工具,但不合理的配置和使用会导致严重的性能问题甚至系统崩溃。
问题表现
- 线程池队列无界导致内存溢出
- 核心线程数设置过小导致任务积压
- 线程池创建过多导致系统资源耗尽
- 任务执行时间过长导致线程池阻塞
问题根源
对线程池工作原理理解不足,未根据业务特点合理配置核心参数,或未正确处理长时间运行的任务。
解决方案
- 合理配置核心参数:
ini
// 根据CPU核心数和任务类型配置
int corePoolSize = Runtime.getRuntime().availableProcessors() + 1;
int maximumPoolSize = Runtime.getRuntime().availableProcessors() * 2;
long keepAliveTime = 60L;
// 使用有界队列,避免内存溢出
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(1000);
// 设置拒绝策略
RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.SECONDS,
workQueue, handler
);
- 区分任务类型:将 CPU 密集型和 IO 密集型任务分开处理,设置不同的线程池参数
- 正确处理长时间任务:避免长时间占用线程,可考虑使用异步回调机制
- 监控线程池状态:通过getActiveCount()、getQueue().size()等方法监控线程池状态,及时调整
四、ThreadLocal 使用不当:内存泄漏与数据污染
ThreadLocal用于提供线程本地变量,但使用不当会导致内存泄漏和跨请求数据污染等问题。
问题表现
- 线程池环境下,ThreadLocal中的数据未清除,导致后续任务获取到错误数据
- ThreadLocal引用的对象未被回收,导致内存泄漏
问题根源
- 未在使用完毕后调用remove()方法清除数据
- ThreadLocal的内部实现使用ThreadLocalMap,其 Entry 是弱引用,若未及时清理会导致值对象无法回收
解决方案
- 使用 try-finally 确保清理:
csharp
ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
try {
userThreadLocal.set(currentUser);
// 业务操作
} finally {
// 必须在finally中清除
userThreadLocal.remove();
}
- 避免存储大对象:减少内存泄漏的影响范围
- 使用静态 ThreadLocal:避免创建过多ThreadLocal实例,降低内存泄漏风险
- 自定义线程池的 ThreadFactory:在线程销毁前清理ThreadLocal数据
五、volatile 的误用:可见性与原子性混淆
volatile关键字保证了变量的可见性,但很多开发者错误地认为它可以保证原子性。
问题表现
ini
public class VolatileProblem {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 10000; i++) {
count++; // volatile无法保证此操作的原子性
}
};
Thread t1 = new Thread(increment);
Thread t2 = new Thread(increment);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("结果:" + count); // 结果不正确
}
}
问题根源
volatile仅保证:当一个线程修改了变量的值,其他线程能立即看到最新值。但它不能保证复合操作的原子性,如count++包含读取、修改、写入三个步骤。
解决方案
- 明确 volatile 的适用场景:
-
- 状态标志(如boolean isRunning)
-
- 双重检查锁定(单例模式)
-
- 不需要原子性的变量
- 结合其他同步机制:对于需要原子操作的场景,结合synchronized或原子类使用
- 使用 AtomicXxx 类:对于基本类型的原子操作,优先使用原子类
六、并发容器使用陷阱:迭代与修改的冲突
Java 提供了ConcurrentHashMap等并发容器,但在迭代过程中修改元素仍可能导致问题。
问题表现
arduino
public class ConcurrentContainerProblem {
public static void main(String[] args) {
Map<String, Integer> map = new ConcurrentHashMap<>();
map.put("A", 1);
map.put("B", 2);
// 迭代过程中修改可能导致结果不一致
for (String key : map.keySet()) {
if ("A".equals(key)) {
map.remove(key);
}
}
}
}
问题根源
虽然并发容器避免了ConcurrentModificationException,但迭代器仍然可能返回修改前的状态,导致业务逻辑错误。
解决方案
- 使用迭代器的 remove 方法:
vbnet
Iterator<String> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
String key = iterator.next();
if ("A".equals(key)) {
iterator.remove(); // 使用迭代器的remove方法
}
}
- 使用批量操作:对于大量修改,先收集需要修改的元素,再批量处理
- 使用快照迭代器:ConcurrentHashMap的迭代器是弱一致性的,理解其特性再使用
- 考虑使用 Stream API:
arduino
map.keySet().removeIf("A"::equals);
七、过度同步:性能损耗与死锁风险
为了保证线程安全,很多开发者会过度使用同步机制,导致性能下降和死锁风险增加。
问题表现
- 对整个方法加锁,而实际只需要保护其中一小部分代码
- 嵌套同步块导致死锁风险增加
- 同步静态方法导致锁竞争激烈
问题根源
对临界区理解不清,未能准确识别需要同步的代码范围,或过度依赖synchronized关键字。
解决方案
- 缩小同步范围:仅同步必要的代码块
csharp
// 不推荐:同步整个方法
public synchronized void update() {
// 大量非临界区代码
criticalSection();
// 大量非临界区代码
}
// 推荐:仅同步临界区
public void update() {
// 大量非临界区代码
synchronized (lock) {
criticalSection();
}
// 大量非临界区代码
}
- 使用细粒度锁:将大对象拆分为小对象,使用多个锁减少竞争
- 优先使用非阻塞数据结构:如AtomicInteger、ConcurrentHashMap等
- 使用读写锁分离:对于读多写少的场景,使用ReentrantReadWriteLock
kotlin
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
// 读操作使用读锁
public Data get() {
readLock.lock();
try {
return data;
} finally {
readLock.unlock();
}
}
// 写操作使用写锁
public void set(Data data) {
writeLock.lock();
try {
this.data = data;
} finally {
writeLock.unlock();
}
}
八、异步任务异常丢失:难以排查的错误
在使用CompletableFuture等异步工具时,若未正确处理异常,会导致异常被默默丢弃,难以排查问题。
问题表现
arduino
public class CompletableFutureException {
public static void main(String[] args) throws InterruptedException {
CompletableFuture.runAsync(() -> {
// 异常会被默默丢弃
int i = 1 / 0;
});
Thread.sleep(1000);
System.out.println("程序结束");
}
}
程序运行时不会抛出任何异常,异常信息被丢失。
问题根源
CompletableFuture的异步任务中未捕获的异常会被存储,但不会主动抛出,若未通过exceptionally()、handle()等方法处理,就会导致异常丢失。
解决方案
- 使用 exceptionally 捕获异常:
ini
CompletableFuture.runAsync(() -> {
int i = 1 / 0;
}).exceptionally(ex -> {
log.error("异步任务发生异常", ex);
return null;
});
- 使用 handle 处理结果和异常:
kotlin
CompletableFuture.supplyAsync(() -> {
if (true) {
throw new RuntimeException("出错了");
}
return "结果";
}).handle((result, ex) -> {
if (ex != null) {
log.error("处理异常", ex);
return "默认值";
}
return result;
});
- 全局异常处理:设置CompletableFuture的全局异常处理器(Java 9+)
arduino
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
log.error("线程{}发生未捕获异常", thread.getName(), throwable);
});
总结:并发编程的基本原则
- 最小权限原则:仅对必要的代码进行同步,仅给线程必要的资源访问权限
- 清晰的资源管理:明确识别共享资源,建立清晰的资源访问规则
- 防御性编程:假设所有异步操作都会失败,为每一步操作添加异常处理
- 避免过早优化:先保证正确性,再通过性能测试定位瓶颈进行优化
- 充分测试:使用多线程测试工具(如 jcstress)验证并发代码的正确性
- 善用工具:熟练掌握 JDK 提供的并发工具类,避免重复造轮子
并发编程是 Java 开发中的进阶技能,需要开发者不仅理解各种并发工具的用法,更要掌握其背后的原理。面对复杂的并发场景,建议先设计清晰的并发模型,再选择合适的工具实现。记住:简单的方案往往比复杂的优化更可靠,能够正确运行的程序远比 "高效但不稳定" 的程序更有价值。