118. 多线程
多线程基础
现代操作系统(Windows,macOS,Linux)都可以执行多任务。多任务就是同时运行多个任务,例如: IE
、 QQ
、网易云音乐
CPU执行代码都是一条一条顺序执行的,但是,即使是单核cpu,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行。
例如,假设我们有语文、数学、英语3门作业要做,每个作业需要30分钟。我们把这3门作业看成是3个任务,可以做1分钟语文作业,再做1分钟数学作业,再做1分钟英语作业。
这样轮流做下去,在某些人眼里看来,做作业的速度就非常快,看上去就像同时在做3门作业一样。
类似的,操作系统轮流让多个任务交替执行,例如,让浏览器执行0.001秒,让QQ执行0.001秒,再让音乐播放器执行0.001秒,在人看来,CPU就是在同时执行多个任务。
即使是多核CPU,因为通常任务的数量远远多于CPU的核数,所以任务也是交替执行的。
进程
在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。
某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。
进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 ┌──────────┐
│Process │
│┌────────┐│
┌──────────┐││ Thread ││┌──────────┐
│Process ││└────────┘││Process │
│┌────────┐││┌────────┐││┌────────┐│
┌──────────┐││ Thread ││││ Thread ││││ Thread ││
│Process ││└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘└──────────┘
┌──────────────────────────────────────────────┐
│ Operating System │
└──────────────────────────────────────────────┘
操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。
因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:
- 多进程模式(每个进程只有一个线程):
1
2
3
4
5
6┌──────────┐ ┌──────────┐ ┌──────────┐
│Process │ │Process │ │Process │
│┌────────┐│ │┌────────┐│ │┌────────┐│
││ Thread ││ ││ Thread ││ ││ Thread ││
│└────────┘│ │└────────┘│ │└────────┘│
└──────────┘ └──────────┘ └──────────┘ - 多线程模式(一个进程有多个线程):
1
2
3
4
5
6
7
8
9┌────────────────────┐
│Process │
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
└────────────────────┘ - 多进程+多线程模式(复杂度最高):
1
2
3
4
5
6
7
8
9┌──────────┐┌──────────┐┌──────────┐
│Process ││Process ││Process │
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘
进程 vs 线程
进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。
具体采用哪种方式,要考虑到进程和线程的特点。
和多线程相比,多进程的缺点在于:
- 创建进程比创建线程开销大,尤其是在Windows系统上;
- 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。
而多进程的优点在于:
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。
多线程
Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。
因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。
和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难。
Java多线程编程的特点又在于:
- 多线程模型是Java程序最基本的并发模型;
- 后续读写网络、数据库、Web开发等都依赖Java多线程模型。
因此,必须掌握Java多线程编程才能继续深入学习其他内容。
创建新线程
Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。
要创建一个新线程非常容易,我们需要实例化一个Thread实例,然后调用它的start()方法:
1
2
3
4
5
6public class Main {
public static void main(String[] args) {
Thread t = new Thread();
t.start(); // 启动新线程
}
}
但是这个线程启动后实际上什么也不做就立刻结束了。我们希望新线程能执行指定的代码,有以下几种方法:
方法一:从Thread派生一个自定义类,然后覆写run()方法:
1
2
3
4
5
6
7
8
9
10
11
12
13public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程
}
}
class MyThread extends Thread {
public void run() {
System.out.println("start new thread!");
}
}输出
1
start new thread!
执行上述代码,注意到
start()
方法会在内部自动调用实例的run()
方法。三步走:
- 继承
Thread
类 - 重写
run()
方法 - 调用
start()
开启线程
- 继承
方法二:创建 Thread 实例时,传入一个 Runnable 实例:
1
2
3
4
5
6
7
8
9
10
11
12
13public class Main {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程
}
}
class MyRunnable implements Runnable {
public void run() {
System.out.println("start new thread!");
}
}输出
1
start new thread!
三步走:
- 实现
Runbable
接口 - 重写
run()
方法 - 执行进程需要丢入
Runnable接口实现类
,调用start()
方法
- 实现
方法三:用
Java8
引入的lambda
语法进一步简写为:1
2
3
4
5
6
7
8public class Main {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程
}
}使用
lambda
简化,其实lambda
也相当于实现类
使用线程执行的打印语句,和直接在main()方法执行有区别吗?
区别大了去了。我们看以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13public class Main {
public static void main(String[] args) {
System.out.println("main start..."); // main
Thread t = new Thread() { // main
public void run() {
System.out.println("thread run...");
System.out.println("thread end.");
}
};
t.start(); // main
System.out.println("main end..."); // main
}
}
我们用 // main
表示主线程,也就是 main线程
,main线程
执行的代码有 4 行,首先打印 main start
,然后创建 Thread 对象,紧接着调用 start() 启动新线程。当 start() 方法被调用时,JVM 就创建了一个新线程,我们通过实例变量 t 来表示这个新线程对象,并开始执行。
接着,main 线程继续执行打印 main end
语句,而 t 线程在 main 线程执行的同时会并发执行,打印 thread run 和 thread end 语句。
当 run() 方法结束时,新线程就结束了。而 main() 方法结束时,主线程也结束了。
我们再来看线程的执行顺序:
- main 线程肯定是先打印 main start,再打印 main end;
- t 线程肯定是先打印 thread run,再打印 thread end。
但是,除了可以肯定,main start 会先打印外,main end 打印在 thread run 之前、thread end 之后或者之间,都无法确定。因为从 t 线程开始运行以后,两个线程就开始同时运行了,并且由操作系统调度,程序本身无法确定线程的调度顺序。
要模拟并发执行的效果,我们可以在线程中调用Thread.sleep(),强迫当前线程暂停一段时间:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
System.out.println("thread end.");
}
};
t.start();
try {
Thread.sleep(20);
} catch (InterruptedException e) {}
System.out.println("main end...");
}
}
sleep() 传入的参数是毫秒。调整暂停时间的大小,我们可以看到 main 线程和 t 线程执行的先后顺序。
要特别注意:直接调用Thread实例的run()方法是无效的:
1
2
3
4
5
6
7
8
9
10
11
12 public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.run();
}
}
class MyThread extends Thread {
public void run() {
System.out.println("hello");
}
}
直接调用 run() 方法,相当于调用了一个普通的 Java 方法,当前线程并没有任何改变,也不会启动新线程。上述代码实际上是在 main() 方法内部又调用了 run() 方法,打印 hello 语句是在 main 线程中执行的,没有任何新线程被创建。
必须调用 Thread 实例的 start() 方法才能启动新线程,如果我们查看 Thread 类的源代码,会看到 start() 方法内部调用了一个private native void start0()
方法,native 修饰符表示这个方法是由 JVM 虚拟机内部的 C 代码实现的,不是由 Java 代码实现的。
线程的优先级
可以对线程设定优先级,设定优先级的方法是:
1
Thread.setPriority(int n) // 1~10, 默认值5
获取优先级
1
Thread.getPriority()
Note:
- 优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
- 先设置优先级,再启动
小结
- Java 用 Thread 对象表示一个线程,通过调用 start() 启动一个新线程;
- 一个线程对象只能调用一次 start() 方法;
- 线程的执行代码写在 run() 方法中;
- 线程调度由操作系统决定,程序本身无法决定调度顺序;
- Thread.sleep() 可以把当前线程暂停一段时间;
- 线程开启不一定立即执行,由 CPU 调度执行
- 推荐使用
Runnable 接口
,因为 Java 单继承的局限性
静态代理模式
- 真实对象(目标对象)和代理对象都要实现同一个接口
- 代理对象要代理真实角色
好处:
- 代理对象可以做很多真实对象做不了的事情
- 真实对象专注做自己的事情
Lambda表达式
- 函数式接口定义: 任何接口,如果只包含唯一一个抽象方法,那么它就是一个函数式接口
- 对于函数式接口,我们可以通过 lambda 表达式来创建该接口的对象。
小结
lambda
表达式只有一行代码的情况下才能简化为一行,如果有多行就用代码块包裹- 前提是:接口是函数式接口
- 多个参数也可以去掉参数类型,若去掉就都去掉,必须加上括号
线程的状态

线程方法
方法 | 说明 |
---|---|
setPriority(int new Priority) |
更改线程的优先级 |
static void sleep(long millis) |
在置顶的毫秒数内让当前正在执行的线程休眠 |
void join() |
等待该线程终止 |
static void yield() |
暂停当前正在执行的线程对象,并执行其他的线程 |
void interrupt |
中断线程,别用这个方式 |
boolean isAlive() |
测试线层是否处于活动状态 |
停止线程
- 不推荐使用JDK提供的
stop()
、destroy()
方法。【已废弃】 - 推荐线程自己停止下来—> 利用次数,不建议死循环
- 建议使用一个标志位进行终止遍历,当
flag==false
,则终止线程运行
线程休眠
sleep(时间)
置顶当前线程阻塞的毫秒数;- sleep 存在 异常 InterruptedException;
- sleep 时间达到后线程进入就去状态;
- sleep 可以模拟网络延时,倒计时等;
- 每一个对象都有一个锁,sleep不会释放锁;
问题
模拟网络延时:放大问题的发生性
线程礼让
- 礼让线程:让当期那正在执行的线程暂停,但不阻塞
- 让线程从运行状态转为就绪状态
- 让CPU重新调度,礼让不一定成功!看CPU心情
线程强制执行(join)
- join 合并线程,待此线程执行完成后,再执行其他线程
- 可以想象成插队
- 记住:插的队的是对当前的线程插队,对其他线程并没有影响,只有插队的线程执行完成后,被插队的线程才可以执行
线程状态观测
Thread.State
在 Java 程序中,一个线程对象只能调用一次 start() 方法启动新线程,并在新线程中执行 run() 方法。一旦 run() 方法执行完毕,线程就结束了。因此,Java 线程的状态有以下几种:
- New:新创建的线程,尚未执行;
- Runnable:运行中的线程,正在执行 run() 方法的 Java 代码;
- Blocked:运行中的线程,因为某些操作被阻塞而挂起;
- Waiting:运行中的线程,因为某些操作在等待中;
- Timed Waiting:运行中的线程,因为执行 sleep() 方法正在计时等待;
- Terminated:线程已终止,因为 run() 方法执行完毕。
用一个状态转移图表示如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 ┌─────────────┐
│ New │
└─────────────┘
│
▼
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┌─────────────┐ ┌─────────────┐
││ Runnable │ │ Blocked ││
└─────────────┘ └─────────────┘
│┌─────────────┐ ┌─────────────┐│
│ Waiting │ │Timed Waiting│
│└─────────────┘ └─────────────┘│
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│
▼
┌─────────────┐
│ Terminated │
└─────────────┘
当线程启动后,它可以在 Runnable、Blocked、Waiting 和 Timed Waiting 这几个状态之间切换,直到最后变成 Terminated 状态,线程终止。
线程终止的原因有:
- 线程正常终止:run() 方法执行到 return 语句返回;
- 线程意外终止:run() 方法因为未捕获的异常导致线程终止;
- 对某个线程的 Thread 实例调用 stop() 方法强制终止(强烈不推荐使用)。
一个线程还可以等待另一个线程直到其运行结束。例如,main 线程在启动 t 线程后,可以通过 t.join() 等待 t 线程结束后再继续运行:
1
2
3
4
5
6
7
8
9
10
11public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("hello");
});
System.out.println("start");
t.start();
t.join();
System.out.println("end");
}
}
当 main 线程对线程对象 t 调用 join() 方法时,主线程将等待变量 t 表示的线程运行结束,即 join 就是指等待该线程结束,然后才继续往下执行自身线程。所以,上述代码打印顺序可以肯定是 main 线程先打印 start,t 线程再打印 hello,main 线程最后再打印 end。
如果 t 线程已经结束,对实例 t 调用 join() 会立刻返回。此外,join(long) 的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。
小结
- Java线程对象Thread的状态包括:New、Runnable、Blocked、Waiting、Timed Waiting和Terminated;
- 通过对另一个线程对象调用join()方法可以等待其执行结束;
- 可以指定等待时间,超过等待时间线程仍然没有结束就不再等待;
- 对已经运行结束的线程调用join()方法会立刻返回。
中断线程
如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行 run() 方法,使得自身线程能立刻结束运行。
我们举个栗子:假设从网络下载一个 100M 的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点 “取消”,这时,程序就需要中断下载线程的执行。
中断一个线程非常简单,只需要在其他线程中对目标线程调用 interrupt() 方法,目标线程需要反复检测自身状态是否是 interrupted 状态,如果是,就立刻结束运行。
我们还是看示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1); // 暂停1毫秒
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
int n = 0;
while (! isInterrupted()) {
n ++;
System.out.println(n + " hello!");
}
}
}
仔细看上述代码,main 线程通过调用 t.interrupt()方法中断 t 线程,但是要注意,interrupt()方法仅仅向 t 线程发出了 “中断请求”,至于 t 线程是否能立刻响应,要看具体代码。而 t 线程的 while 循环会检测 isInterrupted(),所以上述代码能正确响应 interrupt() 请求,使得自身立刻结束运行 run()方法。
如果线程处于等待状态,例如,t.join()会让 main 线程进入等待状态,此时,如果对 main 线程调用 interrupt(),join()方法会立刻抛出 InterruptedException,因此,目标线程只要捕获到 join()方法抛出的 InterruptedException,就说明有其他线程对其调用了 interrupt()方法,通常情况下该线程应该立刻结束运行。
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
39public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动hello线程
try {
hello.join(); // 等待hello线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
hello.interrupt();
}
}
class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
System.out.println("InterruptedException");
break;
}
}
}
}
main 线程通过调用 t.interrupt() 从而通知 t 线程中断,而此时 t 线程正位于 hello.join() 的等待中,此方法会立刻结束等待并抛出 InterruptedException。由于我们在 t 线程中捕获了 InterruptedException,因此,就可以准备结束该线程。在 t 线程结束前,对 hello 线程也进行了 interrupt() 调用通知其中断。如果去掉这一行代码,可以发现 hello 线程仍然会继续运行,且 JVM 不会退出。
另一个常用的中断线程的方法是设置标志位。我们通常会用一个 running 标志位来标识线程是否应该继续运行,在外部线程中,通过把 HelloThread.running 置为 false,就可以让线程结束:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class Main {
public static void main(String[] args) throws InterruptedException {
HelloThread t = new HelloThread();
t.start();
Thread.sleep(1);
t.running = false; // 标志位置为false
}
}
class HelloThread extends Thread {
public volatile boolean running = true;
public void run() {
int n = 0;
while (running) {
n ++;
System.out.println(n + " hello!");
}
System.out.println("end!");
}
}
注意到 HelloThread 的标志位 boolean running 是一个线程间共享的变量。线程间共享变量需要使用 volatile 关键字标记,确保每个线程都能读取到更新后的变量值。
为什么要对线程间共享的变量用关键字 volatile 声明?这涉及到 Java 的内存模型。在 Java 虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Main Memory
│ │
┌───────┐┌───────┐┌───────┐
│ │ var A ││ var B ││ var C │ │
└───────┘└───────┘└───────┘
│ │ ▲ │ ▲ │
─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─
│ │ │ │
┌ ─ ─ ┼ ┼ ─ ─ ┐ ┌ ─ ─ ┼ ┼ ─ ─ ┐
▼ │ ▼ │
│ ┌───────┐ │ │ ┌───────┐ │
│ var A │ │ var C │
│ └───────┘ │ │ └───────┘ │
Thread 1 Thread 2
└ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ┘
这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量 a = true,线程 1 执行 a = false 时,它在此刻仅仅是把变量 a 的副本变成了 false,主内存的变量 a 还是 true,在 JVM 把修改后的 a 回写到主内存之前,其他线程读取到的 a 的值仍然是 true,这就造成了多线程之间共享的变量不一致。
因此,volatile 关键字的目的是告诉虚拟机:
- 每次访问变量时,总是获取主内存的最新值;
- 每次修改变量后,立刻回写到主内存。
volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。
小结
- 对目标线程调用interrupt()方法可以请求中断一个线程,目标线程通过检测isInterrupted()标志获取自身是否已中断。如果目标线程处于等待状态,该线程会捕获到InterruptedException;
- 目标线程检测到isInterrupted()为true或者捕获了InterruptedException都应该立刻结束自身线程;
- 通过标志位判断需要正确使用volatile关键字;
- volatile关键字解决了共享变量在线程间的可见性问题。
守护线程
- 线程分为用户线程和守护线程
- 虚拟机必须确保用户线程执行完毕
- 虚拟机不用等待守护线程执行完毕
- 如:后台记录操作日志、监控内存、垃圾回收等
Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。
如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。
但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程:
1
2
3
4
5
6
7
8
9
10
11
12
13class TimerThread extends Thread {
public void run() {
while (true) {
System.out.println(LocalTime.now());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}
}
如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?
然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?
答案是使用守护线程(Daemon Thread)。
守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
因此,JVM退出时,不必关心守护线程是否已结束。
如何创建守护线程呢?方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:
1
2
3Thread t = new MyThread();
t.setDaemon(true); // 默认是false:表示是用户线程,正常的线程都是用户线程
t.start();
在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
线程同步
简单定义: 多个线程操作同一个资源
当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。
这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。
我们来看一个例子:
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
27public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count += 1; }
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count -= 1; }
}
}
上面的代码很简单,两个线程同时对一个int变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
为何会不安全:由于CPU会使线程进入阻塞状态,使修改资源的操作中断了,但是在这个时候,其他线程开始运行,也修改了这个资源,因此,会造成数据不一致。
例如,对于语句:
1
n = n + 1;
看上去是一行语句,实际上对应了3条指令:
1
2
3ILOAD
IADD
ISTORE
我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,原因在于:
1
2
3
4
5
6
7
8
9
10
11┌───────┐ ┌───────┐
│Thread1│ │Thread2│
└───┬───┘ └───┬───┘
│ │
│ILOAD (100) │
│ │ILOAD (100)
│ │IADD
│ │ISTORE (101)
│IADD │
│ISTORE (101)│
▼ ▼
如果线程1在执行ILOAD后被操作系统中断,此刻如果线程2被调度执行,它执行ILOAD后获取的值仍然是100,最终结果被两个线程的ISTORE写入后变成了101,而不是期待的102。
这说明多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15┌───────┐ ┌───────┐
│Thread1│ │Thread2│
└───┬───┘ └───┬───┘
│ │
│-- lock -- │
│ILOAD (100) │
│IADD │
│ISTORE (101) │
│-- unlock -- │
│ │-- lock --
│ │ILOAD (101)
│ │IADD
│ │ISTORE (102)
│ │-- unlock --
▼ ▼
通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
可见,保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized关键字对一个对象进行加锁:
1
2
3synchronized(lock) {
n = n + 1;
}
synchronized保证了代码块在任意时刻最多只有一个线程能执行。我们把上面的代码用synchronized改写如下:
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
36public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.count -= 1;
}
}
}
}
注意到代码:
1
2
3synchronized(Counter.lock) { // 获取锁
...
} // 释放锁
它表示用 Counter.lock 实例作为锁,两个线程在执行各自的 synchronized(Counter.lock) { … } 代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在 synchronized 语句块结束会自动释放锁。这样一来,对 Counter.count 变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是 0。
使用 synchronized 解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为 synchronized 代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized 会降低程序的执行效率。
我们来概括一下如何使用 synchronized:
- 找出修改共享变量的线程代码块;
- 选择一个共享实例作为锁;
- 使用 synchronized(lockObject) { … }。
在使用 synchronized 的时候,不必担心抛出异常。因为无论是否有异常,都会在 synchronized 结束处正确释放锁:我们再来看一个错误使用synchronized的例子:1
2
3
4
5
6
7
8public void add(int m) {
synchronized (obj) {
if (m < 0) {
throw new RuntimeException();
}
this.value += m;
} // 无论有无异常,都会在此释放锁
}结果并不是 0,这是因为两个线程各自的 synchronized 锁住的不是同一个对象!这使得两个线程各自都可以同时获得锁:因为 JVM 只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。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
37public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock1) {
Counter.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock2) {
Counter.count -= 1;
}
}
}
}
因此,使用synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。
我们再看一个例子:上述代码的 4 个线程对两个共享变量分别进行读写操作,但是使用的锁都是 Counter.lock 这一个对象,这就造成了原本可以并发执行的 Counter.studentCount += 1 和 Counter.teacherCount += 1,现在无法并发执行了,执行效率大大降低。实际上,需要同步的线程可以分成两组:AddStudentThread 和 DecStudentThread,AddTeacherThread 和 DecTeacherThread,组之间不存在竞争,因此,应该使用两个不同的锁,即: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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59public class Main {
public static void main(String[] args) throws Exception {
var ts = new Thread[] { new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() };
for (var t : ts) {
t.start();
}
for (var t : ts) {
t.join();
}
System.out.println(Counter.studentCount);
System.out.println(Counter.teacherCount);
}
}
class Counter {
public static final Object lock = new Object();
public static int studentCount = 0;
public static int teacherCount = 0;
}
class AddStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount += 1;
}
}
}
}
class DecStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount -= 1;
}
}
}
}
class AddTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount += 1;
}
}
}
}
class DecTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount -= 1;
}
}
}
}
AddStudentThread和DecStudentThread使用lockStudent锁:AddTeacherThread和DecTeacherThread使用lockTeacher锁:1
2
3synchronized(Counter.lockStudent) {
...
}这样才能最大化地提高执行效率。1
2
3synchronized(Counter.lockTeacher) {
...
}
不需要synchronized的操作
JVM规范定义了几种原子操作:
- 基本类型(long和double除外)赋值,例如:int n = m;
- 引用类型赋值,例如:List
list = anotherList。
long 和 double 是 64 位数据,JVM 没有明确规定 64 位赋值操作是不是一个原子操作,不过在 x64 平台的 JVM 是把 long 和 double 的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。例如:
1
2
3
4
5public void set(int m) {
synchronized(lock) {
this.value = m;
}
}
就不需要同步。
对引用也是类似。例如:
1
2
3public void set(String s) {
this.value = s;
}
上述赋值语句并不需要同步。
但是,如果是多行赋值语句,就必须保证是同步操作,例如:
1
2
3
4
5
6
7
8
9
10class Pair {
int first;
int last;
public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}
有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:
1
2
3
4
5
6
7class Pair {
int[] pair;
public void set(int first, int last) {
int[] ps = new int[] { first, last };
this.pair = ps;
}
}
就不再需要同步,因为this.pair = ps是引用赋值的原子操作。而语句:
1
int[] ps = new int[] { first, last };
这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。
小结
- 多线程同时读写共享变量时,会造成逻辑错误,因此需要通过 synchronized 同步;
- 同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码;
- 注意加锁对象必须是同一个实例;
- 对 JVM 定义的单个原子操作不需要同步;
- 方法里面需要修改的内容才需要锁,锁的太多,浪费资源;
- 锁的对象就是变化的量:需要增删改的对象
同步方法
我们知道Java程序依靠synchronized对线程进行同步,使用synchronized的时候,锁住的是哪个对象非常重要。
让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized逻辑封装起来。例如,我们编写一个计数器如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class Counter {
private int count = 0;
public void add(int n) {
synchronized(this) {
count += n;
}
}
public void dec(int n) {
synchronized(this) {
count -= n;
}
}
public int get() {
return count;
}
}
这样一来,线程调用 add()、dec() 方法时,它不必关心同步逻辑,因为 synchronized 代码块在 add()、dec() 方法内部。并且,我们注意到,synchronized 锁住的对象是 this,即当前实例,这又使得创建多个 Counter 实例的时候,它们之间互不影响,可以并发执行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18var c1 = Counter();
var c2 = Counter();
// 对c1进行操作的线程:
new Thread(() -> {
c1.add();
}).start();
new Thread(() -> {
c1.dec();
}).start();
// 对c2进行操作的线程:
new Thread(() -> {
c2.add();
}).start();
new Thread(() -> {
c2.dec();
}).start();
现在,对于Counter类,多线程可以正确调用。
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter类就是线程安全的。Java标准库的java.lang.StringBuffer也是线程安全的。
还有一些不变类,例如String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。
最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述几种少数情况,大部分类,例如ArrayList,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList是可以安全地在线程间共享的。
没有特殊说明时,一个类默认是非线程安全的。
我们再观察Counter的代码:
1
2
3
4
5
6
7
8public class Counter {
public void add(int n) {
synchronized(this) {
count += n;
}
}
...
}
当我们锁住的是this实例时,实际上可以用synchronized修饰这个方法。下面两种写法是等价的:
1
2
3
4
5public void add(int n) {
synchronized(this) { // 锁住this
count += n;
} // 解锁
}
1
2
3public synchronized void add(int n) { // 锁住this
count += n;
} // 解锁
因此,用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁。
我们再思考一下,如果对一个静态方法添加synchronized修饰符,它锁住的是哪个对象?
1
2
3public synchronized static void test(int n) {
...
}
对于 static 方法,是没有 this 实例的,因为 static 方法是针对类而不是实例。但是我们注意到任何一个类都有一个由 JVM 自动创建的 Class 实例,因此,对 static 方法添加 synchronized,锁住的是该类的 Class 实例。上述 synchronized static 方法实际上相当于:
1
2
3
4
5
6
7public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
...
}
}
}
我们再考察Counter的get()方法:
1
2
3
4
5
6
7
8public class Counter {
private int count;
public int get() {
return count;
}
...
}
它没有同步,因为读一个int变量不需要同步。
然而,如果我们把代码稍微改一下,返回一个包含两个int的对象:
1
2
3
4
5
6
7
8
9
10
11
12public class Counter {
private int first;
private int last;
public Pair get() {
Pair p = new Pair();
p.first = first;
p.last = last;
return p;
}
...
}
就必须要同步了。
小结
- 用synchronized修饰方法可以把整个方法变为同步代码块,synchronized方法加锁对象是this;
- 通过合理的设计和数据封装可以让一个类变为“线程安全”;
- 一个类没有特殊说明,默认不是thread-safe;
- 多线程能否安全访问某个非线程安全的实例,需要具体问题具体分析。
死锁
Java的线程锁是可重入的锁。
什么是可重入的锁?我们还是来看例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class Counter {
private int count = 0;
public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}
public synchronized void dec(int n) {
count += n;
}
}