1. 认识线程 (Thread)
1.1 概念
1) 线程是什么?
- 通俗理解 :如果把CPU 比作一个工厂 ,它有好几个车间。进程 就是这个工厂,线程 就是车间里的工人 。
- 一个工厂(进程)里至少有一个工人(线程)。
- 一个工厂里可以有多个工人(多线程)。
- 专业理解 :线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
2) 为啥要有线程?
- 没有线程的问题(单线程) :以前的程序大多是单线程的,就像只有一个窗口的银行。不管有多少人排队,一次只能服务一个人。如果这个人在办复杂的业务(比如大文件下载),后面的人就只能干等,浪费时间。
- 有了线程的好处(多线程) :多线程就像开了多个窗口 。
- 并发:虽然工厂只有一个(CPU只有一个),但通过快速切换,看起来好像几个工人在同时干活。
- 效率:比如你在电脑上一边听歌(线程A),一边写代码(线程B),一边下载电影(线程C)。这就是多线程带来的好处,让你的程序"看起来"同时在做多件事。
3) 进程和线程的区别?
- 根本区别 :进程是资源分配的最小单位,线程是调度和执行的最小单位。
- 形象比喻 :
- 进程 = 一个独立的APP(比如微信)。它有自己的内存空间、数据、文件。微信崩了,QQ 还能用,因为它们是独立的"进程",互不干扰。
- 线程 = APP 里的功能模块。比如微信里的"发送消息"和"接收消息"就是两个线程。它们共享微信的内存(比如联系人列表),所以它们之间的通信非常快。
- 资源开销 :
- 进程:像个独立的豪宅,占地大,开门关门(启动关闭)慢。
- 线程:像个豪宅里的房间,大家共享水电(内存、文件),占地小,切换快。
4) Java的线程和操作系统线程的关系?
- Java 线程 = 原生线程(1:1 模型)
- 解释 :你在 Java 代码里
new Thread().start(),Java 虚拟机(JVM)就会去操作系统那里申请创建一个真正的原生线程(由操作系统内核管理)。 - 结论:Java 线程的底层完全依赖于操作系统的原生线程实现。所以,Java 线程的调度、切换都是由操作系统来控制的。
1.2 第一个多线程程序
目标:写一个程序,让两个任务同时运行。
场景:假设我们有两个任务:
- 任务 A:打印 "唱歌..." 50 次。
- 任务 B:打印 "跳舞..." 50 次。
代码实现(最基础写法):
// 任务 A:唱歌
class SingTask implements Runnable {
@Override
public void run() {
for (int i = 0; i < 50; i++) {
System.out.println("唱歌... " + i);
}
}
}
// 任务 B:跳舞
class DanceTask implements Runnable {
@Override
public void run() {
for (int i = 0; i < 50; i++) {
System.out.println("跳舞... " + i);
}
}
}
public class FirstDemo {
public static void main(String[] args) {
// 1. 创建任务对象
Runnable sing = new SingTask();
Runnable dance = new DanceTask();
// 2. 创建线程对象,并把任务交给线程
Thread t1 = new Thread(sing);
Thread t2 = new Thread(dance);
// 3. 启动线程!注意:不是调用 run(),而是 start()!
t1.start();
t2.start();
}
}
- 关键点 :
start()方法才是真正开启一个新线程去执行run()方法。如果直接调用t1.run(),那只是在主线程里普通地调用了一个方法,并没有开启新线程。
1.3 创建线程的方法
Java 里创建线程主要有三种方式(前两种最常用):
方法 1:继承 Thread类
-
步骤 :
- 子类继承
Thread。 - 重写
run()方法(写你要执行的任务)。 - 创建子类对象。
- 调用
start()启动。
- 子类继承
-
代码示例 :
class MyThread extends Thread { @Override public void run() { System.out.println("我是继承Thread实现的线程"); } } public class Test { public static void main(String[] args) { MyThread t = new MyThread(); t.start(); } } -
缺点 :Java 是单继承,继承了
Thread就不能再继承别的类了,不够灵活。
方法 2:实现 Runnable接口(推荐)
-
步骤 :
- 类实现
Runnable接口。 - 实现
run()方法。 - 创建该类对象。
- 把这个对象传给
new Thread(对象)。 - 调用
start()。
- 类实现
-
代码示例 :
class MyRunnable implements Runnable { @Override public void run() { System.out.println("我是实现Runnable接口的线程"); } } public class Test { public static void main(String[] args) { MyRunnable r = new MyRunnable(); new Thread(r).start(); } } -
优点:解决了单继承问题,更加灵活,符合面向对象的设计(任务与线程分离)。
其他变形(Lambda 表达式,Java 8+)
-
如果任务逻辑很简单,可以直接用 Lambda 表达式,不需要专门定义一个类。
-
代码示例 :
public class Test { public static void main(String[] args) { // 直接把 Lambda 表达式传给 Thread Thread t = new Thread(() -> { System.out.println("我是Lambda创建的线程"); }); t.start(); } }
1.4 多线程的优势 - 增加运行速度
演示代码:模拟任务执行
public class SpeedTest {
public static void main(String[] args) throws InterruptedException {
// 任务:计算 1~10000000 的总和
// 1. 单线程执行
long start = System.currentTimeMillis();
calc(); // 执行任务
long end = System.currentTimeMillis();
System.out.println("单线程耗时: " + (end - start) + "ms");
// 2. 多线程执行(分成两份,两个线程算)
long start2 = System.currentTimeMillis();
Thread t1 = new Thread(SpeedTest::calcHalf); // 前半部分
Thread t2 = new Thread(SpeedTest::calcHalf); // 后半部分
t1.start();
t2.start();
t1.join(); // 等待 t1 执行完
t2.join(); // 等待 t2 执行完
long end2 = System.currentTimeMillis();
System.out.println("多线程耗时: " + (end2 - start2) + "ms");
}
// 模拟一个耗时的计算任务
public static void calc() {
long sum = 0;
for (long i = 0; i < 10000000; i++) {
sum += i;
}
}
// 计算一半
public static void calcHalf() {
long sum = 0;
// 只算一半的数据,模拟分担任务
for (long i = 0; i < 10000000 / 2; i++) {
sum += i;
}
}
}
- 预期结果 :
多线程耗时通常会小于单线程耗时。 - 原理:原本一个人(CPU核心)算 100 道题要 10 秒。现在两个人(两个线程)各算 50 道,只要 5 秒。
- 注意 :这仅仅是在多核 CPU 上才有效。如果是单核 CPU,多线程主要是通过"时间片轮转"(快速切换)来实现并发,总时间可能不会减少,甚至因为切换开销而稍微增加。但对于 I/O 密集型任务(如网络请求、读写文件),即使单核,多线程也能大幅提升效率,因为一个线程在等待网络数据时,CPU 可以切去执行另一个线程。
2.2 Thread 的几个常见属性(线程的身份信息)
线程也是有"身份证"的,这些属性决定了它是谁、优先级是多少。
核心知识点:
- id / name:唯一标识和显示名称。
- daemon (守护线程) :前面讲过,如果是
true,JVM 退出时它会跟着死;如果是false(默认),它是主力,不跑完 JVM 不走。 - priority (优先级) :1~10。虽然设置了,但操作系统不一定听,只能作为"建议"。
代码演示:
public class ThreadAttributeDemo {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("线程运行中...");
});
// 1. 设置名字
t.setName("Worker-Thread");
// 2. 判断是不是守护线程(默认是 false)
System.out.println("是否是守护线程: " + t.isDaemon());
// 3. 设置优先级(默认是 5)
t.setPriority(Thread.MAX_PRIORITY); // 设置为最高 10
System.out.println("线程优先级: " + t.getPriority());
t.start();
}
}
2.3 启动一个线程 - start() (最重要的方法)
这是最容易搞混的地方,也是面试常问的坑。
核心知识点:
- 调用
run():没反应。这只是在当前线程里执行了一个普通方法,没有开启新线程。 - 调用
start():才是开启新线程 。底层会调用操作系统的 API 创建一个真实的线程来执行run()里的代码。 - 规则 :同一个线程对象,只能
start()一次 。第二次调用会报错IllegalThreadStateException。
代码演示:
public class StartMethodDemo {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("线程名: " + Thread.currentThread().getName());
});
// 错误示范:这样写不会开启新线程,还是在 main 线程里跑
// t.run();
// 正确示范:开启新线程
t.start();
// 错误示范:同一个对象不能 start 两次
// t.start();
}
}
2.4 中断一个线程(温柔地叫停)
Java 没有 kill命令,不能强制杀死线程(那样会导致数据不一致),只能"商量着来"。
核心知识点:
interrupt():主线程调用这个方法,相当于给子线程发了一个"中断信号"。isInterrupted():子线程内部用来检查自己有没有被标记中断。Thread.interrupted():检查并清除中断标志(静态方法,较少用)。
注意 :如果子线程正在 sleep或 wait,被中断会抛出 InterruptedException,并且标志位会被清除。
代码演示:
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
// 这是一个死循环,模拟干活
while (!Thread.currentThread().isInterrupted()) {
System.out.println("我在干活...");
try {
Thread.sleep(1000); // 模拟耗时操作
} catch (InterruptedException e) {
// 如果在 sleep 时被叫停,会进到这里
System.out.println("哎呀,被打断了!");
// 注意:此时 isInterrupted() 标志已经被清除了,所以循环会继续
// 必须在这里 break 才能真的停下来
break;
}
}
System.out.println("线程正常结束。");
});
t.start();
// 主线程睡 3 秒,让子线程先跑一会
Thread.sleep(3000);
// 主线程发出中断信号
System.out.println("主线程发号施令:停止!");
t.interrupt();
}
}
2.5 等待一个线程 - join() (汇合)
想象一下接力赛,你必须等上一棒跑完了,你才能接棒。join就是干这个的。
核心知识点:
join():当前线程(比如 main 线程)会阻塞,一直等到目标线程执行完毕。join(long millis):最多等这么久,超时了就不等了,继续执行。
代码演示:
public class JoinDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
try {
System.out.println("子线程开始干活,需要 3 秒");
Thread.sleep(3000);
System.out.println("子线程活干完了");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
System.out.println("主线程等待子线程完成...");
// 如果不写下面这行,主线程打印完可能就结束了,导致看不到子线程输出
t.join(); // 主线程在这里停下来,等 t 跑完
System.out.println("主线程也结束了");
}
}
2.6 获取当前线程引用
写多线程代码时,你经常需要知道"现在是谁在干活"。
核心知识点:
Thread.currentThread():这是一个静态方法,返回当前正在执行的线程对象。
代码演示:
public class CurrentThreadDemo {
public static void main(String[] args) {
// main 方法本身就是一个线程
Thread mainThread = Thread.currentThread();
System.out.println("当前线程名字是: " + mainThread.getName());
// 在子线程里获取自己
new Thread(() -> {
Thread current = Thread.currentThread();
System.out.println("子线程里获取的当前名字: " + current.getName());
}, "MyThread").start();
}
}
2.7 休眠当前线程
让当前线程暂停一会儿,把 CPU 让给别人。
核心知识点:
Thread.sleep(millis):静态方法,让当前线程睡眠指定的毫秒数。- 异常 :必须捕获
InterruptedException。
代码演示:
public class SleepDemo {
public static void main(String[] args) {
System.out.println("开始执行: " + System.currentTimeMillis());
try {
// 让当前线程(main)睡 2 秒
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("睡醒了: " + System.currentTimeMillis());
}
}
3.1 观察线程的所有状态
Java 中线程的状态定义在 Thread.State枚举中,一共 6 种:
- NEW (新建) :厨师刚招进来,穿好工装,还没进厨房(还没调用
start())。 - RUNNABLE (可运行) :厨师在厨房里,正在切菜或者炒菜。注意:在 Java 里,正在运行和排队等待 CPU 时间片统称为 RUNNABLE。
- BLOCKED (锁阻塞):厨师想要拿盐,发现盐罐子被另一个厨师拿着(有锁),他在等别人用完(等待获取锁)。
- WAITING (无限等待) :厨师把刀放下,坐在地上等,谁也不找,一直等到有人喊他(如调用了
wait()或join())。 - TIMED_WAITING (计时等待) :厨师去抽烟,说"我只抽 5 分钟烟"(调用了
sleep(5)或wait(5))。 - TERMINATED (终止):厨师下班回家了,活干完了(线程执行完毕)。
3.2 线程状态和状态转移的意义
意义在于:调试 Bug 和生产监控。
如果你发现系统卡死了,你去查日志,看到某个线程的状态一直是 WAITING,你就知道:"哦,它在等人(等锁或者等通知)",这能帮你迅速定位死锁或者线程泄漏的问题。
3.3 观察线程的状态和转移(代码实战)
为了让你看到这些状态,我们需要用到 **jstack** 命令或者 IDEA 的线程调试视图。下面的代码展示了状态的流转过程。
import java.util.concurrent.TimeUnit;
public class ThreadStateDemo {
// 定义一个锁对象
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
// 1. NEW 状态
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
// 3. 进入 WAITING 状态 (无限期等)
System.out.println(Thread.currentThread().getName() + " 拿到锁,准备 wait...");
LOCK.wait();
System.out.println(Thread.currentThread().getName() + " 被唤醒了!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Waiter-Thread");
// 此时 t1 还没 start,状态是 NEW
System.out.println("1. State after new: " + t1.getState()); // NEW
t1.start();
// 休息一下,确保 t1 先跑起来并拿到锁 wait 住
TimeUnit.MILLISECONDS.sleep(100);
// 2. 此时 t1 应该是 WAITING
System.out.println("2. State after start and wait: " + t1.getState()); // WAITING
Thread t2 = new Thread(() -> {
synchronized (LOCK) {
System.out.println(Thread.currentThread().getName() + " 抢到锁了!准备 sleep...");
try {
// 4. 进入 TIMED_WAITING 状态
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 睡醒了,准备唤醒别人");
// 唤醒 t1
LOCK.notify();
}
}, "Notifier-Thread");
t2.start();
// 休息一下,让 t2 执行
TimeUnit.SECONDS.sleep(1);
// 5. 此时 t1 还在 WAITING,t2 在 TIMED_WAITING
System.out.println("3. State of t1 (should be WAITING): " + t1.getState());
System.out.println("4. State of t2 (should be TIMED_WAITING): " + t2.getState());
// 等待 t2 彻底结束
t2.join();
// 6. 线程结束状态
System.out.println("5. State of t1 after all (should be TERMINATED): " + t1.getState());
}
}
核心状态转移图(记忆版)
特别容易混淆的点:Runnablevs Running
- Running (正在运行):这是物理上的概念,指 CPU 正在执行这个线程的指令。
- Runnable (可运行) :这是 Java 里的概念。它包括 Running 以及 Ready (就绪,排队等 CPU) 。
- 就像餐厅里:正在炒锅里炒菜的是 Running,站在灶台边等火候、手里拿着铲子的也是 Runnable。它们都在"跑道"上,只是有的在跑,有的在排队。
把"线程不安全"拆解为三个核心问题来解答:
- 原子性(Atomicity):操作被打断。
- 可见性(Visibility):互相看不见对方的修改。
- 指令重排序(Reordering):执行顺序乱了。
4.1 & 4.2 观察线程不安全 & 概念
通俗理解:
你以为你的代码是一行一行按顺序执行的,但在多线程下,这行代码可能还没执行完 ,CPU 就去执行别人的代码了。或者你改了变量,别人永远看不到你改的值。
代码演示:经典的累加错误
我们开 1000 个线程,每个线程给 count加 1,预期结果应该是 1000。
public class ThreadUnsafeDemo {
// 共享数据
static int count = 0;
public static void main(String[] args) throws InterruptedException {
int threadCount = 1000;
Thread[] threads = new Thread[threadCount];
// 创建 1000 个线程
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
// 每个线程让 count 加 1
for (int j = 0; j < 1000; j++) {
count++;
}
});
threads[i].start();
}
// 等待所有线程跑完(利用 join,上一节学的知识)
for (Thread t : threads) {
t.join();
}
// 预期结果:1000 * 1000 = 1_000_000
System.out.println("最终结果: " + count);
// 实际结果:通常是一个小于 100 万的数(比如 998755)
}
}
4.3 线程不安全的原因(核心剖析)
为什么上面的代码算不对?我们来拆解成三个原因:
原因一:原子性被破坏(Atomicity)
什么是原子性? 就像化学里的原子一样,不可分割。
count++看起来是一行代码,但在计算机底层其实是三步操作:
- 读 (Read) :从内存把
count的值拿出来。 - 改 (Modify):在 CPU 里加 1。
- 写 (Write):把新值放回内存。
惨案现场:
当线程 A 读完 count=0,正准备加 1 的时候,CPU 切换到线程 B。
线程 B 读到的也是 count=0,加 1 变成 1 写回去。
然后线程 A 醒过来,它手里拿的还是旧的 0,加 1 变成 1 写回去。
**结果:两次加法,结果只加了 1。** 这就是典型的"丢失更新"。
原因二:可见性(Visibility)
什么是可见性? 线程 A 修改了变量,线程 B 能不能立刻看到这个修改?
答案:不能。
现代 CPU 为了性能,每个线程都有自己的一小块高速缓存(Cache),而不是每次都去主内存读写。
- 线程 A 改了变量,先存在自己的 Cache 里。
- 线程 B 读变量,读的是自己 Cache 里的旧值。
- 这就叫:不可见。
原因三:指令重排序(Reordering)
**什么是重排序?** 编译器为了优化性能,可能会悄悄改变代码的执行顺序(只要不影响单线程的结果)。
在单线程下没问题,但在多线程下,如果代码的执行顺序变了,可能会导致逻辑错误。
(这是一个比较深的概念,通常在讲 volatile关键字时重点解决,这里先知道有这么个坑即可)。
4.4 解决之前的线程不安全问题
既然知道了原因是"原子性"和"可见性"出了问题,Java 提供了关键字来解决:synchronized 和 volatile。
方案一:使用 synchronized(万能药)
它有两个作用:
- 保证原子性:同一时刻,只有一个人能进这个方法/代码块。
- 保证可见性:你改完之后,强制把缓存刷新到主内存,让别人能看到。
修改后的代码:
public class ThreadSafeDemo {
static int count = 0;
// 创建一个锁对象
static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
int threadCount = 1000;
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
// 加锁:只有拿到锁的线程才能执行这里的代码
synchronized (lock) {
count++;
}
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
// 结果一定是 1000000
System.out.println("最终结果: " + count);
}
}
原理 :synchronized就像一个单人厕所。如果一个人进去了(锁住了),外面的人(其他线程)必须在门口排队。等他出来(解锁),下一个人才能进去。这样里面的操作就不会被打扰了。
方案二:使用 volatile(专治可见性)
如果你的问题仅仅是"我改了变量,别人看不见",用 volatile就够了。
它只保证可见性 和禁止重排序 ,不保证原子性。
适用场景:通常是用来修饰一个标志位。
public class VolatileDemo {
// 加上 volatile,t1 修改后,t2 能立刻看见
static volatile boolean flag = true;
public static void main(String[] args) {
// 线程 1:负责干活,直到 flag 变 false
Thread t1 = new Thread(() -> {
while (flag) {
// 空转
}
System.out.println("线程1 停止了");
});
// 线程 2:负责喊停
Thread t2 = new Thread(() -> {
try {
Thread.sleep(1000); // 睡一会
} catch (InterruptedException e) {}
System.out.println("线程2 把 flag 改成 false");
flag = false; // 修改 flag
});
t1.start();
t2.start();
}
}
总结这一节的知识点:
-
为什么会不安全? 因为
count++不是原子操作(读-改-写被打断),且线程间有缓存(看不见彼此的修改)。 -
怎么解决?
synchronized:最强王者,既管原子性又管可见性(通过互斥锁)。volatile:轻量级,只管可见性和有序性(通过内存屏障),不管原子性。
5. synchronized 关键字(监视器锁)
synchronized就像是给代码加了一把排他性的锁。它的核心思想是:一次只能有一个人进屋办事,办完出来换下一个人。
5.1 核心特性
1) 互斥性 (Mutual Exclusion)
- 含义:同一时刻,只有一个线程能持有这把锁。
- 效果 :当一个线程进入
synchronized代码块时,其他线程必须在外面等待,直到锁被释放。
2) 可重入性 (Reentrancy)
- 含义:同一个线程,可以多次获取自己已经持有的锁。
- 生活类比:就像你进了家门(获取锁),进卧室不需要再掏钥匙开门,因为你已经在屋里了。JVM 会记录持有锁的线程和重入次数,防止自己把自己锁死(死锁)。
5.2 使用示例(三种写法)
写法一:同步实例方法(锁的是 this对象)
public class Counter {
private int count = 0;
// 锁的是当前的 Counter 对象实例
public synchronized void increment() {
count++;
}
}
写法二:同步静态方法(锁的是 Class对象)
public class Counter {
private static int count = 0;
// 锁的是 Counter.class 对象(全局唯一)
public static synchronized void increment() {
count++;
}
}
写法三:同步代码块(锁的是指定对象,推荐)
public class Counter {
private int count = 0;
// 专门定义一个锁对象
private final Object lock = new Object();
public void increment() {
// 只有拿到 lock 对象锁的线程才能执行这里的代码
synchronized (lock) {
count++;
}
}
}
注:写法三粒度最细,性能最好,因为只锁定必要的代码段。
5.3 Java 标准库中的线程安全类
Java 提供了一些内部已经加锁的类,你可以直接使用:
Vector,Hashtable(老类,现在通常用Collections.synchronizedList(new ArrayList<>())代替)StringBuffer(对比StringBuilder,前者是线程安全的)
6. volatile 关键字
如果说 synchronized是"大门",那 volatile就是"大喇叭"。
核心作用:保证内存可见性
- 痛点:为了提高效率,每个线程可能会把自己的变量拷贝一份放在"工作内存"里。如果一个线程修改了变量,另一个线程可能还在看旧值。
- 解决方案 :
volatile告诉 JVM:"每次读这个变量,都要去主内存读;每次写这个变量,都要立刻刷回主内存。"
致命局限:不保证原子性
- 陷阱 :
volatile只管"看见",不管"连贯"。 - 例子 :
i++。虽然线程 A 看到了最新的i,但在它执行+1的这一瞬间,线程 B 可能已经把i改了。volatile救不了这种"读-改-写"的复合操作。
7. wait 和 notify 方法
这两个方法是 Object类的,必须配合 synchronized使用。它们是线程间通信的工具(比如:生产者-消费者模型)。
wait(): 线程说"我现在没货/条件不满足,我要等着"。注意:调用 wait 会释放锁!notifyAll(): 线程说"货来了/条件满足了,大家起来干活了"。
标准写法模板:
synchronized (lock) {
while (条件不满足) { // 必须用 while,防止虚假唤醒
lock.wait(); // 释放锁,进入等待
}
// 执行任务...
lock.notifyAll(); // 唤醒其他等待的线程
}
7.4 wait 和 sleep 的对比(高频面试题)
这是面试必问,直接背表:
| 比较项 | Object.wait() |
Thread.sleep() |
|---|---|---|
| 归属 | java.lang.Object的方法 |
java.lang.Thread的静态方法 |
| 锁的行为 | 会释放锁。释放当前持有的 monitor 锁。 | 不会释放锁。睡着的时候还占着锁不放。 |
| 使用前提 | 必须在 synchronized 代码块或方法中。 | 任何地方都可以用。 |
| 用途 | 线程间协作(等待/通知)。 | 单纯让线程暂停一段时间。 |
| 异常 | 抛出 InterruptedException |
抛出 InterruptedException |
一句话总结:
- wait 是"我去厕所了,把门开着等我,你们谁着急谁先用"(释放锁,等待唤醒)。
- sleep 是"我趴在桌上睡五分钟,谁也别叫我,醒了我还得接着干"(不释放锁,时间到了自动醒)。
8.1 单例模式 (Singleton Pattern)
什么是单例模式?
确保一个类在整个程序运行期间 ,永远只有一个对象实例。
为什么要用?
通常用于管理共享资源。比如:网站的计数器(所有人访问同一个数)、日志系统的文件写入(不能同时写同一个文件)、数据库连接池(连接数是有限的,统一管理)。
1. 饿汉模式 (Eager Initialization)
特点:类一加载,实例就创建好了。
评价:简单、绝对线程安全(ClassLoader 保证),但如果这个对象很大且一直没用,就浪费内存了。
class HungrySingleton {
// 1. 私有化构造方法,别人不能 new
private HungrySingleton() {}
// 2. 类加载时就直接创建实例
private static final HungrySingleton INSTANCE = new HungrySingleton();
// 3. 提供一个全局访问点
public static HungrySingleton getInstance() {
return INSTANCE;
}
}
2. 懒汉模式 (Lazy Initialization) - 面试重点
特点:什么时候用,什么时候才创建(延迟加载)。
痛点:在多线程下,如果不加控制,可能会创建多个对象。
❌ 错误写法(非线程安全):
// 多线程下可能创建两个对象,因为 if(instance == null) 这一步可能被同时执行
public class WrongLazy {
private static WrongLazy instance;
public static WrongLazy getInstance() {
if (instance == null) { // 线程A执行到这里,还没赋值,线程B也进来了
instance = new WrongLazy();
}
return instance;
}
}
✅ 正确写法(双重检查锁定 Double-Checked Locking):
这是最经典的写法,既保证了线程安全,又保证了性能(只加一次锁)。
class CorrectLazy {
// volatile 关键字非常重要!
// 它能防止指令重排序。new LazySingleton() 分为三步:申请内存->赋值->指向引用。
// 如果没有 volatile,可能对象还没初始化完,就被别的线程拿去用了(拿到半吊子对象)。
private static volatile CorrectLazy instance;
public static CorrectLazy getInstance() {
// 第一次检查:如果已经创建了,直接返回,不需要加锁,提升性能
if (instance == null) {
synchronized (CorrectLazy.class) {
// 第二次检查:防止多个线程都通过了第一次检查,排队进来后重复创建
if (instance == null) {
instance = new CorrectLazy();
}
}
}
return instance;
}
}
8.2 阻塞队列 (Blocking Queue)
什么是阻塞队列?
它是一个特殊的队列,不仅是用来装数据的,它还带自动控制线程的功能:
- 队列空了,取数据的线程会自动挂起(Wait)等着。
- 队列满了,放数据的线程会自动挂起(Wait)等着。
- 一旦条件满足(有数据了或腾出空间了),线程会自动唤醒。
这就完美替代了我们在上一节讲的 wait和 notify!
生产者-消费者模型示例 (使用 Java 标准库)
Java 的 ArrayBlockingQueue内部已经帮我们实现了锁和等待逻辑。
import java.util.concurrent.ArrayBlockingQueue;
public class BlockingQueueDemo {
public static void main(String[] args) {
// 创建一个容量为10的阻塞队列
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(10);
// 生产者线程
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 100; i++) {
// put 方法:如果队列满了,就会自动阻塞等待
queue.put("产品-" + i);
System.out.println("生产了: 产品-" + i);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 消费者线程
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 100; i++) {
// take 方法:如果队列空了,就会自动阻塞等待
String product = queue.take();
System.out.println("消费了: " + product);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
producer.start();
consumer.start();
}
}
8.3 定时器 (Timer)
什么是定时器?
就是设定一个时间点,让任务在那个时间执行。比如:每天早上 8 点发送报表,或者 3 秒后关闭弹窗。
使用标准库 Timer
Timer内部其实就是一个单线程在执行任务。
import java.util.Timer;
import java.util.TimerTask;
public class TimerDemo {
public static void main(String[] args) {
Timer timer = new Timer();
// 安排任务:任务,延迟多久执行(ms),每隔多久执行一次(ms)
// 这里表示:立即执行,然后每隔1秒执行一次
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("定时任务执行了: " + System.currentTimeMillis());
}
}, 0, 1000);
}
}
⚠️ 注意 :Timer有一个缺点,如果某个任务执行时间太长(超过了间隔时间),会导致后续任务积压或执行不准。实际开发中,更多使用 ScheduledExecutorService(线程池版的定时器)。
8.4 线程池 (ThreadPool)
什么是线程池?
线程是昂贵的资源(创建和销毁要消耗 CPU 和内存)。线程池的思路是:预先创建好一堆线程放在池子里,用完了不还,而是放回池子,下次再用。
三大好处:
- 降低消耗:避免了频繁创建和销毁线程的开销。
- 提高响应:任务来了,直接从池子里拿现成的线程用,不用等创建。
- 可控管理:可以控制电脑最多跑多少个线程,防止卡死。
标准库中的线程池 (Executors)
虽然 Executors工厂类很好用,但在大厂面试中,通常要求直接使用 ThreadPoolExecutor构造器来创建,因为这样可以更精细地控制队列大小和拒绝策略。
手写简易版线程池核心逻辑(理解原理):
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class SimpleThreadPool {
// 任务队列
private final BlockingQueue<Runnable> taskQueue;
// 线程数组
private final WorkerThread[] workers;
public SimpleThreadPool(int poolSize) {
this.taskQueue = new LinkedBlockingQueue<>();
this.workers = new WorkerThread[poolSize];
// 初始化线程池里的线程
for (int i = 0; i < poolSize; i++) {
workers[i] = new WorkerThread("Worker-" + i);
workers[i].start();
}
}
// 提交任务
public void execute(Runnable task) {
try {
taskQueue.put(task); // 把任务扔进队列,如果满了就等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 工作线程类
private class WorkerThread extends Thread {
public WorkerThread(String name) {
super(name);
}
@Override
public void run() {
while (true) {
try {
// 从队列里取任务,如果没任务就阻塞等待 (take方法)
Runnable task = taskQueue.take();
System.out.println(getName() + " 拿到任务并执行...");
task.run(); // 执行任务逻辑
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 测试
public static void main(String[] args) {
SimpleThreadPool pool = new SimpleThreadPool(3); // 3个工作线程
for (int i = 0; i < 10; i++) {
int taskId = i;
pool.execute(() -> {
System.out.println("任务 " + taskId + " 正在运行");
try { Thread.sleep(1000); } catch (Exception e) {}
});
}
}
}
这是咱们多线程课程的最后一块拼图。这部分内容不考你具体的 API,而是考你的底层逻辑思维。理解了这两部分,你在面试中遇到"多线程问题"就能一眼看穿本质了。
我们还是用最通俗的大白话来讲。
9. 总结:保证线程安全的思路
其实,让线程变安全,就像治理交通拥堵或者保护公共财产一样,无非就是那么几个套路。总结起来就三个字:"隔"、"守"、"查"。
1. "隔":把共享资源隔离开(互斥锁)
- 核心思想:大家都想改同一个东西(比如同一张银行卡余额),那我就不许你们同时改,必须排队!
- 武器 :
synchronized、Lock。 - 效果:同一时刻,只有一个线程能操作这个资源。
2. "守":守不住的时候就通知(等待-通知机制)
- 核心思想:如果资源暂时不可用(比如库存为0了),你与其一直问"好了吗?好了吗?"(这就是 CPU 空转,浪费资源),不如你就乖乖睡觉,等资源好了我再叫你(wait/notify)。
- 武器 :
wait()、notifyAll()。 - 效果:避免无效的循环检查,节省 CPU 资源。
3. "查":查出那些看不见的脏读(可见性)
- 核心思想:有的线程修改变量不告诉别人(因为 CPU 有缓存),导致别的线程还在看旧数据。我们要强制要求:改了就得立刻广播给全世界!
- 武器 :
volatile。 - 效果:保证大家看到的都是最新、最准的数据。
10. 对比线程和进程
这部分是面试必问的。你可以把CPU 想象成一个大工厂,里面有不同的车间和工人。
10.1 线程的优点
为什么要多搞出个"线程"的概念?直接用"进程"不行吗?
答案:为了省钱、省时、灵活。
-
省钱(省内存):
- 进程像是一个独立的豪宅,启动一个进程要申请很多独立资源,成本高。
- 线程就像是豪宅里的一个个房间,大家共享客厅(内存)、水电(文件句柄),只需要一点点额外空间就能造出一个新线程。
-
省时(切换快):
- 从 A 进程切换到 B 进程,就像从北京飞到上海,成本高、时间长。
- 从 A 线程切换到 B 线程,就像在同一个大楼里换办公室,一秒钟搞定。
-
通信方便:
- 进程之间说话很难(比如微信和网易云音乐不能直接互相读写对方的内存),需要特殊的管道。
- 线程之间说话很容易,因为它们住在一起,直接喊一声就行(共享堆内存)。
10.2 进程与线程的区别(重点背诵)
我们可以用**"开公司"**来打个比方:
| 维度 | 进程 (Process) | 线程 (Thread) |
|---|---|---|
| 资源拥有 | 大老板 。拥有独立的内存空间、数据库链接、文件权限。资源是隔离的。 | 员工 。原则上不拥有系统资源,必须生活在进程(公司)里,共享公司的资源。资源是共享的。 |
| 地位 | 独立单位。一个进程崩溃了,通常不会影响隔壁的其他进程(比如 QQ 崩了,微信还在)。 | 调度单位。线程是 CPU 调度和执行的基本单位。一个线程崩溃(比如数组越界),会导致整个进程一起陪葬。 |
| 开销 | 大。创建、销毁、切换都需要操作系统介入,消耗大量时间和内存。 | 小。创建、销毁、切换极快,开销远小于进程。 |
| 通信机制 | 难。必须通过内核(操作系统)中转(如 Socket、管道)。 | 易。直接读写同一块内存区域即可。 |
| 包含关系 | 容器。一个进程可以包含多个线程(公司里有多个员工)。 | 子集。线程不能脱离进程单独存在。 |
面试
"进程是资源分配的最小单位,线程是 CPU 调度和执行的最小单位。"
这句话怎么理解?
- 你想买地盖楼(申请内存、文件),你得找政府批文(操作系统),批下来的是一块地(进程)。
- 楼盖好了,里面的住户是谁、谁去上班干活,这是由物业安排的(CPU 调度),干活的人就是(线程)。
- 线程的状态流转。
- 为什么会出现线程不安全(可见性、原子性)。
- 如何解决不安全(synchronized、volatile、wait/notify)。
- 经典并发模型(单例、阻塞队列、线程池)。
- 底层原理(进程与线程的区别)。