JUC学习笔记——进程与线程
JUC学习笔记——进程与线程
在本系列内容中我们会对JUC做一个系统的学习,本片将会介绍JUC的进程与线程部分
我们会分为以下几部分进行介绍:
- 进程与线程
- 并发与并行
- 同步与异步
- 线程详解
进程与线程
在这一小节我们将简单介绍进程与线程
进程
首先我们来简单了解一下程序:
- 程序由指令和数据组成,我们必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。
接下来我们才能讲解进程的定义:
- 进程就是用来加载指令、管理内存、管理 IO 的
- 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
- 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程,也有的程序只能启动一个实例进程。
线程
我们来简单介绍一下线程:
- 一个进程之内可以分为一到多个线程。
- 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
- Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器
两者区别
我们来介绍一下进程与线程之间的区别:
-
进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
-
进程拥有共享的资源,如内存空间等,供其内部的线程共享
-
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低
此外两者的通信方式也不相同:
- 进程通信:同一台计算机的进程通信称为 IPC;不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
- 线程通信:线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
并发与并行
在这一小节我们将简单介绍并发与并行
并发
首先我们需要了解一下任务调度器:
- 单核 cpu 下,线程实际还是 串行执行 的。
- 操作系统中有一个组件叫做任务调度器,将 cpu 的时间片分给不同的程序使用。
那么我们的并发实际上就是根据任务调度器来工作的:
- 并发是借助任务调度器,在一段时间段内将CPU分给多个线程使用,但由于切换快时间短,所以被看作是同时进行
- 一般会将这种 线程轮流使用 CPU 的做法称为并发
- 实际上并发的情况是:微观串行,宏观并行
通俗来讲:
- 并发(concurrent)是同一时间应对(dealing with)多件事情的能力
例如下图:
CPU | 时间片 1 | 时间片 2 | 时间片 3 | 时间片 4 |
---|---|---|---|---|
core | 线程 1 | 线程 2 | 线程 3 | 线程 4 |
并行
并行的概念就相对而言比较简单了:
- 并行就是借助多核CPU,确确实实地在同一时间执行多个进程
通俗来讲:
- 并行(parallel)是同一时间动手做(doing)多件事情的能力
例如下图:
CPU | 时间片 1 | 时间片 2 | 时间片 3 | 时间片 4 |
---|---|---|---|---|
core 1 | 线程 1 | 线程 1 | 线程 3 | 线程 3 |
core 2 | 线程 2 | 线程 4 | 线程 2 | 线程 4 |
同步与异步
在这一小节我们将简单介绍并发与并行
同步与异步概念
首先我们来简单介绍一下同步与异步:
- 需要等待结果返回才能继续执行的操作就是同步操作
- 不需要等待结果返回就可以继续执行的操作就是异步操作
另外同步操作还有另一个概念:
- 在多线程中,表示多线程的步调一致
同步与异步选择方法
我们的同步与异步的选择通常会决定程序的运行速度,因而选择同步或异步是非常重要的
我们先来介绍同步与异步的实现方式:
- 同步就是在一个线程内完全执行所有命令
- 异步可以在多线程中实现,当一个线程执行复杂操作比较耗时时,另一个线程可以执行其他简单操作
我们再来介绍同步与异步的选择方法:
- 针对比较繁琐的操作,我们通常会单独创建一个新线程来进行处理,避免阻塞主线程
- Tomcat里面的异步Servlet也是异步操作,让用户线程处理耗时较长的操作,避免阻塞Tomcat的工作线程
- UI程序中,开线程进行其他操作,避免UI线程
我们分别给出同步异步的代码展示:
// 同步代码
package cn.itcast.n2;
import cn.itcast.Constants;
import cn.itcast.n2.util.FileReader;
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.Sync")
public class Sync {
public static void main(String[] args) {
FileReader.read(Constants.MP4_FULL_PATH);
log.debug("do other things ...");
}
}
// 异步代码
package cn.itcast.n2;
import cn.itcast.Constants;
import cn.itcast.n2.util.FileReader;
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.Async")
public class Async {
public static void main(String[] args) {
new Thread(() -> FileReader.read(Constants.MP4_FULL_PATH)).start();
log.debug("do other things ...");
}
}
同步与异步实际使用
我们通常采用异步操作来实现应用的速度提升:
// 例如我们有下面三个操作
计算 1 花费 10 ms
计算 2 花费 11 ms
计算 3 花费 9 ms
汇总需要 1 ms
如果我们采用主线程的同步操作来实现:
// 如果是串行执行,那么总共花费的时间是 10 + 11 + 9 + 1 = 31ms
但是如果我们采用三个CPU的异步操作来实现:
// 但如果是四核 cpu,各个核心分别使用线程 1 执行计算 1,线程 2 执行计算 2,线程 3 执行计算 3
// 那么 3 个线程是并行的,花费时间只取决于最长的那个线程运行的时间,即 11ms 最后加上汇总时间只会花费 12ms
下面我们给出同步异步的实际使用规则:
- 多核多线程速度快于单核多线程(异步进行速度较快)
- 单核多线程速度慢于单核单线程(线程切换也需要耗费时间)
但是单核多线程也并非是一无是处:
- 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换
- 不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活
此外多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的 :
- 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分
- 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
最后就是我们的IO操作部分:
- IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】
- 这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程
- 所以才有后面的【非阻塞 IO】和【异步 IO】优化
线程详解
这一小节我们将详细介绍线程的具体内容
创建和运行线程
我们下面将介绍三种创建和运行线程的方法
直接使用 Thread
我们可以直接使用Thread来创建和运行线程:
// 创建线程对象
Thread t = new Thread() {
public void run() {
// 要执行的任务
}
};
// 启动线程
t.start();
我们再给出一个实际例子:
// 构造方法的参数是给线程指定名字,推荐
Thread t1 = new Thread("t1") {
@Override
// run 方法内实现了要执行的任务
public void run() {
log.debug("hello");
}
};
t1.start();
我们给出实际输出结果:
// 我们会注意到:前面标记了[t1]线程~
19:19:00 [t1] c.ThreadStarter - hello
使用 Runnable 配合 Thread
这里我们将Thread里面的方法采用Runnable类型的方法来代替:
// 创建Runnable类型的方法
Runnable runnable = new Runnable() {
public void run(){
// 要执行的任务
}
};
// 创建线程对象
Thread t = new Thread( runnable );
// 启动线程
t.start();
我们给出一个实际例子:
// 创建任务对象
Runnable task2 = new Runnable() {
@Override
public void run() {
log.debug("hello");
}
};
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(task2, "t2");
t2.start();
其结果为:
// 结果正常
9:19:00 [t2] c.ThreadStarter - hello
除此之外,我们在JDK8之后,我们可以采用函数式接口Lambda来简化Runnable的书写:
// 创建任务对象
Runnable task2 = () -> log.debug("hello");
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(task2, "t2");
t2.start();
甚至我们都不用定义task,来直接采用Lambda方法书写Thread中的task:
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(() -> log.debug("hello"), "t2");
t2.start();
底层简单解释:
- 至于Thread为什么能够直接调用Runnable
- Thread在接收Runnable类型后,会将其赋值在this.target
- 而Thread的run方法会先来判断是否存在target,如果存在就直接采用target方法
最后我们介绍一下使用Runnable的好处:
- 方法1 是把线程和任务合并在了一起,方法2 是把线程和任务分开了
- 用 Runnable 更容易与线程池等高级API 配合
- 用 Runnable 让任务类脱离了 Thread 继承体系,更灵活
使用FutureTask 配合 Thread(了解即可)
FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况:
// 创建任务对象(Integer是返回对象)
FutureTask<Integer> task3 = new FutureTask<>(() -> {
log.debug("hello");
return 100;
});
// 参数1 是任务对象; 参数2 是线程名字,推荐
new Thread(task3, "t3").start();
// 主线程阻塞,同步等待 task 执行完毕的结果
Integer result = task3.get();
log.debug("结果是:{}", result);
我们给出结果:
19:22:27 [t3] c.ThreadStarter - hello
19:22:27 [main] c.ThreadStarter - 结果是:100
我们给出简单解释:
- FutureTask内置了一个Callable对象,初始化方法将指定的Callable赋给这个对象。
- FutureTask实现了Runnable接口,并重写了Run方法,在Run方法中调用了Callable中的call方法,并将返回值赋值给outcome变量
- get方法就是取出outcome的值。
多线程运行状况
我们给出单核CPU运行多线程时不断切换进程的状况展示:
// 下述的操作不受我们控制,谁先调用,调用多久都不是我们管控的
@Slf4j(topic = "c.TestMultiThread")
public class TestMultiThread {
public static void main(String[] args) {
new Thread(() -> {
while(true) {
log.debug("running");
}
},"t1").start();
new Thread(() -> {
while(true) {
log.debug("running");
}
},"t2").start();
}
}
我们直接给出结果展示:
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
查看进程线程方法
由于不同系统的查看方法不同,我们主要介绍三种类型查看方法
Window
- 任务管理器可以查看进程和线程数,也可以用来杀死进程
- tasklist 查看进程 :tasklist| findstr (查找关键字)
- taskkill 杀死进程:taskkill /F(彻底杀死)/PID(进程PID)
Linux
- ps -fe 查看所有进程
- ps -fT -p 查看某个进程(PID)的所有线程
- kill 杀死进程 top 按大写 H 切换是否显示线程
- top -H -p 查看某个进程(PID)的所有线程
Java
- jps 命令查看所有 Java 进程
- jstack 查看某个 Java 进程(PID)的所有线程状态
- jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)
线程运行底层解释
我们将会介绍两个与线程底层运行相关的原理
栈与栈帧
下面我们来介绍一下与进程息息相关的底层原理:
- 栈:存放栈帧的个体,每个线程具有一个单独的栈来存放多个栈帧
- 栈帧:方法的全部内存占用,每个方法使用时的全部内存都用一个栈帧来表示
同时栈和栈帧也有一定限制:
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
我们给出一个简单的代码展示:
// 这里展现的是单线程,目前只有一个栈!
package cn.itcast.n3;
public class TestFrames {
// 首先main方法被调用,所以main方法先进入栈中,在main方法执行结束后被抛出栈
public static void main(String[] args) {
method1(10);
}
// 由于main方法调用了method1,所以栈中在存有main栈帧的同时也将method1栈帧调入,在method1方法执行完毕后抛出
private static void method1(int x) {
int y = x + 1;
Object m = method2();
System.out.println(m);
}
// 由于method1方法调用了method2,所以栈中在存有main,method1栈帧的同时也将method2栈帧调入,method2方法执行完毕后抛出
private static Object method2() {
Object n = new Object();
return n;
}
}
// 这里展现的是多线程,主线程和线程t1独自各占有一个栈,互不影响!
package cn.itcast.n3;
public class TestFrames {
// 这里会产生两个栈,两个栈互不影响,两个栈都会顺序调用main,method1,method2栈帧,顺序不定
public static void main(String[] args) {
Thread t1 = new Thread(){
@Override
public void run() {
method1(20);
}
};
t1.setName("t1");
t1.start();
method1(10);
}
private static void method1(int x) {
int y = x + 1;
Object m = method2();
System.out.println(m);
}
private static Object method2() {
Object n = new Object();
return n;
}
}
线程上下文切换
我们再来介绍一下上下文切换:
- 当出现一些状况时,系统会自动将CPU的使用权进行切换,交付给不同的线程进行使用
上下文切换措施:
- 要由操作系统保存当前线程的状态,并恢复另一个线程的状态,
- Java 中对应的就是程序计数器,它的作用是记住下一条 jvm 指令的执行地址,是线程私有的
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
上下文切换时机:
- 线程的 cpu 时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
但是我们需要注意:
- Context Switch 频繁发生会影响性能
线程方法详解
这一小节我们将介绍线程的各种方法
线程方法总述
我们首先给出线程的全部方法一览:
方法 | 功能 | 说明 |
---|---|---|
public void start() | 启动一个新线程;Java虚拟机调用此线程的run方法 | start 方法只是让线程进入就绪,里面代码不一定立刻 运行(CPU 的时间片还没分给它)。每个线程对象的 start方法只能调用一次,如果调用了多次会出现 IllegalThreadStateException |
public void run() | 线程启动后调用该方法 | 如果在构造 Thread 对象时传递了 Runnable 参数,则 线程启动后会调用 Runnable 中的 run 方法,否则默 认不执行任何操作。但可以创建 Thread 的子类对象, 来覆盖默认行为 |
public void setName(String name) | 给当前线程取名字 | |
public void getName() | 获取当前线程的名字。线程存在默认名称:子线程是Thread-索引,主线程是main | |
public static Thread currentThread() | 获取当前线程对象,代码在哪个线程中执行 | |
public static void sleep(long time) | 让当前线程休眠多少毫秒再继续执行。Thread.sleep(0) : 让操作系统立刻重新进行一次cpu竞争 | |
public static native void yield() | 提示线程调度器让出当前线程对CPU的使用 | 主要是为了测试和调试 |
public final int getPriority() | 返回此线程的优先级 | |
public final void setPriority(int priority) | 更改此线程的优先级,常用1 5 10 | java中规定线程优先级是1~10 的整数,较大的优先级 能提高该线程被 CPU 调度的机率 |
public void interrupt() | 中断这个线程,异常处理机制 | |
public static boolean interrupted() | 判断当前线程是否被打断,清除打断标记 | |
public boolean isInterrupted() | 判断当前线程是否被打断,不清除打断标记 | |
public final void join() | 等待这个线程结束 | |
public final void join(long millis) | 等待这个线程死亡millis毫秒,0意味着永远等待 | |
public final native boolean isAlive() | 线程是否存活(还没有运行完毕) | |
public final void setDaemon(boolean on) | 将此线程标记为守护线程或用户线程 | |
public long getId() | 获取线程长整型 的 id | id 唯一 |
public state getState() | 获取线程状态 | Java 中线程状态是用 6 个 enum 表示,分别为: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED |
public boolean isInterrupted() | 判断是否被打 断 | 不会清除 打断标记 |
start与run
我们首先来介绍线程的两个相似的启动方法:
// 首先我们采用start:start方法是启动该线程,线程开始运行
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.start();
log.debug("do other things ...");
}
// 然后我们直接使用run方法:run方法是调用该线程的run方法,实际是main线程在运行t1线程的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 ...");
}
同时我们可以通过查看线程状态来判断start和run的区别:
// 这里我们仅对start判断
public static void main(String[] args) {
Thread t1 = new Thread("t1") {
@Override
public void run() {
log.debug("running...");
}
};
System.out.println(t1.getState());
t1.start();
System.out.println(t1.getState());
}
// 我们可以注意到main状态从就绪状态切换为Runnable
NEW
RUNNABLE
03:45:12.255 c.Test5 [t1] - running...
所以我们给出小结:
-
直接调用 run 是在主线程中执行了 run,没有启动新的线程
-
使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码
sleep 与 yield
我们首先给出sleep的相关解释:
- 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
- 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
- 睡眠结束后的线程未必会立刻得到执行
- 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性 。其底层还是sleep方法
- 在循环访问锁的过程中,可以加入sleep让线程阻塞时间,防止大量占用cpu资源
我们再给出yield的相关解释:
- 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程;具体的实现依赖于操作系统的任务调度器
- 加入当前线程中只有这一个方法,那么停止该方法执行后仍旧执行该方法
我们采用代码来进行展示:
/*sleep状态转换:我们运行下面代码后可以看到其t1状态从就绪状态到Runnable到Timed Waiting*/
public static void main(String[] args) {
Thread t1 = new Thread("t1") {
@Override
public void run() {
log.debug("running...");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
log.debug("wake up...");
e.printStackTrace();
}
}
};
System.out.println(t1.getState());
t1.start();
System.out.println(t1.getState());
Thread.sleep(2000);
System.out.println(t1.getState());
}
/*sleep打断睡眠线程,抛出异常:注意当睡眠时抛出异常后该程序的打断状态为false*/
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread("t1") {
@Override
public void run() {
log.debug("enter sleep...");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
log.debug("wake up...");
e.printStackTrace();
}
}
};
t1.start();
Thread.sleep(1000);
log.debug("interrupt...");
t1.interrupt();
}
// 输出结果为:
03:47:18.141 c.Test7 [t1] - enter sleep...
03:47:19.132 c.Test7 [main] - interrupt...
03:47:19.132 c.Test7 [t1] - wake up...
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at cn.itcast.test.Test7$1.run(Test7.java:14)
/*sleep采用TimeUnit的方法更具有代码可读性*/
@Slf4j(topic = "c.Test8")
public class Test8 {
public static void main(String[] args) throws InterruptedException {
log.debug("enter");
TimeUnit.SECONDS.sleep(1);
log.debug("end");
// Thread.sleep(1000);
}
}
/*yield简单测试:我们需要在Linux虚拟机中使用单核cpu来处理该代码,下面我们同时使用一个cpu处理两个进程*/
@Slf4j(topic = "c.TestYield")
public class TestYield {
public static void main(String[] args) {
Runnable task1 = () -> {
int count = 0;
for (;;) {
System.out.println("---->1 " + count++);
}
};
Runnable task2 = () -> {
int count = 0;
for (;;) {
Thread.yield();
System.out.println("---->2 " + count++);
}
};
Thread t1 = new Thread(task1, "t1");
Thread t2 = new Thread(task2, "t2");
t1.start();
t2.start();
}
}
// 我们会发现task1的count数更多,因为轮到task2时,它会调用yield可能会导致当前线程停止从而去运行另一个线程
---->1 119199
---->2 101074
最后我们讲解一个项目中常用的案例:
// 在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权 给其他程序
while(true) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// - 可以用 wait 或 条件变量达到类似的效果
// - 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
// - sleep 适用于无需锁同步的场景
// wait实现
synchronized(锁对象) {
while(条件不满足) {
try {
锁对象.wait();
} catch(InterruptedException e) {
e.printStackTrace();
}
}
// do sth...
}
// 条件变量实现
lock.lock();
try {
while(条件不满足) {
try {
条件变量.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// do sth...
} finally {
lock.unlock();
}
Priority
下面我们来介绍线程的优先级设置:
- 首先优先级的大小从1~10,默认为5
- 优先级只是起到一定影响作用,不会对线程的执行顺序起到绝对作用,只是优先级越高其获得CPU可能性越大
我们给出简单代码示例:
// 我们同样需要在单核CPU下进行
@Slf4j(topic = "c.TestYield")
public class TestYield {
public static void main(String[] args) {
Runnable task1 = () -> {
int count = 0;
for (;;) {
System.out.println("---->1 " + count++);
}
};
Runnable task2 = () -> {
int count = 0;
for (;;) {
System.out.println(" ---->2 " + count++);
}
};
Thread t1 = new Thread(task1, "t1");
Thread t2 = new Thread(task2, "t2");
t1.setPriority(Thread.MIN_PRIORITY);
t2.setPriority(Thread.MAX_PRIORITY);
t1.start();
t2.start();
}
}
// 运行结果:我们会发现t2的执行次数明显高于t1
---->1 283500
---->2 374389
join
首先我们需要了解join的作用:
- 用于等待直至所指线程结束为止
我们采用一个简单的例子进行解释:
// 下述例子我们希望打印线程t1的r的值,但是我们都知道main和t1线程都是同时运行的,并且t1等待了1s,所以这时的r是没有赋值的,为0
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.start();
log.debug("结果为:{}", r);
log.debug("结束");
}
// 但是我们可以选择使用join方法来使主线程等待,知道t1线程结束后再去运行main线程
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.start();
log.debug("结果为:{}", r);
t1.join();
log.debug("结果为:{}", r);
log.debug("结束");
}
// 注意我们如果采用sleep或许也可以实现相同的效果,但是很难准确确定其实际线程的结束时刻,所以正常情况下无法使用sleep
此外我们还需要讲解join的其他几个性质:
- 我们可以借助join来实现线程之间的同步操作
- 当多个线程都采用join时,我们需要等所有线程都结束后继续运行
- join是可以设置时效性的,当超过时则不再等待而是直接运行,若不超过就按照线程结束时间计算
我们通过几个案例解释上述性质:
// 借助join完成线程之间的同步操作(其实前面第一个例子就是同步操作,我们需要先完成t1线程才能执行main线程的内容)
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.start();
t1.join();
log.debug("结果为:{}", r);
log.debug("结束");
}
// 多个join需要等待所有线程完成
// 如下述,一个需要1s,一个需要2s,但由于同时进行,所以我们只需要2s就可以全部执行,然后再执行main
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是可以设置时效性的,当超过时则不再等待而是直接运行,若不超过就按照线程结束时间计算
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(() -> {
// 这里设置时间,若低于1.5s按sleep时间执行;若高于1.5s则在1.5s时执行
sleep(1);
r1 = 10;
});
long start = System.currentTimeMillis();
t1.start();
// 线程执行结束会导致 join 结束
t1.join(1500);
long end = System.currentTimeMillis();
log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}
interrupt
我们首先来简单介绍一下interrupt方法:
- 翻译过来就是打断,本质是将线程的打断标记设为true,并调用线程的三个parker对象(C++实现级别)unpark该线程。
我们来进行更详细的介绍:
- 打断操作实际上只是通知,不是打断
- 打断线程不等于中断线程,有以下两种情况:
- 打断正在运行中的线程并不会影响线程的运行,但如果线程监测到了打断标记为true,可以自行决定后续处理。
- 打断阻塞中的线程会让此线程产生一个InterruptedException异常,结束线程的运行。
- 但如果该异常被线程捕获住,该线程依然可以自行决定后续处理(终止运行,继续运行,做一些善后工作等等)
总而言之我们的打断其实主要分为两种类型,我们采用代码解释:
/* 正常运行情况下打断,并不会影响线程正常运行,但是会将线程的打断标记设置为true */
package cn.itcast.test;
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.Test12")
public class Test12 {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
// 这里就是一个正常运行的线程,我们在启动后1s对他进行打断
while(true) {
// 打断不会直接对线程造成影响,但是会将打断状态interrupted变为true(由isInterrupted方法得到)
boolean interrupted = Thread.currentThread().isInterrupted();
// 然后我们可以自行根据打断状态来做对应处理!
if(interrupted) {
log.debug("被打断了, 退出循环");
break;
}
}
}, "t1");
t1.start();
// 1s后实现打断操作
Thread.sleep(1000);
log.debug("interrupt");
t1.interrupt();
}
}
// 产生结果为:程序不停止,但打断状态为true
20:57:37.964 [t2] c.TestInterrupt - 打断状态: true
/* 不正常状态被打断,例如sleep,yield,join,会使线程进入阻塞状态,抛出异常,会清空打断状态,使其变为false */
private static void test1() throws InterruptedException {
Thread t1 = new Thread(()->{
sleep(1);
}, "t1");
t1.start();
sleep(0.5);
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的思想模式之两阶段终止:
- 我们需要在线程1调用线程2的interrupt方法,同时使线程2完成它的后续操作
我们首先给出其逻辑图:
我们通过代码解释:
/*首先利用isinterrupt方法来实现思想*/
// 我们需要书写一个检测类负责不断检测打断状态
class TPTInterrupt {
private Thread thread;
public void start(){
thread = new Thread(() -> {
// 首先不断检测
while(true) {
Thread current = Thread.currentThread();
// 当我们发现被打断时
if(current.isInterrupted()) {
// 自行处理后续内容
log.debug("料理后事");
break;
}
// 当我们发现没有被打断时,我们将程序停止一段时间,并进行简单处理
try {
Thread.sleep(1000);
log.debug("将结果保存");
} catch (InterruptedException e) {
// 这里我们需要注意,如果存在sleep等操作就会导致抛出异常InterruptedException
// 但是抛出异常并不会导致程序结束,也不会导致打断标记为true,
// 所以我们需要手动设置打断标记为true,使其在下一次循环时,中断程序
current.interrupt();
}
// 执行监控操作
}
},"监控线程");
thread.start();
}
public void stop() {
thread.interrupt();
}
}
// 我们在主程序调用:
TPTInterrupt t = new TPTInterrupt();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();
// 我们可以得到结果:
11:49:42.915 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:43.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:44.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:45.413 c.TestTwoPhaseTermination [main] - stop
11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事
/*我们还可以手动设置一个参数来负责线程的关闭*/
// 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
// 我们的例子中,即主线程把它修改为 true 对 t1 线程可见
class TPTVolatile {
private Thread thread;
private volatile boolean stop = false;
public void start(){
thread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
if(stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("将结果保存");
} catch (InterruptedException e) {
}
// 执行监控操作
}
},"监控线程");
thread.start();
}
public void stop() {
stop = true;
thread.interrupt();
}
}
// 我们在主程序中调用:
TPTVolatile t = new TPTVolatile();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();
// 我们得到下属结果:
11:54:52.003 c.TPTVolatile [监控线程] - 将结果保存
11:54:53.006 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.007 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop
11:54:54.502 c.TPTVolatile [监控线程] - 料理后事
/*我们还可以通过打断 park 线程来实现思想*/
// 首先我们简单介绍一下park:当打断标记为false时,park起到线程暂停作用;当打断标记为true时,继续运行
// 首先我们给出打断标记为false的状态
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 会失效
private static void test4() {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
log.debug("park...");
LockSupport.park();
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}
});
t1.start();
sleep(1);
t1.interrupt();
}
// 我们可以得到下述结果:
21:13:48.783 [Thread-0] c.TestInterrupt - park...
21:13:49.809 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.812 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
// 我们可以使用 Thread.interrupted() 清除打断状态
// Thread.interrupted()获得当前打断状态,并将打断状态设置为false
// Thread.currentThread().isInterrupted())只能获得当前打断状态,不会影响值
/*
此外还有一些不符合当前状态的打断方式,我们也简单介绍一下:
- 使用线程对象的 stop() 方法停止线程
- stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁
- 使用 System.exit(int) 方法停止线程
- 目的仅是停止一个线程,但这种做法会让整个程序都停止
*/
过时方法介绍
最后我们给出三个线程的过时方法简单解释:
法名 | static | 功能说明 |
---|---|---|
stop() | 停止线程运行 | |
suspend() | 挂起(暂停)线程运行 | |
resume() | 恢复线程运行 |
主线程和守护线程
首先我们简单介绍一下守护线程:
- 守护线程是不重要的线程,当所有的主线程都完成时,无论守护线程是否结束都被迫结束
我们给出简单示例:
// 主函数代码:
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 命令后,不会等待它们处理完当前请求
线程状态
这一小节我们将介绍线程的两种状态形式
线程五种状态
从操作系统的角度来讲,线程具有五种状态:
我们来简单介绍一下:
- 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
- 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
- 【运行状态】指获取了 CPU 时间片运行中的状态
- 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
- 【阻塞状态】
- 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入 【阻塞状态】
- 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
- 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑 调度它们
- 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态
线程六种状态
从Java虚拟机的角度来看,将其分为六种状态:
我们来简单介绍一下:
- NEW 线程刚被创建,但是还没有调用 start() 方法
- RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的 【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为 是可运行)
- BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节 详述
- TERMINATED 当线程代码运行结束
我们给出相关代码进行解释:
package cn.itcast.n3;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
@Slf4j(topic = "c.TestState")
public class TestState {
public static void main(String[] args) throws IOException {
// 代码完善,但并未运行,属于NEW状态
Thread t1 = new Thread("t1") {
@Override
public void run() {
log.debug("running...");
}
};
// 代码完善且一直运行,属于runnable状态
Thread t2 = new Thread("t2") {
@Override
public void run() {
while(true) { // runnable
}
}
};
t2.start();
// 执行一次结束,属于线程执行完毕,TERMINATED 状态
Thread t3 = new Thread("t3") {
@Override
public void run() {
log.debug("running...");
}
};
t3.start();
// 由sleep停止进程,属于timed_waiting状态
Thread t4 = new Thread("t4") {
@Override
public void run() {
synchronized (TestState.class) {
try {
Thread.sleep(1000000); // timed_waiting
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
t4.start();
// 由于等待其他进程结束而等待,属于waiting状态
Thread t5 = new Thread("t5") {
@Override
public void run() {
try {
t2.join(); // waiting
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
t5.start();
// 由于等待锁而等待,属于blocked状态
Thread t6 = new Thread("t6") {
@Override
public void run() {
synchronized (TestState.class) { // blocked
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
t6.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 输出其状态
log.debug("t1 state {}", t1.getState());
log.debug("t2 state {}", t2.getState());
log.debug("t3 state {}", t3.getState());
log.debug("t4 state {}", t4.getState());
log.debug("t5 state {}", t5.getState());
log.debug("t6 state {}", t6.getState());
System.in.read();
}
}
本章小结
我们简单总结一下重点:
- 线程创建
- 线程重要 api,如 start,run,sleep,join,interrupt 等
- 线程状态
- 应用方面
- 异步调用:主线程执行期间,其它线程异步执行耗时操作
- 提高效率:并行计算,缩短运算时间
- 同步等待:join
- 统筹规划:合理使用线程,得到最优效果
- 原理方面
- 线程运行流程:栈、栈帧、上下文切换、程序计数器
- Thread 两种创建方式 的源码
- 模式方面
- 终止模式之两阶段终止
结束语
到这里我们JUC的进程与线程内容就结束了,希望能为你带来帮助~
附录
该文章属于学习内容,具体参考B站黑马程序员满老师的JUC完整教程
这里附上视频链接:01.001-为什么学习并发_哔哩哔哩_bilibili