
Java并发编程:从锁的纷争到线程池的秩序
在Java的世界里摸爬滚打这些年,我深刻体会到,并发编程既是性能提升的利器,也是无数“坑”的源头。记得早期处理一个简单的计数器时,自信满满地没用同步,结果线上数据对不上,排查了大半天。从那以后,我便对Java中的锁机制和线程池管理上了心。今天,我想和你分享的,不只是API怎么用,更是这些年实战中积累的一些经验和教训。
一、锁机制:不仅仅是synchronized
说到锁,很多人第一反应就是`synchronized`关键字。它确实简单好用,是内置的互斥锁。但你知道吗?在复杂的并发场景下,仅靠它是不够的。
1. synchronized的局限与升级
`synchronized`在早期是重量级锁,性能开销大。但经过JVM的不断优化(如偏向锁、轻量级锁、自旋锁、重量级锁的锁升级过程),现在在低竞争场景下性能已经非常不错。它的优点是自动加锁解锁,不易出错。我通常用它来保护简单的临界区,比如单例模式的DCL(双检锁)实现。
public class Singleton {
private static volatile Singleton instance; // 必须volatile,防止指令重排
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免不必要的同步
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查,确保唯一性
instance = new Singleton();
}
}
}
return instance;
}
}
踩坑提示:这里`volatile`关键字至关重要,它防止了JVM的指令重排,确保了对象的完整初始化。我曾经漏写过,在极高并发下,偶尔会拿到一个未初始化完全的对象。
2. 更灵活的显式锁:ReentrantLock
当需要更复杂的控制时,`java.util.concurrent.locks.ReentrantLock`就派上用场了。它提供了`synchronized`不具备的特性:可中断的锁等待、尝试非阻塞获取锁(`tryLock`)、公平锁以及可以绑定多个条件变量(`Condition`)。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TicketCenter {
private int tickets = 100;
private final Lock lock = new ReentrantLock(true); // 创建一个公平锁
public boolean sellTicket() {
lock.lock(); // 手动加锁
try {
if (tickets > 0) {
tickets--;
System.out.println(Thread.currentThread().getName() + "售出一张票,剩余:" + tickets);
return true;
}
return false;
} finally {
lock.unlock(); // 必须在finally中解锁,确保锁释放
}
}
}
实战经验:lock()调用必须紧跟try块,并且在finally中解锁,这是铁律!我见过有人把业务代码写在lock()外面,导致锁根本没起作用。公平锁能防止线程饥饿,但性能会比非公平锁差一些,要根据实际场景选择。
3. 读写分离:ReentrantReadWriteLock
对于“读多写少”的场景,使用互斥锁会导致读性能严重下降。这时就该读写锁登场了。它允许多个线程同时读,但写线程独占资源。
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ConfigCache {
private Map cache = new HashMap();
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public Object get(String key) {
readLock.lock();
try {
return cache.get(key);
} finally {
readLock.unlock();
}
}
public void update(String key, Object value) {
writeLock.lock();
try {
cache.put(key, value);
} finally {
writeLock.unlock();
}
}
}
重要提醒:读写锁有“锁降级”的概念(在持有写锁的情况下获取读锁,然后释放写锁),但不支持锁升级(持有读锁时想获取写锁,会导致死锁)。这个坑我踩过,程序直接卡死。
二、线程池管理:告别野蛮的new Thread()
早期我习惯用`new Thread(() -> {...}).start()`来处理异步任务,直到线上服务因为创建了数千个线程而OOM(内存溢出)。线程池(`ThreadPoolExecutor`)是管理线程生命周期的标准答案。
1. 核心参数详解
创建线程池的关键在于理解这7个参数:
- corePoolSize:核心线程数,即使空闲也不会被回收(除非设置`allowCoreThreadTimeOut`)。
- maximumPoolSize:最大线程数。
- keepAliveTime:非核心线程空闲存活时间。
- workQueue:任务队列。这是最容易出问题的地方。
- threadFactory:线程工厂,可以自定义线程名、优先级等,便于监控。
- handler:拒绝策略。当线程池和队列都满了,如何处理新任务。
2. 任务队列的选择与坑
队列的选择直接影响了线程池的行为:
- SynchronousQueue:不存储元素,来一个任务,如果没有空闲线程,就创建新线程(直到达到maxPoolSize)。适用于任务处理非常快的场景。
- LinkedBlockingQueue(无界队列):`maximumPoolSize`参数会失效,任务永远在队列中等待,可能堆积导致OOM。慎用!
- ArrayBlockingQueue(有界队列):最常用的安全选择,需要合理设置队列大小。
下面是一个我常用的、带监控信息的线程池配置示例:
import java.util.concurrent.*;
public class OrderThreadPool {
private static final ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize
20, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue(100), // 有界队列,容量100
new CustomThreadFactory("Order-Processor"), // 自定义线程工厂
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用者自己运行
);
static class CustomThreadFactory implements ThreadFactory {
private final String namePrefix;
private final AtomicInteger threadNumber = new AtomicInteger(1);
CustomThreadFactory(String namePrefix) { this.namePrefix = namePrefix; }
public Thread newThread(Runnable r) {
return new Thread(r, namePrefix + "-thread-" + threadNumber.getAndIncrement());
}
}
// 一个监控方法,便于排查问题
public static void printPoolStatus() {
System.out.printf("活跃线程: %d, 核心线程: %d, 最大线程: %d, 队列任务数: %d%n",
executor.getActiveCount(),
executor.getCorePoolSize(),
executor.getMaximumPoolSize(),
executor.getQueue().size());
}
}
实战经验:我强烈推荐使用`CallerRunsPolicy`拒绝策略。当系统过载时,它会让提交任务的线程(通常是Tomcat的HTTP处理线程)自己去执行这个任务。这样虽然会拖慢请求速度,但能保证任务不丢失,并且给上游一个明确的“系统正忙”的反压信号,避免了整个系统被压垮。比直接丢弃(`AbortPolicy`)或静默丢弃(`DiscardPolicy`)要好得多。
3. 优雅关闭线程池
这是很多人忽略的一点。直接关闭应用,线程池里的任务可能丢失。正确的关闭流程应该是:
executor.shutdown(); // 不再接受新任务
try {
// 等待现有任务完成,最多等30秒
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 强制取消正在执行的任务
// 再等一次,给任务响应中断的机会
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
System.err.println("线程池未能完全关闭");
}
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt(); // 保持中断状态
}
三、组合实战:一个简单的限流器
最后,我们把锁和线程池结合起来,实现一个简单的并发限流器,控制同时处理任务的线程数不超过某个阈值:
public class SimpleLimiter {
private final Semaphore semaphore; // 使用信号量做限流
private final ThreadPoolExecutor executor;
public SimpleLimiter(int maxConcurrent, int poolSize) {
this.semaphore = new Semaphore(maxConcurrent);
this.executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(poolSize);
}
public Future submitTask(Runnable task) {
return executor.submit(() -> {
try {
semaphore.acquire(); // 获取许可
task.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release(); // 释放许可
}
});
}
// ... 记得添加关闭方法
}
这个例子展示了如何用`Semaphore`(另一种并发工具,本质是计数器)来控制对资源的并发访问量,并将其与线程池的任务执行结合起来。
总结一下,Java并发编程就像驾驶一辆高性能跑车,锁机制是你的刹车和方向盘,让你安全地穿梭在数据竞争的弯道中;而线程池则是你的引擎管理系统,合理调度动力(线程),避免过热(资源耗尽)。理解它们的原理,根据业务场景谨慎配置,多写测试用例验证,才能构建出既高效又稳定的并发程序。希望我的这些经验和“踩坑史”能对你有所帮助。

评论(0)