Java并发编程-基础
[TOC]
总览
进程与线程
进程与线程
- 进程
程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)
- 线程
一个进程之内可以分为一到多个线程。 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器
- 二者对比
进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
进程拥有共享的资源,如内存空间等,供其内部的线程共享
进程间通信较为复杂,同一台计算机的进程通信称为 IPC, 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低
并行与并发
引用 Rob Pike 的一段描述:
并发(concurrent)是同一时间应对(dealing with)多件事情的能力
并行(parallel)是同一时间动手做(doing)多件事情的能力
例子:
家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一 个人用锅时,另一个人就得等待)
雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
异步调用
同步:需要等待结果返回,才能继续运行
异步:不需要等待结果返回,就能继续运行
多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了 5 秒钟,如果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停…
提高效率
充分利用多核 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 才能提高效率,单核仍然时是轮流执行
结论:
单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用 cpu ,不至于一个线程总占用 cpu,别的线程没法干活
多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
- 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率
- 但不是所有计算任务都能拆分(参考后文的【阿姆达尔定律】)
- 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
- IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一 直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化
Java线程
创建和运行线程
- 创建Runnable接口对象配合Thread
1 | // 创建任务对象 |
用 Runnable 更容易与线程池等高级 API 配合
用 Runnable 让任务类脱离了 Thread 继承体系,更灵活
- FutureTask 配合 Thread
1 | public class Test_JUC { |
FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况
观察多个线程同时运行
两个线程交替执行,谁先谁后不受我们控制
1 | public static void main(String[] args) { |
查看进程线程的方法
Windows
- 在Windows环境下,可以通过任务管理器来查看进程和线程数,也可以用来杀死进程
- tasklist 查看进程
- taskkill 杀死进程
Linux
- Linux环境下有关进程的指令
ps -ef
查看所有进程ps -fT -p <PID>
查看某个进程(PID)的所有线程kill
杀死进程top
按大写H切换是否显示进程top -H -p <PID>
查看某个进程(PID)的所有线程
Java
- jps命令查看所有Java进程
- jstack 查看某个Java进程(PID)的所有线程状态
- jconsole 查看某个Java进程中线程的运行情况(图形界面)
jconsole 远程监控配置:
需要以如下方式运行你的 java 类
1 | java -Djava.rmi.server.hostname=`ip地址` -Dcom.sun.management.jmxremote Dcom.sun.management.jmxremote.port=`连接端口` -Dcom.sun.management.jmxremote.ssl=是否安全连接 -Dcom.sun.management.jmxremote.authenticate=是否认证 java类 |
原理之线程运行
栈与栈帧
虚拟机栈:线程运行需要的内存空间
栈帧:每个方法运行时需要的内存
结合JVM学习知识
多线程的情况下,每个线程有自己独立的栈内存,互不干扰
线程上下文切换
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码
- 线程的 cpu 时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当线程上下文切换发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- 线程上下文切换频繁发生会影响性能
常见方法
方法名 | 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() | static | 判断是否被打断 | 不会清除打断标记 |
isAlive() | static | 线程是否存活(还没有运行完毕) | |
interrupt() | static | 打断线程 | 如果被打断线程正在sleep、wait、join会导致被打断的线程抛出InterruptedException,并清除打断标记;如果打断的正在运行的线程,则会设置打断标记;park的线程被打断,也会设置打断标记 |
interrupted() | static | 判断当前线程是否被打断 | 会清除打断标记 |
start与run
1 | public static void main(String[] args) { |
这段代码的输出结果如下:
1 | 17:42:59 [main] c.Sync - main |
可以看见使用run方法去执行一个线程时是单线程的,并不会异步地去调用新线程t1
。
将run方法改为start方法后,执行结果如下:
1 | 17:50:13 [main] c.Sync - do other things ... |
可以看到线程异步调用。
小结
- 直接调用run()是在主线程中执行了run(),并没有直接启动新线程
- 使用start是启动新的线程,通过新的线程间接执行run()中的代码
sleep与yield
sleep
- 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
- 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
- 睡眠结束后的线程未必会立刻得到执行,也就是就绪态,因为操作系统未必会给予cpu进行执行
- 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性,例如
TimeUnit.SECONDS.sleep(1)
表示线程休眠1秒
yield
- 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
- 具体的实现依赖于操作系统的任务调度器
二者区别⭐⭐⭐
sleep
方法将线程从运行状态转移到阻塞状态,线程在指定的时间后返回到就绪状态。yield
方法将线程从运行状态转移到就绪状态,线程愿意让出CPU时间片给其他线程。
- 线程只有在就绪状态下才能获得时间片,而在阻塞状态不会分配时间片。
sleep方法和yield方法
共同点:都会使得当前线程释放cpu资源,让其他线程来运行
不同点:调用sleep方法后,线程进入time_waiting状态,此时不去争抢cpu,而调用yield方法进入ready状态,会去抢占cpu
线程优先级
1 | t1.setPriority(Thread.MIN_PRIORITY); |
该方法有十个优先级,从1到10,默认为5,数字越大优先级越大
需注意:yield和这个线程优先级都是作为提示功能,具体还是看任务调度器分配的时间片
sleep实用案例
1 | while(true) { |
在这段代码中,为防止无限死循环一直占用cpu,可使用sleep或yield方法进行休眠,不让这个程序一直占用cpu
join方法详解
join方法
1 | static int r = 0; |
这段代码中主线程会等待t1线程运行结束后才执行后续的操作,如下为图解:
等待多个结果
1 | public static void main(String[] args) throws InterruptedException { |
在这段代码中有t1线程和t2线程,即使颠倒两个join方法的执行顺序,等待时间依然是两秒,如下为图解:
有时效的join方法
1 | t1.join(500);//最多等0.5s |
如果线程超过这个时间还未结束,则就不等待了。
如果线程在这个时间内提前结束,那么也直接结束了,并不会一直等够那么多时间。
interrupt方法详解
这里说的打断并不是真的直接打断,也只是起到一个提示的作用而已
打断 sleep,wait,join 的线程
这几个方法都会让线程进入阻塞状态
打断 sleep 的线程, 会清空打断状态,以 sleep 为例
1 | private static void test1() throws InterruptedException { |
输出结果:
1 | java.lang.InterruptedException: sleep interrupted |
打断正常运行的线程
打断正常运行的线程, 不会清空打断状态
1 | private static void test2() throws InterruptedException { |
输入结果:
1 | 20:57:37.964 [t2] c.TestInterrupt - 打断状态: true |
两阶段终止模式(通过interrupt实现)⭐⭐⭐
两阶段终止模式是软件工程领域中的一种设计模式,通常用于确保在终止程序或系统时执行必要的清理工作,以防止资源泄漏或其他不良影响。这个模式分为两个阶段:
- 第一阶段(准备阶段):
- 在这个阶段,程序或系统开始准备关闭操作。它可能包括以下任务:
- 停止接受新的请求或事务。
- 完成正在进行的任务或事务。
- 确保不再分配或使用新的资源。
- 第二阶段(终止阶段):
- 在这个阶段,程序或系统执行实际的关闭操作。这通常包括以下任务:
- 释放已分配的资源,如内存、文件句柄、数据库连接等。
- 执行清理工作,例如关闭打开的文件、断开网络连接、保存状态信息等。
- 发送终止通知或事件,以便其他组件或系统可以做出相应的响应。
两阶段终止模式的目标是确保程序或系统在关闭时能够安全地释放资源并维护数据的完整性。这有助于避免资源泄漏和不稳定的状态。
在一个线程T1中如何优雅的终止线程T2?这里的优雅指的是给T2一个料理后事的机会
代码实现:
1 | class TPTInterrupt { |
调用:
1 | TPTInterrupt t = new TPTInterrupt(); |
执行结果:
1 | 11:49:42.915 c.TwoPhaseTermination [监控线程] - 将结果保存 |
打断park线程
使用如下代码,执行park命令时,线程会暂停在那里,只有当interrupt为假时才会暂停在那里,当interrupt为真时线程就不会因该条语句而暂停,会跳过这行代码而进行下面的操作。
1 | LockSupport.park(); |
park的线程被打断,也会设置打断标记,所以在一个线程中进行park打断后,此时的interrupt就为真了,只有在后面手动修改interrupt为假才能继续使用park打断。
不推荐的方法
这三个方法已经过时,而且容易破坏同步代码块,造成线程死锁
方法名 | static | 功能说明 |
---|---|---|
stop() | 停止线程运行 | |
suspend() | 挂起(暂停)线程运行 | |
resume() | 恢复线程运行 |
主线程与守护线程
默认情况下,Java线程需要等待所有线程都运行结束,才会结束。
有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束
示例代码:
1 | public static void main(String[] args) throws InterruptedException { |
在这段代码中,因为t1线程被设置为了守护线程,因为其他非守护线程,即这里的主线程停止了,即使t1里面有个死循环也要强制停止。
- 垃圾回收线程就是一种守护线程
- Tomcat中的Acceptor和Poller线程都是守护线程,所以Tomcat接收到shutdown命令后秒回等待它们处理完当前请求
五种状态
五种状态是从操作系统层面考虑的
初始状态
仅是在语言层面创建了线程对象,还未与操作系统线程关联可运行状态
(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行运行状态
指获取了 CPU 时间片运行中的状态 当 CPU 时间片用完,会从运行状态
转换至可运行状态
,会导致线程的上下文切换阻塞状态
- 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入
阻塞状态
- 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换
运行状态
- 与
可运行状态
的区别是,对阻塞状态
的线程来说只要它们一直不唤醒,调度器就一直不会考虑 调度它们
- 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入
终止状态
表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态
六种状态
六种状态是从 Java API 层面来描述的
NEW
:线程刚被创建,但还没有调用start()方法RUNNABLE
:当调用了start()方法后注意:Java API层面的RUNNABLE状态涵盖了操作系统层面的可运行状态、运行状态和阻塞状态
BLOCKED
、WAITINT
、TIMED_WAITING
都是Java API层面对阻塞状态的细分,后面会在状态转换一节详细描述TERMINATED
:当线程代码运行结束
- 代码展示
1 |
|
执行结果:
1 | 11:02:48.753 c.TestState [t3] - running... |
统筹(烧水泡茶)
其实就是个多线程的问题,开四个线程太浪费了所以就开两个线程实现即可。
代码实现:
1 | public class Test_JUC { |
此种解法的缺陷
- 上面模拟的是Lucy等Kyle的水烧开了,Lucy泡茶,如果现在要让Kyle等Lucy把茶叶拿过来,由Kyle泡茶呢?
- 上面两个线程其实是各执行各的,如果要模拟Kyle把水壶交给Lucy泡茶,或者模拟Lucy把茶叶交给Kyle泡茶呢?
这个缺陷后面会解决
小结
本章的重点在于掌握
线程创建
线程重要 api,如 start,run,sleep,join,interrupt 等
线程状态
应用方面
- 异步调用:主线程执行期间,其它线程异步执行耗时操作
- 提高效率:并行计算,缩短运算时间
- 同步等待:join
- 统筹规划:合理使用线程,得到最优效果
原理方面
- 线程运行流程:栈、栈帧、上下文切换、程序计数器
- Thread 两种创建方式的源码
模式方面
- 终止模式之两阶段终止