【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 毫秒
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);
}
这⾥⽤到的机制,⻢上会给⼤家解释。
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 的静态⽅法.
总结-保证线程安全的思路
- 使⽤没有共享资源的模型
- 适⽤共享资源只读,不写的模型
a. 不需要写共享资源的模型
b. 使⽤不可变对象 - 直⾯线程安全(重点)
a. 保证原⼦性
b. 保证顺序性
c. 保证可⻅性
总结-对⽐线程和进程
1.线程的优点
- 创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多
- 与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多
- 线程占⽤的资源要⽐进程少很多
- 能充分利⽤多处理器的可并⾏数量
- 在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务
- 计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现
- I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
2.进程与线程的区别
- 进程是系统进⾏资源分配和调度的⼀个独⽴单位,线程是程序执⾏的最⼩单位。
- 进程有⾃⼰的内存地址空间,线程只独享指令流执⾏的必要资源,如寄存器和栈。
- 由于同⼀进程的各线程间共享内存和⽂件资源,可以不通过内核进⾏直接通信。
- 线程的创建、切换及终⽌效率更⾼。
小结
讲到这里,关于多线程, 一些基础用法,就交代的差不多了
围绕 Thread 类,各种用法来展开的
多线程编程,其实主要就是在使用到上面讲的这些内容~
多线程, 非常非常重要:面试中,占比非常大,工作中,非常常用的!!!
更多推荐




















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





























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

















所有评论(0)