为什么需要多线程:并发不是炫技
Web 服务器为什么能同时处理几百个请求?下载工具为什么能一边下文件一边显示进度条?答案都是多线程。一个 CPU 核心在同一时刻只能执行一条指令,但操作系统会在多个线程之间快速切换,让你"感觉"它们同时在跑。在 IO 密集场景下(等网络、等磁盘、等数据库),线程把等待时间让给别人用,整体吞吐就能翻好几倍。
但多线程不是免费的午餐。多个线程同时读写同一份数据时,如果没有保护,结果就会出错。比如两个线程同时给一个计数器加一,最后可能只加了一次——这就是经典的竞态条件。写并发代码的核心,就是搞清楚"哪些数据需要保护、用什么方式保护、什么时候不需要保护"。
并发编程的第一原则:能用单线程解决,就不要上多线程。多线程把代码复杂度乘了一个数量级,只有在真的需要并行时才值得付出这个代价。
- 线程池复用线程、控制并发数,不要自己 new Thread 到处跑。
- synchronized自动加锁释放的内置锁,保护临界区最简单的方式。
- 并发容器ConcurrentHashMap 等容器内部已加锁,直接用比自己包装安全。
- 原子类AtomicInteger 用 CAS 实现无锁计数,比 synchronized 轻量。
创建线程的正确姿势
创建线程最原始的方式是 new Thread().start(),但在真实项目里几乎从来不这么写——因为每次 new 一个新线程开销很大,而且没有复用、没有上限,任务一多服务器直接崩。
// 最基础的写法:实现 Runnable 接口
public class PrintTask implements Runnable {
private final String name;
public PrintTask(String name) { this.name = name; }
@Override
public void run() {
System.out.println("线程 " + name + " 正在执行:" +
Thread.currentThread().getName());
}
}
public class Demo {
public static void main(String[] args) {
Thread t = new Thread(new PrintTask("任务A"));
t.start(); // 启动线程,JVM 会回调 run()
// 注意:直接调 t.run() 不会开新线程,只是普通方法调用
}
}
这里有两个新手必踩的坑:调用 start() 才会开新线程,直接调 run() 只是同步执行;线程启动后什么时候跑、跑多久,操作系统说了算,主线程不会等它结束就继续往下走。需要等线程执行完再继续,用 join()。
| 方式 | 适用场景 | 注意事项 |
|---|---|---|
| 继承 Thread | 几乎不用 | Java 单继承,继承了 Thread 就不能继承别的类 |
| 实现 Runnable | 无返回值的任务 | 最常用,还能配合线程池提交 |
| 实现 Callable + Future | 需要返回结果的任务 | 配合线程池 submit,用 Future.get() 拿结果 |
| CompletableFuture | 需要链式编排异步任务 | Java 8 之后的首选,能组合多个异步任务 |
线程池:复用线程的标准方式
线程池的思路和数据库连接池一样:提前创建一批线程放在池子里,来任务就拿一个用,用完还回去而不是销毁。这样既控制了并发线程数量,又省掉了反复创建销毁线程的开销。Executors 类提供了几个工厂方法,但阿里巴巴开发手册明确要求不要用 Executors 快捷方法,而是用 ThreadPoolExecutor 手动构造,明确队列大小和拒绝策略。
// 手动创建线程池,参数全部显式指定
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // 核心线程数
8, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new ArrayBlockingQueue<>(200), // 任务队列
new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满了由调用线程自己跑
);
// 提交无返回值任务
pool.execute(() -> doSomething());
// 提交有返回值任务,拿 Future
Future<String> future = pool.submit(() -> querySomething());
String result = future.get(); // 阻塞等结果
// 应用关闭时优雅关闭
pool.shutdown();
几个参数的含义要记牢:核心线程数是常驻的线程数量,队列满了才会扩到最大线程数,拒绝策略是队列和线程都满了之后怎么办。CallerRunsPolicy 是一种保护性策略——让提交任务的那个线程自己跑,相当于自动降速,比直接丢任务安全。
- CPU 密集型:核心线程数设为 CPU 核数 + 1,太多反而增加切换开销。
- IO 密集型:线程数可以多一些,比如核数的 2 到 4 倍,因为线程大部分时间在等 IO。
- 不要用 Executors.newFixedThreadPool:它的队列是无界的,任务堆积会撑爆内存。
共享可变状态:synchronized 与 Lock
多个线程同时修改同一个变量,是并发 bug 的头号来源。看这个经典例子:
public class Counter {
private int count = 0;
public void increment() {
count++; // 这一行其实是三步:读、加、写
}
public int get() { return count; }
}
// 两个线程各加一万次,预期结果是 20000,实际经常小于 20000
public static void main(String[] args) throws Exception {
Counter c = new Counter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) c.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 10000; i++) c.increment(); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(c.get()); // 大概率不是 20000
}
count++ 看起来是一行,实际是"读当前值、加一、写回"三步。两个线程同时读到同一个值,各自加一写回去,结果就少加了一次。解决办法是用 synchronized 把这段代码包成临界区,同一时刻只允许一个线程进去:
public class Counter {
private int count = 0;
public synchronized void increment() {
count++; // 同一时刻只有一个线程能进入这个方法
}
}
synchronized 是 Java 内置的锁,加在方法上相当于锁住 this 对象。它的好处是自动加锁、自动释放,不会因为异常忘了解锁。如果需要更灵活的控制(比如尝试拿锁、可中断、超时),用 ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
public void doBiz() {
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock(); // 必须在 finally 里释放,否则异常会死锁
}
}
用 Lock 有一个铁律:解锁必须放在 finally 里,否则业务代码抛异常,锁永远不释放,其他线程全部卡死。
并发容器与原子类
日常写业务,大部分时候不需要自己写锁。JDK 提供了一整套并发安全的容器和工具类,直接用就好:
| 类 | 替代谁 | 为什么快 |
|---|---|---|
| ConcurrentHashMap | HashMap + synchronized | 分段锁 / CAS,读几乎无锁 |
| CopyOnWriteArrayList | ArrayList + synchronized | 写时复制,读完全无锁 |
| BlockingQueue | 自己实现的生产者队列 | 内部已处理好阻塞与唤醒 |
| AtomicInteger / AtomicLong | int + synchronized | CAS 无锁更新,性能高一个数量级 |
| CountDownLatch | 自己写等待逻辑 | 一次性门闩,等 N 个任务完成 |
| Semaphore | 自己写信号量 | 控制同时访问的线程数 |
// 原子类做计数器,不需要 synchronized
private final AtomicInteger count = new AtomicInteger(0);
public void onRequest() {
count.incrementAndGet(); // 原子操作,线程安全
}
// ConcurrentHashMap 做缓存,多线程读写不用自己加锁
private final Map<String, User> cache = new ConcurrentHashMap<>();
public User getUser(String id) {
return cache.computeIfAbsent(id, this::loadFromDb);
}
CopyOnWriteArrayList 适合"读多写少"的场景,比如监听器列表:每次修改都复制一份新数组,读操作完全无锁。但如果写入频繁,每次复制数组的开销很大,这种场景就不该用它。选容器的时候先想清楚读写比例。
线程协作:wait / notify 与 CountDownLatch
多线程不只是"同时跑",还经常需要"互相等"。最常见的场景是:主线程要等几个并行任务都完成,再汇总结果。最经典的工具是 CountDownLatch:
public void parallelQuery() throws Exception {
int taskCount = 3;
CountDownLatch latch = new CountDownLatch(taskCount);
ExecutorService pool = Executors.newFixedThreadPool(taskCount);
for (int i = 0; i < taskCount; i++) {
final int idx = i;
pool.submit(() -> {
try {
queryFromService(idx);
} finally {
latch.countDown(); // 每完成一个减一
}
});
}
latch.await(); // 阻塞直到计数归零
System.out.println("所有查询完成,开始汇总");
pool.shutdown();
}
CountDownLatch 是一次性的——计数减到零之后就不能再用了。如果需要"等一批工人到齐再发车"、发完车还能再来一批,用 CyclicBarrier。生产者消费者场景则用 BlockingQueue 就够了,它内部已经处理好了阻塞和唤醒,不需要自己写 wait/notify。
synchronized 配合 wait/notify 是最底层的线程协作方式,现在业务代码里几乎不直接写了——BlockingQueue、CountDownLatch、CompletableFuture 这些高层工具已经把它包装好了。了解原理有助于排错,但实际开发优先用现成工具。
并发 Bug 的排查思路
并发 bug 最烦人的地方是"偶发"——本地跑一百次可能复现一次,线上高并发才炸。排查思路按优先级排:
- 先问共享了什么:把代码里所有可变成员变量列出来,看哪些会被多线程同时写。局部变量和方法参数是线程私有的,不会有问题。
- 看结果对不对:计数器最终值、缓存里的数据、统计数字,这些可观测的指标最容易暴露问题。
- 加日志和线程名:线程池一定要设 ThreadFactory 命名,日志里能看到是哪个线程在操作,不然全是 pool-1-thread-N 没法定位。
- 用 jstack 抓线程栈:程序卡住的时候,jstack 一下就能看到所有线程在等什么锁、卡在哪一行。
- 注意可见性问题:一个线程改了变量,另一个线程可能因为 CPU 缓存一直读旧值。共享变量要加 volatile,或者通过 synchronized 保护。
最后一个原则:能用不可变对象就不要用可变对象。String、LocalDateTime、Long 这些一旦创建就不会变的类,天生线程安全,不需要任何同步。设计类的时候多想想"能不能把字段做成 final、能不能用新对象代替修改旧对象",能从根上减少一大半并发问题。
写并发代码时,先假设每一行代码都可能在任意时刻被其他线程打断。按这个标准审视一遍,很多问题在写代码的时候就避掉了。