并发编程:让程序同时干多件事
单核时代程序一次只干一件事,像一个厨师炒菜。多核时代你要开多个线程(Thread),像开多个灶台同时炒。但线程不是免费的:开多了抢 CPU、抢数据,就会出现"两个线程同时改一个变量,结果对不上"的竞态条件。这一章讲怎么安全地开多线程。
创建线程的正确姿势:别自己 new Thread
线程池 + 两个常见工具
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class ConcurrencyDemo {
public static void main(String[] args) throws Exception {
// 固定大小线程池:最多同时跑 4 个任务
ExecutorService pool = Executors.newFixedThreadPool(4);
// 原子类:多线程自增不会丢数,比 synchronized 轻
AtomicInteger count = new AtomicInteger(0);
// CountDownLatch:等 10 个任务都干完再往下走
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
pool.submit(() -> {
try {
Thread.sleep(100); // 模拟干活
count.incrementAndGet();
} catch (InterruptedException e) { throw new RuntimeException(e); }
finally {
latch.countDown(); // 我干完了,计数器减一
}
});
}
latch.await(); // 等所有任务完成
pool.shutdown(); // 用完必须关池
System.out.println("最终 count = " + count.get()); // 10
}
}
两把锁:synchronized vs ReentrantLock
| synchronized | ReentrantLock | |
|---|---|---|
| 怎么用 | 关键字,加在方法或代码块上,自动释放。 | 类,手动 lock() / unlock(),必须放 finally。 |
| 优点 | 简单,不会忘释放。 | 可公平锁、可尝试 tryLock、可中断,更灵活。 |
| 什么时候用 | 大多数普通互斥场景。 | 需要"试一下拿不到锁就先干别的"等高级场景。 |
volatile 与内存可见性 + 虚拟线程(Java 21+)
// volatile:告诉 JVM 这个变量别缓存,每次都去主内存读
// 典型用途:一个线程写标记,另一个线程读标记来决定停不停
private static volatile boolean running = true;
// Java 21 正式版:虚拟线程(Virtual Threads)——轻量级线程
// 以前一个请求一个操作系统线程,几千个就扛不住;
// 虚拟线程由 JVM 管理,开几十万个也不慌,写法和普通线程几乎一样
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
int finalI = i;
executor.submit(() -> {
System.out.println("虚拟线程跑任务:" + finalI);
return null;
});
}
} // try 结束自动关闭并等待所有任务完成
完整线程池案例:ExecutorService + Callable + Future + CompletableFuture 链式调用
import java.util.concurrent.*;
public class ThreadPoolFullDemo {
public static void main(String[] args) throws Exception {
// ① Callable + Future:提交一个有返回值的任务
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<String> future = pool.submit(() -> {
Thread.sleep(500);
return "任务结果";
});
System.out.println("干别的...");
String result = future.get(); // 阻塞等结果
System.out.println(result);
// 干别的...
// 任务结果
// ② CompletableFuture 链式:thenApply / thenCombine
CompletableFuture<Integer> cf = CompletableFuture
.supplyAsync(() -> 1, pool) // 异步拿到 1
.thenApply(x -> x + 10) // 加 10 → 11
.thenApply(x -> x * 2) // 乘 2 → 22
.thenCombine( // 和另一个任务合并
CompletableFuture.supplyAsync(() -> 100, pool),
(a, b) -> a + b // 22 + 100 = 122
);
System.out.println(cf.get());
// 122
// ③ allOf:等多个任务全部完成
CompletableFuture<Void> all = CompletableFuture.allOf(
CompletableFuture.runAsync(() -> sleep(200), pool),
CompletableFuture.runAsync(() -> sleep(300), pool),
CompletableFuture.runAsync(() -> sleep(100), pool)
);
all.join(); // 等三个都结束
System.out.println("三个任务都干完了");
// 三个任务都干完了
pool.shutdown();
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
}
虚拟线程实战(JDK 21+):一万个请求一万个线程,轻松跑
// 传统平台线程:开 10000 个线程直接 OOM / unable to create new native thread
// 虚拟线程:JVM 调度,开几十万个也不慌,写法和普通线程一样
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
int no = i;
executor.submit(() -> {
// 每个虚拟线程模拟一个 HTTP 请求:睡 100ms
Thread.sleep(100);
return null;
});
}
} // try 关闭时自动等所有虚拟线程跑完
// 总耗时约 100ms(并发跑),不是 10000 × 100ms
线程的一生:六种状态
jstack 排查线程问题时满屏 BLOCKED、WAITING,先知道每个状态在干嘛,才看得懂。
| 状态 | 人话 |
|---|---|
| NEW | 刚 new 出来,还没 start。 |
| RUNNABLE | 正在跑,或等着 CPU 时间片。 |
| BLOCKED | 在等别人释放 synchronized 锁。 |
| WAITING | 无限期等人唤醒(wait / join)。 |
| TIMED_WAITING | 等一会儿(sleep(1000))。 |
| TERMINATED | 跑完了,死了。 |
ThreadLocal:每个线程一份自己的副本
Spring 把当前用户、事务绑定到"当前线程"上,靠的就是 ThreadLocal。大白话:一个 Map,key 是当前线程,value 是你存的东西。A 线程 set 的,B 线程根本看不到,天然线程安全。
// 每个线程存一份自己的"当前用户",互不干扰
static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
CURRENT_USER.set("主线程用户");
Thread t = new Thread(() -> {
CURRENT_USER.set("子线程用户");
System.out.println(CURRENT_USER.get()); // 子线程用户
CURRENT_USER.remove(); // 用完一定 remove!线程池里不删会泄漏
});
t.start();
t.join();
System.out.println(CURRENT_USER.get()); // 主线程用户
}
线程池的线程是复用的,你 set 完不 remove,下一个任务拿到这个线程时,会读到上一个任务残留的值——比如把订单归属到上一个用户头上。铁律:ThreadLocal 用完一定 remove(),最好放 finally。
并发集合:别在多线程里用 HashMap
普通 HashMap、ArrayList 遇到多线程同时写,轻则数据错乱,重则 JDK 7 直接死循环(JDK 8 修了但仍不安全)。java.util.concurrent 包给了线程安全版。
| 并发集合 | 替代谁 / 什么时候用 |
|---|---|
| ConcurrentHashMap | 替代 HashMap。分段锁(JDK8 后用 CAS+synchronized 桶级锁),读几乎无锁,日常并发 Map 首选。 |
| CopyOnWriteArrayList | 替代 ArrayList。写时复制一份新数组,读不加锁。适合"读多写极少"(如监听器列表),写频繁别用。 |
| BlockingQueue | 线程池的队列、生产者-消费者模型专用,put/take 自动阻塞等待。 |
Map<String, Integer> safeMap = new ConcurrentHashMap<>();
// 多线程同时 put 不会死循环、不会丢数据
IntStream.range(0, 1000).parallel().forEach(i ->
safeMap.put("k" + i, i)
);
结构化并发:虚拟线程的正确打开方式(JDK 21+)
开一堆虚拟线程去查数据,最大的痛点是"不知道它们什么时候全完事、某个炸了怎么办"。结构化并发(Structured Concurrency,预览特性)用 StructuredTaskScope 把一组子线程当成一个整体:要么全部成功,要么一个失败其他全部取消,作用域结束自动等齐。
// 并行查用户和订单,两个都回来才往下走;一个失败自动取消另一个
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> fetchUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join(); // 等两个都完成;默认策略下任一失败自动抛异常
System.out.println(user.get() + order.get());
} // try 结束:所有虚拟线程保证都已结束,不会泄漏
论它解决了什么老问题
以前手动 fork 一堆线程再 join,得自己 try-catch、自己设超时、自己记得 join 每个,漏一个线程就"飞了"。结构化并发把"开任务"和"等任务、取消任务"绑在同一个语法块里,作用域一结束,编译期就保证子线程都被收编。版本说明:结构化并发在 JDK 21/24 为预览特性,API 可能随版本微调,以官网最新稳定版为准。
两个线程同时执行 count++,结果可能少加一次。因为 count++ 底层是"读-改-写"三步,两个线程可能同时读到同一个旧值。解决办法:用 AtomicInteger(原子类,无锁),或用 synchronized / ReentrantLock 加锁。写并发代码的第一原则:共享可变变量,要么加锁,要么用并发包工具类。
1.(概念题)synchronized 和 ReentrantLock 区别?什么时候用后者?
查看答案
答案:synchronized 是关键字自动释放,简单不会忘;ReentrantLock 是类手动 lock/unlock(必须放 finally),支持公平锁、tryLock 超时、可中断。需要"拿不到锁就先干别的"或公平排队时用 ReentrantLock。
2.(概念题)volatile 解决什么问题?它是原子操作吗?
查看答案
答案:volatile 保证可见性(一个线程写了,其他线程立刻看到)和禁止指令重排,但不保证原子性。所以 volatile 不能替代 synchronized 做 count++。典型用途:状态标记(running = false 让另一个线程停)。
3.(选择题)线程池核心参数里,哪个决定"最多同时跑几个任务"?A. keepAliveTime B. corePoolSize / maximumPoolSize C. queueCapacity
查看答案
答案:B。corePoolSize 常驻线程数,maximumPoolSize 最大线程数。队列满了才会扩到 max。
4.(概念题)为什么生产环境不要用 Executors.newCachedThreadPool()?
查看答案
答案:它的最大线程数是 Integer.MAX_VALUE,来多少请求开多少线程,直接 OOM。阿里手册明令禁止。手动用 ThreadPoolExecutor 显式指定参数。
5.(新特性题)虚拟线程和传统线程有什么区别?
查看答案
答案:传统平台线程是对操作系统线程的薄封装,一个线程占 1MB 栈,开几千就扛不住;虚拟线程由 JVM 调度,跑在少量载体线程上,一个虚拟线程只占几百字节栈,开几十万个不慌。写法一样,但不要再用线程池缓存虚拟线程。