JVM并发编程(上)

一、进程与线程

1.1 进程与线程

1.1.1 进程

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

1.1.2 线程

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

1.1.3二者对比

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

1.2 并发与并行

并发(concurrent):两个及两个以上的作业在同一时间段执行,操作系统中使用时间片轮转算法,从而实现微观串行,宏观并行

并行(parallel):两个及两个以上的作业在同一时刻段执行。

1.3 同步与异步

同步:发出一个调用之后,在没有得到结果之前,该调用就不可以返回,一直等待。

异步:调用在发出之后,不用等待返回结果,该调用直接返回。

1.4 应用

1.4.1 异步调用

设计:

多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了 5 秒钟,如
果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停…

结论:

  • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
  • tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程
  • ui 程序中,开线程进行其他操作,避免阻塞 ui 线程

1.4.2 提高效率

设计:

由多个线程独立同时计算,最后进行汇总。

结论:

  1. 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用
    cpu ,不至于一个线程总占用 cpu,别的线程没法干活
  2. 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况
    • 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任
      务都能拆分
    • 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
  3. IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一
    直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化

1.5 线程的生命周期和状态

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED :阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换

  • NEW 线程刚被创建,但是还没有调用 start() 方法
  • RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的
    【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为
    是可运行)
  • BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节
    详述
  • TERMINATED 当线程代码运行结束

1.6 线程死锁

指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些线程都将无法向前推进。

1.6.1 死锁产生的必要条件

  1. 互斥条件:在一段时间内某资源仅为一个线程所占有。

  2. 不可剥夺条件:线程所获得的资源在未使用完之前,不能被其他线程强行夺走,即只能由获得该资源的线程自己来释放

  3. 请求并保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。

  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

1.6.2 死锁预防

  1. 破坏互斥条件(不太可行)
  2. 破坏不可剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  3. 破环请求并保持条件:采用预先静态分配方法,一次性申请完它所需要的资源
  4. 破坏循环等待条件:采用顺序资源分配发。按某一顺序申请资源,释放资源则反序释放。

1.6.3 死锁避免

  1. 系统安全状态
  2. 银行家算法

二、Java线程

2.1 创建和运行线程

2.1.1 方法一:直接使用Thread

1
2
3
4
5
6
7
8
//创建线程对象
Thread t = new Thread() {
public void run() {
// 要执行的任务
}
};
//启动线程
t.start();

2.1.2 方法二:使用 Runnable 配置 Thread

把【线程】和【任务】(要执行的代码)分开

  • Thread代表线程
  • Runnable代表可线程执行的任务
1
2
3
4
5
6
7
8
9
10
//创建任务对象
Runnable runnable = new Runnable() {
public void run() {
//要执行的任务
}
};
//创建线程对象
Thread t = new Thread(runnable);
//启动线程
t.start();

lambda表达式

1
2
3
4
5
6
//创建任务对象
Runnable task2 = () -> log.debug("hello");
//创建线程对象
Thread t2 = new Thread(task2, "t2");
//启动线程
t2.start();

2.1.3 Thread与Runnable之间的关系

  1. 方法1 是把线程和任务合并在了一起,方法2 是把线程和任务分开了
  2. 用 Runnable 更容易与线程池等高级 API 配合
  3. 用 Runnable 让任务类脱离了 Thread 继承体系,更灵活

2.1.4 方法三:FutureTask 配合 Thread

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

1
2
3
4
5
6
7
8
9
10
11
12
//创建任务对象
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);

2.2 线程运行原理

2.2.1 栈与栈帧

每个线程启动,虚拟机就会为其分配一块内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

2.2.2 线程上下文切换

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

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

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

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Context Switch 频繁发生会影响性能

2.3 常用API

方法名 static 功能说明 注意
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 的线程被打断,也会设置 打断标记
interrupted() static 判断当前线程是
否被打断
会清除 打断标记
currentThread() static 获取当前正在执
行的线程
sleep(long n) static 让当前执行的线
程休眠n毫秒,
休眠时让出 cpu
的时间片给其它
线程
yield() static 提示线程调度器
让出当前线程对
CPU的使用
主要是为了测试和调试

2.4 start 与 run

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

2.5 sleep 与 yield

2.5.1 sleep

  1. 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
  2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
  3. 睡眠结束后的线程未必会立刻得到执行
  4. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性

sleep() 方法不会释放锁

2.5.2 yield

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

2.5.3 线程优先级

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

2.6 join方法

等待线程执行结束,应用在同步上。

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

解决方法:

  • 用 join,加在 t1.start() 之后即可
  • 如果用sleep,不好控制主线程等待的时间

如果用join(long n),则在等待n毫秒后停止等待。

2.7 interrupt 方法

2.7.1 打断sleep,wait,join的线程

这几个方法都会让线程进入阻塞状态

打断位于阻塞状态的线程,会清空打断状态

2.7.2 打断正常运行的线程

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

2.7.3 两阶段终止模式

在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

错误思路:

  • 使用线程对象的stop()方法停止线程
    • stop() 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其他线程将永远无法获取锁。
  • 使用System.exit(int) 方法停止线程
    • 这种做法会让整个程序都停止

正确做法:

2.7.3.1 利用isInterrupted
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
class TPInterrupt {
private Thread thread;

//启动监控线程
public void start() {
while (true) {
Thread current = Thread.currentThread();
if (current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000); // 情况1 睡眠打断
log.debug("执行监控记录"); // 情况2 正常打断
} catch (InterruptedException e) {
//重新设置打断标记
thread.interrupt();
e.printStackTrace();
}
}
}

//停止监控线程
public void stop() {
thread.interrupt();
}
}
2.7.3.2 利用停止标记
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
// 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
// 我们的例子中,即主线程把它修改为 true 对 t1 线程可见
class TPVolatile {
private Thread thread;
private volatile boolean stop = false;

//启动监控线程
public void start() {
while (true) {
Thread current = Thread.currentThread();
if (stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000); // 情况1 睡眠打断
log.debug("执行监控记录"); // 情况2 正常打断
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

//停止监控线程
public void stop() {
stop = true;
thread.interrupt();
}
}

2.8 打断park线程

park是 LockSupport类中的方法

1
2
3
4
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)

与 Object 的 wait & notify 相比

  • wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必

  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】

  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify

打断park线程,不会清空打断状态

1
2
3
4
5
6
7
8
9
10
11
12
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();
}

如果打断标记已经是true,则park会失效,

可以使用 Thread.interrupted() 清楚打断状态

2.9 主线程与守护线程

默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守
护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 命令后,不会等待他们处理完当前请求。

参考

https://www.bilibili.com/video/BV16J411h7Rd P1 - P49

https://javaguide.cn/java/concurrent/java-concurrent-questions-01.html

作者

lin ronghui

发布于

2022-12-20

更新于

2022-12-20

许可协议

You need to set install_url to use ShareThis. Please set it in _config.yml.
You forgot to set the business or currency_code for Paypal. Please set it in _config.yml.

评论

You forgot to set the shortname for Disqus. Please set it in _config.yml.
You need to set client_id and slot_id to show this AD unit. Please set it in _config.yml.