【Java EE初阶】多线程-初阶
多线程-初阶
本节⽬标
• 认识多线程
• 掌握多线程程序的编写
• 掌握多线程的状态
• 掌握什么是线程不安全及解决思路
• 掌握 synchronized、volatile 关键字
1. 认识线程(Thread)

结论:
进程在进行频繁创建和销毁的时候,开销比较大.(开销主要体现在 资源的申请 和 释放上)
线程 就是解决上述问题方案
线程(Thread)也可以称为"轻量级进程”,在进程的基础上,做出了改进。保持了独立调度执行,这样的"并发支持",同时省去"分配资源”"释放资源"带来的额外开销。
线程是怎么做到的呢?
前面介绍了会使用 PCB 来描述一个进程,现在,也使用 PCB 来描述一个线程
也不是随便搞两个线程,就能资源共享,把能够资源共享的这些线程,分成组,称为"线程组
再换句话讲,线程组,也就是 进程的一部分~

当引入的线程,达到一定数量之后,再继续尝试引入新的线程,就没有办法提升了~
当线程数量太多的时候,线程之间就会相互竞争cpu 的资源了(CPU 核心数是有限的),非但不会提高效率,反而还会增加调度的开销。
多线程,还有一个重要的问题,线程之间,可能会打架! 线程之间起了冲突,就可能会导致代码中出现一些逻辑上的错误.
线程安全问题[重点,难点]
多线程这种方式,不太好驾驭.主要还是因为这个东西,有一定的复杂程度~(后面细说)
多线程还有一个问题,共享资源,也会有副作用.
一个线程如果抛出异常,并且没有处理好,就可能会导致整个进程被终止(其他线程也就无了),如果及时捕获到,处理掉,也不一定导致进程终止~
多线程编程的值得关注的难点~ 一个线程出问题,会影响到别的线程。相比之下,进程和进程之间,独立性更好, 一个进程挂了,一般不会影响到其他进程。
上述讨论的 **线程的基本特点(进程和线程的区别)**非常经典,非常高频的面试题
1.1 概念
1) 线程是什么
⼀个线程就是⼀个 “执⾏流”. 每个线程之间都可以按照顺序执⾏⾃⼰的代码. 多个线程之间 “同时” 执⾏着多份代码.
还是回到我们之前的银⾏的例⼦中。之前我们主要描述的是个⼈业务,即⼀个⼈完全处理⾃⼰的业务。我们进⼀步设想如下场景:
⼀家公司要去银⾏办理业务,既要进⾏财务转账,⼜要进⾏福利发放,还得进⾏缴社保。
如果只有张三⼀个会计就会忙不过来,耗费的时间特别⻓。为了让业务更快的办理好,张三⼜找来两位同事李四、王五⼀起来帮助他,三个⼈分别负责⼀个事情,分别申请⼀个号码进⾏排队,⾃此就有了三个执⾏流共同完成任务,但本质上他们都是为了办理⼀家公司的业务。
此时,我们就把这种情况称为多线程,将⼀个⼤任务分解成不同⼩任务,交给不同执⾏流就分别排队执⾏。其中李四、王五都是张三叫来的,所以张三⼀般被称为主线程(Main Thread)。
2) 为啥要有线程
⾸先, “并发编程” 成为 “刚需”.
• 单核 CPU 的发展遇到了瓶颈. 要想提⾼算⼒, 就需要多核 CPU. ⽽并发编程能更充分利⽤多核 CPU资源.
• 有些任务场景需要 “等待 IO”, 为了让等待 IO 的时间能够去做⼀些其他的⼯作, 也需要⽤到并发编程.
其次, 虽然多进程也能实现 并发编程, 但是线程⽐进程更轻量.
• 创建线程⽐创建进程更快.
• 销毁线程⽐销毁进程更快.
• 调度线程⽐调度进程更快.
最后, 线程虽然⽐进程轻量, 但是⼈们还不满⾜, 于是⼜有了 “线程池”(ThreadPool) 和 “协程”(Coroutine)
关于线程池我们后⾯再介绍. 关于协程的话题我们此处暂时不做过多讨论.
3) 进程和线程的区别
• 进程是包含线程的. 每个进程⾄少有⼀个线程存在,即主线程。
• 进程和进程之间不共享内存空间. 同⼀个进程的线程之间共享同⼀个内存空间.
⽐如之前的多进程例⼦中,每个客⼾来银⾏办理各⾃的业务,但他们之间的票据肯定是不想让别⼈知道的,否则钱不就被其他⼈取⾛了么。⽽上⾯我们的公司业务中,张三、李四、王五虽然是不同的执⾏流,但因为办理的都是⼀家公司的业务,所以票据是共享着的。这个就是多线程和多进程的最⼤区别。
• 进程是系统分配资源的最⼩单位,线程是系统调度的最⼩单位。
• ⼀个进程挂了⼀般不会影响到其他进程. 但是⼀个线程挂了, 可能把同进程内的其他线程⼀起带⾛(整个进程崩溃).
临时小结:
上述讨论的 线程的基本特点(进程和线程的区别) 非常经典,非常高频的面试题(操作系统这一类问题中,出场频率最高的问题,没有之一!!!)
这种 经典面试题,给出的回答都不要刻意去背。还是要写博客,用自己的话来总结表述。
面试的时候,面试官非常讨厌同学"背”
4) Java 的线程 和 操作系统线程 的关系
线程是操作系统中的概念. 操作系统内核实现了线程这样的机制, 并且对⽤⼾层提供了⼀些 API 供⽤⼾使⽤(例如 Linux 的 pthread 库).
Java 标准库中 Thread 类可以视为是对操作系统提供的 API (Application Programming Interface 应用程序编程接口)进⾏了进⼀步的抽象和封装.
1.2 第⼀个多线程程序

感受多线程程序和普通程序的区别:
• 每个线程都是⼀个独⽴的执⾏流
• 多个线程之间是 “并发” 执⾏的.
class MyThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ThreadDemo {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
while (true) {
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
看起来好像没啥区别~当引入多线程之后,代码中就可以同时具备多个执行流了!!!
1.
Thread 父类中,本身有一个 run 方法,程序员编写自己的逻辑,替代自身的 run.
@override
如果不写这个注解,也能否完成方法重写,那为啥还要写这个注解???
这个是检查语法的,语法中有很多的机制,是方便让编译器,对咱们的代码进行自动检查的,人是非常不靠谱的!!机器靠谱!!
怕就怕,你想要重写,结果参数写错了,没有构成重写,如果不报错, 就不容易发现
- t.start();
调用 操作系统 提供的"创建线程"api,在内核中创建对应 pcb, 并且把 pcb 加入到链表中进一步的系统调度到这个线程了之后,就会执行上述 run 方法中的逻辑!
start 创建了一个新的线程,多了一个执行流,能够干活~~(这个代码就可以"一心两用"同时做两件事)
调用 Thread 中自带的 start 方法,才会真正调用系统 api,在系统内核中创建出线程,线程就会执行上面写好的 run 方法了.虽然没有手动调用 run, 但是 run 还是执行了
注意!!!
当有多个线程的时候,这些线程执行的先后顺序,是不确定的!!! 因为操作系统内核中,有一个"调度器" 模块,这个模块的实现方式,是一种类似于"随机调度" 效果
- sleep
抛出try throw异常的快捷键 alt + enter 是 quick fix 功能
如果不抛出异常:
很多开发工具都有,不局限于 java
在 main 方法中处理 sleep 有两种选择
(1) throws
(2) try catch
为啥??在 线程的 run 中sleep就只有一个选择了,只能 try catch???
实际开发中,异常的处理方式:
通过打印的方式,可以看到两个执行流,还可以借助第三方工具, 查看多个线程的详细情况(只是针对 Java 进程来说)
- 使⽤ jconsole 命令观察线程
通过 java 提供的工具,更清楚的看到代码中的线程
jdk 中包含了 jconsole 工具
1.3 创建线程
• ⽅法1 继承 Thread 类(创建子类, 继承 Thread, 重写 run, 通过 start 启动线程)
1.继承 Thread 来创建⼀个线程类.
class MyThread extends Thread { @Override public void run() { System.out.println("这⾥是线程运⾏的代码"); } }2.创建 MyThread 类的实例
MyThread t = new MyThread();3.调⽤ start ⽅法启动线程
t.start(); // 线程开始运⾏
直接继承 Thread,执行的任务本身,和 Thread(线程) 这个概念是耦合在一起的~
写代码的时候,要考虑降低代码的耦合
• ⽅法2 实现 Runnable 接口(创建子类, 实现 Runnable, 重写 run, 搭配 Thread 的实例, 进行 start)
引入 Runnable 就是为了 解耦合,未来如果要更换成其他的方式来执行这些任务,改动成本比较低~
1.实现 Runnable 接⼝
class MyRunnable implements Runnable { @Override public void run() { System.out.println("这⾥是线程运⾏的代码"); } }2.创建 Thread 类实例, 调⽤ Thread 的构造⽅法时将 Runnable 对象作为 target 参数.
Thread t = new Thread(new MyRunnable());3.调⽤ start ⽅法
t.start(); // 线程开始运⾏Runnable 的作用,是描述了一个"任务"。这个任务 和 具体的执行机制无关(通过线程的方式执行,还是通过其他的方式执行)run 也就是要执行的任务内容本身了
通过 Runnable 表示线程要完成的任务 耦合更低。基于这种写法,更好的解耦合
写了一个项目,有很多代码,有很多文件,也有很多类,很多逻辑。把有关联的各种代码,放到一起.
只要和某个功能逻辑相关的东西,都在这一块 高内聚。如果某个功能的代码,这一块,那一块 低内聚
高内聚(一个模块之内,有关联的东西放一起)
低耦合(模块之间,依赖尽量小,影响尽量小)
对⽐上⾯两种⽅法:
第一种写法:是 Thread 自己记录我要干啥.自己记下来自己的作业
第二种写法:通过 Runnable 记录作业是啥.Thread 负责执行.别人给你把作业记下来了,然后 Thread 负责执行
继承 Thread 类, 直接使⽤ this 就表⽰当前线程对象的引⽤.
实现 Runnable 接⼝, this 表⽰的是 MyRunnable 的引⽤. 需要使⽤Thread.currentThread()
其他变形
本质上就是方法1和2,但是换一个写法,使用 匿名内部类 来实现
• 匿名内部类创建 Thread ⼦类对象(创建子类,继承 Thread, 匿名内部类)
本质上就是方法一使用匿名内部类
// 使⽤匿名类创建 Thread ⼦类对象 Thread t = new Thread() { @Override public void run() { System.out.println("使⽤匿名类创建 Thread ⼦类对象"); } }
这样就可以少定义一些类了
一般如果某个代码是"一次性”,就可以使用匿名内部类的写法
• 匿名内部类创建 Runnable ⼦类对象(创建子类,实现 Runnable, 匿名内部类)
// 使⽤匿名类创建 Runnable ⼦类对象 Thread t = new Thread(new Runnable() { @Override public void run() { System.out.println("使⽤匿名类创建 Runnable ⼦类对象"); } });
使用 Runnable,任务和线程概念是分离的
匿名内部类,写法非常常见的!!!
这里最主要的目的是描述这个方法(设置回调函数)。方法不能脱离类, 单独存在。这就导致为了设置回调函数,不得不套上一层类了
上述几种方法,都不常用~因此引入了 lambda 表达式(匿名函数/方法)
• lambda 表达式创建 Runnable ⼦类对象(推荐常用)
第五种写法,针对三和四进一步改进,引入lambda 表达式
// 使⽤ lambda 表达式创建 Runnab` le ⼦类对象 Thread t3 = new Thread(() -> System.out.println("使⽤匿名类创建 Thread ⼦类对象")); Thread t4 = new Thread(() -> { System.out.println("使⽤匿名类创建 Thread ⼦类对象"); });java 语法开了个特殊的口子:函数式接口属于 lambda 背后的实现,相当于 java 在没破坏原有的规则的基础上给了 lambda 一个合法性解释(方法不能脱离类, 单独存在)函数式接口” () -> {} 创建了一个匿名的函数式接口的子类,并且创建出对应的实例,并且重写了里面的方法(编译器在背后做的事情)
这个写法相当于 实现 Runnable 重写 run
lambda 代替了 Runnable 的位置~
此处 Thread 这里要谈到 5 种写法, 都很常用,都要掌握~
上述 5 种写法,都是等价的. 都是可以相互转换的。
本质上, 都是!
1)要把线程执行的任务内容表示出来.
2)通过 Thread 的 start 来创建/启动系统中的线程(Thread 对象和操作系统内核中的线程是一 一对应的关系)
这里也是一个常见面试题:Java 中创建线程都有哪些写法~
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 毫秒
# 总结-对⽐线程和进程
1.线程的优点
- 创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多
- 与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多
- 线程占⽤的资源要⽐进程少很多
- 能充分利⽤多处理器的可并⾏数量
- 在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务
- 计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现
- I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
2.进程与线程的区别
- 进程是系统进⾏资源分配和调度的⼀个独⽴单位,线程是程序执⾏的最⼩单位。
- 进程有⾃⼰的内存地址空间,线程只独享指令流执⾏的必要资源,如寄存器和栈。
- 由于同⼀进程的各线程间共享内存和⽂件资源,可以不通过内核进⾏直接通信。
- 线程的创建、切换及终⽌效率更⾼。
2. Thread 类及常⻅⽅法
Thread 类是 JVM ⽤来管理线程的⼀个类,换句话说,每个线程都有⼀个唯⼀的 Thread 对象与之关联。
⽤我们上⾯的例⼦来看,每个执⾏流,也需要有⼀个对象来描述,类似下图所⽰,⽽Thread 类的对象就是⽤来描述⼀个线程执⾏流的,JVM 会将这些 Thread 对象组织起来,⽤于线程调度,线程管理。
2.1 Thread 的常⻅构造⽅法

Thread():使用这个写法,必须要重写 Thread 的 run
Thread(Runnable target):此时不要重写 Thread 的 run
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");

这里没有看到 main 线程。一个进程启动,肯定得现有 main 线程调用 main 方法
注意!! 此处不是 main 线程没有被创建,而是执行太快,执行完毕了!!
2.2 Thread 的⼏个常⻅属性

• ID 是线程的唯⼀标识,不同线程不会重复。(java 代码无法获取到 pcb 中的 id)这里的 id 和 系统中 pcb 上的 id 是不同的,是 jvm 自己搞的一套 id 体系
• 名称是各种调试⼯具⽤到
• 状态表⽰线程当前所处的⼀个情况,下⾯我们会进⼀步说明
• 优先级⾼的线程理论上来说更容易被调度到
• 关于后台线程,需要记住⼀点:JVM会在⼀个进程的所有⾮后台线程结束后,才会结束运⾏。
• 是否存活,即简单的理解,为 run ⽅法是否运⾏结束了
• 线程的中断问题,下⾯我们进⼀步说明
public class ThreadDemo {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
System.out.println(Thread.currentThread().getName() + ": 我还活着");
Thread.sleep(1 * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ": 我即将死去");
});
System.out.println(Thread.currentThread().getName() + ": ID: " + thread.getId());
System.out.println(Thread.currentThread().getName() + ": 名称: " + thread.getName());
System.out.println(Thread.currentThread().getName() + ": 状态: " + thread.getState());
System.out.println(Thread.currentThread().getName() + ": 优先级: " + thread.getPriority());
System.out.println(Thread.currentThread().getName() + ": 后台线程: " + thread.isDaemon());
System.out.println(Thread.currentThread().getName() + ": 活着: " + thread.isAlive());
System.out.println(Thread.currentThread().getName() + ": 被中断: " + thread.isInterrupted());
thread.start();
while (thread.isAlive()) {}
System.out.println(Thread.currentThread().getName() + ": 状态: " + thread.getState());
}
}
getId():jvm 自动分配的身份标识,会保证唯一性
getState():进程有状态:就绪状态,阻塞状态。线程也有状态,Java 中对线程的状态,又进行了进一步的区分(比系原生的状态,更丰富一些)
getPriority():线程的优先级 在 java 中,设置优先级,效果不是很明显(对内核调度器的调度过程产生一些影响) 说了不算,算了不说,即使你手动给某个线程设置一个非常高的优先级,实际运行效果不一定明显(线程调度是操作系统完成,系统随机调度)
isDaemon():daemon 一一>守护~ 是否是守护线程 (非常抽象) 也可以叫做,是否是"后台线程"。操作系统中是有守护进程概念的(也可以理解成 后台进程 )。前台线程的运行,会阻止进程结束。后台线程的运行,不会阻止进程结束
咱们自己代码创建的线程,包括 main 主线程默认都是前台线程,可以通过 setDaemon 方法来修改!
什么情况要设置为后台线程呢?一一> 我不期望这个线程影响进程结束
比如,有的线程负责进行 gc(垃圾回收),gc 是要周期性持续性执行的.不可能主动结束,要是把他设为前台,进程就永远也结束不了了
要不要有后台线程都是看实际需求
在 jconsole 中看到的 jvm 中包含一些其他的内置的线程, 就属于后台线程了
咱们代码创建的线程t 默认是前台线程,在执行过程中,进程是不能结束的,main 执行完 start 直接结束了.main 不影响了.只要前台线程没执行完,进程就不会结束.即使 main 已经执行完毕了
设为 true 是后台(后台,是躲在背后的人,你感知不到)后台不会阻止进程结束
不设为 true 是前台(前台,是明面上的人,你能感知到)前台会阻止进程结束
再次执行,发现控制台啥都没打印,就没了,进程就结束了!!
注意! !
此处也有一定的概率, 出现t打印一次,然后结束进程的情况。这个事情就看是 main 先执行结束,还是t先执行一次打印(线程之间是抢占式执行,调度顺序不确定)
但是按照经验来看,当前代码结构中,大概率是啥都不打印的。即使你尝试 1w 次,结果可能都是t啥都不打印。
概率不均等的原因在于main 调用 start 速度很快。对t来说,系统要把t线程创建出来之后才能执行打印,创建 本身有时间开销,虽然比进程创建轻量,但是也不是为0.
为什么有的人执行了很多次 都是一定打印?
多线程程序中,存在很多神奇的操作,很多代码,稍微变动一点或者代码即使不变,换了个机器,换了个运行环境,结果都不一样.(难点所在)
isAlive():表示了内核中的线程(PCB)是否还存在。Thread 实例和内核的线程的生命周期,并非是一致的(可能存在, Thread 对象还存活,但是系统中的线程已经销毁的情况)
isAlive():
表示了内核中的线程(PCB)是否还存在。Java 代码中创建的 Thread 对象和系统中的线程是一一对应的关系,代码中定义的线程对象 (Thread) 实例虽然表示一个线程,但这个对象本身的生命周期和 内核中的 pcb 生命周期,是不完全一样的~
t.start(),才真正在内核中创建出这个 pcb, 此时 isAlive 就是 true
当线程 run 执行完了,此时 内核中的线程就结束了(内核 pcb 就释放了)。但是此时 t变量可能还存在,于是 isAlive 也是 false
2.3 启动⼀个线程 - start()
之前我们已经看到了如何通过覆写 run ⽅法创建⼀个线程对象,但线程对象被创建出来并不意味着线程就开始运⾏了。
• 覆写 run ⽅法是提供给线程要做的事情的指令清单
• 线程对象可以认为是把 李四、王五叫过来了
• ⽽调⽤ start() ⽅法,就是喊⼀声:”⾏动起来!“,线程才真正独⽴去执⾏了。
调⽤ start ⽅法, 才真的在操作系统的底层创建出⼀个线程
- 调用 start 动作本身是非常快的~
一旦执行 start, 代码就会立即往下执行,不会产生任何的阻塞等待
- 一个线程对象只能 start 一次~
Thread 类使用 start 方法, 启动一个线程。对于同一个 Thread 对象来说, start只能调用一次!

非法线程的状态异常:start 里面对线程状态做了判定。线程执行了 start 之后, 就是就绪状态/阻塞状态了。对于 就绪状态/阻塞下 线程不能再次 start
要想启动更多线程,就是得创建新的对象!!!
调用 start 创建出新的线程,本质上是 start 会调用系统的 api, 来完成创建线程的操作.
经典面试题:start 和 run 区别 (这个问题,大家一定要注意理解)
(start 和 run 其实是八竿子打不着,互不相干的内容, 但是就是有人搞的不太对)
run 是线程的入口方法, 不需要手动调用;start 是调用系统 api
2.4 中断⼀个线程
中断,也是操作系统中的一个"专用术语"。更好的说法,终止一个线程(终止线程, 在 Java 中,都只是"提醒,建议",真正要不要终止,还得线程本体来进行决定的 !! t 线程,正在执行其他线程,只能提醒一下t是不是要终止了,t 收到这样的提醒之后,也还是得自己决定的)线程之间调度是随机的,万一人家线程正在做一个很重要的工作,干了一半,强制让人家结束,可能就会引起一些 bug.
李四⼀旦进到⼯作状态,他就会按照⾏动指南上的步骤去进⾏⼯作,不完成是不会结束的。但有时我们需要增加⼀些机制,例如⽼板突然来电话了,说转账的对⽅是个骗⼦,需要赶紧停⽌转账,那张三该如何通知李四停⽌呢?这就涉及到我们的停⽌线程的⽅式了。
让一个线程能够结束,核心就是让线程的入口方法(run 方法)执行完毕,线程就随之结束了(run 方法尽快 return) 一一>非常取决于 具体代码 实现方式了
⽬前常⻅的有以下两种⽅式:
- 通过共享的标记来进⾏沟通
为了让线程结束,引入标志位.
通过上述代码,就可以让线程结束掉,具体线程啥时候结束,取决于在另一个线程中何时修改 isQuit 的值
main 线程,想要让 t 线程结束,大前提一定是t线程的代码,对这样的逻辑有所支持,而不是t里的代码随便咋写都能提前结束的。如果代码没有配合,main 无法让 t 提前结束的!!
run 方法和 main 方法是两个线程,这俩线程的执行顺序是不确定的!!!(时刻牢记)
我们注意到开头把isQuit设成了成员变量,如果把 isQuit 作为 main 方法中的 局部变量, 是否可行!!!?
不可行!运行发现编译报错了,为啥会报错???
lambda 表达式 讲过的一个语法,变量捕获
lambda 表达式/匿名内部类,是可以访问到外面定义的局部变量的!!!(变量捕获语法规则)。变量捕获,本质上就是把外面的变量当做参数传进来了(参数是隐藏的)
你这个捕获的变量,得是 final 或者" 事实 final “(虽然没写 final 但是没有修改,虽无夫妻之名,但行夫妻之实)
由于此处 isQuit 确实要修改!!!不能写成 final 也不是"事实 final,局部变量这一手,就行不通!!! 因此就必须写作成员变量。
为啥写作成员变量就可以了??又是哪个语法规定的???
lambda 表达式,本质上是"函数式接口” ——> 匿名内部类,内部类访问外部类的成员,这个事情本身就是可以的!!! 这个事情就不受到变量捕获的影响了。
为啥 java 这里对于变量捕获有 final 的限制?
isQuit 是局部变量的时候是属于 main 方法的栈帧中;但是 Thread lambda 是有自己独立的栈帧的 (另一个线程中的方法)。这两个栈帧的生命周期不一致的!这就可能会导致,main 方法执行完了,栈帧销毁了,同时 Thread 的栈帧还在,还想继续使用 isQuit 。
Java 中的做法就非常的简单粗暴:变量捕获本质上就是传参,换句话说,就是让 lambda 表达式在自己的栈帧中创建一个 新的 isQuit,并把外面的 isQuit 值给拷贝过来(为了避免 例外 isQuit 的值不同步,java 干脆就不让你 isQuit修改)
java 语法里变量捕获已经挺简单了:
相比之下 C++ 里, 变量捕获更复杂了。就需要程序猿手动控制:按照值方式捕获,还是按照引用方式捕获(手动确保生命周期正确),还是按照右值引用的方式捕获。虽然不受到 final 的影响, 可以随意修改,但编码复杂度大幅度提升
相比之下 JS 里, 变量捕获也很复杂,JS 改了变量的生命周期。某个局部变量被其他"匿名函数"捕获生命周期了,此时这个变量就脱离原有的函数级别的(这背后就涉及到一个非常复杂的"作域链"问题/闭包.……)
不要求一下都能理解,慢慢品~ - 调⽤ interrupt() ⽅法来通知
通过刚才的写法,不够优雅, Thread 类还提供了一种更优雅的选择:让 Thread 对象, 内置了这个变量。这个代码本质上,就是使用 Thread 实例内部自带的标志位,来代替刚才手动创建的 isQuit 变量了
Thread.currentThread() 这个操作,是获取当前线程实例(t),哪个线程调用,得到的就是哪个线程的实例(类似于 this)
执行代码,可以看到代码中出现了一个异常,t 线程并没有真的结束!!!
刚才这里的 interrupt 导致sleep 出现异常!!!
如果没有 sleep, interrupt 可以让线程顺利结束,有 sleep 引起了变数!! 在执行 sleep 的过程中,调用 interrupt大概率 sleep 休眠时间还没到, 被提前唤醒了。提前唤醒,会做两件事:
1.抛出 InterruptedException(紧接着就会被 catch 获取到)
2.清除 Thread 对象的 isInterrupted 标志位
只有sleep会清除异常吗? 一一>不只是 sleep ,很多方法都会
通过 interrupt 方法, 已经把标志位设为 true。但是 sleep 提前唤醒操作,就把标志位又设回 false(此时循环还是会继续执行了)
要想让线程结束,只需要在 catch 中加上 break 就行了~
sleep 清空标志位,是为了给程序猿更多的“可操作性空间”:
前一个代码,写的是 sleep(1000),结果现在 1000 还没到, 就要终止线程。这就相当于是两个前后矛盾的操作。
此时,是希望写更多的代码,来对这样的情况进行具体的处理的。此时程序猿就可以在 catch 语句中,加入一些代码,来做一些处理
(1)让线程立即结束: 加上 break
(2)让线程不结束,继续执行: 不加 break
(3)让线程执行一些逻辑之后,再结束: 写一些其他代码,再 break
比如我在游戏,我妈让我去买酱油:(1)立即停下游戏,立即去买(2)无视我妈,装作没听见,继续打游戏(3)给我妈说,我打完这把,再去买
有的人可能会抛出另一种异常:
旧版本的 idea 生成 try catch,catch 里头自动给的代码是 打印调用栈。新版本的 idea生成的代码,是再抛出另一个异常.
实际开发中,catch 语句中的代码,既不会是打印调用栈,也不会是 throw 另一个异常,idea 生成的这两种代码, 都只是占个位置而已. 没啥实际的作用!!!
实际开发中,catch 里应该要写什么样的代码???
(如果你的程序出现异常了,该如何处理,是更合理的??? )对于一个服务器程序来说,稳定性是非常重要的!!! 无法保证服务器就一直不出问题,这些所谓"问题"在 java 代码中, 就会以 异常 的形式体现出来,可以通过 catch 语句,对这些异常进行处理
(1)尝试自动恢复
能自动恢复, 就尽量自动回复。比如出现了一个 网络通信 相关的异常,就可以在 catch 尝试重连网络
(2)记录日志(异常信息记录到 文件中)
有些情况, 并非是很严重的问题,只需要把这个问题记录下来即可.(并不需要立即解决)后面程序猿有空的时候再解决
(3)发出报警
针对一些比较严重的问题了! 包括不限于,给程序猿 发邮件,发短信,发微信, 打电话.….
(4)也有少数的正常的业务逻辑,会依赖到 catch
比如文件操作中有的方法,就是要通过 catch 来结束循环之类的…[非常规用法]
当前阶段,catch 就随意了~ catch 代码放到整个项目代码的哪个层次,都是非常讲究的。《代码大全》也有章节讨论这样的话题~
⽰例-1: 使⽤⾃定义的变量来作为标志位.
需要给标志位上加 volatile 关键字(这个关键字的功能后⾯介绍).
public class ThreadDemo {
private static class MyRunnable implements Runnable {
public volatile boolean isQuit = false;
@Override
public void run() {
while (!isQuit) {
System.out.println(Thread.currentThread().getName() + ": 别管我,我忙着转账呢!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ": 啊!险些误了⼤事");
}
}
public static void main(String[] args) throws InterruptedException {
MyRunnable target = new MyRunnable();
Thread thread = new Thread(target, "李四");
System.out.println(Thread.currentThread().getName() + ": 让李四开始转账。");
thread.start();
Thread.sleep(10 * 1000);
System.out.println(Thread.currentThread().getName() + ": ⽼板来电话了,得赶紧通知李四对⽅是个骗⼦!");
target.isQuit = true;
}
}
⽰例-2: 使⽤ Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替⾃定义标志位.
Thread 内部包含了⼀个 boolean 类型的变量作为线程是否被中断的标记
使⽤ thread 对象的 interrupted() ⽅法通知线程结束.
public class ThreadDemo {
private static class MyRunnable implements Runnable {
@Override
public void run() {
// 两种⽅法均可以
while (!Thread.interrupted()) {
//while (!Thread.currentThread().isInterrupted()) {
System.out.println(Thread.currentThread().getName() + ": 别管我,我忙着转账呢!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
System.out.println(Thread.currentThread().getName() + ": 有内⻤,终⽌交易!");
// 注意此处的 break
break;
}
}
System.out.println(Thread.currentThread().getName() + ": 啊!险些误了⼤事");
}
}
public static void main(String[] args) throws InterruptedException {
MyRunnable target = new MyRunnable();
Thread thread = new Thread(target, "李四");
System.out.println(Thread.currentThread().getName() + ": 让李四开始转账。");
thread.start();
Thread.sleep(10 * 1000);
System.out.println(Thread.currentThread().getName() + ": ⽼板来电话了,得赶紧通知李四对⽅是个骗⼦!");
thread.interrupt();
}
}
thread 收到通知的⽅式有两种:
- 如果线程因为调⽤ wait/join/sleep 等⽅法⽽阻塞挂起,则以 InterruptedException 异常的形式通知,清除中断标志
◦ 当出现 InterruptedException 的时候, 要不要结束线程取决于 catch 中代码的写法. 可以选择忽略这个异常, 也可以跳出循环结束线程. - 否则,只是内部的⼀个中断标志被设置,thread 可以通过
◦ Thread.currentThread().isInterrupted() 判断指定线程的中断标志被设置,不清除中断标志这种⽅式通知收到的更及时,即使线程正在 sleep 也可以⻢上收到。
在 Java 中,线程的终止,是一种"软性"操作。必须要对应的线程配合,才能把终止落实下去。
相比之下, 系统原生的 api,其实还提供了强制终止线程的操作。无论你线程是否愿意配合,无论线程执行到哪个代码,都能强行把这个线程给干掉!! 这样的操作,java 的 api 中没有提供的。
上述强制执行的做法,弊大于利的。如果强行干掉一个线程,很可能线程执行到一半,就可能会出现一些残留的临时性质的"错误"的数据。
假设这个线程正在执行 写文件 操作. 写文件的数据有一定的格式要求(写一个图片文件),如果写图片写了一半,线程嘎了,图片就尴尬了 图片文件,是存在,里面的内容不正确 ,无法正确打开了。
2.5 等待⼀个线程 - join()
有时,我们需要等待⼀个线程完成它的⼯作后,才能进⾏⾃⼰的下⼀步⼯作。
多个线程的执行顺序是不确定(随机调度,抢占式执行)。虽然线程底层的调度是无序的,但是可以在应用程序中,通过一些 api, 来影响到线程执行的顺序。join 就是一种方式影响的线程结束的先后顺序~ 比如, t2 线程等待 t1 线程,此时,一定是 t1 先结束,t2 后结束,join 是可能会使 t2 线程阻塞。
线程 run 方法中的内容执行时间不可预期,使用 join 就可以很好的解决问题。
main 线程中,调用 t.join()。让 main 线程 等待 t 线程结束 [ 谁等谁,这个事情,一定要搞清楚! ](系统原生的 api 就是这样设定)
执行 join 的时候, 就看t线程是否正在运行。如果 t运行中,main 线程就会阻塞(main 线程就暂时不去参与 cpu 执行了;如果 t运行结束,main 线程就会从阻塞中恢复过来, 并且继续往下执行(阻塞, 使这俩线程的结束时间,产生了先后关系)
join方法用的多吗?线程最核心的 api之一,用的非常多!!!
一个典型情况:使用多个线程并发进行一系列的计算,用一个线程阻塞等待上述计算线程,等到所有的线程都计算完了,最终这个线程汇总结果~
那直接按顺序写一个线程不就可以了吗?
线程的执行顺序不确定,线程执行的任务的时间也是不可预期的。如果单个线程,无法发挥多核 cpu 的优势(算的慢);如果多个线程,势必是需要有一个线程进行汇总结果的。注意join不是确定的"执行顺序”,而是确定的"结束顺序"
任何一个线程都可以调用 join, 规则和之前说的是一样的。哪个线程调用 join 哪个线程就阻塞等待。
例如,张三只有等李四转账成功,才决定是否存钱,这时我们需要⼀个⽅法明确等待线程的结束。
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Runnable target = () -> {
for (int i = 0; i < 10; i++) {
try {
System.out.println(Thread.currentThread().getName() + ": 我还在⼯作!");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ": 我结束了!");
};
Thread thread1 = new Thread(target, "李四");
Thread thread2 = new Thread(target, "王五");
System.out.println("先让李四开始⼯作");
thread1.start();
thread1.join();
System.out.println("李四⼯作结束了,让王五开始⼯作");
thread2.start();
thread2.join();
System.out.println("王五⼯作结束了");
}
}
⼤家可以试试如果把两个 join 注释掉,现象会是怎么样的呢?
join(long millis,int nanos)设置一个 ns 级别的时间实际上用处不大~~系统时间也没法精确到 ns
2.6 获取当前线程引⽤
这个⽅法我们已经⾮常熟悉了
Thread.currentThread() 获取到当前线程的 引用(Thread 的引用)
public class ThreadDemo {
public static void main(String[] args) {
Thread thread = Thread.currentThread();
System.out.println(thread.getName());
}
}
如果是继承 Thread,直接使用 this 拿到线程实例;
如果是 Runnable 或者 lambda 的方式,this 就无能为力了。此时 this 已经不再指向 Thread 对象了!! 就只能使用 Thread.currentThread();
2.7 休眠当前线程
也是我们⽐较熟悉⼀组⽅法,有⼀点要记得,因为线程的调度是不可控的,所以,这个⽅法只能保证实际休眠时间是⼤于等于参数设置的休眠时间的。
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
System.out.println(System.currentTimeMillis());
Thread.sleep(3 * 1000);
System.out.println(System.currentTimeMillis());
}
}
3. 线程的状态
就绪: 这个线程随时可以去 cpu 上执行.(也包含正在 cpu 上执行)
阻塞: 这个线程暂时不方便去 cpu 上执行.Java 中,针对阻塞状态又做了进一步的细分
3.1 观察线程的所有状态
线程的状态是⼀个枚举类型 Thread.State
public class ThreadState {
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()) {
System.out.println(state);
}
}
}
Java 中,线程有以下几种状态:
• NEW: 安排了⼯作, 还未开始⾏动
• RUNNABLE: 可⼯作的. ⼜可以分成正在⼯作中和即将开始⼯作.
• BLOCKED: 这⼏个都表⽰排队等着其他事情
• WAITING: 这⼏个都表⽰排队等着其他事情
• TIMED_WAITING: 这⼏个都表⽰排队等着其他事情
• TERMINATED: ⼯作完成了.
- NEW Thread 对象创建好了,但是还没有调用 start 方法在系统中创建线程
- TERMINATED Thread 对象仍然存在,但是系统内部的线程已经执行完毕了
- RUNNABLE 就绪状态,表示这个线程正在 cpu 上执行,或者准备就绪随时可以去 cpu 上执行
- TIMED WAITING 指定时间的阻塞, 就在到达一定时间之后自动解除阻塞。使用 sleep 会进入这个状态. 使用带有超时时间的join也会
- WAITING 不带时间的阻塞 (死等),必须要满足一定的条件,才会解除阻塞。join 或者 wait 都会进入 WAITING
- BLOCKED 由于锁竞争,引起的阻塞.(后面线程安全的时候具体介绍)

3.2 线程状态和状态转移的意义

⼤家不要被这个状态转移图吓到,我们重点是要理解状态的意义以及各个状态的具体意思。
还是我们之前的例⼦:
刚把李四、王五找来,还是给他们在安排任务,没让他们⾏动起来,就是 NEW 状态;
当李四、王五开始去窗⼝排队,等待服务,就进⼊到 RUNNABLE 状态。该状态并不表⽰已经被银⾏⼯作⼈员开始接待,排在队伍中也是属于该状态,即可被服务的状态,是否开始服务,则看调度器的调度;
当李四、王五因为⼀些事情需要去忙,例如需要填写信息、回家取证件、发呆⼀会等等时,进⼊BLOCKED 、 WATING 、 TIMED_WAITING 状态,⾄于这些状态的细分,我们以后再详解;
如果李四、王五已经忙完,为 TERMINATED 状态。
所以,之前我们学过的 isAlive() ⽅法,可以认为是处于不是 NEW 和TERMINATED 的状态都是活着的。
学习这些状态,最大的作用,调试多线程代码的 bug 的时候,给我们作为重要的参考依据。"程序卡住了"意味着一些关键的线程阻塞了,就可以观察线程的状态,就能分析出一些原因。
如果发现某个进程"卡住了,就可以使用 jconsole 这样的工具,查看这个进程中的一些重要线程的状态和调用栈。通过状态,就可以判定线程是否是阻塞,以及什么原因阻塞的~
一个 Thread 对象只能 start 一次,和线程状态密切相关的. 只有处于 NEW状态才能 start。WAITING 和 BLOCKED 后面再介绍
3.3 观察线程的状态和转移
观察 1: 关注 NEW 、 RUNNABLE 、 TERMINATED 状态的转换
public class ThreadStateTransfer {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 1000_0000; i++) {
}
}, "李四");
System.out.println(t.getName() + ": " + t.getState());;
t.start();
while (t.isAlive()) {
System.out.println(t.getName() + ": " + t.getState());;
}
System.out.println(t.getName() + ": " + t.getState());;
}
}
观察 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. 多线程带来的的⻛险-线程安全 (重点)
整个多线程最关键的要点
1.面试
2.工作
如果不理解线程安全问题,很难保证写出正确的多线程代码的,
4.1 线程安全的概念
想给出⼀个线程安全的确切定义是复杂的,但我们可以这样认为:如果多线程环境下代码运⾏的结果是符合我们预期的(即在单线程环境应该的结果),则说这个程序是线程安全的。
某个代码,无论是在单个线程还是多个线程下执行,都不会产生 bug.这个情况就称为"线程安全”。如果单线程下运行正确,但是多线程下就可能会产生 bug,这个情况就称为"线程不安全"或"存在线程安全问题”
4.2 观察线程不安全
// 此处定义⼀个 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);
}
t1.join();
t2.join();
这俩线程, 谁先 join, 谁后 join 无所谓~~
如果不加join,按理说,一个线程自增 5w 次,两个线程,一共自增 10w 次,最终结果应该是 10w。非但这里的结果不是 10w,并且每次运行的结果都不同!!!
实际结果不符合预期,就是 bug !!!上述这个循环自增的代码,就属于是"存在线程安全问题"的代码
站在 cpu 执行指令的角度:
count++相当于 +=1,这个 count++ 其实是三个 cpu 指令构成的
如果是一个线程执行上述的 三个 指令,当然没问题。如果是两个线程,并发的执行上述操作,此时就会存在变数!!!(线程之间调度的顺序是不确定的!!)
这里一共有多少种情况??其实是无数种情况!!!
这里需要分析清楚,什么样的顺序下,执行结果是对的 (两次 ++,得到的结果是 2),什么顺序下结果不对(两次 ++,结果不是 2)
线程的随机调度/抢占式执行~~ 在循环自增 5w 过程中,一旦运算过程中,中间的结果出现类似上面这种形态,这时候得到的最终结果就一定是 小于 10w 的.
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 解决之前的线程不安全问题
如何解决线程安全问题?
知道了原因,就可以对症下药了:
- [根本]操作系统对于线程的调度是随机的:抢占式执行
操作系统的底层设定.咱们左右不了
能否自己写个操作系统,取缔抢占式执行,不就解决线程安全问题了嘛??
理论上当然可行,实际上难度太大了~(1)技术上本身就非常难(2)推广上难上加难 - 多个线程同时修改同一个变量
和代码的结构直接相关。可以调整代码结构,规避一些线程不安全的代码(Java 中有个东西,String 就是采取了"不可变"特性,确保线程安全)
但是这样的方案,不够通用。有些情况下,需求上就是需要多线程修改同一个变量的。比如超买/超卖的问题~ 某个商品,库存 100 件, 能否创建出 101 个订单? - 修改操作,不是原子的.
乍看起来,count++,生成几个指令,咱们也无从干预。但是实际上,可以通过特殊手段,把这三个指令打包到一起,成为"整体”。
Java 中解决线程安全问题,最主要的方案!
加锁!
通过加锁操作,让不是原子的操作,打包成一个原子的操作。计算机中的锁,和生活中的锁,是同样的概念! 锁具有"互斥”“排他"这样的特性
把锁"锁上"称为"加锁”,把锁"解开" 称为"解锁"。一旦把锁加上了,其他人要想加锁,就得阻塞等待
就可以使用锁,把刚才不是原子的 count++ 包裹起来。在 count++ 之前, 先加锁。然后进行 count++.计算完毕之后,再解锁。执行 3 步走 过程中,其他线程就没法 插队了
加锁操作,不是把线程锁死到 cpu 上,禁止这个 线程被调度走。而是禁止其他线程重新加这个锁,避免其他线程的操作!在当前线程执行过程中插队
在 java 中,加锁方式,有好多种,最主要使用的方式:synchronized 关键字~
加锁的目的,是为了把 三个操作,打包成一个原子的 操作
咱们这个代码的写法,是每次 count++ 之前,加锁。count++ 完了之后就释放了
前面说,加锁是把 count++ 这三步操作变成原子了。但是很明显,并非是加锁之后,执行三个操作过程中,线程就不调度了(准确说, 通过锁竞争让第二个线程的指令无法插入到第一个线程的执行指令中间,而不是禁止第一个线程被调度出 cpu ),而是即使加锁的线程被调度走了,其他线程也无法"插队执行”
可能有人会有疑问,这样不就串行执行了嘛?那效率不是有点慢?
加锁之后, 确实会影响到 多线程 的执行效率。但是即使如此,也是比你一个线程串行执行要更快的!!!
这样做仍然是有意义的.仍然要比所有的代码都在串行执行要更快.
实际上开发中,往往一个线程里要完成很多工作1,2,3,4,5··· 很多工作中,只有某个,某几个,才需要加锁, 剩下其他的都是可以并发的。比如,1,2,3, 4,5 其中 1,2,3,5 都能并发执行,4 需要加锁串行执行…
能否把 synchronized 放到 for 的外面呢?
这种情况 在计算所有的 count++ 之前,加锁。计算完所有的 count++ 之后释放锁。
引入多线程, 就是为了 并发 执行,就是为了充分利用 cpu 多核心资源。多进程编程 和 多线程编程 就是在利用多核心的编程手法。
日常工作中,一般都是让加锁范围尽量的小。这样的话,可以并发执行的逻辑就更多,此时外部的逻辑通常是更复杂的~~
// 此处定义⼀个 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);
}
这⾥⽤到的机制,⻢上会给⼤家解释。
#总结-保证线程安全的思路
- 使⽤没有共享资源的模型
- 使⽤共享资源只读,不写的模型
a. 不需要写共享资源的模型
b. 使⽤不可变对象 - 直⾯线程安全(重点)
a. 保证原⼦性
b. 保证顺序性
c. 保证可⻅性
5. synchronized 关键字 - 监视器锁 monitor lock
(JVM 中采用的一个术语。使用锁的过程中抛出一些异常,可能会看到 监视器锁 这样的报错信息)
5.1 synchronized 的特性
(1) 互斥
synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执⾏到同⼀个对象 synchronized 就会阻塞等待.
• 进⼊ synchronized 修饰的代码块, 相当于 加锁
• 退出 synchronized 修饰的代码块, 相当于 解锁
synchronized用的锁是存在Java对象头⾥的。
可以粗略理解成, 每个对象在内存中存储的时候, 都存有⼀块内存表⽰当前的 “锁定” 状态(类似于厕所的 “有⼈/⽆⼈”).
如果当前是 “⽆⼈” 状态, 那么就可以使⽤, 使⽤时需要设为 “有⼈” 状态.
如果当前是 “有⼈” 状态, 那么其他⼈⽆法使⽤, 只能排队
理解 “阻塞等待”.
针对每⼀把锁, 操作系统内部都维护了⼀个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进⾏加锁, 就加不上了, 就会阻塞等待, ⼀直等到之前的线程解锁之后, 由操作系统唤醒⼀个新的线程,再来获取到这个锁.
注意:
• 上⼀个线程解锁之后, 下⼀个线程并不是⽴即就能获取到锁. ⽽是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的⼀部分⼯作.
• 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C都在阻塞队列中排队等待。 但是当 A 释放锁之后, 虽然 B ⽐ C 先来的, 但是 B 不⼀定就能获取到锁,⽽是和 C 重新竞争, 并不遵守先来后到的规则.
锁, 本质上也是操作系统提供的功能,内核提供的功能 =>通过 api 给应用程序了。java (VM)对于这样的系统 api 又进行了封装.
synchronized 是调用 系统的 api 进行加锁。系统 api 本质上是靠 cpu 上的特定指令完成加锁
(2)可重⼊
synchronized 加锁的效果,也可以称为"互斥性。synchronized 还有一些其他特性:
理解 “把⾃⼰锁死”
⼀个线程没有释放锁, 然后⼜尝试再次加锁.
// 第⼀次加锁, 加锁成功
lock();
// 第⼆次加锁, 锁已经被占⽤, 阻塞等待.
lock();
按照之前对于锁的设定, 第⼆次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第⼆个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想⼲了, 也就⽆法进⾏解锁操作. 这时候就会 死锁.
这样的锁称为 不可重⼊锁
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
synchronized (locker) {
count++;
}
}
}
看起来是两次一样的加锁,没有必要。但是实际上开发中,很容易写出这样的代码的。
一旦方法调用的层次比较深,就搞不好容易出现这样的情况
要想解除阻塞,需要往下执行才可以,要想往下执行就需要等到第一次的锁被释放,这样的问题,就称为"死锁”。
这样的代码在 Java 中其实是不会死锁的!!! 为了避免程序猿粗心大意搞出死锁!java引入了"可重入机制",Java 中的 synchronized 是 可重⼊锁, 因此没有上⾯的问题。
最外层“ { ”真正加锁
最外层“ }” 真正解锁
站在 JVM 的视角,看到多个}需要执行,JVM 如何知道哪个}是真正解锁的那个??
先引入一个变量,计数器(0),每次触发{的时候,把计数器++,每次触发 } 的时候,把计数器 - -,当计数器 - - 为 0 的时候, 就是真正需要解锁的时候~
在可重⼊锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息:(1)如果某个线程加锁的时候, 发现锁已经被⼈占⽤, 但是恰好占⽤的正是⾃⼰, 那么仍然可以继续获取到锁, 并让计数器⾃增.(2)解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
死锁是面试中考察的重点,也是工作中,多线程开发中非常核心的注意事项~~
若面试官的问题:
如何自己实现一个可重入锁?
1.在锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判定
2.通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁,
死锁(死锁的进一步讨论)
“死锁”是多线程代码中的一类经典问题, 加锁是能解决线程安全问题,但是如果加锁方式不当,就可能产生死锁!!
死锁同样也是经典面试题!!
死锁的三种典型场景:
场景1. 一个线程, 一把锁.
刚才说情况 如果锁是不可重入锁,并且一个线程针对一个锁对象,连续加锁两次,就会出现死锁(钥匙锁屋里了)
通过引入可重入锁,问题就迎刃而解了
场景2. 两个线程,两把锁
两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁。线程1 获取到 锁A,线程2 获取到 锁B,接下来1 尝试获取 B,2 尝试获取 A,就同样出现死锁了!!!(屋钥匙锁车里了,车钥匙锁屋里了)
如果不加 sleep, 很可能 t1 一口气就把 locker1 和 locker2 都拿到了.这个时候,t2 还没开动呢~ 自然无法构成死锁.
经典面试题:让你手写一个出现死锁的代码:
C++方向,代码就好写,直接加锁两次就行了
Java 方向,就得通过上述代码,两个线程两把锁,精确控制好加锁的顺序
这里也就需要让我们知道,如果遇到死锁问题,就可以通过上述调用栈+状态进行定位了
场景3. N 个线程 M 把锁
一个经典的模型,哲学家就餐问题(学校的操作系统课上,也会有这个东西)
死锁,非常严重的问题~~ 属于程序中最严重的一类 bug !!!
一旦出现死锁,线程就"卡住了"无法继续工作,一个进程中的线程个数,就那么多。更可怕的是,死锁这种bug, 往往都是概率 出现,测试的时候怎么测试都没事,一发布就出问题,发布了也没问题,等到夜深人静,大家都睡着,突然给你整出点问题!比 bug 更可怕的是,“概率性出现的 bug”。虽然概率小,但是我们也需要重视!! 假设上述问题的 概率是 万分之一,同样是需要我们处理的,当时阿里这边的服务器每天的访问量是 3亿次,每天就有 3万个用户,触发了这个 bug!
如何避免死锁问题?
教科书上经典的,死锁的四个必要条件 !!!(下列四个条件,要求大家背下来!!面试经典问题!!)必要条件: 缺一不可!任何一个死锁的场景,都必须同时具备上述四点,只要缺少一个,都不会构成死锁。
1.锁具有互斥特性.
一个线程拿到锁之后,其他线程就得阻塞等待(锁最基本的特性.,不太好破坏)
2.锁不可抢占(不可被剥夺)
一个线程拿到锁之后,除非他自己主动释放锁,否则别人抢不走~~(也是锁最基本的特性.,也不好破坏)
3.请求和保持
一个线程拿到一把锁之后,不释放这个锁的前提下,再尝试获取其他锁。(如果先放下左手的筷子,再拿右手的筷子, 就不会构成死锁! 代码中加锁的时候,不要去“嵌套”。这种做法, 通用性, 不够的。 嵌套,很难避免:有些情况下,确实是需要拿到多个锁, 再进行某个操作的.)
4.循环等待. 多个线程获取多个锁的过程中,出现了循环等待。A 等待 B, B 也等待 A 或者 A 等待 B,B 等待 C, C 等待 A。(约定好加锁的顺序(比如按照编号从小到大的顺序),就可以破除循环等待了)解决死锁问题,核心思路, 破坏上述的必要条件,只要能破坏一个,就搞定!!上述破坏3 4两种 是开发中比较实用的方法,还有一些其他方案,也能解决死锁问题.但引入加锁顺序的规则(普适性高, 方案容易落地)
死锁的小结:
死锁这里非常重要的,时面试高频的问题。
"谈谈你对于死锁的理解”
死锁:
1.死锁是啥
2.死锁的三个场景
3. 死锁的危害
4.死锁的必要条件, 如何解决死锁
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) {
}
}
}
(2) 直接修饰普通⽅法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
public synchronized void methond() {
}
}
修饰一个普通方法,就可以省略"锁对象。
等价于:
(3) 修饰静态⽅法: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
synchronized 修饰普通方法, 相当于给 this 加锁 (锁对象 this)
synchronized 修饰静态方法,相当于给类对象加锁
我们重点要理解,synchronized 锁的是什么.
两个线程竞争同⼀把锁, 才会产⽣阻塞等待.
两个线程分别尝试获取两把不同的锁, 不会产⽣竞争.
- 如果我一个线程加锁,一个线程不加锁,是否会存在线程安全问题?
就不会出现锁竞争了!!!会存在线程安全问题 - 如果两个线程,针对不同的对象加锁呢?
也会存在线程安全问题
在一个程序中,锁,不一定只有一把。一个厕所,可能有多个坑位是一样的。每个坑位都有一个锁,如果你两个线程,针对不同的坑位加锁,不会产生互斥的(也称为 锁竞争/锁冲突)。只有是针对同一个坑位加锁,才有互斥。
代码中,可以创建出多个锁。具体写代码的时候,想搞几个锁,就搞几个。只有多个线程竞争同一把锁,才会产生互斥,针对不同的锁,则不会。 - 针对加锁操作的一些混淆的理解
把 count 放到一个 Test.t 对象中. 通过上述 add 方法来进行修改,加锁的时候锁对象,写作 this
synchronized (Test.class){ } 获取类对象 :

在 java 代码中就可以通过类名.class 的方式拿到这个类对象。反射 api 就是从上述对象中获取信息的。
一个 java 进程中, 某个类,只能有唯一一个类对象

synchronized 的变种写法,可以使用 synchronized 修饰方法 。synchronized (this),也可以等价把 synchronized 加到方法上。

方法中还有一个特殊的情况:
static 修饰的方法,不存在 this.(static 修饰的方法,也叫做"类方法,不是针对"实例"的方法,而是针对类的,在这个方法中, 没有 this.) 此时, synchronized 修饰 static 方法, 相当于针对类对象加锁
其他编程语言中,加锁解锁, 都是单独的方法。对比其他语言,java 的加锁操作风格是独树一帜的。Java 中为啥使用 synchronized + 代码块 做法?而不是采用 lock + unlock 函数的方式来搭配呢?
像 C++ 这种写法, 就可能会,忘记调用 unlock(unlock 没有执行到),如果忘记调用 unlock 其他线程都无法获取到这个锁, 产生严重的 bug!!
Java 采取的 synchronized, 就能确保, 只要出了 } 一定能释放锁. 无论因为 return 还是因为 异常,无论里面调用了哪些其他代码,都是可以确保 解锁 操作执行到的.
只要我写了 lock,就会立即加上 unlock 。这种说法,纯纯的,大猪蹄子行为,你给妹子保证,我这辈子只爱你一个,永远不会变心。就算你非常细心,能够确保每个 条件都加 unlock,但是你不能保证,你们组新来的实习生,也能做到这一点(各位同学们, 你们很可能就是这个实习生)
(其实在 Java 中,也有 lock/unlock 风格的锁, 一般很少使用)
但是c++没有 finally ,只能靠程序猿人工来保证了~~(很有可能,java 程序员代码早早写完,也没啥 bug, 下班回去打游戏了,C++ 程序员还在苦苦寻找哪里没有释放锁)。但是更新版本的 C++ 引入了 lock quard (守卫)这个东西,可以起到类似于 synchronized,代码块结束之后,就能自动释放锁。
5.3 Java 标准库中的线程安全类
- Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, ⼜没有任何加锁措施.(把加锁决策交给程序员)
线程不安全.多个线程,尝试修改同一个上述的对象,就很容易出现问题!! 而不是 100%,也可能你这个代码写出来之后,是没问题的,具体代码具体分析(多线程代码,稍微变换一点,就可能有不一样的结果)
• ArrayList
• LinkedList
• HashMap
• TreeMap
• HashSet
• TreeSet
• StringBuilder - 但是还有⼀些是线程安全的. 使⽤了⼀些锁机制来控制.
自带了锁, 在多线程环境下时候,能好点。也不是 100% 不出问题!! 只是概率比上面小很多,具体代码具体分析!!!(多线程代码,稍微变换一点, 就可能有不一样的结果)
像Vector,HashTable,StringBuffer 这几个类都属于是 标准库 即将弃用,不推荐使用,暂时还留着(保持和老的代码兼容)。这个时候,新的代码就不要用了,未来某一天新版本的 jdk,就把这些内容给删了。
• Vector (不推荐使⽤)
• HashTable (不推荐使⽤)
Java 早起,各位 Java 大佬还不够成熟时,引入的设定。现在的话这些设定已经被推翻了,不建议使用了.
• ConcurrentHashMap
相比于 HashTable 来说,高度优化的版本(后续详细分析)
• StringBuffer
StringBuffer 的核⼼⽅法都带有 synchronized .
一旦代码中, 使用了锁,意味着代码可能会因为锁的竞争,产生阻塞=>程序的执行效率大打折扣.
一定要思考清楚, 这个地方是否确食需要锁,不需要的时候不要乱加.
线程阻塞 =>从 cpu 上调度走,啥时候能调度回来继续执行???不好说了~~ 沧海桑田 - 还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的
• String
6. volatile 关键字
volatile 也是 java 中经典的面试题
有没有整理好的面试题集合??
有,又没有.
面试题都是贯穿在课程中的,我认为,整篇文章就是。面试绝对不是背两个题目, 就能搞定的,背后的前因后果, 来龙去脉都得交代清楚。
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)
while(flag == 0){
}
核心指令2条:
(1)load 从内存读取数据到 cpu 寄存器
(2)cmp(比较,同时会产生跳转)条件成立,继续顺序执行;条件不成立,就跳转到另外一个地址来执行。
由于上述代码,循环体是空着的.后续就没有别的指令. 当前循环旋转速度很快,短时间内出现大量的load 和 cmp 反复执行的效果~~load 执行消耗的时间,会比 cmp 多很多!!多个几干倍,上万倍!!cpu 寄存器的访问速度,也比内存速度快好几个数量级(内存访问速度比硬盘快好几个数量级)
这个执行过程中有两个关键要点:
(1)上述执行过程中,load 速度非常慢,load 操作开销远远超过 条件跳转 !! 执行 一次 load 消耗的时间,顶几干次,上万次 cmp 执行的时间
(2)另外,JVM 还发现每次 load 执行的结果,其实是一样的(要想输入, 过几秒才能输入,在这几秒之内,已经执行了不知道多少次循环(上百亿) )
干脆,JVM 就把上述 load 操作优化掉了:只是第一次真正进行 load,后续再执行到对应的代码,就不再真正 load 了,而是直接读取刚才已经 load 过的寄存器中的值了。把速度慢的给优化掉了,使程序执行速度更快了。
编译器优化
主流编程语言, 编译器的设计者 (对于 Java 来说,谈到的编译器包括 javac 和 jvm)考虑到一个问题: 实际上写 代码的程序员,水平是参差不齐的(差距很大的)。虽然有的程序员水平不高,写的代码效率比较低,编译器在编译执行的时候,分析理解现有代码的意图和效果,然后自动对这个代码进行调整和优化,在确保程序执行逻辑不变的前提下,提高程序的效率。
编译器优化 的效果是很明显~~服务器开启优化,启动时间可能 10min 左右,如果不开启优化,启动时间可能 1h 以上(这个服务器,启动好了要从硬盘上加载 100 多个 G 的数据,cpu 和 IO都是密集的)。
但是大前提是"程序的逻辑不变”。大多数情况下,编译器优化, 都可以做到"逻辑不变"前提,但是在有些特定场景下,编译器优化可能出现"误判"导致逻辑发生改变(想让编译器正确保持,没那么容易。如果是单线程下还好,如果是多线程下,很容易出现误判的!!!)。
优化固然挺好, 是提高效率了.但是因为优化引入 bug,也不合适。(某某公司进行"优化”,其实就是裁员。裁员裁到大动脉了。)
t1 读的是⾃⼰⼯作内存中的内容.上述把 load 优化掉, 导致后续当 t2 对 flag 变量进⾏修改, 此时 t1 感知不到 flag 的变化.(就没有后续 load)
小结: 上述问题本质上还是编译器优化引起的.t1 读的是⾃⼰⼯作内存中的内容.优化掉 load 操作之后,使 t1 线程感知不到 t2 线程的修改。"内存可见性"问题
内存可见性,高度依赖编译器的优化的具体实现,编译器啥时候触发优化,啥时候不触发优化,不好说!!!
上述代码如果稍微改动一点,就可能截然不同了:
如果上述代码中,循环体内存在 IO 操作或者 阻塞操作(sleep),这就会使循环的旋转速度大幅度降低了。
IO 操作:
(1)load,cmp,I0操作 中 I0操作占大头!!此时就没有优化 load 的必要了。(2)另外, IO 操作是不能被优化掉的!!刚才 load 被优化的前提是反复 load 的结果相同,IO 操作,注定是反复执行的结果是不相同的
阻塞操作(sleep):
不加 sleep,一秒钟循环上百亿次,load 操作的整体开销非常大,优化的迫切程度就更高。加了 sleep, 一秒钟循环 1000 次load 整体开销就没那么大了.优化的迫切程度就降低了.
所以 内存可见性 问题,其实是个高度依赖编译器优化的问题。啥时候触发这个问题(优化),啥时候不触发(不优化),不好说。更希望,让咱们代码能够确保,无论当前这个线程代码咋写的,都不要出现这种内存可见性问题。
java 提供了 volatile (强制读取内存!!开销是大了,效率是低了,数据的准确性/逻辑的正确性,提高了),就可以使上述的优化被强制关闭,可以确保每次循环条件都会重新从内存中读取数据了。更多的时候,快没有准更重要的。确实也有时候需要快,不需要准,就不加 volatile。引入 volatile 关键字, 把选择权,交给了程序猿自己。
谈到 volatile ->谈到一个词:JMM
Java 内存模型JMM (Java Memory Model)
Java 规范文档上提到的一个抽象的概念. Java 官方文档的术语.
每个线程,有一个自己的“工作内存”(work memory),同时这些线程共享同一个"主内存”(main memory)。当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中。后续另一个线程修改,也是先修改自己的工作内存,拷贝到主内存里。由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变化。
咱们前面讲的是,把读内存的操作,优化成读寄存器操作。同样的意思!!!这里的“工作内存”其实不是咱们说的内存,CPU 的寄存器和缓存,统称为 work memory(工作内存)!!内存专业术语 就是 main memory,“主内存” 才是咱们真正所说的内存。
为啥引入 JMM (主内存,工作内存) 这一套抽象的概念, 而不是直接说 CPU 寄存器?
主要是为了"跨平台”!为了能够兼容不同的硬件设备。不同的 cpu, 用来缓存上述内存数据的区域,可能不同的.Java 程序员不需要关心,硬件(CPU) 差别的。不同 cpu寄存器情况不一样,缓存有没有也不一样, 缓存有几级也不一样…变数比较多… 搞 Java 的大佬希望咱们不必关注这些细节的。而且,作为规范文档,要严谨表述,每次都说 优化到 cpu 寄存器或缓存中… (非常拗口)
Java 文档为了严谨 也为了表述的没那么绕,就引入了 工作内存 这个概念,代指 cpu 寄存器 + 缓存这一套东西。存储数据, 不只是有内存,还有 外存 (硬盘), 还有 cpu 寄存器,cpu 上还有缓存
面试的时候,被问到内存可见性问题:
就可以按照第一种方式(CPU 寄存器 和 内存)或者第二种方式(JMM:主内存和工作内存)来表述。
我们要知道:内存可见性问题是咋回事,怎么来的, 原因(CPU 寄存器 和 内存/JMM),volatile 能够解决的问题是啥样的, 啥样的问题不能解决。
如果给 flag 加上 volatile
public volatile int flag = 0;
// 执⾏效果
// 当⽤⼾输⼊⾮0值时, t1 线程循环能够⽴即结束.
类似的,上述内存可见性问题,使用 synchronized 也能一定程度的解决~~ 引入 synchronized 其实是因为 加锁操作 本身太重量了.相比于 load 来说, 开销更大,编译器自然就不会对 load 优化了.(和加上sleep/io 操作一样)
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.
volatile 这个关键字, 能够解决内存可见性问题引起的线程安全问题,但是不具备原子性这样的特点~~
synchronized 和 volatile 是两个不同的维度:
(两个线程修改)(一个线程读,一个线程修改)
7. wait 和 notify
线程的 等待通知 机制(协调线程之间的执行逻辑的顺序的)。
由于系统内部,线程之间是抢占式执⾏的,随机调度, 因此线程之间执⾏的先后顺序难以预知。但是实际开发中有时候我们希望合理的协调多个线程之间的执⾏先后顺序。程序员也是有手段干预的、通过"等待”的方式,能够让线程一定程度的按照咱们预期的顺序来执行。无法主动让某个线程被调度,但是可以主动让某个线程等待 (就给别的线程机会了)。
球场上的每个运动员都是独⽴的 “执⾏流” , 可以认为是⼀个 “线程”.
⽽完成⼀个具体的进攻得分动作, 则需要多个运动员相互配合, 按照⼀定的顺序执⾏⼀定的动作, 线程1 先 “传球” , 线程2 才能 “扣篮”.
完成这个协调⼯作, 主要涉及到三个⽅法
• wait() / wait(long timeout): 让当前线程进⼊等待状态.
• notify() / notifyAll(): 唤醒在当前对象上等待的线程.
注意: wait, notify, notifyAll 都是 Object 类的⽅法.
join和wait区别:
join 是等另一个线程彻底执行完, 才继续走。
wait 是等到另一个线程执行 notify, 才继续走(不需要另一个线程执行完),更精细的控制线程之间的执行顺序了。
等待通知可以安排线程之间的 执行顺序,另外,wait notify 也能解决"线程饿死"的问题:
"线程饿死” 不是 死锁。只是因为 某个线程 频繁获取释放锁,由于获取的太快,以至于其他线程捞不着 cpu 资源.
当多个线程竞争一把锁的时候,获取到锁的线程如果释放了,其他是哪个线程拿到锁?
不确定(随机调度)
操作系统的调度是随机的,其他线程都属于在锁上阻塞等待,是阻塞状态,当前这个释放锁的线程,是就绪状态,这个线程有很大的概率能够再次拿到这个锁
系统中的线程调度无序,上述情况很可能出现(不至于长时间一直进进出出,进出个几十次 还是有可能),不会像死锁那样卡死,但是可能会卡住一下下,对于程序的效率,肯定是影响的。等待通知机制,就能够解决上述问题:拿到锁的线程 通过条件,判定看当前逻辑是否能够执行.如果时机还不成熟的时候,不能执行, 就主动 wait (使用 wait 主动进行阻塞等待),就把执行的机会让给别的线程了,避免该线程进行一些无意义的重试。等到后续条件时机成熟了(需要其他线程进行通知的),再让阻塞的线程被唤醒。
7.1 wait()⽅法
wait是 Object 类提供的方法,任何一个对象都有这个方法
wait 做的事情:
• 释放当前的锁
• 使当前执⾏代码的线程进⾏等待. (把线程放到等待队列中)
(第1件和第2件同时进行)
• 满⾜⼀定条件时被唤醒, 重新尝试获取这个锁.
wait 要搭配 synchronized 来使⽤. 脱离 synchronized 使⽤ wait 会直接抛出异常.
代码进入 wait,就会先释放锁,并且阻塞等待。如果其他线程做完了必要的工作,调用 notify 唤醒这个 wait 线程,wait 就会解除阻塞, 重新获取到锁.继续执行并返回.
wait 结束等待的条件:
• 其他线程调⽤该对象的 notify ⽅法.
• wait 等待时间超时 (wait ⽅法提供⼀个带有 timeout 参数的版本, 来指定等待时间).
• 其他线程调⽤该等待线程的 interrupted ⽅法, 导致 wait 抛出 InterruptedException 异常.
- wait提供了2个版本
死等这样的策略一般来说是下策.没有回旋的余地了。
工程上有个术语"鲁棒性”:
"你对他越粗鲁,他表现的越棒!
商业程序,也是要考虑到 鲁棒性 ~~ 容错能力, 即使出现一些错误,也不会有太大影响,甚至能自动恢复。- Java 标准库中,涉及到阻塞的方法,都可能会抛出 InterruptedException
wait 进入阻塞之后, 需要通过 notify 唤醒.默认情况下,wait 的阻塞也是"死等!设定等待的时间上限 (超时时间)
代码⽰例: 观察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()⽅法的线程将程序执⾏完,也就是退出同步代码块之后才会释放对象锁。
- 使用 wait 的时候,阻塞其实是有两个阶段的:
1.WAITING 的阻塞, 通过 wait 等待其他线程的通知.
2.BLOCKED 的阻塞,当收到通知之后,就会重新尝试获取锁, 重新尝试获取锁,很可能又会遇到锁竞争- wait 和 notify 彼此之间是通过 object 对象联系起来的,必须是同一个对象才能唤醒!
object1.wait()和object2. notify() :此时无法唤醒的!必须是两个对象一致才能唤醒!!!
如果有俩 wait 是不同的对象调用的,此时 notify 使用的是哪个对象,就是唤醒哪个对象的wait。如果这俩 wait 是同一个对象调用的呢??随机唤醒其中一个.- 如果有多个线程都在进行 wait (同一个对象上 wait ),此时进行 notify 是随机唤醒其中的一个线程
咱们在多线程中谈到的"随机" 其实不是 “数学上,概率均等的随机”
无法预测~~
取决于调度器, 怎么进行调度。调度器里,其实不是"概率均等的唤醒"内部也是有一套规则的,这套规则,对于程序员是"透明"的。程序员做的,就是不能依赖这里的顺序。
mysql 的时候,select 查询一个数据,得到的结果集,是按照怎样的顺序呢?(是按照 id 的顺序, 时间的顺序,排列的嘛?)mysql 就没有这样的承诺,必须加上 order by。
- 这里唤醒等待的线程同样也是,需要先拿到锁,再进行 notify(属于是 Java 中给出的限制)
wait 操作必须要搭配锁来进行(放到 synchronized里) 是因为要释放锁, 前提是先加上锁.
notify 操作,原则上说,其实可以不放到 synchronized 里(不涉及到加锁解锁操作)但是 Java 中特别约定要把 notify 放到synchronized 里头了(线程,锁, 都是操作系统本身支持的特性,wait 和 notify 在操作系统中, 也有原生的对应的 api,操作系统原生 apì 中,wait 必须搭配锁使用,notify 则不需要.)。
通过另一个线程,调用 notify 来唤醒阻塞的线程的 运用示例:
- 借助 scanner 控制阻塞,用户输入之前,都是阻塞状态:
- 借助 sleep 阻塞通知:
代码⽰例: 使⽤notify()⽅法唤醒线程
• 创建 WaitTask 类, 对应⼀个线程, run 内部循环调⽤ wait.
• 创建 NotifyTask 类, 对应另⼀个线程, 在 run 内部调⽤⼀次 notify
• 注意, WaitTask 和 NotifyTask 内部持有同⼀个 Object locker.。WaitTask 和 NotifyTask 要想配合就需要搭配同⼀个 Object.
static class WaitTask implements Runnable {
private Object locker;
public WaitTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
while (true) {
try {
System.out.println("wait 开始");
locker.wait();
System.out.println("wait 结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
static class NotifyTask implements Runnable {
private Object locker;
public NotifyTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
System.out.println("notify 开始");
locker.notify();
System.out.println("notify 结束");
}
}
}
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(new WaitTask(locker));
Thread t2 = new Thread(new NotifyTask(locker));
t1.start();
Thread.sleep(1000);
t2.start();
}
7.3 notifyAll()⽅法
notify⽅法只是唤醒某⼀个等待线程. 使⽤notifyAll⽅法可以⼀次唤醒所有的等待线程.
假设有很多个线程,都使用同一个对象 wait,针对这个对象进行 notifyAll, 此时就会全都唤醒~~
但是注意,这些线程在wait返回的时候,要重新获取锁,就会因为锁的竞争,使这些线程实际上是一个一个串行执行的.(谁先拿到锁, 谁后拿到, 也是不确定的)
相比之下,还是更倾向于使用 notify,notifyAll, 全都唤醒之后,不太好控制
范例:使⽤notifyAll()⽅法唤醒所有等待线程, 在上⾯的代码基础上做出修改.
• 创建 3 个 WaitTask 实例. 1 个 NotifyTask 实例.
static class WaitTask implements Runnable {
// 代码不变
}
static class NotifyTask implements Runnable {
// 代码不变
}
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(new WaitTask(locker));
Thread t3 = new Thread(new WaitTask(locker));
Thread t4 = new Thread(new WaitTask(locker));
Thread t2 = new Thread(new NotifyTask(locker));
t1.start();
t3.start();
t4.start();
Thread.sleep(1000);
t2.start();
}
此时可以看到, 调⽤ notify 只能唤醒⼀个线程.
• 修改 NotifyTask 中的 run ⽅法, 把 notify 替换成 notifyAll
public void run() {
synchronized (locker) {
System.out.println("notify 开始");
locker.notifyAll();
System.out.println("notify 结束");
}
}
此时可以看到, 调⽤ notifyAll 能同时唤醒 3 个wait 中的线程
注意: 虽然是同时唤醒 3 个线程, 但是这 3 个线程需要竞争锁. 所以并不是同时执⾏, ⽽仍然是有先有后的执⾏.
理解 notify 和 notifyAll
notify 只唤醒等待队列中的⼀个线程. 其他线程还是乖乖等着
notifyAll ⼀下全都唤醒, 需要这些线程重新竞争锁
7.4 wait 和 sleep 的对⽐(⾯试题)
其实理论上 wait 和 sleep 完全是没有可⽐性的,因为⼀个是⽤于线程之间的通信的,⼀个是让线程阻塞⼀段时间,唯⼀的相同点就是都可以让线程放弃执⾏⼀段时间.
wait 提供了一个 带有超时时间的版本,sleep 也能指定时间~~都是时间到, 就继续执行,解除阻塞了
wait 和 sleep 都可以被提前唤醒(虽然时间没到,但是也能提前唤醒)
wait 通过 notify 唤醒,sleep 通过 interrupt 唤醒。
使用 wait,最主要的目标,一定是不知道要等多少时间的前提下使用的,所谓的超时时间,其实是“兜底的”(大多数情况下,wait 都是在超时时间之内就被唤醒了)。
使用 sleep,一定是知道要等多少时间的前提下使用的,虽然能提前唤醒,但是通过异常唤醒,这个操作不应该作为"正常的业务流程”.(sleep 提前唤醒,是通过异常的方式,说明程序应该是出现一些特殊的情况了。正常的业务流程不应该依赖异常处理,异常处理认为是在进行一些补救措施)
经典面试题 sleep 和 wait 的区别:
1.wait 的设计就是为了提前唤醒的.超时时间,是"后手"(B计划)
sleep 的设计就是为了到时间唤醒.虽然也可以通过 Interrupt() 提前唤醒,这样的唤醒是会产生异常的(程序出现不符合预期 的情况, 才称为"异常")
2.wait 需要搭配锁来使用. wait 执行时会先释放锁。sleep 不需要搭配锁使用.当把 sleep 放到 synchronized 内部时,不会释放锁(抱着锁睡的)
另外,实际开发中,wait 比 sleep 用的更多的。
当然为了面试的⽬的,我们还是总结下:
(1) wait 需要搭配 synchronized 使⽤. sleep 不需要.
(2)wait 是 Object 的⽅法 sleep 是 Thread 的静态⽅法.
#1-7小结
讲到这里,关于多线程, 一些基础用法,就交代的差不多了
围绕 Thread 类,各种用法来展开的
多线程编程,其实主要就是在使用到上面讲的这些内容~
多线程, 非常非常重要:面试中,占比非常大,工作中,非常常用的!!!
8. 多线程案例
8.1 单例模式
单个实例. 在一个 java 进程中, 要求指定的类,只能有唯一一个实例。(尝试 new 多个实例的时候, 就会直接编译报错)
单例模式是校招中最常考的设计模式之⼀.
啥是设计模式?
设计模式好⽐象棋中的 “棋谱”. 红⽅当头炮, ⿊⽅⻢来跳. 针对红⽅的⼀些⾛法, ⿊⽅应招的时候有⼀些固定的套路. 按照套路来⾛局势就不会吃亏.
软件开发中也有很多常⻅的 “问题场景”. 针对这些问题场景, ⼤佬们总结出了⼀些固定的套路. 按照这个套路来实现代码, 也不会吃亏.
单例模式能保证某个类在程序中只存在唯⼀⼀份实例, ⽽不会创建出多个实例.
这⼀点在很多场景上都需要. ⽐如 JDBC 中的 DataSource 实例就只需要⼀个.
什么场景适合使用单例模式?
代码中的有些对象,本身就不应该是有多个实例的.从业务角度就应该是单个实例.
单例模式具体的实现⽅式有很多. 最常⻅的是 “饿汉” 和 “懒汉” 两种.(掌握这两个, 应付面试 +日常开发)
饿汉模式
类加载的同时, 创建实例.
class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
在这个类被加载的时候,就会初始化这个 静态成员。实例创建的时机非常早,就使用"饿汉!
万一,其他代码又 new 了这个类的实例咋办呢?需要禁止外部代码来创建该类的实例
虽然构造方法是 private,但是能否在类外面通过 反射 拿到私有构造方法创建实例??
原则上来说,可以做到。但是, 实际开发中, 反射不敢乱用的!!!反射属于非常规的编程,特殊场景下的特殊解决方案!!!! 使用反射要付出很大的代价(会严重影响代码的可读性和封装性)
类似于,通常情况下,你肯定没法直接闯入别人家里。但是, 你不能进, 不代表jc 蜀黍不能进,如果jc 蜀黍到处乱闯,当然也是不行的
代码中随便滥用反射,是非常糟糕的~~
懒汉模式
懒汉模式-单线程版
类加载的时候不创建实例. 第⼀次使⽤的时候才创建实例.(如果不使用了,就会把创建实例的代价就节省下来了)
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
在计算机中,懒 的思想,就非常有意义
如果代码中存在多个单例类,使用饿汉模式,就会导致这些实例都是在程序启动的时候扎堆的创建的.可能把程序启动时间拖慢.
如果是懒汉模式,啥时候首次调用, 调用时机是分散的. 化整为零, 用户不太容易感知到"卡顿
如果是首次调用 getlnstance, 那么此时 instance 引用为 null,就会进入 if 条件,从而把实例创建出来,如果是后续再次调用 getlnstance, 由于 instance 已经不再是 null,此时不会进入if, 直接返回之前创建好的引用了。这样设定,仍然可以保证,该类的实例是唯一一个。与此同时,创建实例的时机就不是程序驱动时了,而是第一次调用getlnstance的时候
这个操作的执行时机就看你程序的实际需求。大概率要比饿汉这种方式要晚一些,甚至有可能整个程序压根用不到这个方法,也就把创建的操作给省下了
有的程序, 可能是根据一定的条件,来决定是否要进行某个操作,进一步的来决定创建某个实例
比如,肯德基有个操作“疯狂星期四”,对于 肯德基 点餐系统来说,就可以判定今天星期几。如果是星期四,才加载 疯狂星期四 相关的逻辑和数据,如果不是星期四,就不用加载了(节省了一定的开销)
懒汉模式-多线程版
上述的代码,饿汉模式和懒汉模式,是否是线程安全的?? 如果在多个线程中, 并发的调用 getlnstance, 这两个代码是否是线程安全的呢??
饿汉: getlnstance 直接返回 Instance 实例. 这个操作本质上就是"读操作"。多个线程读取同一个变量,是线程安全的!!
懒汉: 线程不安全,在多线程环境下可能会创建出多个实例!!在懒汉模式中,代码有读也有写,如果 t1 和 t2 按照下列顺序来执行,就会出现问题!!
上⾯的懒汉模式的实现是线程不安全的.
线程安全问题发⽣在⾸次创建实例时. 如果在多个线程中同时调⽤ getInstance ⽅法, 就可能导致创建出多个实例.
⼀旦实例已经创建好了, 后⾯再多线程环境调⽤ getInstance 就不再有线程安全问题了(不再修改instance 了)
加上 synchronized 可以改善这⾥的线程安全问题.
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
懒汉模式-多线程版(改进)
多线程代码, 其实是非常复杂的,代码稍微变换一点,结论就截然不同!!
因此可千万不要以为,代码中写了 synchronized 就一定线程安全,不写 synchronized 就一定线程不安全!!!
一定要具体问题具体分析.要分析这个代码在各种调度执行顺序下可能的情况,确保每个情况都是正确的!!
此处要想让代码执行正确,其实是需要把 if 和 new 两个操作,打包成一个原子的!!
更加合理的做法,应该是把 synchronized 套到if 外头~~
但上述代码仍然存在问题~~
效率非常低!!!
如果 Instance 已经创建过了,此时后续再调用 getlnstance 就都是直接返回 Instance 实例了(此处的操作就是纯粹的读操作了,也就不会有线程安全问题了)
此时,针对这个已经没有线程安全问题的代码,仍然是每次调用都先加锁再解锁,此时,效率就非常低了!!!加锁就意味着可能会产生阻塞,一旦线程阻塞,啥时候能解除,就不知道了(你可以认为,只要一个代码里加锁了,基本就注定和“高性能"无缘)
在需要加锁的时候才加锁,不该加锁,不能随便乱加。所以除了 StringBuffer 还提供 StringBuilder, 除了 Vector 还提供 ArrayList
这个代码仍然有点问题~~
指令重排序,引起的线程安全问题
指令重排序,也是编译器优化的一种方式,调整原有代码的执行顺序,保证逻辑不变的前提下,提高程序的效率
instance = new singletonLazy();
这行代码,其实可以拆成三个大的步骤,(不是三个指令)
1.申请一段内存空间
2.在这个内存上调用构造方法,创建出这个实例
3.把这个内存地址赋值给 |nstance 引用变量
正常情况下,上述代码是按照 123 的顺序来执行的,但是编译器也可能会优化成132的顺序来执行,无论是123 还是132在单线程下都是可以的~~
1 就相当于是你买了个房子,2 就相当于给房子装修,3 就相当于你拿到房子的钥匙。123 拿到钥匙之后,就得到了装修好的房子. 称为"精装房",132你先拿钥匙,然后自己负责装修.称为"毛坏房"。如果你出去买房子,这两种情况都会存在!!!
但是, 如果是在多线程下,指令重排序,就可能引入问题了!!如果你出去买房子,这两种情况都会存在!!!
t1 按照132 的方式来执行这里的 new 操作:
上述代码中,由于 t1 线程执行完13之后,调度走,此时 instance 指向的是一个 非 null 的,但是未初始化的对象。此时 t2 线程判定 instance == null 不成立,就会直接 return.如果 t2 继续使用 instance 里面的属性或者方法,就会出现问题(此时这里的属性都是未初始化的"全 0"值). 就可能会引起代码的逻辑出现问题.
解决上述问题,核心思路, 还是 volatile
volatile 有两个功能
1.保证内存可见性,每次访问变量必须都要重新读取内存,而不会优化到寄存器/缓存中
2.禁止指令重排序.针对这个呗 volatile 修饰的变量的读写操作相关指令,是不能被重排序的!!

上述的 123这三个要点,标准的面试回答
以下代码在加锁的基础上, 做出了进⼀步改动:
• 使⽤双重 if 判定, 降低锁竞争的频率.
• 给 instance 加上了 volatile.
class Singleton {
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这个代码是一个经典高频面试题,非常重要,咱们同学们最近这几年秋招也会经常遇到这个问题~~
这个题并不简单。加上这三个点,怎么加,容易答上来,为啥要这么加,每个地方解决的什么问题,要想给面试官解释清楚,没那么容易的!!
(1)写博客,提前梳理好你都要说啥。
(2)给面试官讲的过程中,一定要多画图
线下面试,可以自带纸笔;线上面试,一般面试系统也会支持画图功能,可以共享屏幕。有的面试系统 牛客网面试系统,自身就支持画图,包括 腾讯会议,也支持画图
多去画!!!
目前来看线上面试越来越多,越是好的公司,越是线上面试
面试中考察的方法非常简单:
让你现场写一个单例模式的代码
这个代码咋写?直接就写成现在这个模样嘛??
正确的写法:
1.先写一个不带线程安全的单例模式
2.思索片刻, 线程不安全,把锁加上
3.再次思索片刻,加上 if(双重 if)
4. 再次思考片刻, 加上 volatile
意味着这个题不是你提前准备好,是你现场想出来的,面试官就会觉得,你这边很可能没有准备过/很久之前看的,即使如此,能够通过已经掌握的知识,推理出一些结论
一次写出最终版本,再面试官眼里,他觉得这个问题,你正好准备过,此时说明这个题目就考察不出来啥,这题不算,谈下一话题(面试的时候,大部分面试官,看到你的回答有问题的时候,都会进一步去问的)
人生如戏,全靠演技
把问题引导到你自己擅长的角度,把控整个面试的节奏~~
理解双重 if 判定 / volatile:
加锁 / 解锁是⼀件开销⽐较⾼的事情. ⽽懒汉模式的线程不安全只是发⽣在⾸次创建实例的时候. 因此后续使⽤的时候, 不必再进⾏加锁了.
外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了.
同时为了避免 “内存可⻅性” 导致读取的 instance 出现偏差, 于是补充上 volatile .
当多线程⾸次调⽤ getInstance, ⼤家可能都发现 instance 为 null, 于是⼜继续往下执⾏来竞争锁, 其中竞争成功的线程, 再完成创建实例的操作.
当这个实例创建完了之后, 其他竞争到锁的线程就被⾥层 if 挡住了. 也就不会继续创建其他实例.
- 有三个线程, 开始执⾏ getInstance , 通过外层的 if (instance == null) 知道了实例还没有创建的消息. 于是开始竞争同⼀把锁.
- 其中线程1 率先获取到锁, 此时线程1 通过⾥层的 if (instance == null) 进⼀步确认实例是否已经创建. 如果没创建, 就把这个实例创建出来.
- 当线程1 释放锁之后, 线程2 和 线程3 也拿到锁, 也通过⾥层的 if (instance == null) 来确认实例是否已经创建, 发现实例已经创建出来了, 就不再创建了
- 后续的线程, 不必加锁, 直接就通过外层 if (instance == null) 就知道实例已经创建了,从⽽不再尝试获取锁了. 降低了开销.
8.2 阻塞队列
之前学的队列,其实是最基础的队列.实际开发中,针对队列还有很多变种
把阻塞队列单独包装成服务器程序,并且使用单独的机器(集群)来部署,这样的队列称为"消息队列"(MQ)
阻塞队列: 数据结构
消息队列: 基于阻塞队列实现服务器程序
举个例子
由于消息队列这样的数据结构(本体是数据结构)。太好用了,因此实际开发中,经常会把这样的数据结构封装成单独的服务器程序, 单独部署这样的服务器程序,同样也称为消息队列~~消息队列能够起到的作用,就是实现生产者消费者模型
看是需要在一个进程内(直接使用阻塞队列即可),实现生产者消费者模型
还是需要分布式系统中(需要使用单独部署的消息队列服务器),实现生产者消费者模型(一种解决多线程的问题的典型方案)
阻塞队列是什么
阻塞队列是⼀种特殊的队列. 也遵守 “先进先出” 的原则.
阻塞队列能是⼀种线程安全的数据结构, 并且具有以下特性:
• 当队列满的时候, 继续⼊队列就会阻塞, 直到有其他线程从队列中取⾛元素.
• 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插⼊元素.
阻塞队列的⼀个典型应⽤场景就是 “⽣产者消费者模型”. 这是⼀种⾮常典型的开发模型.
⽣产者消费者模型
⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。
⽣产者和消费者彼此之间不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤等待消费者处理,直接扔给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取.
⽐如过年⼀家⼈⼀起包饺⼦. ⼀般都是有明确分⼯, ⽐如⼀个⼈负责擀饺⼦⽪, 其他⼈负责包. 擀饺⼦⽪的⼈就是 “⽣产者”, 包饺⼦的⼈就是 “消费者”.
擀饺⼦⽪的⼈不关⼼包饺⼦的⼈是谁(能包就⾏, ⽆论是⼿⼯包, 借助⼯具, 还是机器包), 包饺⼦的⼈也不关⼼擀饺⼦⽪的⼈是谁(有饺⼦⽪就⾏, ⽆论是⽤擀⾯杖擀的, 还是拿罐头瓶擀, 还是直接从超市买的)
包饺子的流程:
1.和面(一般都是一个人负责,没法多线程完成)
(这俩环节就可以多线程完成了)
2.擀饺子皮
3.包饺子
为了解决这个问题,就可以分工协作。引入生产者消费者模型方案,更好的解决上述问题
生产者消费者模型,在开发中主要有两方面的意义:
1.阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能⼒. (削峰填⾕)
⽐如在 “秒杀” 场景下, 服务器同⼀时刻可能会收到⼤量的⽀付请求. 如果直接处理这些⽀付请求, 服务器可能扛不住(每个⽀付请求的处理都需要⽐较复杂的流程). 这个时候就可以把这些请求都放到⼀个阻塞队列中, 然后再由消费者线程慢慢的来处理每个⽀付请求.这样做可以有效进⾏ “削峰”, 防⽌服务器被突然到来的⼀波请求直接冲垮.
2.阻塞队列也能使⽣产者和消费者之间 解耦.
实际开发中,经常会涉及到"分布式系统”.服务器整个功能不是由一个服务器全部完成的.而是每个服务器负责一部分功能. 通过服务器之间的网络通信,最终完成整个功能~~
引入生产者消费者模型,就可以降低上述的耦合~~
生产者消费者模型付出的代价:
1)引入队列之后,整体的结构会更复杂,
此时, 就需要更多的机器, 进行部署. 生产环境的结构会更复杂,管理起来更麻烦
2)效率会有影响
标准库中的阻塞队列
在 Java 标准库中内置了阻塞队列. 如果我们需要在⼀些程序中使⽤阻塞队列, 直接使⽤标准库中的即可.
• BlockingQueue 是⼀个接⼝. 真正实现的类是 LinkedBlockingQueue.
• put ⽅法⽤于阻塞式的⼊队列, take ⽤于阻塞式的出队列.
• BlockingQueue 也有 offer, poll, peek 等⽅法, 但是这些⽅法不带有阻塞特性.
使用的 put 和 offer 一样都是入队列.但是 put 是带有阻塞功能, offer没带阻塞 (队列满了会返回结果)
take 方法用来出队列,也是带有阻塞功能的.
阻塞队列没有提供带有阻塞功能的获取队首元素的方法
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// ⼊队列
queue.put("abc");
// 出队列. 如果没有 put 直接 take, 就会阻塞.
String elem = queue.take();
⽣产者消费者模型
public static void main(String[] args) throws InterruptedException {
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<Integer>();
Thread customer = new Thread(() -> {
while (true) {
try {
int value = blockingQueue.take();
System.out.println("消费元素: " + value);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "消费者");
customer.start();
Thread producer = new Thread(() -> {
Random random = new Random();
while (true) {
try {
int num = random.nextInt(1000);
System.out.println("⽣产元素: " + num);
blockingQueue.put(num);
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "⽣产者");
producer.start();
customer.join();
producer.join();
}
直接运行,生产者和消费者两个线程的速度,旗鼓相当,所以很难见到阻塞效果。
在生产者中加 sleep,看到的是队列满 还是空?? 空!!
阻塞队列实现
学习编程,大概分成这么几个层次: 1.掌握基本使用方法 2.理解背后的原理 3.能够自己实现出来类似的.
自己实现阻塞队列:
- 先实现普通队列
基于数组来实现(环形队列)
泛型,平时开发中很少用到. 实现库/框架的人,可能会用到泛型。面试的时候,如果人家让你现场写代码,最好不要写泛型的.- 再加上线程安全
如何加锁,锁放到哪里合适?加了锁之后是否还有问题?
都需要我们仔细考虑!!(多线程的难点),确保所有执行顺序下程序的结果都是对的!!
比如:这个 put 正好是添加最后一个元素,如果代码是这种顺序执行,这个代码就会多加一个元素- 再加上阻塞功能
还需要有其他线程唤醒!!
队列不满,就可以唤醒了!!!翻译翻译,什么叫做"队列不满",出队列成功,就是队列不满!!!
对于满了的情况的阻塞,是在出队列成功后唤醒。
队列空了,再出队列,同样也需要阻塞, 同样是在另一个入队列成功后的线程中唤醒
薛定谔的队列:
比如有若干线程使用这个队列。要么所有的线程阻塞在 put, 要么所有的线程阻塞在 take,不可能有一些线程阻塞在 put, 一些阻塞在 take
咱们的队列,一定是"要么空,要么满"不能既是空,又是满(薛定谔的队列)
上述代码还有一个关键环节~~

比如, 我每天早上闹钟 定 7:30(上学),我有可能, 6:30 就醒了 要做的第一件事,拿出手机, 看看几点了每次被唤醒, 都应该确认一下,看看当前是否就应该要继续执行,还是再等待一会!!
而且 java 标准库推荐咱们, 使用 wait 要搭配 while.多一次确认操作!!(N次)
• 通过 “循环队列” 的⽅式来实现.
• 使⽤ synchronized 进⾏加锁控制.
• put 插⼊元素的时候, 判定如果队列满了, 就进⾏ wait. (注意, 要在循环中进⾏ wait. 被唤醒时不⼀定队列就不满了, 因为同时可能是唤醒了多个线程).
• take 取出元素的时候, 判定如果队列为空, 就进⾏ wait. (也是循环 wait)
public class BlockingQueue {
private int[] items = new int[1000];
private volatile int size = 0;
private volatile int head = 0;
private volatile int tail = 0;
public void put(int value) throws InterruptedException {
synchronized (this) {
// 此处最好使⽤ while.
// 否则 notifyAll 的时候, 该线程从 wait 中被唤醒,
// 但是紧接着并未抢占到锁. 当锁被抢占的时候, 可能⼜已经队列满了
// 就只能继续等待
while (size == items.length) {
wait();
}
items[tail] = value;
tail = (tail + 1) % items.length;
size++;
notifyAll();
}
}
public int take() throws InterruptedException {
int ret = 0;
synchronized (this) {
while (size == 0) {
wait();
}
ret = items[head];
head = (head + 1) % items.length;
size--;
notifyAll();
}
return ret;
}
public synchronized int size() {
return size;
}
// 测试代码
public static void main(String[] args) throws InterruptedException {
BlockingQueue blockingQueue = new BlockingQueue();
Thread customer = new Thread(() -> {
while (true) {
try {
int value = blockingQueue.take();
System.out.println(value);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "消费者");
customer.start();
Thread producer = new Thread(() -> {
Random random = new Random();
while (true) {
try {
blockingQueue.put(random.nextInt(10000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "⽣产者");
producer.start();
customer.join();
producer.join();
}
}


8.3 定时器
定时器是什么
定时器也是软件开发中的⼀个重要组件. 类似于⼀个 “闹钟”. 达到⼀个设定的时间之后, 就执⾏某个指定好的代码.
定时器是⼀种实际开发中⾮常常⽤的组件.
比如 写博客, 定时发布. 比如每天早上 9:00 发布, 可能有更高的访问量。 就可以使用定时功能~~
(这个时间点,很多程序员在上班的路上或者刚到公司,要刷一会手机啥的,摸摸鱼,再开始工作)如果你是头一天晚上发布,到了第二天早上,你的博客就已经被其他博客给踩到下面了
⽐如 ⽹络通信中, 如果对⽅ 500ms 内没有返回数据, 则断开连接尝试重连.
⽐如 ⼀个 Map, 希望⾥⾯的某个 key 在 3s 之后过期(⾃动删除).
类似于这样的场景就需要⽤到定时器.
标准库中的定时器
• 标准库中提供了⼀个 Timer 类. Timer 类的核⼼⽅法为 schedule .
• schedule 包含两个参数. 第⼀个参数指定即将要执⾏的任务代码, 第⼆个参数指定多⻓时间之后执⾏(单位为毫秒).
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
}, 3000);

定义一个 timer 添加多个任务,每个任务同时会带有一个时间!
什么样的情况能够使用 lambda?得是函数式接口才行~~ interface 里头只能有这一个方法import java.util.TimerTask; public class Demo { public static void main(String[] args) throws InterruptedException { Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { // 时间到了之后, 要执行的代码 System.out.println("hello timer 3000"); } }, 3000); timer.schedule(new TimerTask() { @Override public void run() { System.out.println("hello timer 2000"); } }, 2000); timer.schedule(new TimerTask() { @Override public void run() { System.out.println("hello timer 1000"); } }, 1000); System.out.println("hello main"); Thread.sleep(5000); timer.cancel(); } }timer里面执行完了也不结束?
timer 不知道你的代码是否还会添加新的任务进来,处在"严阵以待"的状态
需要使用 cancel 主动结束.否则 Timer 不知道是否其他地方还要继续添加任务的
实现定时器
虽然面试不会让你写定时器,理解定时器背后做的工作,也是很重要的事情
思考一下, Timer 里面要包含哪些内容~~
需要有一个 线程,负责帮咱们掐时间. 等任务到达合适的时间,这个线程就负责执行!!
还需要有一个队列/数组,能够保存所有 schedule 进来的任务!!直观想,这个线程,就可以不停的去扫描上述队列中的每个元素,看每个任务是否到时间了.到时间就执行呗!
但是!如果队列很长,这个遍历的过程开销就很大了 O(N)
map set 虽然有序, 但是获取到最小值,有代价:O(logN)
优先级队列!!!yes !!优先级队列是 O(1)
每个任务都是带有 delay 时间的.肯定是先执行时间小的,后执行时间大的呀!!扫描线程就不必遍历了,只需要关注队首元素是否到时间.如果队首没到时间,后续其他元素,也一定没到时间!!
就可以使用标准库提供的 PriorityQueue(线程不安全),手动加锁控制~~
(标准库也提供了 PriorityBlockingQueue(线程安全),在咱们此处的场景中,不太好控制,容易出问题)这个代码,有两个核心问题,是需要解决的!!!
- 线程安全问题
如果把锁加在while循环外面
要放到while里面- 不要“忙等”,应该用wait把 cpu 资源让出来, 让给其他有需要的线程!!
这个代码使用sleep不太合适
定时器的构成
• ⼀个带优先级队列(不要使⽤ 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();
}
}
}
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();
}

完整代码:
import java.util.PriorityQueue;
// 通过这个类, 来描述一个任务
class MyTimerTask implements Comparable<MyTimerTask> {
// 在什么时间点来执行这个任务.
// 此处约定这个 time 是一个 ms 级别的时间戳.
private long time;
// 实际任务要执行的代码.
private Runnable runnable;
public long getTime() {
return time;
}
// delay 期望是一个 "相对时间"
public MyTimerTask(Runnable runnable, long delay) {
this.runnable = runnable;
// 计算一下真正要执行任务的绝对时间. (使用绝对时间, 方便判定任务是否到达时间的)
this.time = System.currentTimeMillis() + delay;
}
public void run() {
runnable.run();
}
@Override
public int compareTo(MyTimerTask o) {
return (int) (this.time - o.time);
// return (int) (o.time - this.time);
}
}
// 通过这个类, 来表示一个定时器
class MyTimer {
// 负责扫描任务队列, 执行任务的线程.
private Thread t = null;
// 任务队列
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
// 搞个锁对象, 此处使用 this 也可以.
private Object locker = new Object();
public void schedule(Runnable runnable, long delay) {
synchronized (locker) {
MyTimerTask task = new MyTimerTask(runnable, delay);
queue.offer(task);
// 添加新的元素之后, 就可以唤醒扫描线程的 wait 了.
locker.notify();
}
}
public void cancel() {
// 结束 t 线程即可
// interrupt
}
// 构造方法. 创建扫描线程, 让扫描线程来完成判定和执行.
public MyTimer() {
t = new Thread(() -> {
// 扫描线程就需要循环的反复的扫描队首元素, 然后判定队首元素是不是时间到了.
// 如果时间没到, 啥都不干
// 如果时间到了, 就执行这个任务并且把这个任务从队列中删除掉.
while (true) {
try {
synchronized (locker) {
while (queue.isEmpty()) {
// 暂时先不处理
locker.wait();
}
MyTimerTask task = queue.peek();
// 获取到当前时间
long curTime = System.currentTimeMillis();
if (curTime >= task.getTime()) {
// 当前时间已经达到了任务时间, 就可以执行任务了.
queue.poll();
task.run();
} else {
// 当前时间还没到, 暂时先不执行
// 不能使用 sleep. 会错过新的任务, 也无法释放锁.
// Thread.sleep(task.getTime() - curTime);
locker.wait(task.getTime() - curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 要记得 start !!!!
t.start();
}
}
public class ThreadDemo31 {
public static void main(String[] args) {
MyTimer timer = new MyTimer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 3000");
}
}, 3000);
}
}
注意:
有些集合类,是对于元素有特定要求的
此处是期望根据时间,时间小的作为优先级更高~~这里千万不要背!!!写代码试一试 就行了
真正站在 多线程 的思维来考虑这个程序的执行!!!
大家还没有建立起"多线程"思考代码的能力(当前觉得这个代码有困难,非常正常的)有一个先模仿的过程
这个代码最大的难点,在于,执行过程不是“顺序执行”,这个代码大家下来之后自己尝试写个 2,3 遍,都是不过分的!!
漫长的 wait 3s 的过程:
task.run()
拓展:(了解即可)
定时器,除了基于 堆(优先级队列) 方式来实现的定时器之外,还有一种方案,基于"时间轮
8.4 线程池
池 非常重要的概念
线程池是什么
最开始, 使用多进程确实能够解决 并发编程 问题。但是频繁创建销毁进程,成本比较高,引入了线程(轻量级进程).复用资源的方式,来提高了创建销毁效率
随着创建销毁线程的频率进一步提升,线程的创建销毁开销仍然无法忽略不计了!!(抛开 剂量谈毒性 都是耍流氓)
就需要想办法优化此处的线程的创建销毁效率
解决方案, 有两种:
- 引入 轻量级 线程 =>也称为 纤程/协程
Java21 里引入的"虚拟线程"就是这个东西
Go 是比较早支持协程的(这个概念很多年前就有,但是真正集成到语言中,Go 是比较早)Go 也是凭借语法简单,协程,就火了
协程本质,是程序猿在用户态代码中进行调度,不是靠内核的调度器调度的. 节省了很多的调度上的开销
用户代码中,基于线程封装出来的.
协程底层是怎么封装,有不同的实现.可能是 N 个协程对应 1个线程,也可能是 N个协程对应M个线程,
使一个代码中,可以创建出很多的协程.(一个进程创建上千个线程,基本上程序就卡死了,跑不起来的) 创建上千个协程,没啥事 都是小意思 - 线程池
把要使用的线程提前创建好.用完了也不要直接释放而是以备下次使用. 就节省了创建/销毁线程的开销
在这个使用的过程中, 并没有真的 频繁创建销毁,而只是从线程池里,取线程使用,用完了还给线程池
为啥 从线程池 里取线程,就比从系统申请更高效呢??
最关键的要点:
直接创建/销毁线程,是需要用户态+内核态配合完成的工作
线程池/协程,创建销毁,只通过用户态即可,不需要内核态的配合
基本的结论:
- 如果一个工作,自己就能完成, 就更可控,更高效.
如果使用线程池,提前把线程都创建好,放到用户态代码中写的数据结构里面(提前把要使用的线程,在线程池中准备好)。后面用的时候,随时从池子里取,用完了放回池子里去。这个过程,完全是用户态代码,不需要和内核进行交互
从线程池里取线程,纯用户态代码,就比从内核操作更快(可控的)- 如果一个工作,要拜托银行的柜员来完成,就不可控,更低效!!
直接调用 api, 通过系统申请创建线程, 销毁线程,这个过程需要内核完成,内核完成的工作很多时候是不太可控的.(不太可控)
线程池最⼤的好处就是减少每次启动、销毁线程的损耗。
标准库中的线程池
- ThreadPoolExecutor 提供了更多的可选参数,可以进⼀步细化线程池⾏为的设定.
- 标准库还提供了另一个版本(因为ThreadPoolExecutor 本身用起来比较复杂),把 ThreadPoolExecutor 给封装了一下,简化线程池的使用。
ThreadPoolExecutor 类
标准库, ThreadPoolExecutor 类表示线程池(java.util.concurrent 并发(很多多线程相关的内容就在这个包里))
ThreadPoolExecutor 提供了更多的可选参数, 可以进⼀步细化线程池⾏为的设定.
动态扩展:
标准库提供的线程池, 持有的线程个数,并非是一成不变的,会根据当前任务量,自适应线程个数(任务非常多,就多搞几个线程; 任务比较少,就少搞几个线程)
ThreadPoolExecutor 这个类,构造方法, 有很多个参数~~ 需要咱们了解一下.(也是经典面试题).通常情况下,面试不会考察 apì 的细节。但是线程池这里是例外,构造方法的参数,侧面映射出线程池的设计思路了
参数都是啥意思??(经典面试题)
- corePoolSize: 正式员⼯的数量. (正式员⼯, ⼀旦录⽤, 永不辞退)
核心线程数,一个线程池里,最少得有多少个任务
- maximumPoolSize: 正式员⼯ + 临时⼯的数⽬. (临时⼯: ⼀段时间不⼲活, 就被辞退).
最大线程数,一个线程池里,最多最多能有多少个线程
- keepAliveTime: 保持存活时间(临时⼯允许的空闲时间).
- unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值(s, min, ms, hour…).
实习生线程,允许最大的空闲摸鱼时间
如果发现某个实习生正在摸鱼 (这个线程空闲),此时要立即马上把这个实习生开除掉嘛? 不应该的!!!担心出现,这边开除了,结果下一时刻,任务突然多了~~ 此处 keepAliveTime,意思就是实习生线程,空闲时间超过了这个时间阈值,就会被销毁掉
实习生线程, 被销毁了,就没了。未来某一天,线程池还会重新招聘实习生,但是不是之前的那个了,不存在"再次放回来"概念
- workQueue: 传递任务的阻塞队列
BlockingQueue < Runnable > workQueue,使用 Runnable 来作为描述任务的主体。和定时器类似,线程池中也可以持有很多个任务~~
也可以设置 PriorityBlockingQueue,带有优先级~
- threadFactory: 创建线程的⼯⼚, 参与具体的创建线程⼯作. 通过不同线程⼯⼚创建出的线程相当于对⼀些属性进⾏了不同的初始化设置.
线程工厂~~ 通过这个工厂类,来创建线程对象(Thread 对象)
在这个类里面提供了方法(也不一定非得是静态的),让方法封装 new Thread 的操作,并且同时给 Thread 设置一些属性,构成了 ThreadFactory 线程工厂
工厂模式,也是一种常见的设计模式,通过专门的"工厂类" / "工厂对象"来创建指定的对象工厂模式本质上是给 java 的语法填坑的(如果,语法层面上,不强制要求,构造方法名字必须和类名一致,就没有上述模式的必要了)
举个栗子
为了解决上述问题,就引入了"工厂模式“
使用普通的方法来创建对象,就是把构造方法封装了一层~~
- RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
– ◦ AbortPolicy(): 超过负荷, 直接抛出异常.
– ◦ CallerRunsPolicy(): 调⽤者负责处理多出来的任务.
– ◦ DiscardOldestPolicy(): 丢弃队列中最⽼的任务.
– ◦ DiscardPolicy(): 丢弃新来的任务.
面试官考察 线程池的参数含义,最想听的就是你对于第七个参数的理解。是整个线程池上述七个参数中,最重要, 最复杂的
面试官考察线程池的参数,就是在考这个~~
面试官问你: 线程池的参数都是啥意思 ??其实考的就是你对于这个参数的理解.前面 6个都是添头
拒绝策略:
线程池中,有一个阻塞队列. 能够容纳的元素有上限的,当任务队列已经满了,如果继续往队列中添加任务,那么线程池会咋办??(你秋招拿 3 个 offer, 但是实际上只能去1个.就需要把另外两个给拒绝掉,具体怎么拒绝,拒绝哪两个??)
正常来说,除非特殊说明,我们写的代码是不希望有这种突发性的阻塞的(可能会对程序造成不可预估的影响),因此直接让添加任务的线程阻塞,其实是不太好的,不太好就意味着应该要有别的办法
标准库的线程池就引入了“拒绝策略”,不同的策略会有不同的效果:
这四个策略,都要记住 (背下来)
这里每个策略,具体的英文名字,单词,不必刻意去背,(稍微翻一下文档就行了)
Executors类
ThreadPoolExecutor 本身用起来比较复杂,因此标准库还提供了另一个版本,把 ThreadPoolExecutor 给封装了一下,简化线程池的使用,也是基于 工厂设计模式。
Executors :工厂类,通过这个类来创建出不同的线程池对象(在内部把ThreadPoolExecutor 创建好了并且设置了不同的参数)
• 使⽤ Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
• 返回值类型为 ExecutorService
• 通过 ExecutorService.submit 可以注册⼀个任务到线程池中.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
Executors 创建线程池的⼏种⽅式:
• newFixedThreadPool: 创建固定线程数的线程池
• newCachedThreadPool: 创建线程数⽬动态增⻓的线程池.
• newSingleThreadExecutor: 创建只包含单个线程的线程池.
• newScheduledThreadPool: 设定 延迟时间后执⾏命令,或者定期执⾏命令. 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装.
啥时候使用 Executors 啥时候使用 ThreadPoolExecutor ??
Executors: 只是简单用一下
ThreadPoolExecutor:希望高度定制化
业界/网络上 流传了一份"武林秘籍”:阿里巴巴 Java 编程规范手册
这份规范中,明确说: 使用线程池,要用 ThreadPoolExecutor 这个版本,而不应该使用 Executors
理由是,使用 Executors 线程数目/拒绝策略 等信息都是隐式的,可能不好控制(用 ThreadPoolExecutor 意味着一切都在掌控之中,避免出现一些不可控的因素)
可以参考,也不必奉为金科玉律,也不是说 Executors 完全就不能用.(简单当然也是优点)。大家要以以后实际入职的公司的编程规范为准
(实际开发)创建线程池的时候,很多时候需要设定线程池的线程数量,这个数量应该怎么设置比较合适??
只要你说出具体的数字,就都是错误的!!
网上很多关于这个问题的资料,都是错误的!!!
假设 cpu 的逻辑核心数是 N,网上的资料就有这些说法:线程数量 N,N+1,1.5N,2N…
不同的程序,能够设定的线程的数量是不同的
必须要具体问题具体分析
综上,由于程序的复杂性,很难直接对线程池的线程数量进行估算。
更合适的做法,通过实验/测试的方式找到合适的线程数目!!!
尝试给线程池,设定不同的线程数目,分别进行性能测试,衡量每种线程数目下,总的时间开销,和系统资源占用的开销,找到这两者之间的合适的值。
实现线程池
自己写代码实现一个简单的线程池~ 直接写一个固定线程数目的线程池(暂时不考虑线程的增加和减少)
面试倒不会让我们写. 但是至少大家要能够理解这里面干了啥
(1)提供构造方法,指定创建多少个线程,在构造方法中,把这些线程都创建好
(2)有一个阻塞队列,能够持有要执行的任务
(3)提供 submit 方法, 可以添加新的任务.
• 核⼼操作为 submit, 将任务加⼊线程池中
• 使⽤ Worker 类描述⼀个⼯作线程. 使⽤ Runnable 描述⼀个任务.
• 使⽤⼀个 BlockingQueue 组织所有的任务
• 每个 worker 线程要做的事情: 不停的从 BlockingQueue 中取任务并执⾏.
• 指定⼀下线程池中的最⼤线程数 maxWorkerCount; 当当前线程数超过这个最⼤值时, 就不再新增线程了.
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 {
// 此处的 take 带有阻塞功能的.
// 如果队列为 空, 此处的 take 就会阻塞.
Runnable runnable = queue.take();
// 取出一个任务就执行一个任务即可
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
}
// 线程池
public class Demo {
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("执行任务" + n + " , 当前线程为: " + Thread.currentThread().getName());
//System.out.println(Thread.currentThread().getName() + "hello");
}
});
}
}
}
注意:
- 如果写成这种代码会因为变量捕获而编译会出错!!!
咋改呢?(最开始lambda 的时候讲到的,后来线程创建,也又讲了一遍)
- 一定要注意,多个线程之间的执行顺序是不确定的
#小结-多线程案例


多线程初阶,就完了~~线程的基础知识,面试要考 +工作要用的
接下来的
多线程进阶,主要讲的是面试要考的(工作中不太用到)
9.总结

更多推荐




















通过 Runnable 表示线程要完成的任务 耦合更低。基于这种写法,更好的解耦合





























解决死锁问题,核心思路, 破坏上述的必要条件,只要能破坏一个,就搞定!!上述破坏3 4两种 是开发中比较实用的方法,还有一些其他方案,也能解决死锁问题.但引入加锁顺序的规则(普适性高, 方案容易落地)




































































所有评论(0)