一、进程与线程
1.1 什么是进程?
- 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至
CPU
,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理IO
的。 - 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
- 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)。
1.2 什么是线程?
- 一个进程之内可以分为一到多个线程。
- 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给
CPU
执行。 Java
中,线程作为最小调度单位,进程作为资源分配的最小单位。 在windows
中进程是不活动的,只是作为线程的容器。
1.3 二者对比
- 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集。
- 进程拥有共享的资源,如内存空间等,供其内部的线程共享。
- 进程间通信较为复杂:
- 同一台计算机的进程通信称为
IPC
(Inter-process communication)。 - 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如
HTTP
。
- 同一台计算机的进程通信称为
- 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量。
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低。
二、并发与并行
2.1 什么是并发?
- 单核
cpu
下,线程都是串行执行的。 - 操作系统中有一个组件叫做任务调度器,它将
cpu
的时间片分给不同的程序使用,只是由于cpu
在线程间的切换非常快,所以给人的感觉是同时运行的。 - 总结一句话:单核
cpu
条件下,线程微观串行,宏观并行。 - 一般会将这种线程轮流使用
cpu
的做法成为并发(concurrent)。
+ - - - - -+
' cpu: '
' '
' +------+ ' +----------------------------+
' | core | ' --> | instruction-sets(thread-1) |
' +------+ ' +----------------------------+
' '
+ - - - - -+
2.2 什么是并行?
- 多核
cpu
下,每个核(core
) 都可以调度运行线程,这时候线程可以是并行(Parallel)的。
+ - - - - - -+
' cpu: '
' '
' +--------+ ' +----------------------------+
' | core-1 | ' --> | instruction-sets(thread-1) |
' +--------+ ' +----------------------------+
' +--------+ ' +----------------------------+
' | core-2 | ' --> | instruction-sets(thread-2) |
' +--------+ ' +----------------------------+
' +--------+ ' +----------------------------+
' | core-3 | ' --> | instruction-sets(thread-3) |
' +--------+ ' +----------------------------+
' +--------+ ' +----------------------------+
' | core-4 | ' --> | instruction-sets(thread-4) |
' +--------+ ' +----------------------------+
' '
+ - - - - - -+
2.3 二者对比
-
并发和并行是两个不同的概念。
-
借用Go创始人Rob Pike的说法:
-
并发(concurrent)是同一时间应对(dealing with)多件事情的能力。
-
并行(parallel)是同一时间动手做(doing)多件事情的能力
-
-
举个简单的例子:华罗庚泡茶,必须有烧水、洗杯子、拿茶叶等步骤,现在我们想尽快做完这件事,也就是“一共要处理很多事情”,有很多方法可以实现并发,例如请多个人同时做,这就是并行。
-
并行是实现并发的一种方式,但不是唯一的方式。我们一个人也可以实现并发,例如先烧水、然后不用等水烧开就去洗杯子,所以通过调整程序运行方式也可以实现并发。
2.4 误区避免
- 单核
cpu
下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用cpu
,避免一个线程总占用cpu
,别的线程没法干活。 - 多核
cpu
可以并行跑多个线程,但能否提高程序运行效率还是要分情况的:- 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率,但不是所有计算任务都能拆分。(具体可参考<阿姆达尔定律>)
- 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没有实际意义。
IO
操作不占用cpu
,只是我们一般拷贝文件使用的是【阻塞IO
】,这时相当于线程虽然不用cpu
,但需要一直等待IO
结束,没能充分利用线程。所以才会涉及到【非阻塞IO
】和【异步IO
】优化。
三、Java 线程使用
3.1 三种使用方式
- 方式一:直接使用 Thread
/**
* 方式一:直接使用 Thread 对象进行创建。
*/
@Test
public void test01() {
// 1.显式创建线程,并指定线程名。
Thread t1 = new Thread("t1") {
@Override
public void run() {
System.out.println(" t1线程 执行 ");
}
};
// 2.启动线程。
t1.start();
// t1线程 执行
}
- 方式二:使用 Runnable 配合 Thread
/**
* 方式二:使用 Runnable + Thread 进行创建。
*/
@Test
public void test02() {
// 1.创建任务对象。
Runnable task = () -> System.out.println(" 任务执行 ");
// 2.创建线程 (传入任务 及 线程名)。
Thread t2 = new Thread(task, "t2");
t2.start();
// 任务执行
}
- 方法三:FutureTask 配合 Thread
/**
* FutureTask 配合 Thread。
*
* @throws ExecutionException 执行异常
* @throws InterruptedException 中断异常
*/
@Test
public void test03() throws ExecutionException, InterruptedException {
// 1.创建任务对象 (接收 Callable 类型的参数 及 返回值)。
FutureTask<String> task = new FutureTask<>(
() -> System.out.println(" 任务执行 "),
"hello");
// 2.创建线程 (传入任务 及 线程名)。
Thread t3 = new Thread(task, "t3");
t3.start();
// 任务执行
// 3.主线程阻塞,同步等待 task 执行完毕的结果。
System.out.println("获取返回值:" + task.get());
// 获取返回值:hello
}
3.2 小结
- 方式一是将线程与任务进行合并,方式二及方式三是将线程和任务进行区分。
- 用
Runnable
更容易与线程池等高级API
配合。 - 用
Runnable
让任务类脱离了Thread
继承体系,更灵活。 - 建议给每个线程设置自定义线程名,便于调试及定位问题。
- 线程是交替执行的,谁先谁后不受我们控制。
四、查看进程与线程
4.1 Windows
-
任务管理器可以查看进程和线程数,也可以用来杀死进程。
-
tasklist
查看进程:
# 查看java进程。
tasklist | findstr "java"
# 查询进程使用的端口号。
netstat -ano | find "PID"
taskkill
杀死进程:
# 根据pid终止进程。
taskkill /f /pid 123456
# 根据进程的名称终止进程。
taskkill /f /im xxx.exe
4.2 Linux
ps -ef
查看所有进程。
# 查看java进程。
$ ps -ef | grep java
-
ps -fT -p PID
查看某个进程(PID
)的所有线程。 -
kill
杀死进程。 -
top
按大写H
切换是否显示线程。 -
top -H -p PID
查看某个进程(PID
)的所有线程。
4.3 Java
-
jps
命令查看所有Java
进程。 -
jstack PID
查看某个Java
进程(PID
)的所有线程状态。 -
jconsole
来查看某个Java
进程中线程的运行情况(图形界面):
五、线程运行原理
5.1 栈与栈帧
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
5.2 线程上下文切换
-
因为以下一些原因导致
cpu
不再执行当前的线程,转而执行另一个线程的代码。- 线程的
cpu
时间片用完。 - 垃圾回收。
- 有更高优先级的线程需要运行。
- 线程自己调用了
sleep()
、yield()
、wait()
、join()
、park()
、synchronized()
、lock()
等方法。
- 线程的
-
当线程上下文切换(
Context Switch
)发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java
中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条jvm
指令的执行地址,是线程私有的。- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等。
- 线程上下文切换(
Context Switch
) 频繁发生会影响性能。
六、常见方法及使用
6.1 方法说明
方法名 | 是否为静态方法 | 功能说明 | 注意事项 |
---|---|---|---|
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() | 是 | 判断当前线程是否被打断。 | 会清除 打断标记。 |
currentThread() | 是 | 获取当前正在执行的线程。 | |
sleep(long n) | 是 | 让当前执行的线程休眠n毫秒,休眠时让出 cpu 的时间片给其它线程。 | |
yield() | 是 | 提示线程调度器让出当前线程对 cpu 的使用。 | 主要是为了测试和调试。 |
6.2 run 与 start
- 使用run方法:
public static void main(String[] args) {
// 1.创建线程。
Thread t1 = new Thread("t1") {
@Override
public void run() {
try {
log.debug("当前执行的线程名:" + Thread.currentThread().getName());
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 2.使用run方法执行。
t1.run();
// [main] DEBUG 当前执行的线程名:main
log.debug("do other things...");
// [main] DEBUG do other things...
}
- 使用start方法:
public static void main(String[] args) {
// 1.创建线程。
Thread t1 = new Thread("t1") {
@Override
public void run() {
try {
log.debug("当前执行的线程名:" + Thread.currentThread().getName());
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 2.使用start方法执行。
t1.start();
// [t1] DEBUG 当前执行的线程名:t1
log.debug("do other things...");
// [main] DEBUG do other things...
}
- 小结:
- 直接调用
run
是在主线程中执行了run
,没有启动新的线程。 - 使用
start
是启动新的线程,通过新的线程间接执行run
中的代码。
- 直接调用
6.3 sleep
- 使用sleep方法:
@Test
public void test01() throws InterruptedException {
Thread t1 = new Thread(
() -> {
try {
log.debug("t1 进入休眠...");
// 睡眠3s。
TimeUnit.SECONDS.sleep(3);
log.debug("t1 休眠结束...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
// 1.启动线程。
t1.start();
log.debug("当前t1线程状态:{}", t1.getState());
// 当前t1线程状态:RUNNABLE
// 2.睡眠50ms。
TimeUnit.MILLISECONDS.sleep(50);
log.debug("当前t1线程状态:{}", t1.getState());
// 当前t1线程状态:TIMED_WAITING
// 3.等待线程结束。
t1.join();
}
- 小结:
- 调用
sleep
会让当前线程从Running
进入Timed Waiting
状态(阻塞)。 - 其它线程可以使用
interrupt
方法打断正在睡眠的线程,这时sleep
方法会抛出InterruptedException
。 - 睡眠结束后的线程未必会立刻得到执行。
- 建议用
TimeUnit
的sleep()
代替Thread
的sleep()
来获得更好的可读性。
- 调用
6.4 join
- 为什么要使用
join()
?首先我们来看一个案例。
@Slf4j
public class JoinSample {
/**
* 成员变量初始值为0。
*/
private int num = 0;
@Test
public void test01() {
Thread t1 = new Thread(
() -> {
try {
TimeUnit.SECONDS.sleep(1);
// 为成员变量重新赋值。
num = 10;
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
t1.start();
log.debug("num={}", num);
// [main] DEBUG JoinSample - num=0
}
}
-
思考:此处为什么
num
得到的值是 0 而不是 10 ? -
分析结论:主线程与子线程 t1 是并行执行的,t1 线程需要等待 1 秒后才能完成赋值操作,而主线程的执行速度快过子线程,所以日志打印结果
num=0
。 -
解决方法(使用join):
@Slf4j
public class JoinSample {
/**
* 成员变量初始值为0。
*/
private int num = 0;
@Test
public void test01() throws InterruptedException {
Thread t1 = new Thread(
() -> {
try {
TimeUnit.SECONDS.sleep(1);
// 为成员变量重新赋值。
num = 10;
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
t1.start();
// 1.使用join等待子线程t1执行结束。
t1.join();
log.debug("num={}", num);
// [main] DEBUG JoinSample - num=10
}
}
- 示意图:
+------------+
| main | -+
+------------+ |
| |
| |
v |
+------------+ |
| t1.start() | |
+------------+ |
| |
| 1s |
v |
+------------+ |
| r=10 | |
+------------+ |
| |
| t1 end |
v |
+------------+ |
| t1.join | <+
+------------+
6.5 interrupt
- 打断阻塞状态的线程(sleep,wait,join ):
/**
* 打断 sleep,wait,join 的线程。
* 这几个方法都会让线程进入阻塞状态,此处以打断 sleep 的线程为例。
* 打断后会清空打断状态。
*
* @throws InterruptedException 中断异常
*/
@Test
public void test01() throws InterruptedException {
Thread t1 = new Thread(
() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
t1.start();
TimeUnit.MILLISECONDS.sleep(100);
t1.interrupt();
log.debug("t1 打断状态:{}", t1.isInterrupted());
// java.lang.InterruptedException: sleep interrupted ...
// [main] DEBUG InterruptSample - t1 打断状态:false
}
- 打断正常运行的线程:
/**
* 打断正常运行的线程。
* 不会清空打断状态。
*
* @throws InterruptedException 中断异常
*/
@Test
public void test02() throws InterruptedException {
Thread t2 = new Thread(
() -> {
while (true) {
boolean interrupted = Thread.currentThread().isInterrupted();
if (interrupted) {
log.debug("t2 打断状态:{}", interrupted);
// [t2] DEBUG InterruptSample - t2 打断状态:true
break;
}
}
}, "t2");
t2.start();
t2.interrupt();
t2.join();
}
- 打断 park 线程:
/**
* 打断 park 线程。
* 如果打断标记已经是 true, 则 park 会失效。
* 可以使用 Thread.interrupted() 清除打断状态。
*
* @throws InterruptedException 中断异常
*/
@Test
public void test03() throws InterruptedException {
Thread t3 = new Thread(
() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("t3 打断状态:{}", Thread.currentThread().isInterrupted());
// [t3] DEBUG InterruptSample - t3 打断状态:true
}, "t3");
t3.start();
t3.interrupt();
t3.join();
}
6.6 线程终止 - 两阶段终止模式
在一个线程 t1 中如何“优雅”终止线程 t2?
-
错误思路1:使用线程对象的
stop()
方法停止线程。stop
方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁。
-
错误思路2:使用
System.exit(int)
方法停止线程。- 目的仅是停止一个线程,但这种做法会让整个程序都停止。
-
正确的做法是:执行完终止处理,再终止线程,即 Two-phase Termination,两阶段终止模式。
-
示意图:
- 方式一:利用
isInterrupted()
实现两阶终止。
static class TPTInterrupt {
/**
* 线程。
*/
private Thread thread;
/**
* 利用 isInterrupted 实现两阶终止。
*/
public void start() {
thread = new Thread(
() -> {
while (true) {
// 1.当前线程是否被打断。
Thread currentThread = Thread.currentThread();
if (currentThread.isInterrupted()) {
// 若被打断标记,则执行收尾工作。
log.debug(" finish work ");
break;
}
try {
// 2.休眠 2 s。
TimeUnit.SECONDS.sleep(2);
log.debug(" save result ");
} catch (InterruptedException e) {
// 异常则设置打断标记。
currentThread.interrupt();
}
// 执行监控记录。
}
}, "monit-thread");
thread.start();
}
/**
* 停止线程方法。
*/
public void stop() {
thread.interrupt();
}
public static void main(String[] args) throws InterruptedException {
TPTInterrupt t = new TPTInterrupt();
t.start();
TimeUnit.SECONDS.sleep(3);
log.debug("stop");
t.stop();
// [monit-thread] DEBUG Sample - save result
// [main] DEBUG Sample - stop
// [monit-thread] DEBUG Sample - finish work
}
}
- 方式二:利用停止标记。
static class TPTVolatile {
/**
* 线程。
*/
private Thread thread;
/**
* 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性。
* 即主线程把它修改为 true 对子线程可见。
*/
private volatile boolean stop = false;
public void start() {
thread = new Thread(
() -> {
while (true) {
// 1.当前线程是否被打断。
if (stop) {
// 若被打断标记,则执行收尾工作。
log.debug(" finish work ");
break;
}
try {
// 2.休眠 2 s。
TimeUnit.SECONDS.sleep(2);
log.debug(" save result ");
} catch (InterruptedException e) {
e.printStackTrace();
}
// 执行监控记录。
}
}, "monit-thread");
thread.start();
}
/**
* 停止线程方法。
*/
public void stop() {
// 重新设置标记。
stop = true;
thread.interrupt();
}
public static void main(String[] args) throws InterruptedException {
TPTVolatile t = new TPTVolatile();
t.start();
TimeUnit.SECONDS.sleep(3);
log.debug("stop");
t.stop();
// [monit-thread] DEBUG Sample - save result
// [main] DEBUG Sample - stop
// [monit-thread] DEBUG Sample - finish work
// java.lang.InterruptedException: sleep interrupted
}
}
6.7 不推荐方法
- 还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁!
方法名 | 是否为静态方法 | 功能说明 |
---|---|---|
stop() | 停止线程运行。 | |
suspend() | 挂起(暂停)线程运行。 | |
resume() | 恢复线程运行。 |
6.8 主线程与守护线程
- 默认情况下,
Java
进程需要等待所有线程都运行结束,才会结束。 - 有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
/**
* [非守护线程]执行结束后,[守护线程]即使没有执行完也会强制结束。
*
* @throws InterruptedException 中断异常
*/
@Test
public void test01() throws InterruptedException {
Thread t1 = new Thread(
() -> {
try {
log.debug(" daemon start...");
TimeUnit.SECONDS.sleep(5);
log.debug(" daemon end...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "daemon");
t1.setDaemon(true);
t1.start();
TimeUnit.SECONDS.sleep(1);
log.debug(" main end ");
// [daemon] DEBUG Sample - daemon start...
// [main] DEBUG Sample - main end
}
- 其他补充:
- 垃圾回收器线程就是一种守护线程。
Tomcat
中的Acceptor
和Poller
线程都是守护线程,所以Tomcat
接收到shutdown
命令后,不会等待它们处理完当前请求。
七、线程状态
7.1 操作系统层面
- 示意图:
- 初始状态:仅是在语言层面创建了线程对象,还未与操作系统线程关联。
- 可运行状态(就绪状态):指该线程已经被创建(与操作系统线程关联),可以由
cpu
调度执行。 - 运行状态:指获取了
cpu
时间片运行中的状态,当cpu
时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换。 - 阻塞状态:
- 如果调用了阻塞
API
,如BIO
读写文件,这时该线程实际不会用到cpu
,会导致线程上下文切换,进入【阻塞状态】。 - 等
BIO
操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】。 - 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们。
- 如果调用了阻塞
- 终止状态:表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态。
7.2 Java API 层面
根据 Thread.State 枚举,分为六种状态。
- 示意图:
NEW
:线程刚被创建,但是还没有调用start()
方法。RUNNABLE
:当调用了 start() 方法之后。注意:Java API 层面的RUNNABLE
状态涵盖了操作系统层面的【可运行状态】、【运行状态】和【阻塞状态】(由于BIO
导致的线程阻塞,在Java
里无法区分,仍然认为是可运行)。BLOCKED
,WAITING
,TIMED_WAITING
都是 Java API 层面对【阻塞状态】的细分。TERMINATED
当线程代码运行结束。
八、结束语
“-------怕什么真理无穷,进一寸有一寸的欢喜。”
微信公众号搜索:饺子泡牛奶。