若文章内容或图片失效,请留言反馈。
部分素材来自网络,若不小心影响到您的利益,请联系博主删除。
写这篇博客旨在制作笔记,方便个人在线阅览,巩固知识。
博客的内容主要来自视频内容和资料中提供的学习笔记。
参考博客
参考书籍
- 《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
系列目录
- 学习笔记:Java 并发编程①_基础知识入门
- 学习笔记:Java 并发编程②_共享模型之管程
- 学习笔记:Java 并发编程③_共享模型之内存
- 学习笔记:Java 并发编程④_共享模型之无锁
- 学习笔记:Java 并发编程⑤_共享模型之不可变
- 学习笔记:Java 并发编程⑥_共享模型之并发工具_线程池
- 学习笔记:Java 并发编程⑥_共享模型之并发工具_JUC
前言
学习该课程前的预备知识
- 线程安全问题,需要你接触过 JavaWeb 开发、Jdbc 开发、Web 服务器、分布式框架 时才会遇到
- 基于 JDK8,最好对函数式编程、Lambda 有一定了解
- 采用了 slf4j 打印日志
- 采用了 lombok 简化 java bean 编写
- 给每个线程好名字,这也是一项好的实践
pom.xml 的依赖
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
</dependencies>
logback.xml 的配置如下
<?xml version="1.0" encoding="UTF-8"?>
<configuration
xmlns="http://ch.qos.logback/xml/ns/logback"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://ch.qos.logback/xml/ns/logback logback.xsd">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date{HH:mm:ss} [%t] %logger - %m%n</pattern>
</encoder>
</appender>
<logger name="c" level="debug" additivity="false">
<appender-ref ref="STDOUT"/>
</logger>
<root level="ERROR">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
不过我自己实际导入的依赖是参考自博客:JAVA 并发编程 (上) 超详细笔记 从入门到深入原理
pom.xml 的依赖
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.22</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>RELEASE</version>
<scope>compile</scope>
</dependency>
</dependencies>
resources
目录下的 logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date{HH:mm:ss} [%t] %logger - %m%n</pattern>
</encoder>
</appender>
<logger name="c" level="debug" additivity="false">
<appender-ref ref="STDOUT"/>
</logger>
<root level="ERROR">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
1.进程和线程
本章内容
- 进程和线程的概念
- 并行和并发的概念
- 线程基本应用
1.1.概念
1.1.1.进程
- 程序由指令和数据组成。
- 但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。
- 在指令运行过程中还需要用到磁盘、网络等设备。
- 进程就是用来加载指令、管理内存、管理 IO 的。
- 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
- 进程就可以视为程序的一个实例。
- 大部分程序可以同时运行多个实例进程(例如:记事本、画图、浏览器 等)。
- 有的程序只能启动一个实例进程(例如:网易云音乐、360 安全卫士等)。
1.1.2.线程
- 一个进程之内可以分为一到多个线程。
- 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。
- Java 中,线程作为小调度单位,进程作为资源分配的最小单位。
- 在 Windows 中进程是不活动的,只是作为线程的容器
1.1.3.对比
- 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集。
- 进程拥有共享的资源(如内存空间等),供其内部的线程共享。
- 进程间通信较为复杂:
- 同一台计算机的进程通信称为 IPC(Inter-process communication)。
- 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议。如 HTTP。
- 线程通信相对简单,因为它们共享进程内的内存。
- 一个例子是多个线程可以访问同一个共享变量。
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低。
1.1.4.上下文概念
上下文是从英文 context 翻译过来,指的是一种环境。
相对于进程而言,就是进程执行时的环境。
具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存信息等。
为什么会有上下文这种概念?
- 内核空间和用户空间是现代操作系统的两种工作模式。
- 内核模块运行在内核空间,而用户态应用程序运行在用户空间。
- 它们代表不同的级别,而对系统资源具有不同的访问权限。
- 内核模块运行在最高级别(内核态),这个级下所有的操作都受系统信任;
- 而应用程序运行在较低级别(用户态),在这个级别下,处理器控制着对硬件的直接访问以及对内存的非授权访问。
- 内核态和用户态有自己的内存映射,即自己的地址空间。
- 其中处理器总处于以下状态中的一种:
- 内核态,运行于进程上下文,内核代表进程运行于内核空间;
- 内核态,运行于中断上下文,内核代表硬件运行于内核空间;
- 用户态,运行于用户空间。
- 系统的两种不同运行状态,才有了上下文的概念。
- 用户空间的应用程序,如果想请求系统服务:
- 比如操作某个物理设备,映射设备的地址到用户空间,必须通过系统调用来实现。
- 系统调用是操作系统提供给用户空间的接口函数。
- 用户空间的应用程序,如果想请求系统服务:
- 通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换。
- 用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组。
- 而用户空间的进程要传递很多变量、参数给内核,
- 内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。
1.2.并行与并发
单核 cpu 下,线程实际还是 串行执行 的。
操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows 下时间片最小约为 15 毫秒)分给不同的程序使用。
只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。
总结为一句话就是: 微观串行,宏观并行 。
一般会将这种线程轮流使用 CPU 的做法称为 并发(concurrent)
CPU | 时间片 1 | 时间片 2 | 时间片 3 | 时间片 4 |
---|---|---|---|---|
core | 线程 1 | 线程 2 | 线程 3 | 线程 4 |
多核 cpu 下,每个 核(core) 都可以调度运行线程,这时候线程可以是 并行(parallel) 的。
CPU | 时间片 1 | 时间片 2 | 时间片 3 | 时间片 4 |
---|---|---|---|---|
core1 | 线程 1 | 线程 2 | 线程 3 | 线程 4 |
core2 | 线程 4 | 线程 4 | 线程 2 | 线程 2 |
Rob Pike – 百度百科(go 语言之父)
引用 Rob Pike 的一段描述:
- 并发(concurrent) 是同一时间应对(dealing with)多件事情的能力
- 并行(parallel) 是同一时间动手做(doing)多件事情的能力
举例
- 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发。
- 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行。
- 这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待。
- 雇了 3 个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行。
1.3.应用
1.3.1.同步和异步
从调用方的角度来讲同步和异步的概念
- 如果需要等待结果返回才能继续运行的话就是同步
- 如果不需要等待就是异步
注意:同步在多线程中还有另外一层意思,即让多个线程步调一致。
1.3.2.案例1(异步调用)
- 设计
多线程可以让方法执行变为异步的(即不要巴巴干等着)。
比如读取磁盘文件时,读取操作需要花费 5 秒钟。
如果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停 … …
本博客主要是学习做笔记,方便在线阅览用。具体的代码实现和细节部分还请见视频和资料。之后不再赘述这点。
此时我们尚未学习到相关的 api,相关的环境搭建和代码实现,了解即可。
- 结论
比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程。
tomcat 的异步 servlet 也是类似的目的:让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程。
ui 程序中,开线程进行其他操作,避免阻塞 ui 线程
1.3.3.案例1(提高效率)
充分利用多核 cpu 的优势,提高运行效率。
想象下面的场景,执行 3 个计算,最后将计算结果汇总。
计算 1 花费 10 ms
计算 2 花费 11 ms
计算 3 花费 9 ms
汇总需要 1 ms
- 如果是串行执行,那么总共花费的时间是 10 + 11 + 9 + 1 = 31ms
- 但如果是四核 cpu,各个核心分别使用不同的线程:
- 线程 1 执行计算 1,线程 2 执行计算 2,线程 3 执行计算 3,那么 3 个线程就是并行的。
- 花费时间只取决于最长的那个线程运行的时间,即 11ms 最后加上汇总时间只会花费 12ms。
注意:需要在多核 cpu 才能提高效率,单核仍然时是轮流执行
1.设计
具体的设计见资料中提供的并发编程_应用.pdf
此时我们尚未学习到相关的 api,相关的环境搭建和代码实现,了解即可。
2.结论
- 单核 cpu 下,多线程不能实际提高程序运行效率。
- 只是为了能够在不同的任务之间切换,不同线程轮流使用 cpu 。
- 防止出现一个线程总占用 cpu,而别的线程没法干活的情况。
- 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的。
- 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。
- 但不是所有计算任务都能拆分(参考后文的 阿姆达尔定律)。
- 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
- 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。
- IO 操作不占用 cpu,只是我们一般拷贝文件使用的是 阻塞 IO。
- 这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程。
- 所以才有后面的 非阻塞 IO 和 异步 IO 优化。
2.Java 线程
本章内容
- 创建和运行线程
- 查看线程
- 线程 API
- 线程状态
2.1.创建和运行线程
方法一:直接使用 Thread
方法二:使用 Runnable 配置 Thread
方法三:FutureTask 配合 Thread
2.1.1.直接使用 Thread
// 创建线程对象
Thread t = new Thread() {
public void run() {
// 要执行的任务
}
};
// 启动线程
t.start();
代码演示
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.Test1")
public class Test_1 {
public static void main(String[] args) {
//构造方法的参数是给线程指定名字(推荐)
Thread thread = new Thread("thread_1") {
@Override
//run 方法内实现了要执行的任务
public void run() {
log.debug("running");
}
};
//也可以使用 void setName(String name)方法来设置线程名
//thread.setName("t1");
thread.start();
log.debug("running");
}
}
打印结果
11:58:49 [main] c.Test1 - running
11:58:49 [thread_1] c.Test1 - running
2.1.2.使用 Runnable 配置 Thread
把 线程 和 任务(要执行的代码) 分开
Thread
代表线程Runnable
可运行的任务(线程要执行的代码)
Runnable runnable = new Runnable() {
public void run() {
// 要执行的任务
}
};
// 创建线程对象
Thread t = new Thread(runnable);
// 启动线程
t.start();
代码演示
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.Test2")
public class Test_2 {
public static void main(String[] args) {
//创建任务对象
Runnable runnable = new Runnable() {
@Override
public void run() {
//要执行的任务
log.debug("HelloWorld!");
}
};
Thread thread = new Thread(runnable, "t2");
thread.start();
}
}
输出结果
12:32:36 [t2] c.Test2 - HelloWorld!
Java 8 以后可以使用 lambda 精简部分代码
//创建任务对象
Runnable runnable = () -> log.debug("HelloWorld!");
2.1.3.原理之 Thread 与 Runnable 的关系
分析 Thread 的源码,理清它与 Runnable 的关系:https://www.bilibili.com/video/BV16J411h7Rd?p=14
- 方法 1 是把线程和任务合并在了一起,方法 2 是把线程和任务分开了(推荐)。
- 用 Runnable 更容易与线程池等高级 API 配合。
- 用 Runnable 让任务类脱离了 Thread 继承体系,更灵活。
2.1.4.FutureTask 配合 Thread
FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况
代码演示
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
@Slf4j(topic = "c.Test3")
public class Test_3 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//创建任务对象
FutureTask<Integer> task = new FutureTask<>(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
log.debug("running... ...");
Thread.sleep(1000);
return 100;
}
});
/**
* Thread(Runnable target, String name)
* 参数 1 为任务对象,参数 2 为线程名
*/
Thread thread = new Thread(task, "线程名_t1");
thread.start();
//主线程阻塞,同步等待 task 执行完毕的结果
Integer result = task.get();
log.debug("结果:{}", result);
}
}
打印结果
16:47:33 [线程名_t1] c.Test3 - running... ...
16:47:34 [main] c.Test3 - 结果:100
2.2.观察多个线程同时进行
旨在理解
- 交替执行
- 多个线程中,谁先谁后,不由我们控制
观察多个线程同时进行的演示视频:https://www.bilibili.com/video/BV16J411h7Rd?p=16
2.3.查看进程线程的方法
- windows
- 任务管理器可以查看进程和线程数,也可以用来杀死进程
tasklist
查看进程taskkill
杀死进程
比如我们可以通过 tasklist | findstr java
来查找 java 相关的进程
再通过 taskkill /F /PID 具体的pid
来强制终止进程。
- linux
ps -fe
查看所有进程ps -fT -p <PID>
查看某个进程(PID)的所有线程kill
杀死进程top
动态查看线程信息(按大写 H 切换是否显示线程)top -H -p <PID>
查看某个进程(PID)的所有线程
例如我们可以上传这样一个文件:TestMultiThread.java
,具体内容如下
之后我们再使用命令编译该文件
javac TestMultiThread.java
再用 java 程序来运行它
java TestMultiThread
使用相关命令来查看进程的情况
如上图所示,若是要强制终止 TestMultiThread 进程,使用命令 kill -9 12943
即可。
- Java
jps
命令查看所有 Java 进程jstack <PID>
查看某个 Java 进程(PID)的所有线程状态jconsole
来查看某个 Java 进程中线程的运行情况(图形界面)
- jconsole 远程监控配置
- 需要以如下方式运行你的 java 类
java -Djava.rmi.server.hostname=ip地址 \
-Dcom.sun.management.jmxremote \
-Dcom.sun.management.jmxremote.port=连接端口 \
-Dcom.sun.management.jmxremote.ssl=是否安全连接(写 true 或者 false) \
-Dcom.sun.management.jmxremote.authenticate=是否认证(写 true 或者 false) \
TestMultiThread
- 修改
/etc/hosts
文件将127.0.0.1
映射至主机名
之后打开 jconsole,使用 ip地址:端口号
连接即可。
如果要认证访问,还需要做如下步骤
- 复制
jmxremote.password
文件 - 修改
jmxremote.password
和jmxremote.access
文件的权限为 600 即文件所有者可读写 - 连接时填入
controlRole
(用户名),R&D
(密码)
2.4.原理之线程运行
2.4.1.栈与栈帧
Java Virtual Machine Stacks(Java 虚拟机栈)
JVM 中由 堆、栈、方法区 所组成,其中栈内存是给谁用的呢?
其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。
- 每个栈由多个 栈帧(Stack Frame) 组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
线程运行原理-栈帧 Debug 的演示视频:https://www.bilibili.com/video/BV16J411h7Rd?p=20
下面那个图看看就好,具体的演示过程和解析还是要看下面的视频
线程运行原理-栈帧图解 的演示视频:https://www.bilibili.com/video/BV16J411h7Rd?p=21
程序计数器是用于存放下一条指令所在单元的地址的地方。
当执行一条指令时,首先需要根据 PC 中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为 “取指令” 。
与此同时,PC 中的地址或自动加 1 或由转移指针给出下一条指令的地址。
此后经过分析指令,执行指令。
完成第一条指令的执行,而后根据 PC 取出第二条指令的地址,如此循环,执行每一条指令。
程序计数器是负责记录要执行的代码行(字节码指令),然后把将要执行的字节码指令交给 cpu 执行。(简单理解)
多线程的栈帧运行:线程的栈内存是相互独立的,每个线程拥有自己独立的栈内存。栈内存里面有多个栈帧,它们互不干扰。
参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
- 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
- 在 JAVA 虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 由于 JAVA 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的。
- 在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
- 因此,为了线程切换后可以恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
- 各条线程之间计数器互不影响,独立存储,我们称这类内存区域为 “线程私有” 的内存。
- 如果线程正在执行的是一个 JAVA 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。
- 如果正在执行的是本地(Native)方法,这个计数器则应为空(Undefined)。
- 此内存区域是唯一一个在《JAVA虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。
2.4.2.线程上下文切换
线程上下文切换(Thread Context Switch)
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码。
- 线程的 cpu 时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当上下文切换(Context Switch)发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态。
Java 中对应的概念就是程序计数器(Program Counter Register)
- 它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- Context Switch 频繁发生会影响性能
2.5.线程中的常见方法
方法名 | 功能说明 | 注意 |
---|---|---|
start() |
启动一个新线程, 在新的线程运行 run 方法中的代码 |
start 方法只是让线程进入就绪, 里面代码不一定立刻运行(CPU 的时间片还没分给它)。 每个线程对象的 start 方法只能调用一次, 如果调用了多次会出现 IllegalThreadStateException。 |
run() |
新线程启动后会调用的方法 | 如果在构造 Thread 对象时传递了 Runnable 参数, 则线程启动后会调用 Runnable 中的 run 方法, 否则默认不执行任何操作。 但可以创建 Thread 的子类对象,来覆盖默认行为。 |
join() |
等待线程运行结束 | |
join(long n) |
等待线程运行结束, 最多等待 n 毫秒 |
|
getId() |
获取线程长整型的 id | id 唯一 |
getName() |
获取线程名 | |
setName(String) |
修改线程名 | |
getPriority() |
获取线程优先级 | |
setPriority(int) |
修改线程优先级 | java 中规定线程优先级是 1~10 的整数, 较大的优先级能提高该线程被 CPU 调度的机率。 |
getState() |
获取线程状态 | Java 中线程状态是用 6 个 enum 表示, 分别是:NEW、RUNNABLE、BLOCKED、 WAITING、TIMED_WAITING、TERMINATED |
isInterrupted() |
判断是否被打断 | 不会清除 打断标记 |
isAlive() |
线程是否存活(还没有运行完毕) | |
interrupt() |
通知目标线程中断,设置中断标志位 | 如果被打断线程正在 sleep,wait,join, 会导致被打断的线程抛出 InterruptedException, 并清除 打断标记 ; 如果打断的正在运行的进程,则会设置 打断标记; park 的线程被打断,也会设置 打断标记 |
方法名 | static | 功能说明 | 注意 |
---|---|---|---|
interrupted() |
static | 判断当前线程是否被打断 | 会清除 打断标记 |
currentThread() |
static | 获取当前正在执行的线程 | |
sleep(long n) |
static | 让当前执行的线程休眠 n 毫秒, 休眠时让出 cpu 的时间片给其它线程 |
|
yield() |
static | 提示线程调度器让出当前线程对 CPU 的使用 | 主要是为了测试和调试 |
2.6.start 与 run
调用 run
public static void main(String[] args) {
Thread t1 = new Thread("t1") {
@Override
public void run() {
log.debug(Thread.currentThread().getName());
FileReader.read(Constants.MP4_FULL_PATH);
}
};
t1.run();
log.debug("do other things ...");
}
输出
19:39:14 [main] c.TestStart - main
19:39:14 [main] c.FileReader - read [1.mp4] start ...
19:39:18 [main] c.FileReader - read [1.mp4] end ... cost: 4227 ms
19:39:18 [main] c.TestStart - do other things ...
程序仍在 main 线程运行,FileReader.read()
方法调用还是同步的
调用 start
将上述代码的 t1.run()
改为 1.start();
输出
19:41:30 [main] c.TestStart - do other things ...
19:41:30 [t1] c.TestStart - t1
19:41:30 [t1] c.FileReader - read [1.mp4] start ...
19:41:35 [t1] c.FileReader - read [1.mp4] end ... cost: 4542 ms
程序在 t1 线程运行,FileReader.read()
方法调用是异步的
小结
- 直接调用
run
是在主线程中执行了run
,没有启动新的线程,这样的话并不能起到一个异步的效果。 - 使用
start
是启动新的线程,通过新的线程间接执行run
中的代码
2.7.sleep 与 yield
2.7.1.sleep 与 yield 介绍
sleep
- 调用
sleep
会让 当前线程 从 Running 进入 Timed Waiting 状态(阻塞) - 其它线程可以使用
interrupt
方法打断正在睡眠的线程,这时sleep
方法会抛出 InterruptedException - 睡眠结束后的线程未必会立刻得到执行
- 建议用 TimeUnit 的
sleep
代替 Thread 的sleep
来获得更好的可读性
yield
- 调用
yield
会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程 - 具体的实现依赖于操作系统的任务调度器
sleep 和 yield 的区别
sleep()
使当前线程进入停滞状态,所以执行sleep()
的线程在指定的时间内肯定不会执行;
yield()
只是使当前线程重新回到可执行状态,所以执行yield()
的线程有可能在进入到可执行状态后马上又被执行。sleep()
可使优先级低的线程得到执行的机会,当然也可以让同优先级和高优先级的线程有执行的机会;
yield()
只能使同优先级的线程有执行的机会。
2.7.2.线程优先级
- 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
- 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片。但 cpu 闲时,优先级几乎没作用
2.7.3.案例:防止 CPU 占用 100%
限制对 CPU 的使用(sleep
实现)
在没有利用 cpu 来计算时,不要让 while(true)
空转浪费 cpu
这时可以使用 yield
或 sleep
来让出 cpu 的使用权给其他程序
可以用 wait
或 条件变量达到类似的效果
不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
sleep
适用于无需锁同步的场景
2.8.join 详解
2.8.1.join 使用的原因
观察下面的代码,猜测 r 可能会输出的值。
static int r = 0;
public static void main(String[] args) throws InterruptedException {
test1();
}
private static void test1() throws InterruptedException {
log.debug("开始");
Thread t1 = new Thread(() -> {
log.debug("开始");
sleep(1);
log.debug("结束");
r = 10;
});
t1.setName("ThreadName-t1");
t1.start();
log.debug("结果为:{}", r);
log.debug("结束");
}
输出结果
19:50:24 [main] c.Test10 - 开始
19:50:24 [ThreadName-t1] c.Test10 - 开始
19:50:24 [main] c.Test10 - 结果为:0
19:50:24 [main] c.Test10 - 结束
19:50:25 [ThreadName-t1] c.Test10 - 结束
Process finished with exit code 0
分析
- 因为主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能算出 r=10
- 而主线程一开始就要打印 r 的结果,所以只能打印出 r=0
解决方法
- 用
sleep
行不行?为什么?- 不推荐。因为 t1 线程从开始到结束的时间难以确定,不好把握主线程等待的时间。
- 用
join
,加在t1.start()
之后即可
输出结果
21:05:03 [main] c.Test10 - 开始
21:05:03 [ThreadName-t1] c.Test10 - 开始
21:05:04 [ThreadName-t1] c.Test10 - 结束
21:05:04 [main] c.Test10 - 结果为:10
21:05:04 [main] c.Test10 - 结束
从调用方的角度来讲
- 如果需要等待结果返回,才能继续运行,那就是同步
- 如果不需要等待结果返回,就能继续运行,那就是异步
2.8.2.join 同步应用
等待多个结果
问:下面代码 cost 大约多少秒?
static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
test2();
}
private static void test2() throws InterruptedException {
Thread t1 = new Thread(() -> {
sleep(1);
r1 = 10;
});
Thread t2 = new Thread(() -> {
sleep(2);
r2 = 20;
});
long start = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
t2.join();
long end = System.currentTimeMillis();
log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}
分析
- 第一个 join:等待 t1 时,t2 并没有停止, 而在运行
- 第二个 join:1s 后,执行到此,t2 也运行了 1s,因此也只需再等待 1s
如果颠倒两个 join 呢?
最终的花费时间都是一样的
21:40:32 [main] c.TestJoin - r1: 10 r2: 20 cost: 2013
2.8.3.join 限时同步
没等够时间
设置 t1 线程 sleep(2);
static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
test3();
}
public static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
sleep(2);
r1 = 10;
});
long start = System.currentTimeMillis();
t1.start();
// 线程执行结束会导致 join 结束
log.debug("join begin");
t1.join(1500);
long end = System.currentTimeMillis();
log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}
输出结果
20:52:15.623 [main] c.TestJoin - r1: 0 r2: 0 cost: 1502
等够时间
设置 t1 线程 sleep(1);
,其余代码同上
输出结果
20:48:01.320 [main] c.TestJoin - r1: 10 r2: 0 cost: 1010
2.9. interrupt 详解
2.9.1.interrupt 打断阻塞/正常
如果打断的正在运行的进程,则会设置 打断标记,其为一个布尔值。
interrupt()
方法并不是中断线程,而是中断阻塞状态,或者将线程的 打断标记 设置为 true。- 对于未阻塞的线程,
interrupt()
只是将 打断标记 设置为 true,线程本身运行状态不受影响。 - 对于阻塞的线程,
interrupt()
会中断阻塞状态,使其转换成非阻塞状态,并清除 打断标记- 即重置为假。
- 造成线程阻塞状态的情况有:
sleep()
、wait()
和join()
。 - 阻塞状态的线程被中断时,只是中断了阻塞状态,即
sleep()
、wait()
和join()
,线程本身还在继续运行。
- interrupt 打断阻塞
打断 sleep
,wait
,join
的线程(这三个方法都会让线程进入阻塞状态)
这里以 sleep
为例
Thread t1 = new Thread(() -> {
log.debug("sleep ...");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
t1.start();
Thread.sleep(1000);
log.debug("interrupt");
t1.interrupt();
log.debug("打断标记:{}",t1.isInterrupted());
输出
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at java.lang.Thread.sleep(Thread.java:340)
at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8)
at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
at java.lang.Thread.run(Thread.java:745)
21:18:10.374 [main] c.TestInterrupt - 打断状态: false
- interrupt 打断正常
打断正常运行的线程, 不会清空打断状态
Thread t1 = new Thread(() -> {
while (true) {
boolean interrupted = Thread.currentThread().isInterrupted();
if (interrupted) {
log.debug("被打断,退出循环");
break;
}
}
}, "t1");
t1.start();
Thread.sleep(1000);
log.debug("interrupt...");
t1.interrupt();
输出
08:30:10 [main] c.Test12 - interrupt...
08:30:10 [t1] c.Test12 - 被打断,退出循环
2.9.2.interrupt 设计模式 两阶段终止
Two Phase Termination
在一个线程 T1 中如何 “优雅” 地终止线程 T2 ?
这里的 优雅 指的是给 T2 一个料理后事的机会。
- 错误思路
-
使用线程对象的
stop()
方法停止线程stop
方法会真正杀死线程- 如果这时线程锁住了共享资源
- 那么当它被杀死后就再也没有机会释放锁
- 其它线程将永远无法获取锁
-
使用
System.exit(int)
方法停止线程- 目的仅是停止一个线程,但这种做法会让整个程序都停止
- 两阶段终止模式
代码演示
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.Test13")
public class Test13 {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTerminal twoPhaseTerminal = new TwoPhaseTerminal();
twoPhaseTerminal.start();
Thread.sleep(3500);
twoPhaseTerminal.stop();
}
}
@Slf4j(topic = "c.TwoPhaseTerminal")
class TwoPhaseTerminal {
private Thread monitor;
//启动监控线程
public void start() {
monitor = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
if (current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);//情况 1
log.debug("执行监控记录");//情况 2
} catch (InterruptedException e) {
e.printStackTrace();
//重新设置打断标记
current.interrupt();
}
}
});
monitor.start();
}
//停止监控线程
public void stop() {
monitor.interrupt();
}
}
输出结果
18:11:13 [Thread-0] c.TwoPhaseTerminal - 执行监控记录
18:11:14 [Thread-0] c.TwoPhaseTerminal - 执行监控记录
18:11:15 [Thread-0] c.TwoPhaseTerminal - 执行监控记录
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at org.example.chapter02.c2_6b.TwoPhaseTerminal.lambda$start$0(Test13.java:34)
at java.lang.Thread.run(Thread.java:748)
18:11:16 [Thread-0] c.TwoPhaseTerminal - 料理后事
细节1
如果在 执行监控记录时,执行了打断,并不会出现异常,但这种情况无需额外做处理。
因为这个时候的打断标记并不会被重置、清除,打断标记依旧为真,仍然可以进入下一轮循环检验。
细节2: interrupt()
、interrupted()
、isInterrupted()
方法名 | static | 功能说明 | 注意 |
---|---|---|---|
interrupt() |
通知目标线程中断,设置线程中断的标志位 | 如果被打断线程正在 sleep,wait,join, 会导致被打断的线程抛出 InterruptedException, 并清除 打断标记; 如果打断的正在运行的进程,则会设置 打断标记; park 的线程被打断,也会设置 打断标记 |
|
interrupted() |
static | 判断当前线程是否被打断 | 会清除 打断标记 |
isInterrupted() |
判断是否被打断 | 不会清除 打断标记 |
2.9.3.interrupt 打断 park
private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}, "t1");
t1.start();
sleep(0.5);
t1.interrupt();
}
输出
21:11:52.795 [t1] c.TestInterrupt - park...
21:11:53.295 [t1] c.TestInterrupt - unpark...
21:11:53.295 [t1] c.TestInterrupt - 打断状态:true
但如果打断标记已经是 true, 则 park 会失效。
此时可以使用 Thread.interrupted()
清除打断状态
2.10.不推荐的方法
还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁
方法名 | static |
功能说明 |
---|---|---|
stop() |
停止线程运行 | |
suspend() |
挂起(暂停)线程运行 | |
resume() |
恢复线程运行 |
2.11.主线程与守护进程
默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。
有一种特殊的线程叫做守护线程。
只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
log.debug("开始运行...");
Thread t1 = new Thread(() -> {
log.debug("开始运行...");
sleep(2);
log.debug("运行结束...");
}, "daemon");
// 设置该线程为守护线程
t1.setDaemon(true);
t1.start();
sleep(1);
log.debug("运行结束...");
输出
08:26:38.123 [main] c.TestDaemon - 开始运行...
08:26:38.213 [daemon] c.TestDaemon - 开始运行...
08:26:39.215 [main] c.TestDaemon - 运行结束...
注意
- 垃圾回收器线程就是一种守护线程
- Tomcat 中的 Acceptor 和 Poller 线程都是守护线程
- 所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求
2.12.五种状态
这是从 操作系统 层面来描述的
- 初始状态 仅是在语言层面创建了线程对象,还未与操作系统线程关联
- 可运行状态(就绪状态) 指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
- 运行状态 指获取了 CPU 时间片运行中的状态
- 当 CPU 时间片用完,会从 运行状态 转换至 可运行状态,会导致线程的上下文切换
- 阻塞状态
- 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入 阻塞状态
- 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至 可运行状态
- 与 可运行状态 的区别是,对 阻塞状态 的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
- 终止状态 表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态
2.13.六种状态
这是从 Java API 层面来描述的
根据 Thread.State 枚举,分为六种状态
- NEW:线程刚被创建,但是还没有调用
start()
方法 - RUNNABLE:调用了
start()
方法之后- 注意:Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的 可运行状态、运行状态 和 阻塞状态
- 由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行
- BLOCKED , WAITING, TIMED_WAITING 都是 Java API 层面对 阻塞状态 的细分,后面会在状态转换一节详述
- TERMINATED 当线程代码运行结束
2.14.本章总结
本章的重点在于掌握
- 线程创建
- 线程的重要 API
- 如 start,run,sleep,join,interrupt 等
- 线程状态
- 应用方面
- 异步调用:主线程执行期间,其它线程异步执行耗时操作
- 提高效率:并行计算,缩短运算时间
- 同步等待:join
- 统筹规划:合理使用线程,得到最优效果
- 原理方面
- 线程运行流程:栈、栈帧、上下文切换、程序计数器
- Thread 两种创建方式 的源码
- 模式方面
- 终止模式之两阶段终止