1. 为什么我们需要多线程?
在单核CPU时代,计算机只能串行执行任务。但随着硬件发展,现代CPU普遍采用多核设计,我的开发机就是8核16线程的配置。如果还只用单线程,就相当于让其他CPU核心闲置,这是对计算资源的巨大浪费。
去年我参与过一个日志分析项目,最初用单线程处理10GB日志文件,耗时近2小时。后来改用8线程并行处理,时间缩短到15分钟。这个案例让我深刻认识到多线程的价值:
- 提高吞吐量:Web服务器用多线程能同时处理更多请求
- 降低延迟:GUI程序用后台线程避免界面卡顿
- 充分利用多核:计算密集型任务可以线性提升速度
- 简化模型:某些异步场景用多线程更直观
注意:多线程不是银弹。我在初期经常犯的错误是盲目使用多线程,反而因为线程切换开销导致性能下降。合理的线程数应该等于或略大于CPU核心数。
2. Java线程的创建方式
2.1 继承Thread类
这是最基础的方式,适合简单的线程任务。上周我刚用这种方式实现了一个心跳检测功能:
java复制class HeartbeatThread extends Thread {
@Override
public void run() {
while(!isInterrupted()) {
checkServerStatus();
try {
Thread.sleep(5000); // 每5秒检测一次
} catch (InterruptedException e) {
break;
}
}
}
}
使用时直接new HeartbeatThread().start()。但这种方式有个明显缺点:Java是单继承的,继承了Thread就不能继承其他类。
2.2 实现Runnable接口
更推荐的方式,也是我在项目中用得最多的:
java复制class LogProcessor implements Runnable {
private final String logFile;
public LogProcessor(String file) {
this.logFile = file;
}
@Override
public void run() {
processLogFile(logFile);
}
}
// 使用方式
new Thread(new LogProcessor("app.log")).start();
这种方式更灵活,还可以配合线程池使用。我经常用它来处理批量文件导入任务。
2.3 使用Callable和Future
当需要获取线程执行结果时,这是我首选的方案。比如在用户注册时异步发送邮件:
java复制ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Boolean> future = executor.submit(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
return sendWelcomeEmail(user);
}
});
// 其他业务逻辑...
if(future.get(5, TimeUnit.SECONDS)) {
logger.info("邮件发送成功");
}
这里用future.get()可以设置超时时间,避免线程阻塞太久。我在电商项目中就用这个机制保证促销短信的及时发送。
3. 线程安全的核心问题
3.1 竞态条件(Race Condition)
去年排查过一个诡异的bug:库存扣减偶尔会出现负数。最终发现是多个线程同时执行stock = stock - quantity导致的。这就是典型的竞态条件。
java复制// 不安全的实现
public void unsafeDeduct(int quantity) {
if(stock >= quantity) {
// 这里可能被其他线程打断
stock = stock - quantity;
}
}
3.2 内存可见性问题
更隐蔽的问题是内存可见性。考虑这个场景:
java复制class TaskRunner {
private boolean running = true;
public void stop() { running = false; }
public void run() {
while(running) {
// 执行任务...
}
}
}
在某些JVM实现中,running变量可能被缓存,导致stop()方法修改后,run()方法看不到变化。这就是著名的"无限循环"问题。
3.3 解决方案对比
我整理了一张常用同步方案的对比表:
| 方案 | 适用场景 | 性能影响 | 我的使用经验 |
|---|---|---|---|
| synchronized | 方法/代码块同步 | 较高 | 简单但要注意锁粒度 |
| volatile | 可见性保证 | 较低 | 适合状态标志位 |
| ReentrantLock | 复杂同步需求 | 中等 | 可中断、可超时 |
| Atomic类 | 单一变量原子操作 | 最低 | 计数器首选 |
在最近的消息队列项目中,我综合使用了ReentrantLock和AtomicInteger,既保证了线程安全,又保持了高性能。
4. 线程池的最佳实践
4.1 为什么要用线程池
早期我经常直接new Thread,直到遇到这些问题:
- 创建销毁线程开销大
- 线程数量不可控导致OOM
- 缺乏统一管理
现在我的项目都会使用线程池。Java通过Executors提供了几种常用配置:
java复制// 固定大小线程池 - 适合计算密集型
ExecutorService computePool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
// 缓存线程池 - 适合IO密集型
ExecutorService ioPool = Executors.newCachedThreadPool();
// 单线程池 - 保证任务顺序执行
ExecutorService singleThread = Executors.newSingleThreadExecutor();
4.2 自定义线程池参数
对于生产环境,我更喜欢自定义参数:
java复制ThreadPoolExecutor customPool = new ThreadPoolExecutor(
4, // 核心线程数
16, // 最大线程数
60, // 空闲时间
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100), // 任务队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
关键参数选择经验:
- 核心线程数 = CPU核心数 × (1 + 等待时间/计算时间)
- 队列容量根据内存和业务特点调整
- 拒绝策略推荐CallerRunsPolicy,避免任务丢失
4.3 常见坑点
- 线程泄漏:记得调用shutdown(),我在Spring应用中会用
@PreDestroy注解确保关闭 - 任务堆积:监控队列大小,设置合理的拒绝策略
- 上下文切换:避免过多线程,我通常控制在CPU核心数的2-4倍
- 异常处理:为线程池设置UncaughtExceptionHandler
5. 并发工具类实战
5.1 CountDownLatch应用
在数据导出功能中,我用它等待所有子任务完成:
java复制CountDownLatch latch = new CountDownLatch(3);
executor.execute(() -> {
exportUsers();
latch.countDown();
});
executor.execute(() -> {
exportOrders();
latch.countDown();
});
executor.execute(() -> {
exportProducts();
latch.countDown();
});
latch.await(10, TimeUnit.MINUTES); // 最多等待10分钟
5.2 CyclicBarrier案例
模拟多阶段任务,比如游戏服务器初始化:
java复制CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有服务初始化完成!");
});
new Thread(() -> {
initDB();
barrier.await();
}).start();
new Thread(() -> {
initCache();
barrier.await();
}).start();
new Thread(() -> {
initNetwork();
barrier.await();
}).start();
5.3 CompletableFuture技巧
Java8的异步编程利器,我经常这样组合操作:
java复制CompletableFuture.supplyAsync(() -> queryFromDB(userId))
.thenApply(user -> enrichUserInfo(user))
.thenAcceptAsync(user -> sendNotification(user))
.exceptionally(ex -> {
logger.error("处理失败", ex);
return null;
});
这种链式调用比传统回调清晰多了,特别适合微服务调用场景。
6. 性能优化经验
6.1 锁优化技巧
- 减小锁粒度:把一个大锁拆分为多个小锁。比如ConcurrentHashMap的分段锁设计
- 锁分离:读写锁分离,我用ReentrantReadWriteLock优化过配置中心
- 无锁编程:用CAS操作替代锁,Atomic类就是典型实现
6.2 避免伪共享
这是个高级话题。我在处理高频计数器时遇到过:
java复制// 不优化的写法
class Counter {
volatile long count1;
volatile long count2; // 可能和count1在同一个缓存行
}
// 优化后 - 使用缓存行填充
class PaddedCounter {
volatile long count1;
long p1, p2, p3, p4, p5, p6, p7; // 填充
volatile long count2;
}
6.3 线程局部变量
ThreadLocal是解决线程安全的另一把利器。我的典型用法:
java复制private static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 每个线程有自己的SimpleDateFormat实例
String date = dateFormat.get().format(new Date());
但要注意内存泄漏问题,用完后记得remove()。
7. 调试与问题排查
7.1 线程转储分析
当遇到死锁时,我最先检查线程dump:
bash复制jstack <pid> > thread.dump
然后查找"deadlock"关键词。上周刚解决过一个数据库连接池死锁问题。
7.2 可视化工具
推荐两个我常用的:
- JConsole:监控线程状态、死锁检测
- VisualVM:更强大的采样和分析功能
7.3 日志记录技巧
好的日志能事半功倍。我习惯这样记录线程信息:
java复制MDC.put("traceId", UUID.randomUUID().toString());
MDC.put("thread", Thread.currentThread().getName());
logger.info("开始处理请求");
这样在日志中就能清晰看到每个请求的线程上下文。
