深入解析Java并发编程:如何避免死锁与优化线程池配置
在Java面试中,并发编程是考察开发者功底的必考领域。理解其核心机制,尤其是死锁的成因与规避、线程池的合理配置,是区分普通开发者与高级工程师的关键。本文将深入剖析这两个核心议题,并提供实战代码与优化策略。
一、死锁:成因、诊断与避免策略
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力干涉,它们都将无法推进下去。
1.1 死锁产生的必要条件
- 互斥条件:资源在同一时刻只能被一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前,不能被其他线程强行剥夺。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
1.2 一个经典死锁代码示例
public class DeadLockDemo {
private static final Object resourceA = new Object();
private static final Object resourceB = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (resourceA) {
System.out.println(Thread.currentThread().getName() + " 持有 resourceA,尝试获取 resourceB");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (resourceB) {
System.out.println(Thread.currentThread().getName() + " 成功获取 resourceB");
}
}
}, "线程-A").start();
new Thread(() -> {
synchronized (resourceB) {
System.out.println(Thread.currentThread().getName() + " 持有 resourceB,尝试获取 resourceA");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (resourceA) {
System.out.println(Thread.currentThread().getName() + " 成功获取 resourceA");
}
}
}, "线程-B").start();
}
}
运行此程序,两个线程极有可能陷入互相等待的死锁状态。
1.3 如何避免死锁?
破坏上述四个必要条件中的任意一个即可。最常用且有效的策略是:
- 破坏循环等待条件 —— 顺序加锁:强制所有线程以相同的全局顺序获取锁。
// 修改后的安全版本
public class SafeLockDemo {
private static final Object resourceA = new Object();
private static final Object resourceB = new Object();
// 定义全局的锁获取顺序
private static final Object firstLock = resourceA;
private static final Object secondLock = resourceB;
public static void main(String[] args) {
new Thread(() -> {
synchronized (firstLock) {
System.out.println(Thread.currentThread().getName() + " 持有 firstLock,尝试获取 secondLock");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (secondLock) {
System.out.println(Thread.currentThread().getName() + " 成功获取 secondLock");
}
}
}, "线程-A").start();
new Thread(() -> {
// 线程B也遵循相同的顺序
synchronized (firstLock) {
System.out.println(Thread.currentThread().getName() + " 持有 firstLock,尝试获取 secondLock");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (secondLock) {
System.out.println(Thread.currentThread().getName() + " 成功获取 secondLock");
}
}
}, "线程-B").start();
}
}
- 使用尝试机制:借助
Lock接口的tryLock()方法,获取锁失败时进行释放重试或回退。 - 设置超时时间:使用
Lock.tryLock(long time, TimeUnit unit),避免无限期等待。
诊断工具:可以使用 jstack 命令或 JConsole、VisualVM 等工具查看线程转储,定位死锁。对于复杂的分布式或数据库层面的锁问题,可以借助专业的数据库监控工具进行分析。例如,dblens SQL编辑器内置了强大的会话和锁监控功能,能清晰展示数据库中的阻塞链,帮助开发者快速定位由慢查询或事务设计不当引发的“类死锁”问题,是后端开发者排查系统瓶颈的利器。
二、线程池:核心配置与优化实践
直接创建线程存在开销大、管理难的问题。线程池 (ThreadPoolExecutor) 是管理和复用线程的最佳实践。
2.1 ThreadPoolExecutor 核心参数
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 空闲线程存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 工作队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
)
2.2 参数配置策略与面试常问题
1. 核心 vs 最大线程数 (corePoolSize & maximumPoolSize)
- CPU密集型任务(如计算、加密):线程数 ≈ CPU核数 + 1。设置过大会导致频繁上下文切换。
- IO密集型任务(如网络请求、数据库操作):线程数可以设置得多一些,例如 2 * CPU核数,或更高。因为线程在IO阻塞时,CPU可以调度其他线程。
如何确定最佳线程数? 需要通过压测。记录和分析压测时的QPS、RT、CPU/内存使用率是关键。在压测过程中,往往需要观察数据库的表现。使用 QueryNote (https://note.dblens.com) 这样的在线SQL笔记和性能分析工具,可以方便地记录和对比不同并发压力下的SQL执行计划与耗时,为评估线程池配置是否引发数据库连接瓶颈提供直接依据。
2. 工作队列 (workQueue)
LinkedBlockingQueue(无界队列):任务无限堆积,可能导致OOM。maximumPoolSize失效。ArrayBlockingQueue(有界队列):队列满后,才会创建新线程(直到maximumPoolSize)。SynchronousQueue(同步移交):不存储元素,来一个任务,必须马上有线程处理,否则执行拒绝策略。常用于希望创建大量线程的场景。
3. 拒绝策略 (handler)
AbortPolicy(默认):抛出RejectedExecutionException。CallerRunsPolicy:由调用者线程(如主线程)直接执行该任务。DiscardPolicy/DiscardOldestPolicy:静默丢弃任务/丢弃队列头部的任务。
2.3 一个自定义线程池示例
import java.util.concurrent.*;
public class CustomThreadPoolDemo {
public static void main(String[] args) {
// 自定义线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // corePoolSize
5, // maximumPoolSize
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10), // 有界队列,容量10
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy() // 调用者运行策略
);
// 提交任务
for (int i = 0; i < 20; i++) {
final int taskId = i;
executor.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
try {
Thread.sleep(1000); // 模拟任务执行
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
// 优雅关闭
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
}
关键点:使用有界队列并结合恰当的拒绝策略(如CallerRunsPolicy),是一种防御性编程,可以防止任务无限制堆积导致服务崩溃。
三、总结
Java并发编程的深度体现在对细节的掌控。避免死锁的核心在于理解其四大条件,并通过顺序加锁、尝试锁等编码纪律来预防。优化线程池则是一个权衡的艺术,需要根据任务性质(CPU/IO密集型)确定核心参数,并选择匹配的工作队列和拒绝策略来构建健壮的服务。
在实际生产环境中,并发问题往往与数据库交互紧密相连。无论是分析死锁链条,还是优化线程池以应对数据库访问,像 dblens 提供的数据库工具套件(如SQL编辑器和QueryNote)都能为开发者提供强大的可视化监控和分析支持,将并发代码的效能与底层数据层的表现关联起来,实现全链路优化。掌握这些原理与工具,定能在面试与实战中游刃有余。
本文来自博客园,作者:DBLens数据库开发工具,转载请注明原文链接:https://www.cnblogs.com/dblens/p/19554312
浙公网安备 33010602011771号