多线程
创建新线程
要创建一个新线程非常容易,我们希望新线程能执行指定的代码,有以下几种方法:
- 从 Thread 派生一个自定义类,然后覆写 run() 方法
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println("start new thread!");
}
}
- 创建 Thread 实例时,传入一个 Runnable 实例
public class Main {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程
}
}
线程的优先级
线程调度由操作系统决定,程序本身无法决定调度顺序,但我们可以对线程设定优先级,设定优先级的方法是:
Thread.setPriority(int n) // 1 ~ 10 ,默认值 5
JVM 自动把 1(低)~ 10(高)的优先级映射到操作系统实际优先级上(不同操作系统有不同的优先级数量)。优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
线程的状态
Java 线程的状态有以下几种:
- New:新创建的线程,尚未执行
- Runnable:运行中的线程,正在执行 run() 方法的 Java 代码
- Blocked:运行中的线程,因为某些操作被阻塞而挂起
- Waiting:运行中的线程,因为某些操作在等待中
- Timed Waiting:运行中的线程,因为执行 sleep() 方法正在计时等待
- Terminated:线程已终止,因为 run() 方法执行完毕
用一个状态转移图表示如下:

线程终止的原因有:
- 线程正常终止:run() 方法执行到 return 语句返回
- 线程意外终止:run() 方法因为未捕获的异常导致线程终止
- 对某个线程的 Thread 实例调用 stop() 方法强制终止(强烈不推荐使用)
一个线程还可以等待另一个线程直到其运行结束。例如,main 线程在启动t线程后,可以通过 t.join() 等待 t 线程结束后再继续运行
public 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) 重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。
中断线程
中断一个线程非常简单,只需要在其他线程中对目标线程调用 interrupt() 方法。目标线程需要反复检测自身状态是否是 interrupted 状态,如果是,就立刻结束运行
public 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!");
}
}
}
如果线程处于等待状态,例如,t.join() 会让 main 线程进入等待状态,此时,如果对 main 线程调用interrupt() ,join() 方法会立刻抛出 InterruptedException ,因此,目标线程只要捕获到 join() 方法抛出的 InterruptedException ,就说明有其他线程对其调用了 interrupt() 方法,通常情况下该线程应该立刻结束运行
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread myThread = new Thread(() -> {
Thread helloThread = new Thread() {
@Override
public void run() {
super.run();
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
};
helloThread.start();
try {
helloThread.join(); // 等待 hello 线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
helloThread.interrupt();
});
myThread.start();
Thread.sleep(1000);
myThread.interrupt(); // 中断 t 线程
myThread.join(); // 等待 t 线程结束
System.out.println("end");
}
}
另一个常用的中断线程的方法是设置标志位。我们通常会用一个 running 标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running 置为 false ,就可以让线程结束
public 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!");
}
}
守护线程
守护线程是指为其他线程服务的线程。在 JVM 中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。因此,JVM 退出时,不必关心守护线程是否已结束。
如何创建守护线程呢?方法和普通线程一样,只是在调用 start() 方法前,调用 setDaemon(true) 把该线程标记为守护线程
Thread t = new MyThread();
t.setDaemon(true);
t.start();
在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
线程同步
Java 程序使用 synchronized 关键字对一个对象进行加锁
synchronized(lock) {
n = n + 1;
}
synchronized 保证了代码块在任意时刻最多只有一个线程能执行
public class Main {
public static void main(String[] args) throws Exception {
var addThread = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (Counter.lock) {
Counter.count += 1;
}
}
});
var decThread = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (Counter.lock) {
Counter.count -= 1;
}
}
});
addThread.start();
decThread.start();
addThread.join();
decThread.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock = new Object();
public static int count = 0;
}
注意到代码
synchronized(Counter.lock) { // 获取锁
...
} // 释放锁
它表示用 Counter.lock 实例作为锁,两个线程在执行各自的 synchronized(Counter.lock) { ... } 代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在 synchronized 语句块结束会自动释放锁。这样一来,对 Counter.count 变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是 0 。
在使用 synchronized 的时候,不必担心抛出异常。因为无论是否有异常,都会在 synchronized 结束处正确释放锁
public void add(int m) {
synchronized (obj) {
if (m < 0) {
throw new RuntimeException();
}
this.value += m;
} // 无论有无异常,都会在此释放锁
}
不需要 synchronized 的操作
JVM 规范定义了几种原子操作:
- 基本类型(long 和 double 除外)赋值,例如:int n = m
- 引用类型赋值,例如:List<String> list = anotherList
long 和 double 是 64 位数据,JVM 没有明确规定 64 位赋值操作是不是一个原子操作,不过在 x64 平台的 JVM 是把 long 和 double 的赋值作为原子操作实现的。
单条原子操作的语句不需要同步
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}
对引用也不需要同步
public void set(String s) {
this.value = s;
}
但如果是多行赋值语句,就必须保证是同步操作
class Point {
int x;
int y;
public void set(int x, int y) {
synchronized(this) {
this.x = x;
this.y = y;
}
}
}
不但写需要同步,读也需要同步
class Point {
int x;
int y;
public void set(int x, int y) {
synchronized(this) {
this.x = x;
this.y = y;
}
}
public int[] get() {
int[] copy = new int[2];
copy[0] = x;
copy[1] = y;
}
}
假定当前坐标是 (100, 200) ,那么当设置新坐标为 (110, 220) 时,上述未同步的多线程读到的值可能有:
- (100, 200):x ,y 更新前
- (110, 200):x 更新后,y 更新前
- (110, 220):x ,y 更新后
如果读取到 (110, 200) ,即读到了更新后的 x ,更新前的 y ,那么可能会造成程序的逻辑错误,无法保证读取的多个变量状态保持一致。
有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成
class Point {
int[] ps;
public void set(int x, int y) {
int[] ps = new int[] { x, y };
this.ps = ps;
}
}
就不再需要写同步,因为 this.ps = ps 是引用赋值的原子操作。而语句
int[] ps = new int[] { x, y };
这里的 ps 是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。
不过要注意,读方法在复制 int[] 数组的过程中仍然需要同步。
不可变对象无需同步
如果多线程读写的是一个不可变对象,那么无需同步,因为不会修改对象的状态
class Data {
List<String> names;
void set(String[] names) {
this.names = List.of(names);
}
List<String> get() {
return this.names;
}
}
注意到 set() 方法内部创建了一个不可变 List ,这个 List 包含的对象也是不可变对象 String ,因此,整个 List<String> 对象都是不可变的,因此读写均无需同步。
分析变量是否能被多线程访问时,首先要理清概念,多线程同时执行的是方法。对于下面这个例子
class Status {
List<String> names;
int x;
int y;
void set(String[] names, int n) {
List<String> ns = List.of(names);
this.names = ns;
int step = n * 10;
this.x += step;
this.y += step;
}
StatusRecord get() {
return new StatusRecord(this.names, this.x, this.y);
}
}
如果有 A 、B 两个线程,同时执行是指:
- 可能同时执行 set()
- 可能同时执行 get()
- 可能 A 执行 set() ,同时 B 执行 get()
类的成员变量 names 、x 、y 显然能被多线程同时读写,但局部变量(包括方法参数)如果没有“逃逸”,那么只有当前线程可见。局部变量 step 仅在 set() 方法内部使用,因此每个线程同时执行 set 时都有一份独立的 step 存储在线程的栈上,互不影响,但是局部变量 ns 虽然每个线程也各有一份,但后续赋值后对其他线程就变成可见了。对 set() 方法同步时,如果要最小化 synchronized 代码块,可以改写如下
void set(String[] names, int n) {
// 局部变量其他线程不可见:
List<String> ns = List.of(names);
int step = n * 10;
synchronized(this) {
this.names = ns;
this.x += step;
this.y += step;
}
}
因此,深入理解多线程还需理解变量在栈上的存储方式,基本类型和引用类型的存储方式也不同。
同步方法
让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把 synchronized 逻辑封装起来。例如,我们编写一个计数器 Counter
public 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 实例的时候,它们之间互不影响,可以并发执行:
var 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 的代码
public class Counter {
public void add(int n) {
synchronized(this) {
count += n;
}
}
...
}
当我们锁住的是 this 实例时,实际上可以用 synchronized 修饰这个方法。下面两种写法是等价的
public void add(int n) {
synchronized(this) { // 锁住 this
count += n;
} // 解锁
}
public synchronized void add(int n) { // 锁住 this
count += n;
} // 解锁
因此,用 synchronized 修饰的方法就是同步方法,它表示整个方法都必须用 this 实例加锁。
我们再思考一下,如果对一个静态方法添加 synchronized 修饰符,它锁住的是哪个对象?
public synchronized static void test(int n) {
...
}
对于 static 方法,是没有 this 实例的,因为 static 方法是针对类而不是实例。但是我们注意到任何一个类都有一个由 JVM 自动创建的 Class 实例,因此,对 static 方法添加 synchronized ,锁住的是该类的 Class 实例。上述 synchronized static 方法实际上相当于
public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
...
}
}
}
我们再考察 Counter 的 get() 方法
public class Counter {
private int count;
public int get() {
return count;
}
...
}
它没有同步,因为读一个 int 变量不需要同步。
然而,如果我们把代码稍微改一下,返回一个包含两个 int 的对象
public class Counter {
private int first;
private int last;
public Pair get() {
Pair p = new Pair();
p.first = first;
p.last = last;
return p;
}
...
}
就必须要同步了。
死锁
Java 的线程锁是可重入的锁。
什么是可重入的锁?我们还是来看例子
public 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;
}
}
观察 synchronized 修饰的 add() 方法,一旦线程执行到 add() 方法内部,说明它已经获取了当前实例的 this 锁。如果传入的 n < 0 ,将在 add() 方法内部调用 dec() 方法。由于 dec() 方法也需要获取 this 锁,现在问题来了:
对同一个线程,能否在获取到锁以后继续获取同一个锁?
答案是肯定的。JVM 允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。
由于 Java 的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录 +1 ,每退出 synchronized 块,记录 -1 ,减到0的时候,才会真正释放锁。
一个线程可以获取一个锁后,再继续获取另一个锁。例如
public void add(int m) {
synchronized(lockA) { // 获得 lockA 的锁
this.value += m;
synchronized(lockB) { // 获得 lockB 的锁
this.another += m;
} // 释放 lockB 的锁
} // 释放 lockA 的锁
}
public void dec(int m) {
synchronized(lockB) { // 获得 lockB 的锁
this.another -= m;
synchronized(lockA) { // 获得 lockA 的锁
this.value -= m;
} // 释放 lockA 的锁
} // 释放 lockB 的锁
}
在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程 1 和线程 2 如果分别执行 add() 和 dec() 方法时:
- 线程1:进入 add() ,获得 lockA
- 线程2:进入 dec() ,获得 lockB
随后:
- 线程1:准备获得 lockB ,失败,等待中
- 线程2:准备获得 lockA ,失败,等待中
此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束 JVM 进程。
因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。
那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取 lockA ,再获取 lockB 的顺序,改写 dec() 方法如下
public void dec(int m) {
synchronized(lockA) { // 获得 lockA 的锁
this.value -= m;
synchronized(lockB) { // 获得 lockB 的锁
this.another -= m;
} // 释放 lockB 的锁
} // 释放 lockA 的锁
}
wait & notify
在 Java 程序中,synchronized 解决了多线程竞争的问题。例如,对于一个任务管理器,多个线程同时往队列中添加任务,可以用 synchronized 加锁:
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
}
但是 synchronized 并没有解决多线程协调的问题。
仍然以上面的 TaskQueue 为例,我们再编写一个 getTask() 方法取出队列的第一个任务
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
public synchronized String getTask() {
while (queue.isEmpty()) {
}
return queue.remove();
}
}
上述代码看上去没有问题:getTask() 内部先判断队列是否为空,如果为空,就循环等待,直到另一个线程往队列中放入了一个任务,while() 循环退出,就可以返回队列的元素了。
但实际上 while() 循环永远不会退出。因为线程在执行 while() 循环时,已经在 getTask() 入口获取了 this 锁,其他线程根本无法调用 addTask() ,因为 addTask() 执行条件也是获取 this 锁。
因此,执行上述代码,线程会在 getTask() 中因为死循环而 100% 占用 CPU 资源。
如果深入思考一下,我们想要的执行效果是:
- 线程 1 可以调用 addTask() 不断往队列中添加任务
- 线程 2 可以调用 getTask() 从队列中获取任务。如果队列为空,则 getTask() 应该等待,直到队列中至少有一个任务时再返回
因此,多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
对于上述 TaskQueue ,我们先改造 getTask() 方法,在条件不满足时,线程进入等待状态
public synchronized String getTask() {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
当一个线程执行到 getTask() 方法内部的 while 循环时,它必定已经获取到了 this 锁,此时,线程执行 while 条件判断,如果条件成立(队列为空),线程将执行 this.wait() ,进入等待状态。
这里的关键是:wait() 方法必须在当前获取的锁对象上调用,这里获取的是 this 锁,因此调用 this.wait() 。
调用 wait() 方法后,线程进入等待状态,wait() 方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait() 方法才会返回,然后,继续执行下一条语句。
有些仔细的童鞋会指出:即使线程在 getTask() 内部等待,其他线程如果拿不到 this 锁,照样无法执行 addTask() ,肿么办?
这个问题的关键就在于 wait() 方法的执行机制非常复杂。首先,它不是一个普通的 Java 方法,而是定义在 Object 类的一个 native 方法,也就是由 JVM 的 C 代码实现的。其次,必须在 synchronized 块中才能调用 wait() 方法,因为 wait() 方法调用时,会释放线程获得的锁,wait() 方法返回后,线程又会重新试图获得锁。
因此,只能在锁对象上调用 wait() 方法。因为在 getTask() 中,我们获得了 this 锁,因此,只能在 this 对象上调用 wait() 方法
public synchronized String getTask() {
while (queue.isEmpty()) {
// 释放 this 锁:
this.wait();
// 重新获取 this 锁
}
return queue.remove();
}
当一个线程在 this.wait() 等待时,它就会释放 this 锁,从而使得其他线程能够在 addTask() 方法获得 this 锁。
现在我们面临第二个问题:如何让等待的线程被重新唤醒,然后从 wait() 方法返回?答案是在相同的锁对象上调用 notify() 方法。我们修改 addTask() 如下:
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 唤醒在 this 锁等待的线程
}
注意到在往队列中添加了任务后,线程立刻对 this 锁对象调用 notify() 方法,这个方法会唤醒一个正在 this 锁等待的线程(就是在 getTask() 中位于 this.wait() 的线程),从而使得等待线程从 this.wait() 方法返回。我们来看一个完整的例子
import java.util.*;
public class Main {
public static void main(String[] args) throws InterruptedException {
var q = new TaskQueue();
var ts = new ArrayList<Thread>();
for (int i=0; i<5; i++) {
var t = new Thread() {
public void run() {
// 执行task:
while (true) {
try {
String s = q.getTask();
System.out.println("execute task: " + s);
} catch (InterruptedException e) {
return;
}
}
}
};
t.start();
ts.add(t);
}
var add = new Thread(() -> {
for (int i=0; i<10; i++) {
// 放入task:
String s = "t-" + Math.random();
System.out.println("add task: " + s);
q.addTask(s);
try { Thread.sleep(100); } catch(InterruptedException e) {}
}
});
add.start();
add.join();
Thread.sleep(100);
for (var t : ts) {
t.interrupt();
}
}
}
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
this.notifyAll();
}
public synchronized String getTask() throws InterruptedException {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}
这个例子中,我们重点关注 addTask() 方法,内部调用了 this.notifyAll() 而不是 this.notify() ,使用 notifyAll() 将唤醒所有当前正在 this 锁等待的线程,而 notify() 只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在 getTask() 方法内部的 wait() 中等待,使用 notifyAll() 将一次性全部唤醒。通常来说,notifyAll() 更安全。有些时候,如果我们的代码逻辑考虑不周,用 notify() 会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。
但是,注意到 wait() 方法返回时需要重新获得 this 锁。假设当前有3个线程被唤醒,唤醒后,首先要等待执行 addTask() 的线程结束此方法后,才能释放 this 锁,随后,这 3 个线程中只能有一个获取到 this 锁,剩下两个将继续等待。
再注意到我们在 while() 循环中调用 wait() ,而不是 if 语句
public synchronized String getTask() throws InterruptedException {
if (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
这种写法实际上是错误的,因为线程被唤醒时,需要再次获取 this 锁。多个线程被唤醒后,只有一个线程能获取 this 锁,此刻,该线程执行 queue.remove() 可以获取到队列的元素,然而,剩下的线程如果获取 this 锁后执行 queue.remove() ,此刻队列可能已经没有任何元素了,所以,要始终在 while 循环中 wait() ,并且每次被唤醒后拿到 this 锁就必须再次判断
while (queue.isEmpty()) {
this.wait();
}
所以,正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常。
ReentrantLock
从 Java 5 开始,引入了一个高级的处理并发的 java.util.concurrent 包,它提供了大量更高级的并发功能,能大大简化多线程程序的编写。
我们知道 Java 语言直接提供了 synchronized 关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。
java.util.concurrent.locks 包提供的 ReentrantLock 用于替代 synchronized 加锁,我们来看一下传统的 synchronized 代码
public class Counter {
private int count;
public void add(int n) {
synchronized(this) {
count += n;
}
}
}
如果用 ReentrantLock 替代,可以把代码改造为
public class Counter {
private final Lock lock = new ReentrantLock();
private int count;
public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}
因为 synchronized 是 Java 语言层面提供的语法,所以我们不需要考虑异常,而 ReentrantLock 是 Java 代码实现的锁,我们就必须先获取锁,然后在 finally 中正确释放锁。
顾名思义,ReentrantLock 是可重入锁,它和 synchronized 一样,一个线程可以多次获取同一个锁。
和 synchronized 不同的是,ReentrantLock 可以尝试获取锁
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
上述代码在尝试获取锁的时候,最多等待 1 秒。如果 1 秒后仍未获取到锁,tryLock() 返回 false ,程序就可以做一些额外处理,而不是无限等待下去。
所以,使用 ReentrantLock 比直接使用 synchronized 更安全,线程在 tryLock() 失败的时候不会导致死锁。
Condition
使用 ReentrantLock 比直接使用 synchronized 更安全,可以替代 synchronized 进行线程同步。
但是,synchronized 可以配合 wait 和 notify 实现线程在条件不满足时等待,条件满足时唤醒,用 ReentrantLock 我们怎么编写 wait 和 notify 的功能呢?
答案是使用 Condition 对象来实现 wait 和 notify 的功能。
我们仍然以 TaskQueue 为例,把前面用 synchronized 实现的功能通过 ReentrantLock 和 Condition 来实现
class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
可见,使用 Condition 时,引用的 Condition 对象必须从 Lock 实例的 newCondition() 返回,这样才能获得一个绑定了 Lock 实例的 Condition 实例。
Condition 提供的 await() 、signal() 、signalAll() 原理和 synchronized 锁对象的 wait() 、notify() 、notifyAll() 是一致的,并且其行为也是一样的:
- await() 会释放当前锁,进入等待状态
- signal() 会唤醒某个等待线程
- signalAll() 会唤醒所有等待线程
- 唤醒线程从 await() 返回后需要重新获得锁
此外,和 tryLock() 类似,await() 可以在等待指定时间后,如果还没有被其他线程通过 signal() 或 signalAll() 唤醒,可以自己醒来
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}
可见,使用 Condition 配合 Lock ,我们可以实现更灵活的线程同步。
ReadWriteLock
ReentrantLock 保证了只有一个线程可以执行临界区代码
public class Counter {
private final Lock lock = new ReentrantLock();
private int[] counts = new int[10];
public void inc(int index) {
lock.lock();
try {
counts[index] += 1;
} finally {
lock.unlock();
}
}
public int[] get() {
lock.lock();
try {
return Arrays.copyOf(counts, counts.length);
} finally {
lock.unlock();
}
}
}
但是有些时候,这种保护有点过头。因为我们发现,任何时刻,只允许一个线程修改,也就是调用 inc() 方法是必须获取锁,但是,get() 方法只读取数据,不修改数据,它实际上允许多个线程同时调用。
实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。
使用 ReadWriteLock 可以解决这个问题,它保证:
- 只允许一个线程写入(其他线程既不能写入也不能读取)
- 没有写入时,多个线程允许同时读(提高性能)
用 ReadWriteLock 实现这个功能十分容易。我们需要创建一个 ReadWriteLock 实例,然后分别获取读锁和写锁
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];
public void inc(int index) {
wlock.lock(); // 加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁
}
}
public int[] get() {
rlock.lock(); // 加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁
}
}
}
把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。
使用 ReadWriteLock 时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。
例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用 ReadWriteLock 。
StampedLock
前面介绍的 ReadWriteLock 可以解决多线程同时读,但只有一个线程能写的问题。
如果我们深入分析 ReadWriteLock ,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
要进一步提升并发执行效率,Java 8 引入了新的读写锁:StampedLock 。
StampedLock 和 ReadWriteLock 相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。我们来看例子
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
和 ReadWriteLock 相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过 tryOptimisticRead() 获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过 validate() 去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。
可见,StampedLock 把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是 StampedLock 是不可重入锁,不能在一个线程中反复获取同一个锁。
StampedLock 还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在 if-then-update 的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。
Semaphore
前面我们讲了各种锁的实现,本质上锁的目的是保护一种受限资源,保证同一时刻只有一个线程能访问(ReentrantLock),或者只有一个线程能写入(ReadWriteLock)。
还有一种受限资源,它需要保证同一时刻最多有 N 个线程能访问,比如同一时刻最多创建 100 个数据库连接,最多允许 10 个用户下载等。
这种限制数量的锁,如果用 Lock 数组来实现,就太麻烦了。
这种情况就可以使用 Semaphore ,例如,最多允许 3 个线程同时访问
public class AccessLimitControl {
// 任意时刻仅允许最多3个线程获取许可:
final Semaphore semaphore = new Semaphore(3);
public String access() throws Exception {
// 如果超过了许可数量,其他线程将在此等待:
semaphore.acquire();
try {
// TODO:
return UUID.randomUUID().toString();
} finally {
semaphore.release();
}
}
}
使用 Semaphore 先调用 acquire() 获取,然后通过 try ... finally 保证在 finally 中释放。
调用 acquire() 可能会进入等待,直到满足条件为止。也可以使用 tryAcquire() 指定等待时间
if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
// 指定等待时间3秒内获取到许可:
try {
// TODO:
} finally {
semaphore.release();
}
}
Semaphore 本质上就是一个信号计数器,用于限制同一时间的最大访问数量。
Atomic
Java 的 java.util.concurrent 包除了提供底层锁、并发集合外,还提供了一组原子操作的封装类,它们位于 java.util.concurrent.atomic 包。
我们以 AtomicInteger 为例,它提供的主要操作有:
- 增加值并返回新值:int addAndGet(int delta)
- 加 1 后返回新值:int incrementAndGet()
- 获取当前值:int get()
- 用 CAS 方式设置:int compareAndSet(int expect, int update)
Atomic 类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了 CAS:Compare and Set 。
如果我们自己通过 CAS 编写 incrementAndGet() ,它大概长这样
public int incrementAndGet(AtomicInteger var) {
int prev, next;
do {
prev = var.get();
next = prev + 1;
} while ( ! var.compareAndSet(prev, next));
return next;
}
CAS 是指,在这个操作中,如果 AtomicInteger 的当前值是 prev,那么就更新为 next ,返回 true 。如果 AtomicInteger 的当前值不是 prev ,就什么也不干,返回 false 。通过 CAS 操作并配合 do ... while 循环,即使其他线程修改了 AtomicInteger 的值,最终的结果也是正确的。
我们利用 AtomicLong 可以编写一个多线程安全的全局唯一 ID 生成器
class IdGenerator {
AtomicLong var = new AtomicLong(0);
public long getNextId() {
return var.incrementAndGet();
}
}
通常情况下,我们并不需要直接用 do ... while 循环调用 compareAndSet实现复杂的并发操作,而是用 incrementAndGet() 这样的封装好的方法,因此,使用起来非常简单。
在高度竞争的情况下,还可以使用 Java 8 提供的 LongAdder 和 LongAccumulator 。
线程池
线程池内部维护了若干个线程,没有任务的时候,这些线程都处于等待状态。如果有新任务,就分配一个空闲线程执行。如果所有线程都处于忙碌状态,新任务要么放入队列等待,要么增加一个新线程进行处理。
Java 标准库提供了 ExecutorService 接口表示线程池,它的典型用法如下
// 创建固定大小的线程池:
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交任务:
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.submit(task4);
executor.submit(task5);
因为 ExecutorService 只是接口,Java 标准库提供的几个常用实现类有:
- FixedThreadPool:线程数固定的线程池;
- CachedThreadPool:线程数根据任务动态调整的线程池;
- SingleThreadExecutor:仅单线程执行的线程池。
创建这些线程池的方法都被封装到 Executors 这个类中。我们以 FixedThreadPool 为例,看看线程池的执行逻辑:
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}
class Task implements Runnable {
private final String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}
我们观察执行结果,一次性放入 6 个任务,由于线程池只有固定的 4 个线程,因此,前 4 个任务会同时执行,等到有线程空闲后,才会执行后面的两个任务。
线程池在程序结束的时候要关闭。使用 shutdown() 方法关闭线程池的时候,它会等待正在执行的任务先完成,然后再关闭。shutdownNow() 会立刻停止正在执行的任务,awaitTermination() 则会等待指定的时间让线程池关闭。
如果我们把线程池改为 CachedThreadPool ,由于这个线程池的实现会根据任务数量动态调整线程池的大小,所以 6 个任务可一次性全部同时执行。
如果我们想把线程池的大小限制在 4 ~ 10 个之间动态调整怎么办?我们查看 Executors.newCachedThreadPool() 方法的源码
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
因此,想创建指定动态范围的线程池,可以这么写:
int min = 4;
int max = 10;
ExecutorService es = new ThreadPoolExecutor(min, max,
60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
还有一种任务,需要定期反复执行,例如,每秒刷新证券价格。这种任务本身固定,需要反复执行的,可以使用 ScheduledThreadPool 。放入 ScheduledThreadPool 的任务可以定期反复执行。
创建一个 ScheduledThreadPool 仍然是通过 Executors 类
ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);
我们可以提交一次性任务,它会在指定延迟后只执行一次:
// 1 秒后执行一次性任务:
ses.schedule(new Task("one-time"), 1, TimeUnit.SECONDS);
如果任务以固定的每 3 秒执行,我们可以这样写
// 2 秒后开始执行定时任务,每 3 秒执行:
ses.scheduleAtFixedRate(new Task("fixed-rate"), 2, 3, TimeUnit.SECONDS);
如果任务以固定的 3 秒为间隔执行,我们可以这样写
// 2 秒后开始执行定时任务,以 3 秒为间隔执行:
ses.scheduleWithFixedDelay(new Task("fixed-delay"), 2, 3, TimeUnit.SECONDS);
注意 FixedRate 和 FixedDelay 的区别。FixedRate 是指任务总是以固定时间间隔触发,不管任务执行多长时间:

而 FixedDelay 是指,上一次任务执行完毕后,等待固定的时间间隔,再执行下一次任务:

因此,使用 ScheduledThreadPool 时,我们要根据需要选择执行一次、FixedRate 执行还是 FixedDelay 执行。
细心的童鞋还可以思考下面的问题:
- 在 FixedRate 模式下,假设每秒触发,如果某次任务执行时间超过 1 秒,后续任务会不会并发执行?
不会并发执行,而会延迟执行。等待当前任务执行完成后,立刻执行下一个任务,持续地补时差,直到回归正常的一一对应的(时间 -> 任务)序列
- 如果任务抛出了异常,后续任务是否继续执行?
负责执行该任务的线程进入(阻塞?)状态,后续的任务不会被执行。
Java 标准库还提供了一个 java.util.Timer 类,这个类也可以定期执行任务,但是,一个 Timer 会对应一个 Thread ,所以,一个 Timer 只能定期执行一个任务,多个定时任务必须启动多个 Timer ,而一个 ScheduledThreadPool 就可以调度多个定时任务,所以,我们完全可以用 ScheduledThreadPool 取代旧的 Timer 。
Future
在执行多个任务的时候,使用 Java 标准库提供的线程池是非常方便的。我们提交的任务只需要实现 Runnable 接口,就可以让线程池去执行
class Task implements Runnable {
public String result;
public void run() {
this.result = longTimeCalculation();
}
}
Runnable 接口有个问题,它的方法没有返回值。如果任务需要一个返回结果,那么只能保存到变量,还要提供额外的方法读取,非常不便。所以,Java 标准库还提供了一个 Callable 接口,和 Runnable 接口比,它多了一个返回值
class Task implements Callable<String> {
public String call() throws Exception {
return longTimeCalculation();
}
}
并且 Callable 接口是一个泛型接口,可以返回指定类型的结果。
现在的问题是,如何获得异步执行的结果?
如果仔细看 ExecutorService.submit() 方法,可以看到,它返回了一个 Future 类型,一个 Future 类型的实例代表一个未来能获取结果的对象
ExecutorService executor = Executors.newFixedThreadPool(4);
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得 Future :
Future<String> future = executor.submit(task);
// 从 Future 获取异步执行返回的结果:
String result = future.get(); // 可能阻塞
当我们提交一个 Callable 任务后,我们会同时获得一个 Future 对象,然后,我们在主线程某个时刻调用 Future 对象的 get() 方法,就可以获得异步执行的结果。在调用 get() 时,如果异步任务已经完成,我们就直接获得结果。如果异步任务还没有完成,那么 get() 会阻塞,直到任务完成后才返回结果。
一个 Future<V> 接口表示一个未来可能会返回的结果,它定义的方法有:
- get():获取结果(可能会等待)
- get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间
- cancel(boolean mayInterruptIfRunning):取消当前任务
- isDone():判断任务是否已完成
CompletableFuture
使用 Future 获得异步执行结果时,要么调用阻塞方法 get() ,要么轮询看 isDone() 是否为 true ,这两种方法都不是很好,因为主线程也会被迫等待。
从 Java 8 开始引入了 CompletableFuture ,它针对 Future 做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。
我们以获取股票价格为例,看看如何使用 CompletableFuture
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 创建异步执行任务:
CompletableFuture<Double> cf = CompletableFuture.supplyAsync(Main::fetchPrice);
// 如果执行成功:
cf.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 如果执行异常:
cf.exceptionally((e) -> {
e.printStackTrace();
return null;
});
// 主线程不要立刻结束,否则 CompletableFuture 默认使用的线程池会立刻关闭:
cf.join();
// Thread.sleep(200);
}
static Double fetchPrice() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
if (Math.random() < 0.3) {
throw new RuntimeException("fetch price failed!");
}
return 5 + Math.random() * 20;
}
}
创建一个 CompletableFuture 是通过 CompletableFuture.supplyAsync() 实现的,它需要一个实现了 Supplier 接口的对象:
public interface Supplier<T> {
T get();
}
这里我们用 lambda 语法简化了一下,直接传入 Main::fetchPrice ,因为 Main.fetchPrice() 静态方法的签名符合 Supplier 接口的定义(除了方法名外)。
紧接着,CompletableFuture 已经被提交给默认的线程池执行了,我们需要定义的是 CompletableFuture 完成时和异常时需要回调的实例。完成时,CompletableFuture 会调用 Consumer 对象
public interface Consumer<T> {
void accept(T t);
}
异常时,CompletableFuture 会调用 Function 对象
public interface Function<T, R> {
R apply(T t);
}
可见 CompletableFuture 的优点是
- 异步任务结束时,会自动回调某个对象的方法
- 异步任务出错时,会自动回调某个对象的方法
- 主线程设置好回调后,不再关心异步任务的执行
如果只是实现了异步回调机制,我们还看不出 CompletableFuture 相比 Future 的优势。CompletableFuture 更强大的功能是,多个 CompletableFuture 可以串行执行,例如,定义两个 CompletableFuture ,第一个 CompletableFuture 根据证券名称查询证券代码,第二个 CompletableFuture 根据证券代码查询证券价格,这两个 CompletableFuture 实现串行操作如下
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 第一个任务:
CompletableFuture<String> cfQuery = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油");
});
// cfQuery 成功后继续执行下一个任务:
CompletableFuture<Double> cfFetch = cfQuery.thenApplyAsync((code) -> {
return fetchPrice(code);
});
// cfFetch 成功后打印结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则 CompletableFuture 默认使用的线程池会立刻关闭:
Thread.sleep(2000);
}
static String queryCode(String name) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return "601857";
}
static Double fetchPrice(String code) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}
除了串行执行外,多个 CompletableFuture 还可以并行执行。例如,我们考虑这样的场景:同时从新浪和网易查询证券代码,只要任意一个返回结果,就进行下一步查询价格,查询价格也同时从新浪和网易查询,只要任意一个返回结果,就完成操作
public class Main {
public static void main(String[] args) throws Exception {
// 两个 CompletableFuture 执行异步查询:
CompletableFuture<String> cfQueryFromSina = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://finance.sina.com.cn/code/");
});
CompletableFuture<String> cfQueryFrom163 = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://money.163.com/code/");
});
// 用 anyOf 合并为一个新的 CompletableFuture :
CompletableFuture<Object> cfQuery = CompletableFuture.anyOf(cfQueryFromSina, cfQueryFrom163);
// 两个 CompletableFuture 执行异步查询:
CompletableFuture<Double> cfFetchFromSina = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://finance.sina.com.cn/price/");
});
CompletableFuture<Double> cfFetchFrom163 = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://money.163.com/price/");
});
// 用 anyOf 合并为一个新的 CompletableFuture :
CompletableFuture<Object> cfFetch = CompletableFuture.anyOf(cfFetchFromSina, cfFetchFrom163);
// 最终结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则 CompletableFuture 默认使用的线程池会立刻关闭:
Thread.sleep(200);
}
static String queryCode(String name, String url) {
System.out.println("query code from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return "601857";
}
static Double fetchPrice(String code, String url) {
System.out.println("query price from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}
上述逻辑实现的异步查询规则实际上是:

除了 anyOf() 可以实现“任意个 CompletableFuture 只要一个成功”,allOf() 可以实现“所有 CompletableFuture 都必须成功”,这些组合操作可以实现非常复杂的异步流程控制。
最后我们注意 CompletableFuture 的命名规则:
- xxx():表示该方法将继续在已有的线程中执行;
- xxxAsync():表示将异步在线程池中执行。
ForkJoin
Java 7 开始引入了一种新的 Fork/Join 线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行。
我们举个例子:如果要计算一个超大数组的和,最简单的做法是用一个循环在一个线程内完成;还有一种方法,可以把数组拆成两部分,分别计算,最后加起来就是最终结果,这样可以用两个线程并行执行;如果拆成两部分还是很大,我们还可以继续拆,用 4 个线程并行执行。这就是 Fork/Join 任务的原理:判断一个任务是否足够小,如果是,直接计算,否则,就分拆成几个小任务分别计算。这个过程可以反复“裂变”成一系列小任务。
我们来看如何使用 Fork/Join 对大数据进行并行求和
import java.util.Random;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
public class Main {
public static void main(String[] args) throws Exception {
// 创建 2000 个随机数组成的数组:
long[] array = new long[2000];
long expectedSum = 0;
for (int i = 0; i < array.length; i++) {
array[i] = random();
expectedSum += array[i];
}
System.out.println("Expected sum: " + expectedSum);
// fork/join:
ForkJoinTask<Long> task = new SumTask(array, 0, array.length);
long startTime = System.currentTimeMillis();
Long result = ForkJoinPool.commonPool().invoke(task);
long endTime = System.currentTimeMillis();
System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
}
static Random random = new Random(0);
static long random() {
return random.nextInt(10000);
}
}
class SumTask extends RecursiveTask<Long> {
static final int THRESHOLD = 500;
long[] array;
int start;
int end;
SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
// 如果任务足够小则直接计算:
long sum = 0;
for (int i = start; i < end; i++) {
sum += this.array[i];
// 故意放慢计算速度:
try {
Thread.sleep(1);
} catch (InterruptedException e) {
}
}
return sum;
}
// 任务太大则一分为二:
int middle = (end + start) / 2;
System.out.println(String.format("split %d~%d ==> %d~%d, %d~%d", start, end, start, middle, middle, end));
SumTask subtask1 = new SumTask(this.array, start, middle);
SumTask subtask2 = new SumTask(this.array, middle, end);
invokeAll(subtask1, subtask2);
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
Long result = subresult1 + subresult2;
System.out.println("result = " + subresult1 + " + " + subresult2 + " ==> " + result);
return result;
}
}
观察上述代码的执行过程,一个大的计算任务 0~2000 首先分裂为两个小任务 0~1000 和 1000~2000 ,这两个小任务仍然太大,继续分裂为更小的 0~500 ,500~1000 ,1000~1500 ,1500~2000,最后,计算结果被依次合并,得到最终结果。
因此,核心代码 SumTask 继承自 RecursiveTask ,在 compute() 方法中,关键是如何“分裂”出子任务并且提交子任务
class SumTask extends RecursiveTask<Long> {
protected Long compute() {
// “分裂”子任务:
SumTask subtask1 = new SumTask(...);
SumTask subtask2 = new SumTask(...);
// invokeAll 会并行运行两个子任务:
invokeAll(subtask1, subtask2);
// 获得子任务的结果:
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
// 汇总结果:
return subresult1 + subresult2;
}
}
Fork/Join 线程池在 Java 标准库中就有应用。Java 标准库提供的 java.util.Arrays.parallelSort(array) 可以进行并行排序,它的原理就是内部通过 Fork/Join 对大数组分拆进行并行排序,在多核 CPU 上就可以大大提高排序的速度。
ThreadLocal
多线程是 Java 实现多任务的基础,Thread 对象代表一个线程,我们可以在代码中调用 Thread.currentThread() 获取当前线程。例如,打印日志时,可以同时打印出当前线程的名字
public class Main {
public static void main(String[] args) throws Exception {
log("start main...");
new Thread(() -> {
log("run task...");
}).start();
new Thread(() -> {
log("print...");
}).start();
log("end main.");
}
static void log(String s) {
System.out.println(Thread.currentThread().getName() + ": " + s);
}
}
对于多任务,Java 标准库提供的线程池可以方便地执行这些任务,同时复用线程。Web 应用程序就是典型的多任务应用,每个用户请求页面时,我们都会创建一个任务,类似
public void process(User user) {
checkPermission();
doWork();
saveStatus();
sendResponse();
}
然后,通过线程池去执行这些任务。
观察 process() 方法,它内部需要调用若干其他方法,同时,我们遇到一个问题:如何在一个线程内传递状态?
process() 方法需要传递的状态就是 User 实例。有的童鞋会想,简单地传入 User 就可以了
public void process(User user) {
checkPermission(user);
doWork(user);
saveStatus(user);
sendResponse(user);
}
但是往往一个方法又会调用其他很多方法,这样会导致 User 传递到所有地方
void doWork(User user) {
queryStatus(user);
checkStatus();
setNewStatus(user);
log();
}
这种在一个线程中,横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context),它是一种状态,可以是用户身份、任务信息等。
给每个方法增加一个 context 参数非常麻烦,而且有些时候,如果调用链有无法修改源码的第三方库,User 对象就传不进去了。
Java 标准库提供了一个特殊的 ThreadLocal ,它可以在一个线程中传递同一个对象。
ThreadLocal 实例通常总是以静态字段初始化如下
static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
它的典型使用方式如下
void processUser(user) {
try {
threadLocalUser.set(user);
step1();
step2();
} finally {
threadLocalUser.remove();
}
}
通过设置一个 User 实例关联到 ThreadLocal 中,在移除之前,所有方法都可以随时获取到该 User 实例
void step1() {
User u = threadLocalUser.get();
log();
printUser();
}
void log() {
User u = threadLocalUser.get();
println(u.name);
}
void step2() {
User u = threadLocalUser.get();
checkUser(u.id);
}
注意到普通的方法调用一定是同一个线程执行的,所以,step1() 、step2() 以及 log() 方法内,threadLocalUser.get() 获取的 User 对象是同一个实例。
实际上,可以把 ThreadLocal 看成一个全局 Map<Thread, Object> :每个线程获取 ThreadLocal 变量时,总是使用 Thread 自身作为 key
Object threadLocalValue = threadLocalMap.get(Thread.currentThread());
因此,ThreadLocal 相当于给每个线程都开辟了一个独立的存储空间,各个线程的 ThreadLocal 关联的实例互不干扰。
最后,特别注意 ThreadLocal 一定要在 finally 中清除
try {
threadLocalUser.set(user);
...
} finally {
threadLocalUser.remove();
}
这是因为当前线程执行完相关代码后,很可能会被重新放入线程池中,如果 ThreadLocal 没有被清除,该线程执行其他代码时,会把上一次的状态带进去。
为了保证能释放 ThreadLocal 关联的实例,我们可以通过 AutoCloseable 接口配合 try (resource) {...} 结构,让编译器自动为我们关闭。例如,一个保存了当前用户名的 ThreadLocal 可以封装为一个 UserContext 对象
public class UserContext implements AutoCloseable {
static final ThreadLocal<String> ctx = new ThreadLocal<>();
public UserContext(String user) {
ctx.set(user);
}
public static String currentUser() {
return ctx.get();
}
@Override
public void close() {
ctx.remove();
}
}
使用的时候,我们借助 try (resource) {...} 结构,可以这么写
try (var ctx = new UserContext("Bob")) {
// 可任意调用 UserContext.currentUser() :
String currentUser = UserContext.currentUser();
} // 在此自动调用 UserContext.close() 方法释放 ThreadLocal 关联对象
这样就在 UserContext 中完全封装了 ThreadLocal ,外部代码在 try (resource) {...} 内部可以随时调用 UserContext.currentUser() 获取当前线程绑定的用户名。

浙公网安备 33010602011771号