多线程与并发

进程 vs 线程

进程就是工厂,线程就是工厂里的工人。一个工厂可以有很多工人,工人之间共享工厂的资源(内存),但每个工厂是独立的

对比项 进程 线程
比喻 工厂 工厂里的工人
资源 独立内存空间 共享进程的内存
开销 创建/销毁开销大 轻量级,开销小
通信 进程间通信(IPC)比较麻烦 直接共享变量,但要注意安全
崩溃影响 一个进程挂了不影响其他进程 一个线程挂了可能整个进程都挂

举个例子:你打开Chrome浏览器,每个标签页是一个进程(所以Chrome很吃内存),每个标签页内部渲染、网络请求等是不同的线程

线程创建三种方式

Java里创建线程有三种方式,实际开发中最常用的是线程池(后面讲),但面试必考这三种

方式一:继承Thread类

最简单但最不推荐,因为Java是单继承,继承了Thread就不能继承别的类了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void testExtendThread() throws InterruptedException {
// 方式一:继承 Thread
class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println(Thread.currentThread().getName() + " 正在执行: " + i);
}
}
}

MyThread t = new MyThread();
t.start(); // ⚠️ 用 start() 不是 run()!run() 是普通方法调用,不会开新线程
t.join(); // 等待线程执行完
}

方式二:实现Runnable接口(推荐)

比继承Thread灵活,因为接口可以多实现

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testRunnable() throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 3; i++) {
System.out.println(Thread.currentThread().getName() + " Runnable执行: " + i);
}
};

Thread t = new Thread(task, "我的线程");
t.start();
t.join();
}

方式三:实现Callable接口(可以有返回值)

和Runnable的区别:Callable可以返回结果、可以抛异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void testCallable() throws Exception {
Callable<Integer> task = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
return sum; // 可以返回结果!
};

FutureTask<Integer> futureTask = new FutureTask<>(task);
Thread t = new Thread(futureTask);
t.start();

Integer result = futureTask.get(); // 阻塞等待结果
System.out.println("1+2+...+100 = " + result); // 5050
}
对比 Thread Runnable Callable
方式 继承类 实现接口 实现接口
返回值 有(通过Future获取)
异常 不能抛checked异常 不能抛checked异常 可以抛异常
灵活性 差(单继承限制)
推荐度 ⭐⭐⭐ ⭐⭐⭐

线程状态与生命周期

线程从出生到死亡,要经历这几个状态,面试高频考点

1
2
3
4
5
6
7
8
9
NEW(新建)
↓ start()
RUNNABLE(可运行/运行中)
↓ 等待锁 / 等待I/O
BLOCKED(阻塞)/ WAITING(等待)/ TIMED_WAITING(超时等待)
↓ 获得锁 / 被唤醒 / 超时
RUNNABLE(继续运行)
↓ run()执行完毕
TERMINATED(终止)
状态 说明 怎么进入
NEW 线程对象创建了,还没start new Thread()
RUNNABLE 可运行状态(包括正在CPU上跑的和排队等CPU的) start()
BLOCKED 等待获取锁 遇到 synchronized 但锁被别人占了
WAITING 无限期等待 wait()join()LockSupport.park()
TIMED_WAITING 有时限的等待 sleep(毫秒)wait(毫秒)join(毫秒)
TERMINATED 线程执行完了或异常退出 run() 结束

常见坑sleep() 不会释放锁,wait() 会释放锁。面试必考这个区别

synchronized关键字

最基本的加锁方式,就像卫生间门锁——一次只能一个人用

对象锁:锁的是某个对象实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Test
public void testSynchronizedMethod() throws InterruptedException {
class Counter {
private int count = 0;

// 同步方法:锁的是 this 对象
public synchronized void increment() {
count++;
}

// 同步代码块:锁的是指定的对象
public void incrementBlock() {
synchronized (this) {
count++;
}
}

public int getCount() { return count; }
}

Counter counter = new Counter();

// 创建两个线程同时操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) counter.increment();
});

t1.start(); t2.start();
t1.join(); t2.join();

System.out.println("最终count = " + counter.getCount()); // 20000(如果不加synchronized,结果可能小于20000)
}

类锁:锁的是整个Class对象,所有实例共享同一把锁

1
2
3
4
5
6
7
8
9
10
11
// 静态同步方法:锁的是 Counter.class
public static synchronized void staticMethod() {
// ...
}

// 等价于
public static void staticMethod() {
synchronized (Counter.class) {
// ...
}
}

volatile关键字

volatile解决的是可见性问题:一个线程修改了变量,其他线程能立刻看到

比喻:普通变量像每个人手里的草稿纸(线程本地缓存),volatile变量像白板上的公共通知——改了所有人都能看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void testVolatile() throws InterruptedException {
class Flag {
// 不加 volatile,子线程可能永远看不到 main 线程的修改!
volatile boolean running = true;
}

Flag flag = new Flag();

Thread t = new Thread(() -> {
System.out.println("线程开始...");
while (flag.running) {
// 忙等待
}
System.out.println("线程结束!");
});

t.start();
Thread.sleep(100);
flag.running = false; // 修改标志位,volatile保证子线程能看到
t.join();
}

volatile不保证原子性i++ 这种操作即使加了volatile也不安全,因为 i++ 实际上是”读→加1→写”三步操作

对比 volatile synchronized
保证可见性
保证原子性
性能 好(无锁) 差一些(要加锁解锁)
适用场景 一个线程写、多个线程读 多个线程读写

Lock接口(ReentrantLock)

比synchronized更灵活的锁,像是一把可以设置各种模式的高级门锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void testReentrantLock() throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
int[] count = {0}; // 用数组模拟共享变量(lambda里要effectively final)

Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
lock.lock(); // 加锁
try {
count[0]++;
} finally {
lock.unlock(); // ⚠️ 一定要在 finally 里解锁!否则异常时会死锁
}
}
};

Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();

System.out.println("count = " + count[0]); // 20000
}
对比 synchronized ReentrantLock
加锁方式 自动(进入代码块加锁,退出释放) 手动 lock/unlock
可中断 不可以 可以(lockInterruptibly)
公平锁 只有非公平 可以选择公平/非公平
条件变量 只有一个wait/notify 可以多个Condition
推荐 简单场景用这个 需要高级功能时用这个

线程池(ExecutorService)

为什么用线程池?因为频繁创建和销毁线程开销很大,线程池就像出租车公司——提前雇好一批司机(线程),有活儿就派出去,没活儿就等着,不用每次都现招人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void testThreadPool() throws InterruptedException {
// 创建固定大小的线程池(3个线程)
ExecutorService pool = Executors.newFixedThreadPool(3);

for (int i = 0; i < 10; i++) {
int taskId = i;
pool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
});
}

pool.shutdown(); // 温柔关闭:执行完已提交的任务再关
pool.awaitTermination(5, TimeUnit.SECONDS);
}

核心参数(面试高频)

参数 含义 比喻
corePoolSize 核心线程数 正式员工数量
maximumPoolSize 最大线程数 正式员工 + 临时工的总数
keepAliveTime 临时线程存活时间 临时工没活干多久后被辞退
workQueue 任务队列 等待区的椅子数量
handler 拒绝策略 等待区也满了怎么办

常见的线程池

newFixedThreadPool(n):固定n个线程,适合任务量已知的场景

newCachedThreadPool():线程数不限(按需创建),适合短期大量任务

newSingleThreadExecutor():只有1个线程,保证任务顺序执行

newScheduledThreadPool(n):定时/周期执行任务

常见坑:阿里巴巴开发手册禁止用 Executors 快捷方法创建线程池,因为 newFixedThreadPool 的队列是无界的,任务堆积会OOM。推荐用 new ThreadPoolExecutor(...) 手动指定参数

线程安全集合

普通的 集合框架 如 HashMapArrayList 都不是线程安全的,多线程同时操作会出问题

线程不安全 线程安全替代 说明
HashMap ConcurrentHashMap 分段锁/CAS,高性能
ArrayList CopyOnWriteArrayList 写时复制,适合读多写少
HashSet CopyOnWriteArraySet 同上
TreeMap ConcurrentSkipListMap 跳表实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void testConcurrentMap() throws InterruptedException {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// 多个线程同时往里塞数据,不会出问题
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) map.put("t1-" + i, i);
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) map.put("t2-" + i, i);
});

t1.start(); t2.start();
t1.join(); t2.join();

System.out.println("map大小: " + map.size()); // 2000,不会丢数据
}

**别用 HashtableCollections.synchronizedMap()**,性能太差(整个方法加锁),面试的时候知道就行,实际开发用 ConcurrentHashMap

并发工具类简介

JUC(java.util.concurrent)包里有一些很实用的工具类

CountDownLatch(倒计数门闩):等所有人到齐了再开始

比喻:运动会起跑,裁判等所有运动员就位(countDown),全到齐了才发令枪(await)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void testCountDownLatch() throws InterruptedException {
int workerCount = 3;
CountDownLatch latch = new CountDownLatch(workerCount);

for (int i = 0; i < workerCount; i++) {
int id = i;
new Thread(() -> {
System.out.println("工人" + id + "完成工作");
latch.countDown(); // 计数器减1
}).start();
}

latch.await(); // 主线程等待,直到计数器归零
System.out.println("所有工人都完成了,开始汇总!");
}

Semaphore(信号量):控制同时访问的线程数

比喻:停车场只有3个车位,同时最多进3辆车

CyclicBarrier(循环栅栏):所有线程互相等待,都到齐了再一起往下走

和CountDownLatch区别:CyclicBarrier可以重复使用,CountDownLatch是一次性的

⭐ 安全角度:竞态条件(Race Condition)漏洞

什么是TOCTOU(Time-of-check to time-of-use)

检查的时候是安全的,但真正使用的时候条件已经变了。就像你去ATM查余额还有1000,准备取1000,但在你按确认的一瞬间,另一笔交易已经把钱扣走了

实际案例:双线程超额提现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Test
public void testRaceConditionVulnerability() throws InterruptedException {
// ⭐ 模拟竞态条件漏洞:余额只有1000,但两个线程同时提现1000
class BankAccount {
private int balance = 1000;

// ❌ 有漏洞的写法:check 和 use 之间没有加锁
public void withdrawUnsafe(int amount) {
if (balance >= amount) { // 1. 检查(check)
// 这里有个时间窗口!另一个线程可能也通过了检查
try { Thread.sleep(10); } catch (Exception e) {} // 模拟延迟
balance -= amount; // 2. 使用(use)
System.out.println(Thread.currentThread().getName()
+ " 提现成功,余额: " + balance);
} else {
System.out.println(Thread.currentThread().getName() + " 余额不足");
}
}

// ✅ 修复方法:把 check 和 use 放在同一把锁里
public synchronized void withdrawSafe(int amount) {
if (balance >= amount) {
balance -= amount;
System.out.println(Thread.currentThread().getName()
+ " 提现成功,余额: " + balance);
} else {
System.out.println(Thread.currentThread().getName() + " 余额不足");
}
}
}

BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdrawUnsafe(1000), "用户A");
Thread t2 = new Thread(() -> account.withdrawUnsafe(1000), "用户B");
t1.start(); t2.start();
t1.join(); t2.join();
// 两个线程都可能提现成功!余额变成 -1000,银行亏钱了
}

Web场景中的竞态条件

优惠券重复领取:两个请求同时判断”用户还没领过”,然后都给发了

库存超卖:两个订单同时判断”库存>0”,然后都扣了

防御:数据库乐观锁(version字段)、分布式锁(Redis)、synchronized

常见坑

坑1:调用 run() 而不是 start(),不会创建新线程,只是普通方法调用

坑2synchronized 锁的对象不一致,比如两个方法锁了不同的对象,等于没锁

坑3:在 finally 里忘记 unlock(),异常时死锁

坑4:线程池用完不 shutdown(),程序无法退出

坑5:以为 volatile 能替代 synchronizedvolatile 只保证可见性不保证原子性

练习题

题1:用两个线程交替打印1-100(一个打印奇数,一个打印偶数)

题2:用线程池实现:10个任务,每个任务睡眠随机时间后返回结果,主线程收集所有结果

题3(⭐ 安全题):分析这段代码的竞态条件风险,并给出修复方案

1
2
3
4
5
6
7
// 用户注册时检查用户名是否存在
public void register(String username) {
if (!userDao.existsByUsername(username)) { // check
userDao.insert(new User(username)); // use
}
}
// 提示:两个请求同时注册同一个用户名会怎样?

上一章 目录 下一章
枚举类型 java基础 网络编程