一、基础知识
1、进程与线程
1)进程
程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存,在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的,当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程,进程拥有共享的资源,如内存空间等,供其内部的线程共享。
进程可以视为程序的一个实例,大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)
2)线程
一个进程之内可以分为一到多个线程,一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行。Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。
二者对比
进程基本上相互独立的,而线程存在于进程内,是进程的一个子集。
进程间通信较为复杂,同一台计算机的进程通信称为 IPC(Inter-process communication) ,不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP。线程通信相对简单,因为它们共享进程内的内存。例如:多个线程可以访问同一个共享变量。
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低。
2、并行与并发
单核 CPU 下,线程实际还是串行执行的,操作系统中有一个组件叫做任务调度器,将 CPU 的时间片分给不同的程序使用,只是由于 CPU 在线程间的切换非常快,人类感觉同时运行的。
一般会将这种线程轮流使用 CPU 的做法称为并发。
CPU | 时间片 1 | 时间片 2 | 时间片 3 | 时间片 4 |
core | 线程 1 | 线程 2 | 线程 3 | 线程 4 |
多核 CPU 下,每个核都可以调度运行线程,这时候线程可以是并行的。
CPU | 时间片 1 | 时间片 2 | 时间片 3 | 时间片 4 |
core 1 | 线程 1 | 线程 1 | 线程 3 | 线程 3 |
core 2 | 线程 2 | 线程 2 | 线程 4 | 线程 4 |
3、同步与异步
以调用方角度来讲,如果需要等待结果返回,才能继续运行就是同步。不需要等待结果返回,就能继续运行就是异步。
多线程可以让方法执行变为异步的(即不要一直等待)。
- 在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程。
- Tomcat 的异步 Servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 Tomcat 的工作线程。
- UI 程序中,开线程进行其他操作,避免阻塞 UI 线程。
4、注意
IO 操作不占用 CPU,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用CPU,但需要一直等待 IO 结束,没能充分利用线程,所以才有后面的【非阻塞 IO】和【异步 IO】优化。
二、Java线程
1、创建和运行线程
方法一,直接使用 Thread
// 构造方法的参数是给线程指定名字
Thread t1 = new Thread("t1") {
@Override
// run 方法内实现了要执行的任务
public void run() {
log.debug("running");
}
};
// 启动线程
t1.start();
方法二,使用 Runnable 配合 Thread
// 参数1 是任务对象; 参数2 是线程名字
new Thread(() -> {
log.debug("hello");
}, "t1").start();
原理之 Thread 与 Runnable 的关系
Thread 类的 run 方法:
public void run() {
if (target != null) {
target.run();
}
}
方法一重写了父类的 run 方法,方法二传入了 target 对象。
用 Runnable 更容易与线程池等高级 API 配合,让任务类脱离了 Thread 继承体系,更灵活。
方法三,FutureTask 配合 Thread
FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况
// 创建任务对象
FutureTask<Integer> task = new FutureTask(() ->{
log.debug("hello");
return 100;
});
// 参数1是任务对象; 参数2是线程名字
new Thread(task, "t1").start();
// 主线程阻塞,同步等待 task 执行完毕的结果
log.debug("结果是:{}",task.get());
2、查看进程线程的方法
jps:命令查看所有 Java 进程
jstack <PID>:查看某个 Java 进程(PID)的所有线程状态
jconsole:来查看某个 Java 进程中线程的运行情况(图形界面)
3、原理之线程运行
1)栈与栈帧
JVM 由堆、栈、方法区等所组成,其中栈内存(操作数栈)就是给线程用的,每个线程启动后,虚拟机就会为其分配一块栈内存,每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存,每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
2)线程上下文切换(Thread Context Switch)
因为以下一些原因导致 CPU 不再执行当前的线程,转而执行另一个线程的代码
- 线程的 CPU 时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当线程上下文切换时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器,它的作用是记住下一条 JVM 指令的执行地址,是线程私有的。
4、常见方法
方法名 | static | 功能说明 | 注意 |
start() | 启动一个新线程,在新的线程运行 run 方法中的代码 | start 方法只是让线程进入就绪,里面代码不一定立刻运行(CPU 的时间片还没分给它)。每个线程对象的 start 方法只能调用一次,如果调用了多次会出现 llegalThreadStateException | |
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 的使用 | 主要是为了测试和调试 |
5、start 与 run
调用 run
public static void main(String[] args) {
Thread t1 = new Thread("t1"){
@Override
public void run(){
log.debug("running");
}
};
t1.run();
}
12:15:21.278 [main] DEBUG c.Test1 - running
程序仍在 main 线程运行
调用 start
12:16:37.672 [t1] DEBUG c.Test2 - running
程序在 t1 线程运行
小结:直接调用 run 是在主线程中执行了 run,没有启动新的线程,使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码。
6、sleep 与 yield
sleep:调用 sleep 会让当前线程从运行状态进入阻塞状态,其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException,睡眠结束后的线程未必会立刻得到执行。
yield:调用 yield 会让当前线程从运行状态进入就绪状态,具体的实现依赖于操作系统的任务调度器。
7、线程优先级
线程优先级会提示调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它,如果 CPU 比较忙,那么优先级高的线程会获得更多的时间片,但 CPU 闲时,优先级几乎没作用。
8、 join 方法详解
1)为什么需要 join
下面的代码执行,打印 r 是什么?
static int r = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
r = 10;
},"t1");
t1.start();
//t1.join();
log.debug("结果为:{}", r);
}
0,因为主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能算出 r = 10 ,而主线程一开始就要打印 r 的结果,所以只能打印出 r = 0。
解决方法:用 join,加在 t1.start() 之后即可
2)有时效的 join
等够时间
static int r1 = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
r1 = 20;
},"t1");
long start = System.currentTimeMillis();
t1.start();
t1.join(1500);
long end = System.currentTimeMillis();
log.debug("r1: {} cost: {}", r1, end - start);
线程执行结束会导致 join 结束
14:33:34.687 [main] DEBUG c.Test3 - r1: 20 cost: 1003
没等够时间
修改t1线程的睡眠时间为 2 s
14:34:16.928 [main] DEBUG c.Test3 - r1: 0 cost: 1502
9、interrupt 方法详解
1)打断 sleep,wait,join 的线程
如果被打断线程正在 sleep,wait,join 会导致被打断的线程抛出 InterruptedException,并清除打断标记。以sleep为例
Thread t1 = new Thread(()->{
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
log.debug(" 打断状态: {}", Thread.currentThread().isInterrupted());
}
}, "t1");
t1.start();
Thread.sleep(1000);
t1.interrupt();
输出:
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at p3_9.Test1.lambda$main$0(Test1.java:16)
at java.lang.Thread.run(Thread.java:748)
14:46:05.675 [t1] DEBUG c.Test1 - 打断状态: false
2)打断正常运行的线程
打断正常运行的线程,不会清空打断状态。
Thread t1 = new Thread(() -> {
while (true){
boolean interrupted = Thread.currentThread().isInterrupted();
if(interrupted){
log.debug("打断状态:{}",interrupted);
break;
}
}
},"t1");
t1.start();
TimeUnit.SECONDS.sleep(1);
t1.interrupt();
输出:
14:48:46.446 [t1] DEBUG c.Test2 - 打断状态:true
3)打断 park 线程
打断 park 线程, 不会清空打断状态
Thread t1 = new Thread(() -> {
LockSupport.park();
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}, "t1");
t1.start();
TimeUnit.SECONDS.sleep(1);
t1.interrupt();
输出:
14:51:45.344 [t1] DEBUG c.Test3 - 打断状态:true
如果打断标记已经是 true,则 park 会失效,正常情况下调用 park 会阻塞住。
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
LockSupport.park();
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}
},"t1");
t1.start();
TimeUnit.SECONDS.sleep(1);
t1.interrupt();
输出:
14:53:18.468 [t1] DEBUG c.Test4 - 打断状态:true
14:53:18.471 [t1] DEBUG c.Test4 - 打断状态:true
14:53:18.471 [t1] DEBUG c.Test4 - 打断状态:true
14:53:18.471 [t1] DEBUG c.Test4 - 打断状态:true
14:53:18.471 [t1] DEBUG c.Test4 - 打断状态:true
可以使用 Thread.interrupted() 清除打断状态
4)应用
在线程 T1 中“优雅”终止线程 T2。这里的【优雅】指的是给 T2 一个料理后事的机会。
错误思路
- 使用线程对象的 stop() 方法停止线程,stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁。
- 使用 System.exit(int) 方法停止线程,目的仅是停止一个线程,但这种做法会让整个程序都停止。
利用 isInterrupted
@Slf4j(topic = "c.TPTInterrupt")
public class TPTInterrupt {
private Thread thread;
public void start(){
thread = new Thread(() -> {
while (true){
if (thread.isInterrupted()){
log.debug("料理后事");
break;
}
try {
TimeUnit.SECONDS.sleep(1);
log.debug("将结果保存");
} catch (InterruptedException e) {
//添加中断标记
thread.interrupt();
}
//执行监控操作
}
} ,"监控线程");
thread.start();
}
public void stop(){
thread.interrupt();
}
public static void main(String[] args) throws InterruptedException {
TPTInterrupt t = new TPTInterrupt();
t.start();
Thread.sleep(2500);
t.stop();
}
}
输出:
15:09:16.491 [监控线程] DEBUG c.TPTInterrupt - 将结果保存
15:09:17.502 [监控线程] DEBUG c.TPTInterrupt - 将结果保存
15:09:17.976 [监控线程] DEBUG c.TPTInterrupt - 料理后事
10、不推荐的方法
这些方法已过时,容易破坏同步代码块,造成线程死锁。
方法名 | static | 功能说明 |
stop() | 停止线程运行 | |
suspend() | 挂起(暂停)线程运行 | |
resume() | 恢复线程运行 |
原因
- stop() 方法过于暴力,强行把执行到一半的线程终止,可能会引起一些数据不一致的问题。
- suspend() 方法在导致线程暂停的同时,并不会释放任何锁资源。
- 如果 resume() 方法操作意外地在 suspend() 方法前就执行了,那么被挂起的线程可能很难有机会被继续执行。而且,对于被挂起的线程,从它的线程状态上看,居然还是 Runnable,这也会严重影响我们对系统当前状态的判断。
11、主线程与守护线程
默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
垃圾回收器线程就是一种守护线程。
log.debug("开始运行...");
Thread t1 = new Thread(() -> {
log.debug("开始运行...");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("运行结束...");
}, "daemon");
// 设置该线程为守护线程
t1.setDaemon(true);
t1.start();
TimeUnit.SECONDS.sleep(1);
log.debug("运行结束...");
输出:
15:14:27.193 [main] DEBUG c.Test1 - 开始运行...
15:14:27.227 [daemon] DEBUG c.Test1 - 开始运行...
15:14:28.234 [main] DEBUG c.Test1 - 运行结束...