进程与线程

进程与线程

进程

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器 等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)

线程

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。
  • java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器。

两者对比

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集。
  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享。
  • 进程间通信较为复杂,同一台计算机的进程通信称为 IPC(Inter-process communication),不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP。
  • 线程通信相对简单,因为它们共享进程内的内存,多个线程可以访问同一个共享变量。
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低。

并行与并发

并发

单核 cpu 下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感、觉是同时运行的 。总结为一句话就是: 微观串行,宏观并行 。

img

并行

多核 cpu下,每个核(core) 都可以调度运行线程,这时候线程可以是并行的,不同的线程同时使用不同的 cpu 在执行。

img

二者对比

引用 Rob Pike 的一段描述:并发(concurrent)是同一时间应对(dealing with)多件事情的能力,并行(parallel)是同一时间动手做(doing)多件事情的能力。

  • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发

  • 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行

  • 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一 个人用锅时,另一个人就得等待)

应用

同步和异步的概念

以调用方的角度讲,如果需要等待结果返回才能继续运行的话就是同步,不需要等待就是异步。

1)设计

多线程可以使方法的执行变成异步的,比如说读取磁盘文件时,假设读取操作花费了5秒,如果没有线程的调度机制,那么 cpu 只能等 5 秒,啥都不能做。

  1. 结论
  • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程。

  • tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程

  • ui 程序中,开线程进行其他操作,避免阻塞 ui 线程

总结

  1. 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用 cpu ,不至于一个线程总占用 cpu,别的线程没法干活。
  2. 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的。
  • 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率,但不是所有计算任 务都能拆分。

  • 而也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义。

  1. IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一 直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化。
1
注:以后的代码示例均在添加 @Slf4j 依赖的情况下执行

Java线程

创建和运行线程

方法一,直接使用 Thread

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
// 匿名内部类方式创建 Thread
Thread t = new Thread("t1") {
@Override
public void run() {
log.debug("running");
}
};

t.start();
log.debug("running");
}

方法二,使用 Runnable 配合 Thread(推荐)

把【线程】和【任务】(要执行的代码)分开,Thread 代表线程,Runnable 可运行的任务(线程要执行的代码)。

1
2
3
4
5
6
public static void main(String[] args) {
// 使用 lambda 表达式,因为 Runnable 接口
// 标注了 @FunctionalInterface 这个注解,表示是一个函数式接口,可以使用 lambda 表达式
Runnable r = () -> log.debug("running");
new Thread(r, "t1").start();
}

小结

  • 方法1 是把线程和任务合并在了一起,方法2 是把线程和任务分开了。
  • 用 Runnable 更容易与线程池等高级 API 配合,用 Runnable 让任务类脱离了 Thread 继承体系,更灵活。
  • 通过查看源码可以发现,方法二其实到底还是通过方法一执行的!

方法三,FutureTask 配合 Thread

FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况 ,FutureTask是Future和Runable的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 实现多线程的第三种方法可以返回数据
FutureTask futureTask = new FutureTask<>(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
log.debug("多线程任务");
Thread.sleep(100);
return 100;
}
});
// 主线程阻塞,同步等待 task 执行完毕的结果
new Thread(futureTask,"我的名字").start();
log.debug("主线程");
log.debug("{}",futureTask.get());
}

Future 就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。

1
2
3
4
5
6
7
8
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}

Future提供了三种功能:   

  1. 判断任务是否完成;   

  2. 能够中断任务;   

  3. 能够获取任务执行结果。

多个线程同时执行

  • 交替执行
  • 谁先谁后,不由我们控制

查看进程线程的方法

windows

  • 任务管理器可以查看进程和线程数,也可以用来杀死进程。
  • tasklist 查看进程。
  • taskkill 杀死进程。

linux

  • ps -fe 查看所有进程。
  • ps -fT -p 查看某个进程(PID)的所有线程。
  • kill 杀死进程。
  • top 按大写 H 切换是否显示线程。
  • top -H -p 查看某个进程(PID)的所有线程。

java

  • jps 命令查看所有 java 进程。
  • jstack 查看某个 java 进程(PID)的所有线程状态。
  • jconsole 来查看某个 java 进程中线程的运行情况(图形界面)。

线程运行原理

虚拟机栈与栈帧

虚拟机栈描述的是 java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(stack frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,是属于线程私有的。

  • 当 java 中使用多线程时,每个线程都会维护它自己的栈帧。
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码:

  • 线程的 cpu 时间片用完(每个线程轮流执行,看前面并行的概念)。
  • 垃圾回收。
  • 有更高优先级的线程需要运行。
  • 线程自己调用了 sleepyieldwaitjoinparksynchronizedlock 等方法。

当 上下文切换发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。

Thread的常见方法

start 与 run

调用 start

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {

Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
log.info(Thread.currentThread().getName() + " running....");
}
}, "t1");

// 测试通过 Thread 类实例 t1 对象直接调用 run 方法
t1.run();

log.info(Thread.currentThread().getName() + " running...");
}

输出:

1
2
14:56:56 [main] c.Code_05_Test - main running....
14:56:56 [main] c.Code_05_Test - main running...

调用 run

将上面代码的thread.start();改为 thread.run();输出结果如下:

1
2
14:59:35 [main] c.Code_05_Test - main running...
14:59:35 [t1] c.Code_05_Test - t1 running....

通过打印台的输出,发现结果是不一样的,使用 start 方式,CPU 会为创建的线程分配时间片,线程进入运行状态,然后线程调用 run 方法执行逻辑。而直接使用 run 的方式,虽然会创建了线程,但是它是直接调用方法,而不是像 start 方式那样,这个线程对象会处一直处在新建状态,从结果上也可以看出,run 方法是 main 线程调用,而不是 t1 线程。

小结

  • 直接调用 run() 是在主线程中执行了 run(),没有启动新的线程。
  • 使用 start() 是启动新的线程,通过新的线程间接执行 run()方法 中的代码。

sleep 与 yield

sleep

  1. 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)。
  2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,那么被打断的线程这时就会抛出 InterruptedException异常【注意:这里打断的是正在休眠的线程,而不是其它状态的线程】。
  3. 睡眠结束后的线程未必会立刻得到执行(需要分配到cpu时间片)。
  4. 建议用 TimeUnit 的 sleep() 代替 Thread 的 sleep()来获得更好的可读性。

yield(礼让)

  1. 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程。
  2. 具体的实现依赖于操作系统的任务调度器。

小结

yield 使 cpu 调用其它线程,但是 cpu 可能会再分配时间片给该线程;而 sleep 需要等过了休眠时间之后才有可能被分配 cpu 时间片。

线程优先级

  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它。
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用。

join

join用于等待某个线程结束。哪个线程内调用join()方法,就等待哪个线程结束,然后再去执行其他线程。如在主线程中调用t1.join(),则是主线程需要等待t1线程结束,才能执行其线程。

1
2
3
4
5
Thread t1 = new Thread();
//等待 t1 线程执行结束
t1.join();
// 最多等待 1000ms,如果 1000ms 内线程执行完毕,则会直接执行下面的语句,不会等够 1000ms
t1.join(1000);

为什么需要join?

下面的代码执行,打印 r 是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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("结束");
}

分析:

  • 因为主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能算出 r=10。

  • 而主线程一开始就要打印 r 的结果,所以只能打印出 r=0。

解决方法:要想打印出r=10,用 join加在 t1.start() 之后即可。

注意:

  • 需要等待结果返回,才能继续运行就是同步。

  • 不需要等待结果返回,就能继续运行就是异步。

interrupt()

interrupt 打断线程有两种情况,如下:

  • 如果一个线程在在运行中被打断,打断标记会被置为 true 。
  • 如果是打断的是因sleep、wait、join 方法而被阻塞的线程,会将打断标记置为 false 。

isInterrupted() 与 interrupted() 比较

首先,isInterrupted 是实例方法,interrupted 是静态方法,它们的用处都是查看当前打断的状态,但是 isInterrupted 方法查看线程的时候,不会将打断标记清空,也就是置为 false,interrupted 查看线程打断状态后,会将打断标志置为 false,也就是清空打断标记。

以 sleep 为例:

1
2
3
4
5
6
7
8
9
10
private static void test1() throws InterruptedException {
Thread t1 = new Thread(()->{
sleep(1);
}, "t1");
t1.start();

sleep(0.5);//t2睡眠0.5s
t1.interrupt();
log.debug(" 打断状态: {}", t1.isInterrupted());
}

输出:

1
2
3
4
5
6
7
8
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.heu.n2.util.Sleeper.sleep(Sleeper.java:8)
at cn.heu.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
at java.lang.Thread.run(Thread.java:745)
21:18:10.374 [main] TestInterrupt - 打断状态: false

打断正常运行的线程, 不会清空打断状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static void test2() throws InterruptedException {
Thread t2 = new Thread(()->{
while(true) {
Thread current = Thread.currentThread();
boolean interrupted = current.isInterrupted();
if(interrupted) {
log.debug(" 打断状态: {}", interrupted);
break;
}
}
}, "t2");
t2.start();
sleep(0.5);//t2睡眠0.5s
t2.interrupt();
}

输出:

1
20:57:37.964 [t2] TestInterrupt - 打断状态: true

注:打断 park 线程, 不会清空打断状态。而如果打断标记已经是 true, 则 park 会失效。

​ 可以使用 Thread.interrupted() 清除打断状态。

模式之两阶段终止

Two Phase Termination,就是考虑在一个线程T1中如何优雅地终止另一个线程T2,这里的优雅指的是给T2一个料理后事的机会(如释放锁)。

如下所示:线程的isInterrupted()方法可以取得线程的打断标记,如果线程在睡眠sleep期间被打断,打断标记是不会变的,为false,但是sleep期间被打断会抛出异常,我们据此手动设置打断标记为true;如果是在程序正常运行期间被打断的,那么打断标记就被自动设置为true。处理好这两种情况那我们就可以放心地来料理后事啦!

image-20210603153151665

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class Test {

public static void main(String[] args) throws InterruptedException {
TwoParseTermination twoParseTermination = new TwoParseTermination();
twoParseTermination.start();
Thread.sleep(3500);
twoParseTermination.stop();
}

}

class TwoParseTermination {

private Thread monitor;

// 启动线程
public void start() {
monitor = new Thread(() -> {
while (true) {
Thread thread = Thread.currentThread();
if(thread.isInterrupted()) { // 调用 isInterrupted 不会清除标记
log.info("料理后事 ...");
break;
} else {
try {
Thread.sleep(1000);
log.info("执行监控的功能 ...");
} catch (InterruptedException e) {
log.info("设置打断标记 ...");
thread.interrupt();
e.printStackTrace();
}
}
}
}, "monitor");
monitor.start();
}

// 终止线程
public void stop() {
monitor.interrupt();
}
}

不推荐的方法

还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁。

  1. stop:停止线程运行。
  2. suspend() :挂起(暂停)线程运行。
  3. resume():恢复线程运行。

主线程与守护线程

默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守

护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。

注意

  • 垃圾回收器线程就是一种守护线程

  • Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求。

五种状态

五种状态的划分主要是从操作系统的层面进行划分的:

1583507073055
  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联。

  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行。

  • 【运行状态】指获取了 CPU 时间片运行中的状态。

    • 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换。
  • 【阻塞状态】

    • 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】;
    • 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】。
    • 与【可运行状态】的区别是:对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们。
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态。

六种状态

这是从 Java API 层面来描述的,根据 Thread.State 枚举,分为六种状态:

image-20210630120605408
  • 【NEW】: 线程刚被创建,但是还没有调用 start() 方法。

  • 【RUNNABLE】: 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)。

  • 【BLOCKED】 , 【WAITING】 , 【TIMED_WAITING】 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节

详述。

  • 【TERMINATED】 当线程代码运行结束。