八、多线程编程
文章目录
1、多线程基础
首先我们需要知道现代的操作系统,不管是windows、macOS、还是Linux,都是可以执行多任务的。否则你也不能边打开网页、边听着音乐,还登录着QQ。
在我们感觉上,cpu好像是在同时执行所有的任务,那是因为我们被计算机给“欺骗”了,在操作系统中,多个程序是被cpu交替执行的,也就是说,cpu会让浏览器执行0.001秒,让QQ执行0.001秒,再让音乐播放器执行0.001秒,只是这个时间太短,我们感觉不出来罢了。
即使对于今天的多核CPU,因为你的任务通常也是多于核数,所以任务仍然是交替执行。
1.1 进程
要学习线程,就必须得提到 进程
了,在计算机中,我们把一个任务称为一个进程,像你使用的浏览器就是一个进程。
然而在进程中又需要执行多个字任务。例如,我们在使用word时,可以一边打字,Word还会一边进行拼写检查,甚至还在后台打印,这种字任务就称为线程。
1.2 进程与线程
进程与线程的关系:
-
进程是分配和调度资源的独立单位,而线程是CPU调度的基本单元。
-
线程
依存于进程
而存在,没有单独存在的线程。所以进程结束后它拥有的所有线程也会被销毁,而线程的结束不会影响进程的存在。 -
一个进程可以有多个线程,但只会有一个线程。多个线程共享进程的资源。
1.3 工作模式
一个应用程序,既可以有多进程,可以多线程工作,因此实现多任务,就有以下几种方法:
- 多进程模式(每个进程只有一个线程)
- 多线程模式(一个进程有多个线程)
- 多进程 + 多线程模式(复杂度最高)
对于选择上面的哪种模式,就要考虑进程和线程的优缺点:
- 多进程的缺点:
- 创建进程比创建线程开销更大;
- 进程之间的通信比线程的通信要慢。
- 多进程的优点:
- 多进程更加稳定,因为一个进程崩溃,不会影响到其他的进程。而在多线程的情况下,任何一个线程崩溃会导致整个进程崩溃。
2、创建线程
在 Java 中创建一个新线程是非常容易的,有以下方法:
-
方法一:从
Thread
派生一个自定义类,然后覆写run()
方法:/*方法一*/ /*MyThread01.java*/ public class MyThread01 extends Thread { @Override public void run() { System.out.println("启动了线程MyThread01"); } }
@Test public void m0() { Thread myThread01 = new MyThread01(); myThread01.start(); //启动新线程,`start()` 方法会自动调用覆写的 `run()` 方法 }
-
创建一个
Thread
实例,传入一个Runnable
实例:/*MyThread02.java*/ public class MyThread02 implements Runnable { @Override public void run() { System.out.println("启动了线程MyThread02"); } }
@Test public void m1() { Thread myThread01 = new MyThread01(); Thread myThread02= new Thread(new MyThread02()); myThread01.start(); myThread02.start(); }
Thread.java中的
start()
方法通知 “线程管理器” 此线程已经准备就绪,等待调用该线程的run()
方法。具体什么时候调用,需要看系统的安排,有了异步执行的效果。因此不能直接调用thread.run()
方法,否则就变成了同步,相当于调用了一个普通的方法。
上面的代码可能还看不出多线程和单线程的区别,我们可以再写一个例子:
/*创建自定义线程类*/
public class MyThread02 implements Runnable {
@Override
public void run() {
for(int i=0; i < 10; ++i) {
System.out.println(Thread.currentThread().getName()+ "-" + i);
int time = (int) (Math.random() * 100);
try {
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
/*测试*/
@Test
public void m0() {
Thread myThread01 = new Thread(new MyThread02());
myThread01.start();
for(int i=0; i < 10; ++i) {
System.out.println(Thread.currentThread().getName()+ "-" + i);
int time = (int) (Math.random() * 100);
try {
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/*输出结果*/
main-0
Thread-0-0
main-1
main-2
main-3
Thread-0-1
main-4
main-5
Thread-0-2
Thread-0-3
main-6
Thread-0-4
main-7
main-8
Thread-0-5
main-9
2.1 Runnable接口
打开Thread.java
,我们可以发现,Thread
类实际上就是继承了 Runnable
接口,它们之间具有多态关系。
public class Thread implements Runnable {
但是继承 Thread
类创建新线程的最大缺陷就是:Java不支持多继承,如果想要创建的线程类已经有了一个父类,这时就需要实现 Runnable
接口来对应。
2.2 优先级
可以对线程设定优先级,设定优先级的方法是:
Thread.setPriority(int n) // 1~10, 默认值5
优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
3、线程的状态
在Java程序中,一个线程对象只能调用一次 start()
方法启动新线程,并在新线程中执行 run()
方法。一旦 run()
方法执行完毕,线程也就结束了。
Java线程的状态有以下几种:
-
New:新创建的线程,还没有执行;
-
Runnable:执行中的线程,正在执行
run()
方法中的代码; -
Blocked:运行中的线程,因为某些操作被阻塞而挂起;
-
Waiting:运行中的线程,因为某些操作在等待中;
-
Timed Waiting:运行中的线程,因为
sleep()
方法正在计时等待; -
Terminated:因为
run()
方法执行完毕,线程已终止。
当线程启动之后,可以在Runnable
、Blocked
、Waiting
和Timed Waiting
这几个状态之间切换,直到最后变成 Terminated
状态,线程终止。而终止的原因也有多种:
- 正常终止:
run()
方法执行到return
语句返回; - 意外终止:
run()
方法因为未捕获到异常而终止; - 对某个线程对象调用了
stop()
方法而终止(强烈不推荐使用)。
一个线程A 可以等待另外一个线程B直到B执行结束才开始执行。例如,main
线程等待 myThread01
线程结束才再运行:
@Test
public void m0() throws InterruptedException {
Thread myThread01 = new Thread(new MyThread02());
myThread01.start();
myThread01.join();
System.out.println("end");
}
join(long)
的重载方法可以指定一个等待时间,超过等待时间之后就不再继续等待。
4、中断线程
线程的使用中,经常需要中断线程,例如:用户正在下载一个100MB的文件,但是突然又不想要了,就会在下载过程中点击“取消”,这时,程序就需要中断下载线程的执行。
中断线程有两种方法:
- 在其他线程中对目标线程调用
interrupt()
方法; - 设置标志位,通过
running
标志位判断线程是否应该继续执行。
方法一:调用 interrupt()
方法
我们先来看一下刚开始写测试代码时,出现的一个java.lang.InterruptedException: sleep interrupted
问题代码:
/*MyThread03.java*/
public class MyThread03 extends Thread {
private int n = 0;
@Override
public void run() {
while (!this.isInterrupted()) {
n++;
System.out.println(n);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
/*测试*/
@Test
public void m1() throws InterruptedException {
Thread myThread03 = new MyThread03();
myThread03.start();
Thread.sleep(5);
myThread03.interrupt();
myThread03.join();
System.out.println("end");
}
上面的输出结果如下:
//输出结果
1
2
3
4
5
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at thread.MyThread03.run(MyThread03.java:19)
6
7
8
9
10
11
12
.....
我们发现在对 myThread03
线程调用 myThread03.interrupt()
之后,由于该线程处于等待状态,所以会立刻抛出 InterruptedException
,但此时线程仍然没有中断,因此会一直执行。
处理方法是:在线程捕捉到 InterruptedException
后,就知道是有其他线程对其调用了中断方法,这时需要立刻结束运行该线程。
/*修改MyThread03.java*/
public class MyThread03 extends Thread {
private int n = 0;
@Override
public void run() {
while (!this.isInterrupted()) {
n++;
System.out.println(n);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
/*在捕获到异常后,跳出循环*/
System.out.println("============interrupted==============");
break;
}
}
}
}
方法二:通过设置running
标志位
/*MyThread04.java*/
public class MyThread04 extends Thread {
//使用volatile关键字标记
public volatile boolean running = true;
private int n = 0;
@Override
public void run() {
while (running) {
n++;
System.out.println(n);
}
}
}
/*测试*/
@Test
public void m1() throws InterruptedException {
MyThread04 myThread04 = new MyThread04();
myThread04.start();
Thread.sleep(1);
//中断线程
myThread04.running = false;
myThread04.join();
System.out.println("================end==============");
}
为什么
MyThread04
的标志位running
需要使用volatile
标记?在
MyThread04
类中的running
,我们把它作为了一个线程间共享的变量,在其他线程将running
赋值为false
后,需要及时结束 运行中的线程。但是在Java虚拟机中,变量的值保存在主内存中,当线程访问变量时,它只会获取到一个副本,修改该变量后也会先保存在自己的工作区,当然修改后的值需要保存下来,不靠谱的是虚拟机会在某个不确定的时刻把值写回主内存。
上述的问题就造成了,多线程之间共享的变量不一致。在其他线程都已经
running = false
之后,主内存中的running
值还是true
,不能及时结束线程。标示
volatile
关键字的目的就是告诉虚拟机:
- 我每次都要访问该变量的最新值;
- 该变量修改后,要立刻写回到主内存。
5、守护线程
Java程序的入口就是main
线程,在main
线程中又可以启动其他线程。在所有的线程结束之后,JVM才会退出。
意思就是说:只要有一个线程没有退出,JVM进程就不会退出。
但是就有一种线程,它的目的就是无限循环,例如,一个执行定时任务的线程:
class TimerThread extends Thread {
@Override
public void run() {
while (true) {
//执行任务
System.out.println(“定时任务执行”);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}
}
根据代码可以知道上面这个线程只要启动了之后,就不会结束。那问题就是:谁负责结束这个线程呢?
事实上,不需要任何线程来负责它们的结束,我们可以使用守护线程(Daemon Thread)。
守护线程就是为其他线程服务的线程。在其他线程都执行结束后,虚拟机是不管守护线程是否结束的,会自动退出。
5.1 创建守护线程
方法和普通线程一样,只是在调用start()
方法前,调用setDaemon(true)
把该线程标记为守护线程:
Thread t = new MyThread();
t.setDaemon(true); //标记为守护线程
t.start();
需要注意的是:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
6、线程同步
在多线程同时运行时,线程的调度是由操作系统决定的,程序根本不能决定。就时候问题就来了:如果多个线程读、修改共享变量,就会出现数据不一致的问题。
下面的例子,一个线程相当于一个售票窗口,总共有5张票,每个窗口一次会卖出5张,正常来说,应该是有一个窗口会买不到票,但是如下程序结束后,2个窗口都卖出了票,余票变成了-5。
public class Ticket {
public static void main(String[] args) throws InterruptedException {
SellThread t1 = new SellThread();
t1.start();
SellThread t2 = new SellThread();
t2.start();
t1.join();
t2.join();
System.out.println(Count.total);
}
}
class Count {
public static int total = 5;
}
class SellThread extends Thread {
@Override
public void run() {
int total = Count.total;
if (total > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
Count.total = Count.total - 5;
System.out.println("卖出5张票");
}else {
System.out.println("没有票了");
}
}
}
//输出结果
卖出5张票
卖出5张票
余票为-5
上面的错误在于:
要想解决对共享变量的读写问题,必须保证以原子的方法执行售票,即某一线程在执行时,其他线程必须等待:
通过加锁和解锁的方式保证了一段代码的原子性,从而解决了共享变量的问题。在Java中,是使用 synchronized
关键字对一个对象进行加锁:
synchronized(lock) {
n = n + 1;
}
/*用 synchronized 改写上面的代码*/
class SellThread extends Thread {
@Override
public void run() {
synchronized (Count.lock) { //获取锁
int total = Count.total;
if (total > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
Count.total = Count.total - 5;
System.out.println("卖出5张票");
}else {
System.out.println("没有票了");
}
} //释放锁
}
}
两个线程在执行各自的
synchronized(Counter.lock) { ... }
代码块时,必须先获得锁,才能开始售票。使用
synchronized
解决了多线程同步访问共享变量的问题。但是,它的缺点是带来了性能下降的问题。因此synchronized
代码块不能并发执行,加锁和解锁也需要消耗时间。
在使用synchronized
的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized
结束处正确释放锁:
public void add(int m) {
synchronized (obj) {
if (m < 0) {
throw new RuntimeException();
}
this.value += m;
} // 无论有无异常,都会在此释放锁
}
6.1 不需要synchronized的操作
JVM规范定义了几种原子操作:
- 基本类型(
long
和double
除外)赋值,例如:int n = m
; - 引用类型赋值,例如:
List list = anotherList
。
例如:
public void set(String s) {
this.value = s;
}
但如果是多行赋值语句,就必须保证是同步操作。
7、同步方法
8、死锁
对于多线程来说,必须要面临死锁的问题。看下面的场景:
- 线程1获得了资源A;
- 线程2获得了资源B。
随后(假设只有一个资源A、一个资源B,并且此时线程1并没有释放资源A,线程2也没有释放资源B),
- 线程1想要获得资源B,失败,等待。
- 线程2想要获得资源A,失败,等待。
这时候就发生了死锁,2个线程都在无限等待着。
对于死锁的解决,可以规定获取资源的顺序,即严格按照先获取资源A,再获取资源B的顺序。
9、使用wait和notify
synchronized
解决了多线程的竞争问题。多个线程同时向队列中添加任务,可以用 synchronized
加锁解决:
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
}
但是 synchronized
并没有解决多线程协调的问题。例如,我们在编写一个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();
}
}
上面的代码的问题是:在线程执行 getTash()
时,如果为空,就会一直循环,也根本不会释放 this
锁,而其他线程因为获取不到 this
锁,不能添加任务到队列。
我们想要执行的效果是:
- 线程1可以调用
addTask()
不断添加任务到队列; - 线程2可以调用
getTask()
获取任务。如果队列为空,就应该等待,同时释放锁,知道队列中至少有一个任务。
因此,多线程协调运行的原则就是:当条件不满足,线程就等待;当满足时,线程被唤醒。
/*修改 TaskQueue,并测试*/
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 锁
this.wait();
//重新获取 this 锁
}
return queue.remove();
}
}
对于
wait()
需要注意的是:
- 该方法必须在当前获取的锁对象上调用,上面获取的锁是
this
锁,因此调用this.wait()
。- 在
wait()
方法调用时,放释放线程获得的锁,wait()
方法返回后,会重新试图获取锁。对于
notifyAll()
和notify()
需要注意的是:
使用
notifyAll()
将唤醒所有当前正在this
锁等待的线程,而notify()
只会唤醒其中一个。通常使用notifyAll()
更加安全,因为我们我们考虑不周,会有些线程永远没有被唤醒。假设现在有3个线程被唤醒,但是也只有一个能获取到
this
锁,剩下两个将继续等待。再次被唤醒时,需要再判断队列是否为空,所以下面的写法是错误的:/*错误写法*/ public synchronized String getTask() throws InterruptedException { if (queue.isEmpty()) { this.wait(); } return queue.remove(); }
10、使用ReentrantLock
从Java 5开始,引入了一个高级的处理并发的java.util.concurrent
包,它提供了大量更高级的并发功能,能大大简化多线程程序的编写。
虽然Java语言直接提供的 synchronized
关键字能用于加锁,但是这种锁一是重,而是获取的时候必须一直等待,没有额外的尝试机制。
java.util.concurrent.locks
包提供的ReentrantLock
用于替代synchronized
加锁。
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();
}
}
}
ReentrantLock
是可重入锁,它和synchronized
一样,一个线程可以多次获取同一个锁。
10.1 和synchronized
不同
和synchronized
不同的是,ReentrantLock
可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()
返回false
,程序就可以做一些额外处理,而不是无限等待下去。
所以,使用ReentrantLock
比直接使用synchronized
更安全,线程在tryLock()
失败的时候不会导致死锁。
11、使用Condition
从上节知道,使用ReentrantLock
比直接使用synchronized
更安全,可以替代synchronized
进行线程同步。但是在处理多线程协调问题时,我们就需要使用 Conditon
来实现 wait
和 notify
的功能。
下面通过 ReentrantLock
和 Condition
来实现 TaskQueue
:
public 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()
。
await()
可以在等待指定时间后,如果还没有被唤醒,可以自己醒来:if (condition.await(1, TimeUnit.SECOND)) { // 被其他线程唤醒 } else { // 指定时间内没有被其他线程唤醒 }
通过使用
Condition
配合Lock
,我们可以实现更灵活的线程同步。
12、使用ReadWriteLock
前面的那些,不管是 synchronized
,还是 ReentrantLock
,都保证了只有一个线程能够执行临界区代码。但有时候这种保护有点过头。例如,在add()
方法中因为要修改变量,所以应该加锁,但是在 get()
方法中只读取数据,不会修改数据,此时允许多个线程同时调用不会有问题。
我们想要实现的状态如下:
读 | 写 | |
---|---|---|
读 | 允许 | 不允许 |
写 | 不允许 | 不允许 |
使用 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(); // 释放读锁
}
}
}
允许多个线程同时获得读锁,提高了并发读得效率。
13、使用StampedLock
在前面的 ReadWriteLock
中,可以发现:如果有线程在读,写线程需要等待读线程释放锁之后才能获取写锁,因为读的过程中不允许写,这是一种悲观的读锁。
为了进一步提高并发执行效率,Java8引入了新的写锁:StampedLock
。
StampedLock
:在读的过程中允许获取写锁写入,但是这样以来,读取到的数据就有可能不一致,所以,还需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
- 乐观锁:估计读的过程中不会写,十分的乐观。
- 悲观锁:认为读的过程中总会发生写。
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 distance() throws InterruptedException {
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)
Thread.sleep(5);
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
System.out.println("有线程写入");
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
在读取时,我们通过
tryOptimisticRead()
获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()
去验证版本号,如果不成功,说明有数据写入,需要重新读取。由于写入的概率不高,在大多数情况下通过乐观锁就可以获取数据,极少使用悲观锁。
StampedLock
把读锁细分为乐观读和悲观读,优点是能进一步提升并发效率。缺点是:
- 代码更加复杂;
StampedLock
是不可重入锁,不能在一个线程中反复获取同一个锁。
14、使用Concurrent集合
我们前面已经通过 ReentrantLock
和 Condition
实现了 TaskQueue
,可以看 11、使用Condition
。
TaskQueue
是 BlockingQueue
:当一个线程调用其中的 getTash()
时,如果队列为空会让线程等待,直到队列不为空时被唤醒。
在Java标准库的 java.util.concurrent
包下提供了线程安全的集合: ArrayBlockingQueue
。
除此之外,针对List
、Map
、Set
、Deque
等,java.util.concurrent
包也提供了对应的并发集合类。
interface | non-thread-safe | 线程安全 |
---|---|---|
List | ArrayList | CopyOnWriteArrayList |
Map | HashMap | ConcurrentHashMap |
Set | HashSet / TreeSet | CopyOnWriteArraySet |
Queue | ArrayDeque / LinkedList | ArrayBlockingQueue / LinkedBlockingQueue |
Deque | ArrayDeque / LinkedList | LinkedBlockingDeque |
线程安全集合和非线程安全集合类的用法相同。例如:
Map<String, String> map = ConcurrentHashMap<>();
// 在不同的线程读写:
map.put("A", "1");
map.put("B", "2");
map.get("A", "1");
java.util.Collections
工具类还提供了一个旧的线程安全集合转换器,可以这么用:
Map unsafeMap = new HashMap();
Map threadSafeMap = Collections.synchronizedMap(unsafeMap);
但是它实际上是用一个包装类包装了非线程安全的Map
,然后对所有读写方法都用synchronized
加锁,这样获得的线程安全集合的性能比java.util.concurrent
集合要低很多,所以不推荐使用。
15、使用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 pre, next;
do {
prev = var.get();
next = prev + 1;
} while(!var.compareAndSet(prev, next));
return next;
}
CAS是指,在 var.compareAndSet(prev, next)
操作中,如果var
的当前值是prev
,那么就更新为next
,返回true
。如果不是prev
,说明值发生了修改,就什么也不干,返回 false
,然后重新获取值,修改。
通过CAS操作并配合 do...while
循环,即使其他线程修改了 AtomicInteger
的值,最终的结果也正确。
通常情况下,我们不需要自己实现复杂的并发操作,而是用 incrementAndGet()
这样封装好的方法。
在高度竞争的情况下,还可以使用Java8提供的LongAdder
和LongAccumulator
。
16、使用线程池
使用线程池的原因是:创建线程需要操作系统资源(线程资源、栈空间等),频繁创建和销毁大量线程需要消耗大量时间。
我们希望能够把很多的小任务让一组线程执行,而不是一个任务用一个线程执行。这一组线程就是线程池。
在线程池内维护了若干线程,没有任务时,这些线程就出了等待状态。如果有新任务,就分配一个空闲线程执行。如果这时所有线程都在忙碌,有两种处理方法:
- 让新任务等待;
- 在创建一个新线程处理。
在Java标准库中提供了 ExecutorService
接口表示线程池,它的常用实现类有:
FixedThreadPool
:线程数固定的线程池;CachedThreadPool
:线程数根据任务动态调整的线程池;SingleThreadExecutor
:仅单线程执行的线程池。
线程池的典型用法如下:
// 创建固定大小的线程池:
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交任务:
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.submit(task4);
executor.submit(task5);
//关闭线程池
executor.shutdown();
线程池在程序结束的时候要关闭。关闭的方法有3种:
shutdown()
:等正在执行的任务完成后,再关闭线程池。shutdownNow()
:立刻停止正在执行的任务。awaitTermination()
:等待指定的时间关闭线程池。
下面我们以FixedThreadPool
为例,看看线程池的执行逻辑:
public class Main {
public static void main(String[] args) {
// 创建一个固定大小为4的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 8; i++) {
//一次性放入8个任务,先会执行4个,在线程空闲后执行后面4个
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}
class Task extends Thread {
private String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("启动任务:" + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行结束:" + name);
}
}
如果我们把线程池控制在 4~10个之间动态调整该怎么办?打开 Executors.newCachedThreadPool()
方法的源码:
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
因此,我们可以这样写:
ExecutorService es = new ThreadPoolExecutor(4, 10,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
16.1 ScheduledThreadPool
ScheduledThreadPool
是针对需要定期的、反复执行的任务。例如,每5分钟报时。
创建一个 ScheduledThreadPool
仍然是通过Executors
类:
ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);
虽然是定期重复执行,但也有这3种执行状态:
-
特定延迟后只执行一次:
// 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);
后两种方式怎么看着没有区别,我们通过一张图就很容易理解:
Java标准库还提供了一个java.util.Timer
类,这个类也可以定期执行任务,但是,一个Timer
只会对应一个Thread
,所以,一个Timer
只能定期执行一个任务,多个定时任务必须启动多个Timer
,而一个ScheduledThreadPool
就可以调度多个定时任务,所以,我们完全可以用ScheduledThreadPool
取代旧的Timer
。
17、使用Future
在使用线程池执行任务时,我们提交的任务只需要实现 Runnable
接口就可以。
如果现在我们想要方法给返回值,就只能保存在变量中,再通过额外的方法读取,非常不便。所以,Java提供了一个 Callable
接口:
public class Task implements Callable<String> {
@Override
public String call() throws Exception {
return "";
}
}
那么我们现在怎么才能获取到异步执行的结果呢,线程提交任务的 ExecutorService.submit()
方法,会返回一个 Future
实例,它代表了一个未来能获取结果的对象。
ExecutorService executor = Executors.newFixedThreadPool(4);
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得Future:
Future<String> future = executor.submit(task);
// 从Future获取异步执行返回的结果:
String result = future.get(); // 可能阻塞
在调用
Future
对象的get()
方法时,如果异步任务完成,就直接获得到了结果。否则,get()
会阻塞,执行任务结束才返回结果。
Future 接口定义的方法有:
get()
:获取结果(可能会等待)get(long timeout, TimeUnit unit)
:获取结果,但只等待指定的时间;cancel(boolean mayInterruptIfRunning)
:取消当前任务;isDone()
:判断任务是否已完成。
18、使用CompletableFuture
使用 Future
可以获得异步执行的结果,但是在调用 get()
方法时,如果还没有执行结束,会被迫等待。
因此,在Java8中引入了 CompletableFuture
,它是对 Future
的改进,可以传入回调对象,当异步任务完成或者发生异常时,自动的调用回调对象的回调方法。
public class Main {
public static void main(String[] args) throws Exception {
// 创建异步执行任务:
CompletableFuture<Double> cf = CompletableFuture.supplyAsync(new FetchPrice());
// 如果执行成功:
cf.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 如果执行异常:
cf.exceptionally((e) -> {
e.printStackTrace();
System.out.println("发生异常");
return null;
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(2000);
}
}
class FetchPrice implements Supplier<Double> {
@Override
public Double get() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
if (Math.random() < 0.3) {
throw new RuntimeException("fetch price failed!");
}
return 5 + Math.random() * 20;
}
}
创建一个 CompletableFuture
是通过 CompletableFuture.supplyAsync
实现的,它需要一个实现了 Supplier
接口的对象。
接着,CompletableFuture
就会被提交给默认的线程池执行,我们需要定义的就是 CompletableFuture
执行完成或者异常时需要回调的实例。
在完成时,CompletableFuture
会调用Consumer
对象:
public interface Consumer<T> {
void accept(T t);
}
在异常时,CompletableFuture
会调用Function
对象:
public interface Function<T, R> {
R apply(T t);
}
这里我们都用lambda语法简化了代码。
CompletableFuture
的优点是:
- 异步任务结束时,自动回调方法;
- 异步任务出错时,自动回调方法;
- 主线程只用设置好回调,就不用关心异步任务的执行。
18.1 多个CompletableFuture串行执行
18.2 多个CompletableFuture并行执行
这两个等用到的时候再看吧。
19、使用Fork/Join
Java7开始引入了一种新的 Fork/Join
线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行。例如,我们需要计算一个超大数组的和,如果通过 for
循环执行需要的时间较多,这时,我们可以把数组拆分成两部分,用两个线程分别计算,最后加起来就是最终结果。当然,如果两部分还是太大,还可以继续拆分,用4个线程来执行。
这就是 Fork/Join
的原理:判断一个任务是否足够小,如果是,就直接计算,否则,就拆分为几个小任务分别计算。
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);
}
}
/*核心代码,SumTask继承了RecursiveTask,并覆写了compute()方法。*/
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;
}
}
Fork/Join线程池在Java标准库中就有应用。Java标准库提供的java.util.Arrays.parallelSort(array)
可以进行并行排序,它的原理就是内部通过Fork/Join对大数组分拆进行并行排序,在多核CPU上就可以大大提高排序的速度。
20、使用ThreadLocal
在Web应用中,每一个用户请求页面时,我们都会创建一个任务,类似:
public void process(User user) {
checkPermission();
doWork();
saveStatus();
sendResponse();
}
然后让线程池去执行这些任务,但是我们想怎么在一个线程中传递状态呢?总不能把 User
实例传到每个方法中吧。在Java中,提供了一个特殊的 ThreadLocal
,它可以在一个线程中横跨多个方法传递需要的对象。而这个对象我们称为上下文(Context),它是一种状态,可以是用户信息等。
下面我们看一个例子:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @author :
* @date : 2020-03-12 22:28
* @description :
*/
public class Main {
public static void main(String[] args) throws Exception {
ExecutorService es = Executors.newFixedThreadPool(3);
String[] users = new String[] { "Bob", "Alice", "Tim", "Mike", "Lily", "Jack", "Bush" };
for (String user : users) {
es.submit(new Task(user));
}
es.shutdown();
}
}
/*为了保证 ThreadLocal 的释放,可以通过实现 AutoCloseable接口配合 try(resource){...}*/
class UserContext implements AutoCloseable {
//ThreadLocal实例通常设置为静态字段
private static final ThreadLocal<String> ctx = new ThreadLocal<>();
public UserContext(String user) {
ctx.set(user);
System.out.printf("[%s] init user %s...\n", Thread.currentThread().getName(), UserContext.getUser());
}
public static String getUser() {
return ctx.get();
}
@Override
public void close() throws Exception {
System.out.printf("[%s] cleanup for user %s...\n", Thread.currentThread().getName(), getUser());
ctx.remove();
}
}
class Task implements Runnable {
private String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
try (UserContext uc = new UserContext(this.name)) {
new Task1().process();
new Task2().process();
new Task3().process();
} catch (Exception e) {
e.printStackTrace();
}
}
}
class Task1 {
public void process() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
System.out.printf("[%s] check user %s...\n", Thread.currentThread().getName(), UserContext.getUser());
}
}
class Task2 {
public void process() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
System.out.printf("[%s] %s registered ok.\n", Thread.currentThread().getName(), UserContext.getUser());
}
}
class Task3 {
public void process() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
System.out.printf("[%s] work of %s has done.\n", Thread.currentThread().getName(),
UserContext.getUser());
}
}
注意:普通的方法调用一定是在同一个线程中执行的,所以,
Task1
、Task2
以及Task3
中UserContext.getUser()
获取的User
对象是同一个实例。我们可以把
ThreadLocal
看成是一个全局的Map<Thread, Object>
:每个线程在获取上下文时,通过Thread
自身最为key去获取。不同线程的ThreadLocal
互不干扰。