多线程基础操作
Thread类
回顾Thread类
Thread 类, 就是 Java 基于操作系统去封装的用于操作线程的 API. 我们只需要通过这个类, 我们就可以去控制操作系统中的线程了
下面我们回顾一下上次书写第一个多线程程序的过程
首先我们创建一个类, 去继承 Thread 类
class MyThread extends Thread {
}
然后去重写里面的run()
方法, 打印一个 Hello World. 这个run()
方法的核心功能就是去告诉线程, 它的工作是什么. 这里它的工作就是去打印一个 Hello World
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Hello World");
}
}
最后, 我们就在main()
方法中去创建这个 MyThread 类的对象, 然后调用里面的start()
方法即可. start()
方法用于去启动线程
public class Main {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
}
}
最后运行查看打印结果
此时我们就完成了我们多线程的第一个程序, 此时可能有人觉得这个似乎和我们之前学习的东西没有什么区别. 确实我们现在的这个简单程序, 是看不出具体的效果的, 实际上它的执行流程应该是如图所示的
这里我们启动 main 方法后, 首先会自动启动一个 主线程, 然后主线程再去调用这个start()
方法启动 MyThread 线程, 随后 MyThread 线程再去打印这个 Hello World.
观察线程运行
上面我们初步了解了 Thread 类的使用, 书写了我们的第一个多线程程序. 但是此时可能有人就要问了: 你的这个程序, 我也没看出有什么明显的区别啊? 你这个程序, 多线程多在哪了?
那么当然, 只是打印一条语句, 当然是很难看出的, 因此我们下面就写一个效果更加明显的代码.
我们将刚刚重写的 run() 方法, 改为一个始终执行的循环, 让他一直打印 Hello Thread, 如下所示
class MyThread extends Thread {
@Override
public void run() {
while(true){
System.out.println("Hello Thread");
}
}
}
接下来我们在 main() 方法中, 调用 start() 去启动线程, 同时在启动后写一个类型的无限循环, 打印 Hello main. 如下所示
public class Main {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
while(true){
System.out.println("Hello main");
}
}
}
写完上述代码后, 我们尝试运行一下这个程序.
可以看到, 这两个打印在疯狂的打印自己对应的语句, 此时它们就是在同时的执行自己的工作, 实现了并发的效果.
假如使用图片来看, 那么我们的代码就类似下图所示
那此时可能有人就要问了, 这个初始的线程是哪来的, 我寻思我们的代码好像也没创啊.
实际上, 这个线程是 JVM 运行程序的时候自动创建的, 这个线程和其他的线程也没什么区别.
此时如果我们尝试把myThread.start()
改成myThread.run()
. 此时就会发现我们的代码变为了只会打印 Hello Thread
这是为什么呢?
实际上我们说过, start() 方法是用于启动线程的, 而 run() 方法是用于告诉线程他需要做什么事情的. 如果我们直接去调用 run() 方法, 那么就相当于我们的主线程就直接去做 run() 方法里面的事情了, 而不是创建一个新线程去做
此时流程图如下所示
很明显, 我们现在是通过打印的方式来观察线程的运行, 这样很明显不太直观, 有没有什么工具可以进行观察呢?
实际上, 我们可以通过 IDEA 或者 JDK 中提供的一个工具来观察线程. IDEA 主要就是通过调试模式启动程序, 就可以看到有什么线程, 线程的状态, 线程运行到哪了这样的信息.
不过我们这里重点还是介绍一下 JDK 提供的工具 jconsole, 它主要就是用于去观察 Java 程序的状态的. 那么既然我们要使用 JDK 中的工具, 自然我们就需要找到 JDK 的安装位置. 然后进入 bin 目录, 搜索 jconsole 即可
这里有两点需要注意:
- 打开 jconsole 前, 先确保一下我们刚刚的程序已经在运行了
- 如果看不到信息, 可以尝试通过管理员身份打开程序
打开后, 可以看到里面不仅仅有我们的程序, 还有一个什么 jetbrain 什么的以及 jconsole 本身, 这个 jetbrain 的实际上就是我们的 IDEA. 这个 jconsole 他会展示我们电脑上所有的 Java 程序, 我们就进入这个 Main 即可
进去后, 左上角可以选择观察什么, 这里我们就选择线程
点开之后, 可以看到左下角就是我们这个程序中有的线程, 其中 main 和 Thread-0 就分别对应着我们的 main 线程以及 myThead 启动的线程
那此时可能有人就要问了: 为啥还有这么多其他的不知道干啥的线程? 它是哪来的?
实际上, 这些线程就是 JVM 为了做一些工作, 去自动创建的线程, 例如去进行一些垃圾回收, 资源统计等等的工作. JVM 为了能够保证我们的程序能够正常运行, 还是私下做了非常多的工作的.
这个线程的右边, 就可以看到线程的一些信息, 具体是什么信息它上面也标识出来了, 就是名称, 状态, 堆栈信息这样的. 其中涉及到的一些名称, 状态这样的内容, 我们后续会慢慢进行介绍
后续我们书写多线程程序的时候, 如果出现了一些问题, 就可以通过这个 jconsole 去观察线程的运行状态, 例如我们的程序卡死了, 那么此时就可以看看线程是否在运行, 还是在干什么其他的.
线程的休眠
很明显, 上面我们的那个死循环代码, 打印的简直就是飞快, 根本看不清. 那有没有办法让他能够跑慢一点呢?
实际上, Thread 内部就提供了一个静态方法sleep()
, 它主要的功能就是能够去让当前的线程去休眠一段时间.
例如我们如果想让刚刚这两个死循环打印完后, 休息 1s, 那么我们就可以修改代码如下
class MyThread extends Thread {
@Override
public void run() {
while(true){
System.out.println("Hello Thread");
// 休眠 1 秒, 需要显式处理异常
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public class Main {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
while(true){
System.out.println("Hello main");
// 休眠 1 秒, 需要显式处理异常
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
这里需要注意以下两点:
- 这个 sleep() 方法的单位是 ms, 因此我们这里提供的数据是 1000, 那么就等于 1s.
- 这个 sleep() 需要我们显式处理异常, 通过 throws 或者 try-catch 都可以进行处理
随后我们再尝试运行, 会发现运行速度慢了很多.
此时可能有人就要问了: 为什么两个线程都是等待 1s, 然后进行打印, 有时候顺序会变化呢?
实际上, 当我们的线程休眠后, 那么当时间到了后, 它并不是说就可以直接运行, 而是需要等待 CPU 去进行调度, 调度上去后才能进行操作. 而这个调度, 就是一个类似于"随机"的调度, 具体如何进行, 与操作系统中的调度模块有关.
我们可以通过下面这个程序来看, 这个等待时间实际上每一次都是不太一样的. 实际上一部分就是因为调度的随机性导致的
public class SleepDemo {
public static void main(String[] args) throws InterruptedException {
// 记录开始时间
long start = System.currentTimeMillis();
// 休眠 1s
Thread.sleep(1000);
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println(end - start);
}
}
常用方法
构造方法
方法 | 效果 |
---|---|
Thread() | 创建Thread对象 |
Thread(String name) | 创建Thread对象, 并命名 |
Thread(Runnable target) | 使用Runnable对象创建Thread对象 |
Thread(Runnable target, String name) | 使用Runnable对象创建Thread对象, 并命名 |
这里有一个可以指定名称的构造方法, 它主要就是可以去给这个 Thread 对象命名, 这个名字主要就是便于我们后续的调试.
但是此时我们会发现一个很矛盾的事情, 我们用的都不是 new Thread()
去创建线程对象, 而是通过 new 自己继承出来的子类来做, 那我们如何使用这个构造方法呢?
实际上这就是我们接下来要介绍的, 一些其他创建线程的方式.
我们之前是通过继承 Thread 类, 重写 run() 方法的方式. 那每一次都要新创建一个类, 这很明显还是比较繁琐的, 而且后续这个类好像也用不太到了, 即使调用 start() 方法也是调用的父类 Thread 类里面的方法.
那么此时我们就可以用到一个东西来简化这部分的工作, 叫做匿名内部类.
我们通过匿名内部类, 就可以在创建对象的时候, 直接写一个匿名的类去继承 Thread 类, 同时重写里面的 run() 方法. 代码如下所示
public class ThreadNameDemo {
public static void main(String[] args) {
// 使用匿名内部类, 创建线程对象
Thread myThread = new Thread() {
@Override
public void run() {
System.out.println("Hello Thread");
}
};
myThread.start();
}
}
那么此时, 我们就可以传入线程的名称了, 如下所示
public class ThreadNameDemo {
public static void main(String[] args) {
// 使用匿名内部类, 创建线程对象
Thread myThread = new Thread("我是一个Thread") {
@Override
public void run() {
while(true){
System.out.println("Hello Thread");
}
}
};
myThread.start();
}
}
接下来我们就运行程序, 使用 jconsole 观察一下线程的名称
此时有人可能发现, 这里 main 线程怎么消失了呢?
实际上, 在我们这里的代码中, main 在启动完 myThread 线程后, 就没有其他工作了, 因此这里 main 就已经干完了工作, 跑路了.
除了通过继承 + 匿名内部类的方式, 我们还可以看到有一个通过一个 Runnable 来构造的构造方法. 那么这个 Runnable 是一个什么呢?
我们借助搜索找到它可以看到, 它实际上是一个接口, 里面有一个和我们之前重写过的方法一样的一个 run() 方法
实际上, 这个 Runnable 它主要就是去代表了一个可执行的任务. 我们之前通过继承 Thread 重写内部的 run() 就相当于直接告诉 Thread 它负责的任务是什么. 而这里则是相当于, 我先创建好任务, 然后再把任务交给 Thread.
那么为什么要这么做呢? 其实答案也很简单, 就三个字, 解耦合.
这里主要就是为了降低这个任务与线程的耦合程度, 因为有些时候, 我的任务可能与这个线程并不是强相关的, 我可以单线程的运行, 也可以多线程的运行, 或者是通过其他的方式去执行. 那么此时如果我把这个任务分离开, 那么我此时就可以选择最合适的方法去处理.
那么接下来我们就来通过实现 Runnable 接口, 来创建一个线程
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Hello Runnable");
}
}
public class RunnableDemo {
public static void main(String[] args) {
Runnable runnable = new MyRunnable();
// 将 Runnable 对象作为参数传递给 Thread 类
Thread thread = new Thread(runnable);
// 启动线程
thread.start();
}
}
当然, 这里也是可以使用匿名内部类去简化的, 主要就是在传参的时候, 去直接在传参的过程中创建出这个对象, 简化后如下所示
public class RunnableDemo {
public static void main(String[] args) {
// 传递参数的时候, 使用匿名内部类
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Hello Runnable");
}
});
// 启动线程
thread.start();
}
}
同时, 我们在看 Runnable 源码的时候也看到了, 它实际上是一个函数式接口.
那么对于函数式接口, 还有一种简化的书写方法, 就是 Lambda 表达式. 我们采用 Lambda 表达式去简化这个匿名内部类的代码, 那么最终如下所示
public class RunnableDemo {
public static void main(String[] args) {
// 传递参数的时候, 使用 Lambda 表达式
Thread thread = new Thread(() -> System.out.println("Hello Runnable"));
// 启动线程
thread.start();
}
}
可以看到, 使用 Lambda 表达式的方式比起我们之前使用过的所有方法都要更加简洁, 因此我们后续主要也会使用这种方式去创建线程
目前, 虽然我们看到了很多种创建线程的方法, 但是实际上本质就两种方法:
- 通过继承 Thread 类, 重写 run() 方法
- 通过实现 Runnable 接口, 重写 run() 方法
剩余的匿名内部类, Lambda 表达式, 其实本质上都是基于这两个方法做出的简化操作, 因此我们就不重复提及了.
实际上, 创建线程的方式并不仅局限于这两种, 其他创建线程的方式我们在后续会逐渐了解到.
属性获取方法
下面这些方法, 主要就是用于去获取一些线程相关的属性的, 我们大致进行一下简单了解
方法 | 效果 |
---|---|
getId() | 获取线程的Id |
getName() | 获取线程的名字, 这里的名字就是前面构造方法中可以赋值的那个名字 |
getState() | 获取线程的状态 |
getPriority() | 获取线程的优先级 |
isAlive() | 检查线程是否存活 |
isDaemon() | 检查线程是否为后台线程/守护线程 |
isInterrupted() | 检查线程是否中断 |
- 线程的 ID:
这个 ID 并不是之前我们介绍进程和线程中提到的 PID, 而是 Java 自己提供的一个 ID, 主要就是用于作为线程的身份标识的
public class ThreadIdDemo {
public static void main(String[] args) {
Thread t1 = new Thread(() -> System.out.println(Thread.currentThread().getId()));
Thread t2 = new Thread(() -> System.out.println(Thread.currentThread().getId()));
t1.start();
t2.start();
}
}
- 线程的名字:
这个主要就是我们上面构造方法提到的那个名字, 如果没有在构造方法中设置, 也可以通过setName()
方法设置
public class ThreadNameDemo {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
System.out.println("我是线程1号, 我在打印");
}, "线程1号");
thread.start();
System.out.println(thread.getName());
}
}
- 线程的状态:
主要就是用于去描述线程现在是在运行, 还是等待这样的不同的状态, 具体我们在后续进行介绍, 这里简单了解即可
- 线程的优先级:
这个优先级主要就是影响系统进行的调度的, 不过我们在应用层面一般感知不到, 因此我们不用过于关心
- 线程存活:
有一些时候, 线程即使在系统中被销毁了, 但是此时线程对象可能还没有被销毁, 那么此时就可以通过这个来进行查看
public class ThreadAliveDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
System.out.println("thread开始运行");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("thread运行结束");
});
//线程开始前
System.out.println(thread.isAlive());
System.out.println("启动线程");
thread.start();
//线程开始后
System.out.println(thread.isAlive());
Thread.sleep(3000);
//线程结束
System.out.println(thread.isAlive());
}
}
- 后台线程/守护线程:
相对的是前台线程, 如果前台线程未结束, 那么无论后台线程状态如何, 进程都不会结束. 但是如果前台线程一旦结束, 即使后台线程还在工作, 此时进程也会直接结束.
public class DaemonThreadDemo {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (true) {
System.out.println("hello thread");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
//设置thread为后台线程
thread.setDaemon(true);
thread.start();
System.out.println("主线程开始");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("主线程结束");
}
}
可以看到, 代码中主线程结束后, 后台线程就直接停了, 如果我们把设置后台线程的代码去掉, 那么就可以看到依旧是一直运行, 我们这里就不再演示了, 感兴趣的可以自行尝试
- 线程中断:
主要就是线程内部会提供一个标志位, 用于进行线程中断的操作, 具体的我们后面进行介绍
中断线程
假如一个线程一直在工作, 那么有没有办法可以让它去不要继续了呢? 此时就要涉及到中断/终止线程的操作.
Java 这边退出线程的方法, 是尽量让这个线程赶紧结束, 而不是直接把它关了. 有一些语言它可以直接强制的去终止一个线程, 但是这样可能会产生一些问题, 例如它正在处理一些数据,然后你就把它干掉了, 此时可能就会残留一些数据.
那么我们如何才能够让一个线程尽快的去结束呢? 一般来说, 如果有一个线程一直在运行, 那么一般是内部有循环正在执行, 那么我们就可以给循环设置一个标志位
public class InterruptDemo {
// 标志位
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while(flag) {
System.out.println("thread: 我在打印");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
System.out.println("main:1s后终止线程");
Thread.sleep(1000);
System.out.println("main:执行终止操作");
flag = false;
}
}
但此时有两个问题:
- 假如我的标志位设定在方法内, 那么此时就会涉及到匿名内部类/Lambda表达式的变量捕获问题, 这个标志位就是不可修改的了, 如果修改了, 那么内部的变量就不可用
- 如果线程在休眠状态, 那么无法停止
public class InterruptDemo {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while(flag) {
System.out.println("thread: 即将休眠50000ms");
try {
Thread.sleep(50000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
System.out.println("main:1s后终止线程");
Thread.sleep(1000);
System.out.println("main:执行终止操作");
flag = false;
}
}
很明显, 这里一定要等到这个线程休眠完, 才能执行判断, 然后终止线程. 那假如线程产生了一些问题, 一直在等待, 那么此时必须等到这个休眠结束, 那这个方案肯定不太行.
那么此时, 我们就可以直接使用 Thread 内置的标志位, 也就是通过上面介绍的 isInterrupted() 获取的标志位.
但是我们并不能像下面的这样去使用
它这里也给出了提示, thread 并没有初始化好. 这里要注意的是, 我们这里还在填写 Thread 类的构造方法, 那么此时 thread 都还没初始化好, 你怎么能直接拿 thread 对象来用呢?
因此 Thread 也提供了一个静态方法去获取当前的线程对象, 就是 currentThread() 方法. 此时我们将这个对象修改为Thread.currentThread()
即可
后续我们终止线程的时候, 直接通过线程对象调用 interrupt() 方法即可, 示例代码如下
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while(!Thread.currentThread().isInterrupted()) {
System.out.println("thread: 即将进入休眠");
try {
Thread.sleep(50000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
System.out.println("main: 1s后终止线程");
Thread.sleep(1000);
System.out.println("main:执行终止操作");
thread.interrupt();
}
}
可以看到, 此时如果这个线程正在休眠, 那么就会直接抛出一个InterruptedException
, 然后由于我们这里的处理是直接抛出异常, 因此线程直接终止掉了.
但是很明显这样不够优雅, 我们这里希望不要直接抛出异常, 而是直接停止就行, 那么我们此时就尝试把这个抛出异常的语句给删掉
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while(!Thread.currentThread().isInterrupted()) {
System.out.println("thread: 即将进入休眠");
try {
Thread.sleep(50000);
} catch (InterruptedException e) {
// 修改为打印一条语句
System.out.println("thread: 线程被中断");
}
}
});
thread.start();
System.out.println("main: 1s后终止线程");
Thread.sleep(1000);
System.out.println("main: 执行终止操作");
thread.interrupt();
}
}
但是此时我们会发现, 当我们不抛出异常后, 这个线程不但没有停止, 反而又开始休眠了. 换句话说, 我们中断了个寂寞. 那么这是为什么呢?
实际上 Java 中的这个抛出InterruptedException
的设定比较特殊, 它希望一个线程抛出这个异常后, 不要直接强制停下, 而是通过异常的处理去判断到底是否要停下. 因此它会在抛出这个异常后, 自动恢复我们刚刚设置的标志位, 导致线程继续运行
为什么要让线程自己判断是否要停下, 其实就是为了能够给一些线程一些操作空间, 例如可以进行一些资源回收这样的工作, 然后再结束, 或者是考虑可以不结束.
但是, 这个可操作空间的前提是需要触发InterruptedException
, 假如说没有 sleep(), 没有触发这个异常, 那么此时就会直接停止, 感兴趣的可以尝试运行一下如下代码, 可以发现 thread 依旧是直接停止的
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while(!Thread.currentThread().isInterrupted()) {
System.out.println("thread: 运行中");
}
});
thread.start();
System.out.println("main: 1s后终止线程");
Thread.sleep(1000);
System.out.println("main: 执行终止操作");
thread.interrupt();
}
}
那么结合我们上面的讨论, 我们这里主要就是需要去手动的在异常的处理中去结束这个循环, 我们这里就直接 break 即可
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while(!Thread.currentThread().isInterrupted()) {
System.out.println("thread: 即将进入休眠");
try {
Thread.sleep(50000);
} catch (InterruptedException e) {
System.out.println("thread: 线程被中断");
break;
}
}
});
thread.start();
System.out.println("main: 1s后终止线程");
Thread.sleep(1000);
System.out.println("main: 执行终止操作");
thread.interrupt();
}
}
可以看到, 此时线程就正常终止了.
线程状态
我们之前初步介绍进程状态的时候, 主要就介绍了进程的两个状态: 就绪状态和阻塞状态, 这两个状态对于线程也是同样适用的. 而 Java 中对线程的状态也进行了表示进行了细分, 目前我们先了解一部分状态, 剩余的我们在后续学习中了解
Java 中线程的状态主要是通过枚举来表示的, 主要有六种状态
这六种状态的名称及其对应的意思如下所示
状态名称 | 意义 |
---|---|
NEW | 只创建了线程对象, 但是没有启动线程 |
RUNNABLE | 就绪状态(运行中或随时准备运行) |
TIMED_WAITING | 由于 sleep() 这种固定时间造成的阻塞状态 |
TERMINATED | 线程终止 |
WAITING | 不固定时间造成的阻塞状态(也就是不知道什么时候解除阻塞) |
BLOCKED | 由于锁竞争造成的阻塞状态 |
后面两个状态我们后续再了解, 我们先来演示一下前面几个状态
- NEW状态
public class ThreadState {
public static void main(String[] args) {
Thread thread = new Thread(() -> {});
// 没有启动线程, NEW 状态
System.out.println(thread.getState());
}
}
- RUNNABLE状态
public class ThreadState {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (true) {
// 便于观察, 不做任何工作
}
});
thread.start();
System.out.println(thread.getState());
}
}
- TIME_WAITING状态
public class ThreadState {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (true) {
System.out.println("thread: 即将休眠");
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
// 稍微等待一会, 防止执行过快
Thread.sleep(1000);
System.out.println(thread.getState());
}
}
- TERMINATED状态
public class ThreadState {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {});
thread.start();
// 稍微等待一会, 防止执行过快
Thread.sleep(1000);
System.out.println(thread.getState());
}
}
线程等待
上面我们提到了一个不固定时间等待的线程状态, 那么接下来我们就来介绍一下相关的实现
我们首先先试想一个场景: 新创建一个线程, 让它执行让一个变量自增100次的操作, 随后在主线程打印出这个变量
那么此时我们写出的代码可能如下所示
public class WaitDemo {
private static int count = 0;
public static void main(String[] args) {
Thread thread = new Thread(() -> {
for (int i = 0; i < 100; i++) {
count++;
}
});
thread.start();
System.out.println(count);
}
}
此时会发现, 我们打印出来的是一个 0, 这是为什么呢?
实际上, 由于我们这里的两个线程是兵分两路, 并发执行的, 因此在 main 线程启动 thread 线程后, 此时 thread 线程都还没开始加, 你就直接打印了
那么我们有没有什么办法来让这个主线程等一下哪个thread线程, 等他走完然后再打印呢?
当然有, 就是通过 join() 方法去实现, 我们先看上面的这个例子修改后的代码
public class WaitDemo {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
for (int i = 0; i < 100; i++) {
count++;
}
});
thread.start();
// thread 调用 join() 方法
thread.join();
System.out.println(count);
}
}
可以看到, 此时打印的就是 thread 自增完的结果, 也就是 100.
这里指的一提的是, 这里这个谁等谁的问题, 在初学的时候还是比较容易搞混的.
我们上面这里是 thread 对象在 main 线程里调用 join() 方法, 就相当于 thread 和 main 线程说: “等等我” 一样. 也就是说, 谁的线程对象调用这个方法, 就是谁在喊: 等等我. 在哪个线程里喊得, 那么就是叫哪个线程等.
但是这个时候又有一个问题, 假如这个 thread 出问题了, 死循环了, 那么此时我们的 main 难道会一直等吗? 这不会有问题吗?
实际上, 这种情况确实是一个问题, 也叫做死等问题, 也就是只要等不到那就一直等. 但是很明显这种设定是不科学的, 那么有没有办法让 main 不要去死等呢?
实际上, join() 方法自己有重载一个带时间参数的方法. 它允许我们和调用 sleep() 方法一样, 提供一个时间参数. 如果超过了这个时间, 那么就不再等了, 直接结束
public class Demo10 {
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
for (int i = 0; i < 100;) {
// 此时这个线程出了bug, 一直运行
num++;
}
});
thread.start();
// 设定等待时间为 5s, 超过直接不等
thread.join(5000);
System.out.println(num);
}
}
同时, 我们这里也可以观察一下这两种情况的状态
public class WaitDemo {
public static void main(String[] args) throws InterruptedException {
// 创建一个 t1 线程, 一直执行
Thread t1 = new Thread(() -> {
while(true){}
});
t1.start();
// 创建第二个线程, 用于等待
Thread t2 = new Thread(() -> {
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
Thread.sleep(1000);
System.out.println(t2.getState());
}
}
可以看到, 此时的状态就是 WAITING
同时我们也测试一下, 设置了等待时间的状态是什么
public class WaitDemo {
public static void main(String[] args) throws InterruptedException {
// 创建一个 t1 线程, 一直执行
Thread t1 = new Thread(() -> {
while(true){}
});
t1.start();
// 创建第二个线程, 用于等待
Thread t2 = new Thread(() -> {
try {
t1.join(50000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
Thread.sleep(1000);
System.out.println(t2.getState());
}
}
可以看到, 当我们设定了时间后, 状态就变为了 TIMED_WAITING
初识synchronized
问题引入
接下来我们来实现一个简单的代码: 新创建两个线程, 让它们分别执行让同一个变量自增 50000 次的操作, 最后在主线程中打印出结果
那么此时结合我们上面学习的知识, 我们就可写出如下代码
public class SynDemo01 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 创建两个线程,每个线程对 count 进行 50000 次自增操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
// 启动线程
t1.start();
t2.start();
// 等待两个线程执行完毕
t1.join();
t2.join();
System.out.println(count);
}
}
此时我们的预期结果肯定是 100000, 但是当我们尝试运行代码的时候, 就会发现结果和我们的预期不符合.
并且每一次运行这个结果还可能不一样, 如下图又是另一次的运行结果
那么这肯定是我们的程序发生了 Bug, 那么为什么为发生 Bug, 又如何去解决这些 Bug 呢?
其实这个问题的根源, 就是出在这两个线程它是在同时对同一个变量进行自增操作. 如果我们此时改变一下代码, 不要让他同时进行, 那么此时就可以解决这个问题.
这个代码虽然解决了问题, 但是我们还是不清楚, 为什么上面的代码会产生问题. 为了探究这个问题为什么会产生, 我们首先需要去了解一下一个 count++ 到底是如何执行的.
count++ 的这个操作, 本质上是大致分为了三步骤去执行的:
- 从内存中读取 count, 放入寄存器
- 把 count 读取出来, 然后在 CPU 中执行 + 1 操作, 存回寄存器
- 把寄存器的数据写入内存中
而此时, 如果有两个线程, 它们在同时执行上述操作, 那么由于 CPU 的调度顺序是随机的, 那么此时就会导致在某一些调度的情况下, 执行的结果出现问题. 什么意思呢? 我们通过一个图来看
假设现在三个步骤分别对应着 load
, add
, save
, 那么此时可能产生如下这些调度情况(我们这里假设的是单核 CPU 的情况)
此时两个线程的这几个操作会疯狂的抢占 CPU 然后去执行自己对应的指令, 实际上这上面三个情况并不是所有的情况, 这里的情况其实是无数种的, 因为有可能出现 t1 一次都没执行完, t2 执行了两次说着三次这样的情况.
那么这样会导致什么问题呢? 我们就借助这个极端的例子来看, 它执行完这一轮后, count 的值是多少.
可以看到, 经历了一番折腾, 最后执行了三次自增结果得到的还是 1. 而在我们的代码中, 50000 次就可能有非常多次是这些不正常的情况, 那么自然我们的结果也就是不正确的了.
这种单个线程能够顺利执行, 而在多线程代码中就无法得到正确结果的情况, 就被称作是线程安全问题. 经过我们上面的讨论, 我们这里的这个线程安全问题的主要产生条件就是:
- 线程在操作系统中的调度是随机的, 线程会以抢占性执行的方式去争夺 CPU 执行自己的指令
- 多个线程, 针对同一个变量进行修改
- 自增操作需要分步进行
这里我们再稍微提及一下第三点, 这里我们的 count++ 操作, 是分为了三步来进行的, 那么此时再结合调度的随机性, 就会产生问题, 那么假如我们的 count++ 是一个原子操作, 也就是它的这个步骤无法拆分, 还会引发问题吗?
答案是: 不会引发, 我们依旧是通过图片来看
很明显, 当我们的操作无法拆分后, 无论你如何调度, 我们结果都是类似于一种串行方式, 此时就不会有任何问题了.
那此时可能有人就要问了: 那你这样操作之后, 岂不是和多线程也没有多大关系了? 归根究底还是类似于串行的方式去做了.
实际上, 如果涉及到线程安全问题, 那么确实就需要去通过一些降低效率的手段去维护数据的一致性. 这也算是我们书写程序中的一个真理: 没有完美的方案, 有优点就会势必有对应的缺点. 多线程确实一定程度上会提高我们的程序执行速度, 但是也势必就会引发一系列的问题, 此时我们势必也就需要一些额外的手段来对这些问题来进行处理.
而这个真理我们在后续的学习中会不断的遇到, 一个新的看起来比较好的方案, 它也势必会有一定的缺陷. 那么此时我们有没有什么策略呢? 当然有, 既然缺点无法避免, 那么我们就可以去结合场景, 将缺点最小化, 甚至是去利用缺点从而转换为一种优点, 这也是我们在后续学习过程中要去体会的.
回到原来的话题, 如果要解决问题, 此时就需要我们去把这个 count++ 变成一个原子的操作, 那么我们有没有什么办法, 让这个 count++ 的操作能够是一个原子性的操作呢?
当然有, 就是去给它加锁, 而 Java 中比较常用的一个加锁方式, 就是通过 synchronized 关键字来进行的, 下面我们就来简单的使用一下这个关键字, 来修复我们的代码.
初步使用
要使用 synchronized 给我们的 count++ 操作进行加锁, 其实也非常的简单, 我们就直接用一个代码块把 count++ 包起来, 然后在代码块前面写上 synchronized
但是很明显此时还没完, 我们还需要给这个 synchronized 提供一个对象, 用于给他进行加锁. 例如下面这样写
具体这个锁对象是什么, 我们后面进行介绍, 我们现在就先使用即可.
随后我们给两个 count++ 都加上这个 synchronized, 随后尝试运行代码
public class SynDemo01 {
private static int count = 0;
public static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// 创建两个线程,每个线程对 count 进行 50000 次自增操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock){
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock){
count++;
}
}
});
// 启动线程
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
最后运行可以发现, 结果正确
初步了解
上面我们运用了 synchronized 关键字给 count++ 操作进行加锁, 但是我们依旧留下了很多的谜团. 那个锁对象又是什么东西? 它又是如何使得我们的代码可以正确执行的? 接下来我们就来依次介绍
这个锁对象, 实际上是我们加锁操作是否能够成功的关键步骤. 当代码执行到 synchronized 的时候, 当前线程就会去给这个锁对象进行加锁, 随后运行完代码块的时候再去释放锁. 在加锁的过程中, 其他的线程就不能给这个对象再进行加锁了, 必须要等到占用锁的线程把锁给释放了, 才能够进行加锁.
我们举一个通俗的例子, 假如现在有两个小伙子张三和李四, 它们都想要去追求小美. 后来张三优先追求成功, 那么此时如果李四想要去追求小美, 那么就只能等待到张三和小美分手后才能追求了.
但是假如说, 如果两个线程不是对同一个对象加锁, 那么此时第二个线程就不用等待, 而是可以直接进行加锁操作.
好比上面的李四, 它追求的不是小美, 而是小红, 那么此时自然就不用等到张三和小美分手了.
但是在我们上面的这个代码里面, 如果两个线程不是对同一个对象进行加锁, 那么此时效果其实和没有加锁是一样的, 还是会出现错误
package lock;
public class SynDemo01 {
private static int count = 0;
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static void main(String[] args) throws InterruptedException {
// 创建两个线程,每个线程对 count 进行 50000 次自增操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock1){
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock2){
count++;
}
}
});
// 启动线程
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
结合我们上面的讨论, 当我们的两个线程对同一个锁对象进行加锁的时候, 此时的执行流程就会变为如下图所示的情况
那么此时可能有人就要问了: 那这个锁对象有没有什么要求呢?
实际上, 这个锁对象并没有什么要求, 只要是一个对象就行. 主要是因为这个加锁相关的东西是存储在 Java 的对象头里面的, 这个对象头指的就是 Java 的每一个对象都会有一块区域, 这些区域就是存储一些自带的属性的, 其中就有一些属性去标识加锁的相关信息.
不过如果是要加不同的锁的时候, 务必注意要使用不同的对象来进行加锁.
这里值得一提的是, 不要过度理解这里的加锁操作, 我们这里描述的加锁规则有且仅有"针对一个对象进行加锁, 如果对象被加锁了, 就阻塞等待. 如果对象没有被加锁, 那么就执行加锁操作, 然后执行自己的代码, 执行完就释放锁".
当然, synchronized 并不仅仅可以用于加锁一个代码块, 也可以修饰方法, 如下所示
public class SynDemo01 {
private static int count = 0;
// 使用 synchronized 修饰方法
public synchronized void increase(){
count++;
}
public static void main(String[] args) throws InterruptedException {
SynDemo01 synDemo01 = new SynDemo01();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// 调用方法进行自增
synDemo01.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synDemo01.increase();
}
});
// 启动线程
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
此时看似没有锁对象, 但是实际上它这里的锁对象是synDemo01
这个对象, 也就是通过 this 来加锁的. 相当于如下代码
public void increase() {
synchronized (this) {
count++;
}
}
同时我们也可以对静态方法进行加锁, 此时使用的锁对象就是类对象
public class SynDemo01 {
private static int count = 0;
// 使用 synchronized 修饰方法
public synchronized static void increase(){
count++;
}
public static void main(String[] args) throws InterruptedException {
// 创建两个线程,每个线程对 count 进行 50000 次自增操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
increase();
}
});
// 启动线程
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
此时这个自增的方法相当于如下代码
public static void increase() {
synchronized (SynDemo01.class) {
num++;
}
}
可重入锁
synchronized 加的锁是可重入锁, 可重入锁指的就是同一个线程可以对同一个锁对象反复加锁, 比如下面的代码
synchronized (lock) {
synchronized (lock) {
//操作
}
}
假如是不可重入锁, 那么在这种嵌套锁的代码中, 就会产生问题, 如下图所示
这个情况就非常类似于, 你手上拿着手机然后去找手机在什么地方一样的这种操作.
因此为了避免这种问题, synchronized被设定为了可重入锁, 那么此时又引出了一个问题, 什么时候释放锁呢?
为了更好的解释这个问题, 我们先看一个图片
此时我们可以在出去绿色区域的时候直接把锁释放掉吗? 很明显不太行, 因为如果我们都给红色区域的这些代码进行了加锁, 证明这一部分的所有代码我都应该希望能够加锁执行, 但是你如果在绿色出来就放掉, 此时后面的代码就没有在锁的环境下运行了.
因此我们应该在出去红色区域的时候, 才能够释放锁, 那么如何做到这种效果呢? 实际上也很简单, 我们直接通过一个计数器去记录加锁的次数, 每进一层计数器就加一, 每出一层计数器就减一
死锁
死锁, 简单地说就是由于锁冲突导致的线程卡死问题. 例如我们刚刚介绍的不可重入锁的嵌套问题, 实际上就是一个死锁问题.
那么什么时候容易发生死锁呢? 实际上一般就分为两种情况, 一个就是我们刚刚介绍的不可重入锁的嵌套问题, 还有一个就是多个线程多把锁同时存在的时候.
下面我们就通过两个线程, 两把锁来演示一下死锁
public class LockDemo {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("t1: 拿到锁1");
// 等待一下, 防止执行过快直接跑完了
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock2) {
System.out.println("t1: 拿到锁2");
}
}
System.out.println("t1: 线程结束");
});
Thread t2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("t2: 拿到锁2");
// 等待一下, 防止执行过快直接跑完了
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock1) {
System.out.println("t2: 拿到锁1");
}
}
System.out.println("t2: 线程结束");
});
t1.start();
t2.start();
Thread.sleep(5000);
// 观察一下线程状态
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
这个代码中, 我们主要就是让两个线程嵌套的去获得另一把锁, 同时打印一下状态.
此时我们可以看到, 两个线程都没有获取到第二把锁, 并且两个线程都在阻塞等待.
此时就相当于是 t1 线程拿着 lock1 希望拿到 t2 手上的 lock2, 但是 t2 就拿着 lock2 希望拿到 t1 手上的 lock1.
这就好比一种尴尬的情况: 张三和李四出去吃饺子, 然后张三手上拿着酱油, 李四手上拿着醋. 此时张三和李四都想要同时加入醋和酱油, 但是两个人都不愿意放下手上的酱油/醋先给对面用, 然后就卡死了.
那么很明显, 我们这里产生死锁的一个重要原因就是, 对面尝试获取第二把锁的时候, 都是手上拿着第一把锁的. 那假如我不让这两个锁嵌套, 而是并列, 此时会产生问题吗? 我们尝试一下
public class LockDemo {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("t1: 拿到锁1");
// 等待一下, 防止执行过快直接跑完了
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
synchronized (lock2) {
System.out.println("t1: 拿到锁2");
}
System.out.println("t1: 线程结束");
});
Thread t2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("t2: 拿到锁2");
// 等待一下, 防止执行过快直接跑完了
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
synchronized (lock1) {
System.out.println("t2: 拿到锁1");
}
System.out.println("t2: 线程结束");
});
t1.start();
t2.start();
Thread.sleep(5000);
// 观察一下线程状态
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
很明显, 此时就没有任何问题了, 两者都顺利结束
此时就相当于, 张三用完了酱油就放下了, 李四用完了醋也放下了, 此时就不会卡死了.
上面的方案虽然解决了问题, 但是有一些时候就是难以避免的需要去加多把锁, 那么有没有什么解决方案呢?
实际上也是可以的, 我们可以对加锁的顺序进行约定, 例如约定从更小编号的锁/更大编号的锁来进行加锁, 此时就可以解决这个问题.
例如在我们上面的代码中, 我们就要求只能先加锁 lock1, 然后才能加锁 lock2. 修改代码如下
public class LockDemo {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("t1: 拿到锁1");
// 等待一下, 防止执行过快直接跑完了
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock2) {
System.out.println("t1: 拿到锁2");
}
}
System.out.println("t1: 线程结束");
});
Thread t2 = new Thread(() -> {
synchronized (lock1) {
System.out.println("t2: 拿到锁2");
// 等待一下, 防止执行过快直接跑完了
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock2) {
System.out.println("t2: 拿到锁1");
}
}
System.out.println("t2: 线程结束");
});
t1.start();
t2.start();
Thread.sleep(5000);
// 观察一下线程状态
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
这里就相当于, 虽然张三和李四都想要加酱油和醋, 但是它们约定好了, 都只能先加酱油后加醋. 那么当另一个人手上在用酱油的时候, 另一个人就只能先等对面用完, 然后再用, 此时就不会发生手上拿着一个, 想着另一个的情况了(吃着碗里的, 想着锅里的).
实际上, 死锁的产生原因有四个必要条件, 换句话说, 这四个条件少一个都不会触发死锁, 这四个条件分别是:
- 互斥使用(锁的性质): 锁只能被一个人占用,其他人想要占用必须等前一个人用完
- 不可抢占(锁的性质): 锁只能由加锁的人自己释放, 后面的人不能强行释放
- 请求保持(代码结构): 涉及到多把锁的时候, 要获取第后续的锁的时候前面的锁不会释放而是会保持
- 环路等待(代码结构): 多个锁和多个线程配合, 形成了环形结构
这个环形依赖, 看起来比较抽象, 其实我们上面也是介绍过的. 就是张三和李四手上分别拿着酱油和醋不放的情景, 就是形成了环形结构
由于 1 和 2 都是锁本身的特性, 我们无法解决, 因此我们只能在写代码的时候注意不要出现 3 和 4. 其中 3 和 4 的对应情况和解决方案其实我们都已经介绍过了.
对于情况 3, 主要就是尽可能地不要去嵌套不同的锁, 在条件允许的情况下, 并列使用锁.
对于情况 4, 可以约定加锁顺序, 防止形成环形结构.
volatile
问题引入
接下来我们依旧是先来写一个简单的代码: 写一个循环的线程, 循环的判断标志使用一个自己定义的标志位, 然后在主线程将标志位更改为 false, 观察代码运行
public class VolDemo {
private static boolean flag = true;
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (flag) {
// do nothing
}
System.out.println("t1: 线程结束");
});
thread.start();
System.out.println("main: 1s后执行终止操作");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
flag = false;
System.out.println("main: 线程终止");
}
}
尝试运行, 我们会发现 thread 线程根本就没有结束的迹象, 日志都没有打印出来
很明显这也是一个线程安全问题, 那么这个线程安全问题是由谁引发的呢? 我们这里也没有什么修改操作啊
这实际上是编译器的优化机制导致的. 众所周知, CPU 从内存中读取数据是一个开销较大的工作, 那么我们这个循环处于一直执行的状态, 并且速度极快, 但是如果一直从内存中读取数据就会极大的降低程序的运行效率. 那么此时编译器就会检测, 假如它检测到你的这个标志位似乎是不变的, 那么此时编译器就会从内存中复制一份标志位的拷贝到 CPU 的寄存器中, 此时 CPU 从寄存器中读取数据就快很多, 就会起到优化的效果.
但是假如我们在拷贝后改变了这个变量, 由于我们这个修改操作是在另外一个线程进行的, 编译器检测不到你这个操作会涉及到另外一个线程, 并不会更新那个拷贝, 那么我们这个循环就会一直执行下去了.
那么这个问题就被称作是"内存可见性"问题, 也属于线程安全问题的一种.
此时可能有人就要问了: 为什么叫做内存可见性问题呢?
当编译器复制粘贴一份拷贝, 从而让 CPU 不去读这一块内存后, CPU 读取的就不是内存而是寄存器了, 此时就相当于 CPU 被寄存器蒙蔽了双眼, 看不到内存里面的东西了, 因此它就叫做内存可见性问题.
那么我们如何解决内存可见性问题呢? 此时就需要通过 volatile 关键字来实现了
初步使用
volatile 关键字的使用也非常简单, 对于这个 flag 变量, 我们是希望编译器不要去优化它的, 那么此时我们就可以通过 volatile 关键字去修饰这个变量, 从而来告诉编译器不要优化, 那么此时这个问题就解决了
public class VolDemo {
private volatile static boolean flag = true;
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (flag) {
// do nothing
}
System.out.println("t1: 线程结束");
});
thread.start();
System.out.println("main: 1s后执行终止操作");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
flag = false;
System.out.println("main: 线程终止");
}
}
可以看到, 此时线程正常终止
实际上这个优化机制是难以摸索的, 比如我们在这个循环里面加一个睡眠 1ms, 此时就会因为执行次数没那么多, 编译器认为不需要优化, 那么就不会触发优化机制
public class VolDemo {
private static boolean flag = true;
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (flag) {
// 休眠 1ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1: 线程结束");
});
thread.start();
System.out.println("main: 1s后执行终止操作");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
flag = false;
System.out.println("main: 线程终止");
}
}
因此与其摸索编译器的优化机制, 不如我们直接手动加上 volatile 更加靠谱.
实际上编译器的优化并不局限于此, 编译器还有一个优化叫做指令重排序, 它同样也会引起线程安全问题, 并且也需要通过 volatile 告诉编译器不要优化从而解决, 不过这个问题我们就到后续遇到的时候再进行介绍了.
此时可能有人就要说了: 你这编译器不是坑人吗? 优化反而弄出了 Bug.
实际上, 编译器的初心是好的, 是为了能够在保证代码逻辑基本不变的情况下, 能够使得程序更加高效的运行. 但是问题就在于, 编译器它的优化是对于单个线程来说没有问题的, 而在多线程的代码中, 编译器并没有办法兼顾所有的情况, 那么此时就需要我们手动的去进行一些介入, 从而防止编译器的优化出现问题.
volatile 与 synchronized
虽然 volatile 和 synchronized 它们都用于去解决线程安全问题, 但是它们解决的问题并不相同, volatile 主要用于解决内存可见性问题, 并不能和 synchronized 一样去解决线程同步问题
例如我们将上面的 synchronized 使用到的例子采用 volatile 来尝试进行解决, 此时就是将那个自增 100000 次的变量加上 volatile, 然后尝试运行代码
public class SynDemo01 {
private volatile static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 创建两个线程,每个线程对 count 进行 50000 次自增操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
// 启动线程
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
此时可以看到结果就是不正确的
并且我们给这个 count 加上 volatile 后, IDEA 也会给出提示说这个操作不是原子的操作
线程顺序控制
初步了解
一般来说, 线程的执行顺序是由系统的随机调度决定的, 不过随机的东西总是有它的不可控性, 因此我们大多数时候还是希望能够控制线程的执行顺序.
虽然之前我们学习过一个 join() 方法似乎可以控制一些顺序, 但是那个方式主要是影响线程的结束顺序, 而有些时候我们也希望能在线程不结束的情况下进行线程执行顺序的控制, 因此就有了 wait() 和 notify() 这两个方法
这两个方法实际上是属于 Object 类的, 因此随便一个对象都可以调用这两个方法. 两个方法顾名思义, wait() 是用来让线程停下的, 而 notify() 是用来唤醒线程的.
此时可能有人就要问了: 那如果我要调用 wait() 方法, 就一定要一个对象吗? 这是什么设定?
这个问题, 我们先留一个悬念, 我们在下面使用 wait() 的时候, 自然就可以明白为什么了. 因此, 我们先来看看 wait() 的使用.
wait()
wait() 的使用也非常的简单, 主要就是通过一个对象调用, 然后当前的线程就会进入等待状态, 当然这里也是可以提供一个等待时间的, 提供的方式就和 join(), sleep() 一样
那此时假如我们直接尝试让主线程停下
public class WaitDemo {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
object.wait();
}
}
尝试运行, 发现直接抛出异常IllegalMonitorStateException
那么这是为什么呢? 实际上, 这里的 Moinitor 翻译过来就是监视器, 而 synchronized 锁实际上有一个称呼叫做监视器锁. 那么我们的这个 wait() 和 synchronized 又有什么关系呢?
此时就不得不提到 wait() 到底是如何执行的了, wait() 执行的过程一般分为三个步骤:
- 释放当前的锁
- 让线程进入阻塞状态
- 当线程被唤醒的时候, 重新获得锁
也就是说, 如果想要使用 wait(), 那么调用 wait() 的对象必须是被加锁后的对象, 并且这个 wait() 一定是在对应对象的 synchronized 修饰的代码块里面执行, 因为出了外边对象的锁就没了.
此时我们将代码进行修改, 发现成功执行
public class WaitDemo {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object){
object.wait();
}
}
}
使用 wait() 就可以让一直占用锁的线程停止占用, 并且释放锁, 等到之后在合适的时机来唤醒这个线程
此时可能有人要问了: 你的这个 wait() 好像是没有时间的等待, 但是似乎也涉及到了锁, 那么在阻塞的时候它应该是什么什么状态呢?
想要探究这个问题, 也非常的简单, 直接写一个代码验证即可
public class WaitStateDemo {
public static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() ->{
synchronized (lock){
try {
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
// 等待一下防止执行过快
Thread.sleep(1000);
System.out.println(thread.getState());
}
}
最后可以发现, 这里的状态实际上是 WAITING.
实际上 BLOCKED 一般指的是涉及到锁竞争的状态, 例如一个线程占用锁, 另一个线程也尝试获取锁, 此时尝试获取锁的线程的状态就是 BLOCKED. 而这里这个线程是在主动的进行等待, 并没有涉及到冲突, 因此自然也就是一个 WAITING 状态了.
notify()
notify() 的使用也和 wait() 是大差不差的, 也需要用锁对象调用这个方法, 并且也要在 synchronized 修饰的代码块中执行. 调用 notify() 后, 就会唤醒这个锁对象管理的处于 wait() 状态下的线程.
下面我们简单写一个例子
public class WaitDemo {
public static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("t: wait执行前");
synchronized (lock) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t: wait执行后");
});
t.start();
System.out.println("main: 2s后唤醒 t 线程");
Thread.sleep(2000);
synchronized (lock) {
lock.notify();
}
}
}
可以发现 t 线程成功被唤醒
但是假如此时有多个线程被阻塞了, 那么此时会唤醒谁呢? 我们接下来还是通过代码来测试一下
public class WaitDemo {
public static final Object lock = new Object();
public static int waitCount = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock) {
try {
System.out.println("t1: 开始等待");
lock.wait();
System.out.println("t1: wait 执行后, 被唤醒");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
try {
System.out.println("t2: 开始等待");
lock.wait();
System.out.println("t2: wait 执行后, 被唤醒");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t3 = new Thread(() -> {
synchronized (lock) {
try {
System.out.println("t3: 开始等待");
lock.wait();
System.out.println("t3: wait 执行后, 被唤醒");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
t3.start();
System.out.println("main: 5s 后尝试唤醒线程");
Thread.sleep(5000);
synchronized (lock) {
lock.notify();
lock.notify();
lock.notify();
}
}
}
此时上面的这个程序, 就相当于有三个线程, 分别会去执行 wait(), 然后依次进入阻塞状态, 随后 main 线程会尝试去依次唤醒线程. 那么接下来我们来尝试运行代码查看效果
可以发现, 进入等待的顺序, 和唤醒的顺序是不一样的. 实际上, 这里的唤醒是随机进行的, 并不会根据先来后到的原则执行.
并且由于我们的 notify() 也是占用锁执行的, 而唤醒线程后, 那个线程又要重新尝试占用锁对象, 所以真正的唤醒要等到 notify() 这边把锁释放后才能执行. 就好比张三正在一个厕所的位置上厕所, 此时它上完了就和李四说你也可以来了. 但是李四来了后, 张三还在里面冲水呢, 此时李四就需要在外面等待张三出来后, 才能进去上厕所.
实际上 notify() 还有一个相关的方法就是 notifyAll(), 这个方法看名字也知道是干什么的, 就是直接把所有的线程直接给唤醒.
防止线程饿死
实际上, wait() 和 notify() 也可以防止线程饿死. 什么是饿死, 这个实际上我们在谈进程的调度的时候也说过, 就是一个进程一直占用资源导致进程吃不到资源. 而线程是调度的基本单位, 因此对于线程来说, 就是线程一直不会被调度.
我们来看一个例子, 假设现在有一个贩卖机, 然后张三现在在里面尝试买饮料, 后面排着一堆的人等着
此时张三看没有自己想喝的, 就想着出去等会, 但是它半只脚刚踏出门, 就又想回来看看有有没有自己想喝的. 然后一直这样进行循环
很明显, 张三这样干, 就容易直接被后面排队的人干掉. 虽然这种情况在现实中基本不会出现, 但是在计算机中是非常有可能出现这样的情况的.
为什么呢? 因为线程想要调度到 CPU 上进行执行, 那么就势必要经过操作系统的调度过程. 而上面张三的这个线程, 它就是直接在 CPU 上面跑着的, 不需要调度就可以直接获取, 因此此时张三线程是更有优势的.
那么此时针对这种情况, 我们就可以采用 wait() 和 notify() 来进行解决, 我们可以先让张三调用 wait() 进入阻塞状态, 给其他的线程提供进入贩卖机的机会. 随后等到后面的工作人员来补货的时候, 就可以调用对应的 notify() 方法. 后续等待补货人员补完了, 张三就可以回去买饮料/等着买饮料了.
简述 wait() 和 sleep() 的区别
实际上这两个东西本质上都不是同一个类别的东西, 一个用于控制执行速度, 一个用于控制执行顺序, 不过这个问题有一些时候还是会遇到的, 因此我们就提及一下
- sleep() 必须指定一个等待时间, 而 wait() 可以不指定等待时间(允许死等)
- sleep() 会使线程进入 TIMED_WAITING 状态, 唤醒方式一般是时间到了自动唤醒. 而 wait() 会使线程进入 WAITING 状态, 一般是等别的线程调用 notify() 方法来唤醒
- sleep() 可以在线程代码中的任意位置书写, 但是 wait() 需要在 synchronized 关键字修饰的代码块中才可以使用
- sleep() 静态方法属于 Thread 类, 通常直接使用类名调用. wait() 方法属于 Object 类, 任何作为锁对象的对象都可以调用