掌握多线程:从入门到精通
本节目标
• 认识多线程
• 掌握多线程程序的编写
• 掌握多线程的状态
• 掌握什么是线程不安全及解决思路
• 掌握synchronized、volatile关键字
1. 认识线程(Thread)
1.1 概念
1) 线程是什么
⼀个线程就是⼀个"执⾏流".每个线程之间都可以按照顺序执⾏⾃⼰的代码.多个线程之间"同时"执⾏ 着多份代码.
还是回到我们之前的银行的例子中。之前我们主要描述的是个人业务,即一个人完全处理自己的业 务。我们进一步设想如下场景:
一家公司要去银行办理业务,既要进行财务转账,又要进行福利发放,还得进行缴社保。 如果只有张三一个会计就会忙不过来,耗费的时间特别长。为了让业务更快的办理好,张三又找来两 位同事李四、王五一起来帮助他,三个人分别负责一个事情,分别申请一个号码进行排队,自此就有 了三个执行流共同完成任务,但本质上他们都是为了办理一家公司的业务。 此时,我们就把这种情况称为多线程,将一个大任务分解成不同小任务,交给不同执行流就分别排队 执行。其中李四、王五都是张三叫来的,所以张三一般被称为主线程(Main Thread)。
2) 为啥要有线程
首先, "并发编程" 成为 "刚需"
单核 CPU 的发展遇到了瓶颈. 要想提高算力, 就需要多核 CPU. 而并发编程能更充分利用多核 CPU 资源.
有些任务场景需要 "等待 IO", 为了让等待 IO 的时间能够去做一些其他的工作, 也需要用到并发编程.
其次, 虽然多进程也能实现 并发编程, 但是线程比进程更轻量.
• 创建线程比创建进程更快.
• 销毁线程比销毁进程更快.
• 调度线程比调度进程更快.
最后, 线程虽然比进程轻量, 但是人们还不满足, 于是又有了 "线程池"(ThreadPool) 和 "协程" (Coroutine)
关于线程池我们后面再介绍. 关于协程的话题我们此处暂时不做过多讨论.
3) 进程和线程的区别
进程是包含线程的. 每个进程至少有一个线程存在,即主线程
进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间
比如之前的多进程例子中,每个客户来银行办理各自的业务,但他们之间的票据肯定是不想让别人知 道的,否则钱不就被其他人取走了么。而上面我们的公司业务中,张三、李四、王五虽然是不同的执 行流,但因为办理的都是一家公司的业务,所以票据是共享着的。这个就是多线程和多进程的最大区别
进程是系统分配资源的最小单位,线程是系统调度的最小单位
一个进程挂了一般不会影响到其他进程. 但是一个线程挂了, 可能把同进程内的其他线程一起带走(整个进程崩溃)
4) Java 的线程 和 操作系统线程 的关系
线程是操作系统中的概念. 操作系统内核实现了线程这样的机制, 并且对用户层提供了一些 API 供用户 使用(例如 Linux 的 pthread 库).
Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装.
1.2 第一个多线程程序
感受多线程程序和普通程序的区别:
• 每个线程都是一个独立的执行流
• 多个线程之间是 "并发" 执行的.
(一个类或接口在某个包中,导入这个包,才可以使用这个类或接口)

使用 jconsole 命令观察线程(注意你写的程序一定要运行起来才能观察哈)


1.3 创建线程
方法1 继承 Thread 类
建议大家养成习惯写上@Override @Override 的核心作用就是让编译器帮你校验: 确保当前方法确实是重写了父类 / 接口里的方法(比如方法名、参数列表、返回值要匹配), 如果写错了(比如方法名拼错),编译器会直接报错提醒,避免你写出 “伪重写” 的代码~

方法2 实现 Runnable 接口
对比上面两种方法:
继承 Thread 类, 直接使用 this 就表示当前线程对象的引用.
实现 Runnable 接口, this 表示的是 MyRunnable 的引用. 需要使用 Thread.currentThread()
其他变形
• 匿名内部类创建 Thread 子类对象

匿名内部类创建 Runnable 子类对象

lambda 表达式创建 Runnable 子类对象

1.4 多线程的优势-增加运行速度
可以观察多线程在一些场合下是可以提高程序的整体运行效率的。
• 使用 System.nanoTime() 可以记录当前系统的 纳秒 级时间戳.
• serial 串行的完成一系列运算. concurrency 使用两个线程并行的完成同样的运算.
public class ThreadAdvantage {
// 多线程并不一定就能提高速度,可以观察,count 不同,实际的运行效果也是不同的
private static final long count = 10_0000_0000;
public static void main(String[] args) throws InterruptedException {
// 使用并发方式
concurrency();
// 使用串行方式
serial();
}
private static void concurrency() throws InterruptedException {
long begin = System.nanoTime();
// 利用一个线程计算 a 的值
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a--;
}
}
});
thread.start();
// 主线程内计算 b 的值
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
// 等待 thread 线程运行结束
thread.join();
// 统计耗时
long end = System.nanoTime();
double ms = (end - begin) * 1.0 / 1000 / 1000;
System.out.printf("并发: %f 毫秒%n", ms);
}
private static void serial() {
// 全部在主线程内计算 a、b 的值
long begin = System.nanoTime();
int a = 0;
for (long i = 0; i < count; i++) {
a--;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long end = System.nanoTime();
double ms = (end - begin) * 1.0 / 1000 / 1000;
System.out.printf("串行: %f 毫秒%n", ms);
}
}
并发: 399.651856 毫秒
串行: 720.616911 毫秒
2. Thread 类及常见方法
Thread 类是 JVM 用来管理线程的一个类,换句话说,每个线程都有一个唯一的 Thread 对象与之关联。
用我们上面的例子来看,每个执行流,也需要有一个对象来描述,类似下图所示,而 Thread 类的对象 就是用来描述一个线程执行流的,JVM 会将这些 Thread 对象组织起来,用于线程调度,线程管理。
2.1 Thread 的常见构造方法

2.2 Thread 的几个常见属性
• ID 是线程的唯一标识,不同线程不会重复
• 名称是各种调试工具用到 • 状态表示线程当前所处的一个情况,下面我们会进一步说明
• 优先级高的线程理论上来说更容易被调度到
• 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。
• 是否存活,即简单的理解,为 run 方法是否运行结束了
• 线程的中断问题,下面我们进一步说明
2.3 启动一个线程 - start()
之前我们已经看到了如何通过覆写 run 方法创建一个线程对象,但线程对象被创建出来并不意味着线 程就开始运行了。
• 覆写 run 方法是提供给线程要做的事情的指令清单
• 线程对象可以认为是把 李四、王五叫过来了
• 而调用 start() 方法,就是喊一声:”行动起来!“,线程才真正独立去执行了
调用 start 方法, 才真的在操作系统的底层创建出一个线程.
2.4 中断一个线程
李四一旦进到工作状态,他就会按照行动指南上的步骤去进行工作,不完成是不会结束的。但有时我 们需要增加一些机制,例如老板突然来电话了,说转账的对方是个骗子,需要赶紧停止转账,那张三 该如何通知李四停止呢?这就涉及到我们的停止线程的方式了
目前常见的有以下两种方式:
1. 通过共享的标记来进行沟通
2. 调用 interrupt() 方法来通知
示例-1: 使用自定义的变量来作为标志位

示例-2: 使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替自定义标志位.
Thread 内部包含了一个 boolean 类型的变量作为线程是否被中断的标记.

thread 收到通知的方式有两种:
1. 如果线程因为调用 wait/join/sleep 等方法而阻塞挂起,则以 InterruptedException 异常的形式通 知,清除中断标志
当出现 InterruptedException 的时候, 要不要结束线程取决于 catch 中代码的写法. 可以选择忽 略这个异常, 也可以跳出循环结束线程
2. 否则,只是内部的一个中断标志被设置,thread 可以通过 ◦ Thread.currentThread().isInterrupted() 判断指定线程的中断标志被设置,不清除中断标志

这种方式通知收到的更及时,即使线程正在 sleep 也可以马上收到
2.5 等待一个线程 - join()
有时,我们需要等待一个线程完成它的工作后,才能进行自己的下一步工作。例如,张三只有等李四 转账成功,才决定是否存钱,这时我们需要一个方法明确等待线程的结束。
注意join()中可以写多少毫秒,表示等待这个线程多少毫秒
例如t.join(5000);表示等待t线程5s

上面这种是一个任务逻辑绑定一个线程

这种是一个任务逻辑可以绑定多个线程
2.6 获取当前线程引用
这个方法我们已经非常熟悉了


2.7 休眠当前线程
也是我们比较熟悉一组方法,有一点要记得,因为线程的调度是不可控的,所以,这个方法只能保证 实际休眠时间是大于等于参数设置的休眠时间的。

System.currentTimeMillis():Java 的系统类System提供的方法,用于获取当前时间的毫秒时间戳。- 毫秒级时间戳(从 1970 年 1 月 1 日 00:00:00 UTC 到当前时刻的毫秒数)。
3. 线程的状态
3.1 观察线程的所有状态
线程的状态是一个枚举类型 Thread.State


• NEW: 安排了工作, 还未开始行动(意思是任务逻辑给了线程,但是线程还没有start)
• RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作.
---------------------------------------------------------------------------------------------------------------------------------
• BLOCKED: 这几个都表示排队等着其他事情
• WAITING: 这几个都表示排队等着其他事情
• TIMED_WAITING: 这几个都表示排队等着其他事情
• TERMINATED: 工作完成了.
这几种以后会说到哈
3.2 线程状态和状态转移的意义

3.3 观察线程的状态和转移
观察 1: 关注 NEW 、 RUNNABLE 、 TERMINATED 状态的转换

观察 2: 关注 WAITING 、 BLOCKED 、 TIMED_WAITING 状态的转换
public static void main(String[] args) {
final Object object = new Object();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (object) {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}, "t1");
t1.start();
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (object) {
System.out.println("hehe");
}
}
}, "t2");
t2.start();
}

使用 jconsole 可以看到 t1 的状态是 TIMED_WAITING , t2 的状态是 BLOCKED
修改上面的代码, 把 t1 中的 sleep 换成 wait
public static void main(String[] args) {
final Object object = new Object();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (object) {
try {
// [修改这里就可以了!!!!!]
// Thread.sleep(1000);
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}, "t1");
...
}
使用 jconsole 可以看到 t1 的状态是 WAITING
结论:
• BLOCKED 表示等待获取锁, WAITING 和 TIMED_WAITING 表示等待其他线程发来通知.
• TIMED_WAITING 线程在等待唤醒,但设置了时限; WAITING 线程在无限等待唤醒
4. 多线程带来的的风险-线程安全 (重点)
4.1 观察线程不安全
// 此处定义一个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
// 如果没有这俩 join, 肯定不行的. 线程还没自增完, 就开始打印了. 很可能打印出来的
count 就是个 0
t1.join();
t2.join();
// 预期结果应该是 10w
System.out.println("count: " + count);
}
大家观察下是否适用多线程的现象是否一致?同时尝试思考下为什么会有这样的现象发生呢?
4.2 线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的
4.3 线程不安全的原因
线程调度是随机的
这是线程安全问题的 罪魁祸首
随机调度使一个程序在多线程环境下, 执行顺序存在很多的变数.
程序猿必须保证 在任意执行顺序下 , 代码都能正常工作
修改共享数据
多个线程修改同一个变量
上面的线程不安全的代码中, 涉及到多个线程针对 count 变量进行修改. 此时这个 count 是一个多个线程都能访问到的 "共享数据"

原子性

什么是原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入 房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进 不来了。这样就保证了这段代码的原子性了。
有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
一条 java 语句不一定是原子的,也不一定只是一条指令
比如刚才我们看到的 n++,其实是由三步操作组成的:
1. 从内存把数据读到 CPU
2. 进行数据更新
3. 把数据写回到 CPU
不保证原子性会给多线程带来什么问题
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的
这点也和线程的抢占式调度密切相关. 如果线程不是 "抢占" 的, 就算没有原子性, 也问题不大
可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的 并发效果.

• 线程之间的共享变量存在 主内存 (Main Memory).
• 每一个线程都有自己的 "工作内存" (Working Memory) .
• 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据. • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线 程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.
1) 初始情况下, 两个线程的工作内存内容一致

2) 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步.

此时引入了两个问题:
• 为啥要整这么多内存?
• 为啥要这么麻烦的拷来拷去?
1) 为啥整这么多内存?
实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法. 所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存
2) 为啥要这么麻烦的拷来拷去?
因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).
比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是 第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了. 效率就大大提高了.
那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥?
答案就是一个字: 贵

值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘.
对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜
指令重排序
什么是代码重排序
一段代码是这样的
1. 去前台取下 U 盘
2. 去教室写 10 分钟作业
3. 去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序
编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但是 在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的 执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论
4.4 解决之前的线程不安全问题
这里用到的机制,我们马上会给大家解释
// 此处定义一个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
// 如果没有这俩 join, 肯定不行的. 线程还没自增完, 就开始打印了. 很可能打印出来的
count 就是个 0
t1.join();
t2.join();
// 预期结果应该是 10w
System.out.println("count: " + count);
}
5. synchronized 关键字 - 监视器锁 monitor lock
5.1 synchronized 的特性
1) 互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行 到同一个对象 synchronized 就会阻塞等待.
• 进入 synchronized 修饰的代码块, 相当于 加锁
• 退出 synchronized 修饰的代码块, 相当于 解锁

synchronized用的锁是存在Java对象头里的。

可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 "锁定" 状态(类似于厕所 的 "有人/无人").
如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态.
如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队

理解 "阻塞等待".
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试 进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.
注意:
• 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也就 是操作系统线程调度的一部分工作.
• 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
synchronized的底层是使用操作系统的mutex lock实现的
2) 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
理解 "把自己锁死"
一个线程没有释放锁, 然后又尝试再次加锁
// 第一次加锁, 加锁成功 lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. lock();
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二 个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进 行解锁操作. 这时候就会 死锁
Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
synchronized (locker) {
count++;
}
}
}
在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息
• 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
• 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
5.2 synchronized 使用示例
synchronized 本质上要修改指定对象的 "对象头". 从使用角度来看, synchronized 也势必要搭配一个 具体的对象来使用.
1) 修饰代码块: 明确指定锁哪个对象
锁任意对象
public class SynchronizedDemo {
private Object locker = new Object();
public void method() {
synchronized (locker) {
}
}
}
锁当前对象
public class SynchronizedDemo {
public void method() {
}
}
}
在这段代码里,synchronized (this) 中的 this 指的是当前这个 SynchronizedDemo 类的实例对象,也就是调用 method() 方法的那个对象本身。
2) 直接修饰普通方法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
public synchronized void methond() {
}
}
3) 修饰静态方法: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
我们重点要理解,synchronized 锁的是什么 两个线程竞争同一把锁, 才会产生阻塞等待.
两个线程分别尝试获取两把不同的锁, 不会产生竞争.

5.3 Java 标准库中的线程安全类
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施
• ArrayList
• LinkedList
• HashMap
• TreeMap
• HashSet
• TreeSet
• StringBuilder
但是还有一些是线程安全的. 使用了一些锁机制来控制.
• Vector (不推荐使用)
• HashTable (不推荐使用)
• ConcurrentHashMap
• StringBuffer

还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的
6. volatile 关键字
volatile 能保证内存可见性
volatile 修饰的变量, 能够保证 "内存可见性"

代码在写入 volatile 修饰的变量的时候,
• 改变线程工作内存中volatile变量副本的值
• 将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候,
• 从主内存中读取volatile变量的最新值到线程的工作内存中
• 从工作内存中读取volatile变量的副本
前面我们讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快, 但是可能出现数据不一致的情况
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了
代码示例
在这个代码中
• 创建两个线程 t1 和 t2
• t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.
• t2 中从键盘读入一个整数, 并把这个整数赋值给 flag
• 预期当用户输入非 0 的值的时候, t1 线程结束.
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
// do nothing
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
t1.start();
t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
t1 读的是自己工作内存中的内容.
当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化
如果给 flag 加上 volatile
static class Counter {
public volatile int flag = 0;
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束.
volatile 不保证原子性
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性
代码示例
这个是最初的演示线程安全的代码.
• 给 increase 方法去掉 synchronized
• 给 count 加上 volatile 关键字
static class Counter {
volatile public int count = 0;
void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
此时可以看到, 最终 count 的值仍然无法保证是 100000.
7. wait 和 notify
由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知. 但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.

球场上的每个运动员都是独立的 "执行流" , 可以认为是一个 "线程"
而完成一个具体的进攻得分动作, 则需要多个运动员相互配合, 按照一定的顺序执行一定的动作, 线程 1 先 "传球" , 线程2 才能 "扣篮"
完成这个协调工作, 主要涉及到三个方法
• wait() / wait(long timeout): 让当前线程进入等待状态.
• notify() / notifyAll(): 唤醒在当前对象上等待的线程
注意: wait, notify, notifyAll 都是 Object 类的方法
7.1 wait()方法
wait 做的事情
• 使当前执行代码的线程进行等待. (把线程放到等待队列中)
• 释放当前的锁
• 满足一定条件时被唤醒, 重新尝试获取这个锁
wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
wait 结束等待的条件:
• 其他线程调用该对象的 notify 方法.
• wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
• 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常
代码示例: 观察wait()方法使用
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("等待中");
object.wait();
System.out.println("等待结束");
}
}
这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就 需要使用到了另外一个方法唤醒的方法notify()。
7.2 notify()方法
notify 方法是唤醒等待的线程.
• 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其 它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
• 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到") • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行 完,也就是退出同步代码块之后才会释放对象锁
代码示例: 使用notify()方法唤醒线程
• 创建 WaitTask 类, 对应一个线程, run 内部循环调用 wait.
• 创建 NotifyTask 类, 对应另一个线程, 在 run 内部调用一次 notify
• 注意, WaitTask 和 NotifyTask 内部持有同一个 Object locker. WaitTask 和 NotifyTask 要想配合就 需要搭配同一个 Object.
package file;
import java.util.Scanner;
public class demo2 {
public static void main(String[] args) {
Object object=new Object();
Thread t1=new Thread(()->{
synchronized (object){
try {
System.out.println("wait之前");
object.wait();
System.out.println("wait之后");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2=new Thread(()->{
Scanner scanner=new Scanner(System.in);
System.out.println("输入任意内容");
scanner.next();
synchronized (object){
object.notify();
}
});
t1.start();
t2.start();
}
}
7.3 notifyAll()方法
notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程
范例:使用notifyAll()方法唤醒所有等待线程, 在上面的代码基础上做出修改
• 创建 3 个 WaitTask 实例. 1 个 NotifyTask 实例.
package file;
import java.util.Scanner;
public class demo3 {
public static void main(String[] args) {
Object object=new Object();
Thread t1=new Thread(()->{
synchronized (object){
try {
System.out.println("wait1之前");
object.wait();
System.out.println("wait1之后");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2=new Thread(()->{
synchronized (object){
try {
System.out.println("wait2之前");
object.wait();
System.out.println("wait2之后");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t3=new Thread(()->{
Scanner scanner=new Scanner(System.in);
System.out.println("请输入:");
scanner.next();
synchronized (object){
object.notifyAll();
}
});
t1.start();
t2.start();
t3.start();
}
}
7.4 wait 和 sleep 的对比(面试题)
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,
唯一的相同点就是都可以让线程放弃执行一段时间.
当然为了面试的目的,我们还是总结下:
1. wait 需要搭配 synchronized 使用. sleep 不需要.
2. wait 是 Object 的方法 sleep 是 Thread 的静态方法.
8. 多线程案例
8.1 单例模式
单例模式是校招中最常考的设计模式之一
啥是设计模式?
设计模式好比象棋中的 "棋谱". 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有一 些固定的套路. 按照套路来走局势就不会吃亏.
软件开发中也有很多常见的 "问题场景". 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这 个套路来实现代码, 也不会吃亏.
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例
这一点在很多场景上都需要. 比如 JDBC 中的 DataSource 实例就只需要一个
单例模式具体的实现方式有很多. 最常见的是 "饿汉" 和 "懒汉" 两种
饿汉模式

懒汉模式-单线程版
类加载的时候不创建实例. 第一次使用的时候才创建实例.
加载时:JVM 会为 instance 这个静态变量分配内存,并把它初始化为 null,但不会执行 new Singleton()。
第一次使用时:当你第一次调用 getInstance() 方法,才会检查 instance 是否为 null,如果是,才会执行 new Singleton() 创建实例。

懒汉模式-多线程版
上面的懒汉模式的实现是线程不安全的.
线程安全问题发生在首次创建实例时. 如果在多个线程中同时调用 getInstance 方法, 就可能导致创建出多个实例.
一旦实例已经创建好了, 后面再多线程环境调用 getInstance 就不再有线程安全问题了(不再修改 instance 了)
加上 synchronized 可以改善这里的线程安全问题
public static SingletonLazy getInstance(){
synchronized (locker){//修改必须是原子的
if (instance==null){
instance=new SingletonLazy();//=虽然是原子操作 但必须判断==后,所以变为非原子操作
}
}
return instance;
}
懒汉模式-多线程版(改进)
以下代码在加锁的基础上, 做出了进一步改动:
• 使用双重 if 判定, 降低锁竞争的频率.
• 给 instance 加上了 volatile.
锁的可重入性,指的是同一个线程在已经持有锁的情况下,可以再次进入同一个锁保护的代码块,而不会被自己阻塞。
package file;
class SingletonLazy{
private static volatile SingletonLazy instance=null;
private static Object locker=new Object();
public static SingletonLazy getInstance(){
if (instance==null){
synchronized (locker){//修改必须是原子的
if (instance==null){
instance=new SingletonLazy();//=虽然是原子操作 但必须判断==后,所以变为非原子操作
}
}
}
return instance;
}
private SingletonLazy(){
}
}
public class demo6 {
public static void main(String[] args) {
SingletonLazy s1=SingletonLazy.getInstance();
SingletonLazy s2=SingletonLazy.getInstance();
System.out.println(s1==s2);
}
}


8.2 阻塞队列
阻塞队列是什么
阻塞队列是一种特殊的队列. 也遵守 "先进先出" 的原则.
阻塞队列能是一种线程安全的数据结构, 并且具有以下特性
• 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
• 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.
阻塞队列的一个典型应用场景就是 "生产者消费者模型". 这是一种非常典型的开发模型
生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用 等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.
1. 阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力. (削峰填谷)
比如在 "秒杀" 场景下, 服务器同一时刻可能会收到大量的支付请求. 如果直接处理这些支付请求, 服务 器可能扛不住(每个支付请求的处理都需要比较复杂的流程). 这个时候就可以把这些请求都放到一个 阻塞队列中, 然后再由消费者线程慢慢的来处理每个支付请求.
这样做可以有效进行 "削峰", 防止服务器被突然到来的一波请求直接冲垮.
2. 阻塞队列也能使生产者和消费者之间 解耦.
比如过年一家人一起包饺子. 一般都是有明确分工, 比如一个人负责擀饺子皮, 其他人负责包. 擀饺子皮的人就是 "生产者", 包饺子的人就是 "消费者".
擀饺子皮的人不关心包饺子的人是谁(能包就行, 无论是手工包, 借助工具, 还是机器包), 包饺子的人也 不关心擀饺子皮的人是谁(有饺子皮就行, 无论是用擀面杖擀的, 还是拿罐头瓶擀, 还是直接从超市买的).
标准库中的阻塞队列
在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可.
• BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue.
• put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
• BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性.
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
//offer poll也能用,但是put take才有阻塞效果
// 入队列
queue.put("abc");
// 出队列. 如果没有 put 直接 take, 就会阻塞.
String elem = queue.take();
生产者消费者模型
package file;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
public class demo8 {
public static void main(String[] args) {
BlockingDeque<Integer> queue=new LinkedBlockingDeque<>();
Thread t1=new Thread(()->{
try {
Integer n=0;
for (int i = 0; i < 100; i++) {
queue.put(n);
System.out.println("生产元素: "+n);
n++;
Thread.sleep(1000);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"producer");
Thread t2=new Thread(()->{
try {
for (int i = 0; i < 100; i++) {
Integer cur=queue.take();
System.out.println("消费元素: "+cur);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"consumer");
t1.start();
t2.start();
}
}
package file;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
public class demo8 {
public static void main(String[] args) {
BlockingDeque<Integer> queue=new LinkedBlockingDeque<>();
Thread t1=new Thread(()->{
try {
Integer n=0;
for (int i = 0; i < 100; i++) {
queue.put(n);
System.out.println("生产元素: "+n);
n++;
Thread.sleep(1000);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"producer");
Thread t2=new Thread(()->{
try {
for (int i = 0; i < 100; i++) {
Integer cur=queue.take();
System.out.println("消费元素: "+cur);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"consumer");
t1.start();
t2.start();
}
}
阻塞队列实现
• 通过 "循环队列" 的方式来实现
• 使用 synchronized 进行加锁控制.
• put 插入元素的时候, 判定如果队列满了, 就进行 wait. (注意, 要在循环中进行 wait. 被唤醒时不一定 队列就不满了, 因为同时可能是唤醒了多个线程)
• take 取出元素的时候, 判定如果队列为空, 就进行 wait. (也是循环 wait)
package file;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
public class demo8 {
public static void main(String[] args) {
BlockingDeque<Integer> queue=new LinkedBlockingDeque<>();
Thread t1=new Thread(()->{
try {
Integer n=0;
for (int i = 0; i < 100; i++) {
queue.put(n);
System.out.println("生产元素: "+n);
n++;
Thread.sleep(1000);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"producer");
Thread t2=new Thread(()->{
try {
for (int i = 0; i < 100; i++) {
Integer cur=queue.take();
System.out.println("消费元素: "+cur);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},"consumer");
t1.start();
t2.start();
}
}
8.3 线程池
线程池是什么
虽然创建线程 / 销毁线程 的开销
想象这么一个场景:
在学校附近新开了一家快递店,老板很精明,想到一个与众不同的办法来经营。店里没有雇人,而是 每次有业务来了,就现场找一名同学过来把快递送了,然后解雇同学。这个类比我们平时来一个任 务,起一个线程进行处理的模式。
很快老板发现问题来了,每次招聘 + 解雇同学的成本还是非常高的。老板还是很善于变通的,知道 了为什么大家都要雇人了,所以指定了一个指标,公司业务人员会扩张到 3 个人,但还是随着业务 逐步雇人。于是再有业务来了,老板就看,如果现在公司还没 3 个人,就雇一个人去送快递,否则 只是把业务放到一个本本上,等着 3 个快递人员空闲的时候去处理。这个就是我们要带出的线程池 的模式。
线程池最大的好处就是减少每次启动、销毁线程的损耗
标准库中的线程池
• 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
• 返回值类型为 ExecutorService
• 通过 ExecutorService.submit 可以注册一个任务到线程池中.
Executors 创建线程池的几种方式
• newFixedThreadPool: 创建固定线程数的线程池
• newCachedThreadPool: 创建线程数目动态增长的线程池
• newSingleThreadExecutor: 创建只包含单个线程的线程池
• newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer. Executors 本质上是 ThreadPoolExecutor 类的封装
实现线程池
• 核心操作为 submit, 将任务加入线程池中
• 使用 Worker 类描述一个工作线程. 使用 Runnable 描述一个任务.
• 使用一个 BlockingQueue 组织所有的任务
• 每个 worker 线程要做的事情: 不停的从 BlockingQueue 中取任务并执行.
• 指定一下线程池中的最大线程数 maxWorkerCount; 当当前线程数超过这个最大值时, 就不再新增 线程了
package file;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
class MyThreadPool {
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
// 通过这个方法, 来把任务添加到线程池中.
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
// n 表示线程池里有几个线程.
// 创建了一个固定数量的线程池.
public MyThreadPool(int n) {
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
while (true) {
try {
// 取出任务, 并执行~~
Runnable runnable = queue.take();
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
}
public class demo24 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool pool=new MyThreadPool(4);
for (int i = 0; i < 1000; i++) {
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"hello");
}
});
}
}
}
8.4 定时器
定时器是什么
定时器也是软件开发中的一个重要组件. 类似于一个 "闹钟". 达到一个设定的时间之后, 就执行某个指定好的代码.
定时器是一种实际开发中非常常用的组件.
比如网络通信中, 如果对方 500ms 内没有返回数据, 则断开连接尝试重连.
比如一个 Map, 希望里面的某个 key 在 3s 之后过期(自动删除).
类似于这样的场景就需要用到定时器.
标准库中的定时器
• 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule .
• schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后 执行(单位为毫秒).
package file;
import java.util.Timer;
import java.util.TimerTask;
public class demo25 {
public static void main(String[] args) {
Timer timer=new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
},3000);
}
}
实现定时器
定时器的构成
• 一个带优先级队列(不要使用 PriorityBlockingQueue, 容易死锁!)
• 队列中的每个元素是一个 Task 对象.
• Task 中带有一个时间属性, 队首元素就是即将要执行的任务
• 同时有一个 worker 线程一直扫描队首元素, 看队首元素是否需要执行
1. Timer 类提供的核心接口为 schedule, 用于注册一个任务, 并指定这个任务多长时间后执行.
public class MyTimer {
public void schedule(Runnable command, long after) {
// TODO
}
}
2. Task类⽤于描述⼀个任务(作为Timer的内部类).⾥⾯包含⼀个Runnable对象和⼀个time(毫秒时 间戳)
这个对象需要放到优先队列中.因此需要实现Comparable 接⼝
class MyTask implements Comparable<MyTask> {
public Runnable runnable;
// 为了⽅便后续判定, 使⽤绝对的时间戳.
public long time;
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
// 取当前时刻的时间戳 + delay, 作为该任务实际执⾏的时间戳
this.time = System.currentTimeMillis() + delay;
}
@Override
public int compareTo(MyTask o) {
// 这样的写法意味着每次取出的是时间最⼩的元素.
// 到底是谁减谁?? 俺也记不住!!! 随便写⼀个, 执⾏下, 看看效果~~
return (int)(this.time - o.time);
}
}
3. Timer实例中,通过PriorityQueue来组织若⼲个Task对象.
通过schedule来往队列中插⼊⼀个个Task对象
class MyTimer {
// 核⼼结构
private PriorityQueue<MyTask> queue = new PriorityQueue<>();
// 创建⼀个锁对象
private Object locker = new Object();
public void schedule(Runnable command, long after) {
// 根据参数, 构造 MyTask, 插⼊队列即可.
synchronized (locker) {
MyTask myTask = new MyTask(runnable, delay);
queue.offer(myTask);
locker.notify();
}
}
}
3. Timer实例中,通过PriorityQueue来组织若⼲个Task对象. 通过schedule来往队列中插⼊⼀个个Task对象.
class MyTimer {
// 核⼼结构
private PriorityQueue<MyTask> queue = new PriorityQueue<>();
// 创建⼀个锁对象
private Object locker = new Object();
public void schedule(Runnable command, long after) {
// 根据参数, 构造 MyTask, 插⼊队列即可.
synchronized (locker) {
MyTask myTask = new MyTask(runnable, delay);
queue.offer(myTask);
locker.notify();
}
}
}
4. Timer类中存在⼀个worker线程,⼀直不停的扫描队⾸元素,看看是否能执⾏这个任务
所谓"能执⾏"指的是该任务设定的时间已经到达了.
// 在这⾥构造线程, 负责执⾏具体任务了.
public MyTimer() {
Thread t = new Thread(() -> {
while (true) {
try {
synchronized (locker) {
// 阻塞队列, 只有阻塞的⼊队列和阻塞的出队列, 没有阻塞的查看队⾸元
素.
while (queue.isEmpty()) {
locker.wait();
}
MyTask myTask = queue.peek();
long curTime = System.currentTimeMillis();
if (curTime >= myTask.time) {
// 时间到了, 可以执⾏任务了
queue.poll();
myTask.runnable.run();
} else {
// 时间还没到
locker.wait(myTask.time - curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
9. 总结-保证线程安全的思路
1. 使用没有共享资源的模型
2. 适用共享资源只读,不写的模型
a. 不需要写共享资源的模型
b. 使用不可变对象
3. 直面线程安全(重点)
a. 保证原子性
b. 保证顺序性
c. 保证可见性
10. 对比线程和进程
10.1 线程的优点
1. 创建一个新线程的代价要比创建一个新进程小得多
2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3. 线程占用的资源要比进程少很多
4. 能充分利用多处理器的可并行数量
5. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务 \
6. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
7. I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
10.2 进程与线程的区别
1. 进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位。
2. 进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈。
3. 由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信。
4. 线程的创建、切换及终止效率更高。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐




所有评论(0)