1. 为什么需要关注Java多线程设计模式?
我第一次接触Java多线程是在一个电商秒杀系统的开发中。当时系统在高峰期频繁崩溃,排查后发现是线程安全问题导致的。那次经历让我深刻认识到,掌握多线程设计模式不是锦上添花,而是Java开发者必须跨过的门槛。
多线程设计模式本质上是前人经验的结晶,它们针对特定并发问题提供了经过验证的解决方案。比如单例模式确保关键服务全局唯一,阻塞队列解决生产者-消费者问题,线程池管理线程生命周期。这些模式不是银弹,但能帮我们避免重蹈覆辙。
在实际项目中,我见过太多因为忽视这些模式而导致的灾难:内存泄漏、死锁、竞态条件。更可怕的是,这些问题往往在测试阶段难以发现,直到线上环境高并发时才暴露出来。
2. 单例模式的线程安全实现
2.1 饿汉式单例:简单但不够灵活
public class EagerSingleton { private static final EagerSingleton instance = new EagerSingleton(); private EagerSingleton() {} public static EagerSingleton getInstance() { return instance; } }这是最简单的实现方式,在类加载时就创建实例。优点是绝对线程安全,缺点是不支持延迟加载,如果实例创建开销大且不一定会被使用,就会浪费资源。
我在一个日志服务中用过这种实现,因为日志组件必须随时可用,且初始化成本不高。但对于数据库连接池这类重量级对象,就不太适合了。
2.2 懒汉式单例:双重检查锁定
public class LazySingleton { private static volatile LazySingleton instance; private LazySingleton() {} public static LazySingleton getInstance() { if (instance == null) { synchronized (LazySingleton.class) { if (instance == null) { instance = new LazySingleton(); } } } return instance; } }这个版本解决了延迟加载问题。volatile关键字很关键,它能防止指令重排序导致的初始化问题。我曾在一个性能敏感的场景去掉volatile,结果在高并发下出现了极难复现的NPE。
注意:Java 5之前,即使使用volatile,双重检查锁定也可能失效。这是JVM内存模型的历史问题。
2.3 枚举单例:最安全的实现
public enum EnumSingleton { INSTANCE; public void doSomething() { // 业务方法 } }Joshua Bloch在《Effective Java》中推荐这种方式。它不仅能防止反射攻击,还能自动处理序列化问题。我在一个分布式配置中心使用了枚举单例,因为它需要同时应对反射和序列化的场景。
3. 阻塞队列的生产者-消费者模式
3.1 ArrayBlockingQueue实战
BlockingQueue<String> queue = new ArrayBlockingQueue<>(10); // 生产者 new Thread(() -> { while (true) { try { String item = produceItem(); queue.put(item); // 队列满时阻塞 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }).start(); // 消费者 new Thread(() -> { while (true) { try { String item = queue.take(); // 队列空时阻塞 processItem(item); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }).start();我在一个订单处理系统中使用这种模式,生产者接收用户订单,消费者处理订单。关键点是合理设置队列容量——太小会导致频繁阻塞,太大会占用过多内存。
3.2 优先级阻塞队列的应用
BlockingQueue<Order> queue = new PriorityBlockingQueue<>(10, Comparator.comparingInt(Order::getPriority)); // VIP订单优先处理 class Order { private int priority; // 0普通, 1VIP // getter/setter }对于需要优先处理的场景,PriorityBlockingQueue非常有用。但要注意比较器的线程安全性,我曾因为比较器中使用非线程安全的计数器导致排序错乱。
4. 定时器与调度线程池
4.1 Timer vs ScheduledThreadPoolExecutor
// 不推荐 - Timer单线程执行,一个任务异常会影响其他任务 Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { // 定时任务 } }, 1000, 2000); // 推荐 - 使用线程池 ScheduledExecutorService executor = Executors.newScheduledThreadPool(3); executor.scheduleAtFixedRate(() -> { // 定时任务 }, 1, 2, TimeUnit.SECONDS);在监控系统中,我曾用Timer执行多个采集任务,结果一个任务的OOM导致整个定时调度瘫痪。改用ScheduledThreadPoolExecutor后,即使个别任务异常也不会影响其他任务。
4.2 正确处理定时任务异常
executor.scheduleAtFixedRate(() -> { try { doTask(); } catch (Exception e) { // 必须捕获异常,否则后续调度会停止 log.error("Task failed", e); } }, 1, 2, TimeUnit.SECONDS);这是很多人容易忽略的点。即使使用线程池,如果任务抛出未捕获异常,固定频率的调度也会停止。我在第一次使用时踩过这个坑,导致半夜告警任务停止运行。
5. 线程池的深度配置
5.1 手动创建线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor( 5, // 核心线程数 10, // 最大线程数 60, // 空闲线程存活时间 TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), // 工作队列 new ThreadFactory() { // 线程工厂 private final AtomicInteger counter = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(r, "worker-" + counter.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );直接使用Executors的快捷方法虽然方便,但隐藏了关键参数。我在一个高并发接口中使用了无界队列的FixedThreadPool,最终导致OOM。现在我都显式指定所有参数。
5.2 合理设置线程池参数
- CPU密集型:核心线程数 = CPU核心数 + 1
- IO密集型:核心线程数 = CPU核心数 * 2
可以通过Runtime.getRuntime().availableProcessors()获取CPU核心数。但实际配置还需要考虑其他因素:
- 任务特性:平均执行时间、是否阻塞
- 系统资源:内存、网络、数据库连接池大小
- SLA要求:最大可接受延迟
我在一个文件处理服务中,开始设置为CPU核心数2,但发现吞吐量不理想。通过监控发现任务80%时间在IO等待,最终调整为CPU核心数4才达到最佳性能。
5.3 监控线程池状态
// 定期打印线程池状态 executor.setRejectedExecutionHandler((r, executor) -> { log.warn("Task rejected: {}", executor.toString()); // 记录详细状态 log.warn("Active: {}, Queue: {}, Completed: {}", executor.getActiveCount(), executor.getQueue().size(), executor.getCompletedTaskCount()); // 降级处理或告警 });完善的监控能帮我们及时发现容量问题。我曾遇到一个案例:线程池队列积压导致内存飙升,但因为缺乏监控,直到OOM才发现问题。现在我会在拒绝策略中记录详细状态。
6. 模式组合应用案例
6.1 订单处理系统设计
// 单例的订单服务 public class OrderService { private static final OrderService instance = new OrderService(); private final ExecutorService executor; private OrderService() { this.executor = new ThreadPoolExecutor(...); } public static OrderService getInstance() { return instance; } public void processOrder(Order order) { executor.submit(() -> { // 使用阻塞队列实现订单分阶段处理 Order firstStage = processStage1(order); orderQueue.put(firstStage); }); } private final BlockingQueue<Order> orderQueue = new ArrayBlockingQueue<>(1000); { // 启动消费者线程 new Thread(() -> { while (true) { Order order = orderQueue.take(); processStage2(order); } }).start(); } }这个设计融合了单例、线程池和阻塞队列三种模式。我在实际项目中采用类似架构处理日均百万级订单,关键点是:
- 使用单例确保服务全局可用
- 线程池控制并发度
- 阻塞队列实现生产消费解耦
- 不同阶段使用独立队列防止相互影响
6.2 资源清理的注意事项
Runtime.getRuntime().addShutdownHook(new Thread(() -> { executor.shutdown(); try { if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } }));忘记关闭线程池是常见错误,会导致应用无法正常退出。特别是使用单例持有线程池时,因为单例生命周期长,更容易忽略资源释放。我现在的做法是强制为每个单例线程池添加关闭钩子。
7. 常见陷阱与最佳实践
7.1 避免过度使用单例
单例虽然方便,但滥用会导致:
- 测试困难:难以mock和隔离
- 内存泄漏:生命周期与JVM相同
- 隐藏依赖:通过静态方法隐式获取
我见过最糟糕的案例是一个应用中200多个单例,相互之间还有依赖关系。最终导致启动时间长达3分钟,测试几乎无法进行。
经验法则:只有真正需要全局唯一性的场景才使用单例,如配置服务、日志组件等。
7.2 线程池的优雅关闭
void shutdownAndAwaitTermination(ExecutorService pool) { pool.shutdown(); // 拒绝新任务 try { if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { pool.shutdownNow(); // 取消当前任务 if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { log.error("Pool did not terminate"); } } } catch (InterruptedException ie) { pool.shutdownNow(); Thread.currentThread().interrupt(); } }正确的关闭顺序很重要。直接调用shutdownNow()可能导致任务丢失。我现在的标准做法是:先温和关闭,给任务完成时间,超时后再强制关闭。
7.3 上下文传递问题
ExecutorService executor = Executors.newFixedThreadPool(4); // 错误:子线程无法获取父线程的ThreadLocal值 executor.submit(() -> { // 这里获取不到调用线程的ThreadLocal }); // 解决方案1:手动传递 Object context = threadLocal.get(); executor.submit(() -> { threadLocal.set(context); try { // 业务逻辑 } finally { threadLocal.remove(); } }); // 解决方案2:使用InheritableThreadLocal(注意线程池复用问题) // 解决方案3:阿里开源的TransmittableThreadLocal这是实际开发中最容易忽略的问题之一。我在一个需要传递用户认证信息的系统中,花了三天才定位到是因为线程池导致上下文丢失。现在对于需要跨线程传递的数据,我会显式作为参数传递。