前言
什么是线程安全?最近我看了一篇文章,觉得不错,参考知乎如果你这样回答“什么是线程安全”,面试官都会对你刮目相看
JUC的全称是java.util.concurrent
,从JDK5开始,Java提供了该工具包用于解决多线程问题。多线程问题围绕三点:1.原子性 2.可见性 3.有序性
一、内存可见性与volatile关键字
1.1 什么是内存可见性问题
多线程问题的实质是由于是多个线程共享同一个资源造成的。 如以下代码示例,主线程和子线程共同操作同一个变量flag
,子线程修改该变量的值,主线程不断读取,直到子线程将flag
的值修改后,打印退出。
// jdk1.8
public class Main {
@Test
public void testA() {
A aa = new A();
new Thread(aa).start();
while (true) {
if (aa.isFlag()) {
System.out.println("-------------");
break;
}
}
}
}
class A implements Runnable {
private boolean flag = false;
// get or set method
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
// 确保main线程先读一次flag
Thread.sleep(2000);
} catch (InterruptedException e) {
}
flag = true;
System.out.println(flag);
}
}
期望的结果是:子程序输出"true",主程序输出"-------------",程序退出。
但是,实际程序运行结果如下:
程序运行结果说明,子线程确实将flag
的值改为true
了,但是主线程读到的flag
值始终是false。也就是说,子线程对共享数据修改对主线程不可见。这就是内存可见性问题。
- 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
1.2 内存可见性问题是如何产生的
内存可见性问题是由于缓存造成的。CPU在执行的时候,每一个线程具有独自的一小块缓存区。主线程读取flag
的值后,进行缓存,每次循环读取的其实是自己缓存的数据,因此造成死循环。
上面的示例代码为了确保主线程缓存了
flag
的值,所以让子线程休眠
具体流程如下图:
1.3 可见性问题的解决方法
(1) synchronized
while (true) {
synchronized (this){
if (aa.isFlag()) {
System.out.println("-------------");
break;
}
}
}
synchronized关键字的作用:
(1)确保线程互斥的访问同步代码(2)保证共享变量的修改能够及时可见(3)有效解决重排序问题。
synchronized
关键字的缺点:获取锁和释放锁是消耗性能的。该部分代码同时只能有一个线程访问,效率低。
(2) volatile
private volatile boolean flag = false;
变量flag
被volatile
修饰,表示线程本地缓存无效。可以认为主线程和子线程直接对内存中的数据进行操作,从而保证内存的可见性。
volatile
关键字的缺点:不具备锁的互斥性,不能解决多线程的原子性。
volatile 关键字解决了多线程下的可见性和有序性问题,至于有序性问题,不在JUC范围内讨论。
二、原子变量和CAS算法
2.1 i++的执行过程
对于赋值语句j = i
包含两步过程:读值,写值(赋值):
// 这里只是模拟执行过程,实际的执行过程是通过寄存器进行的,temp代表寄存器中的一块存储区域
int temp = i; // 寄存器读取内存中的值
j = temp; // 寄存器将读到的值写入内存j
对于基本操作i++
的执行过程其实分为三步:读值、改值、写值(赋值):
// 这里只是模拟执行过程,实际的执行过程是通过寄存器进行的,temp代表寄存器中的一块存储区域
int temp = i; // 寄存器读取内存中的i
temp = temp + 1; // 寄存器进行运算
i = temp; // 将运算的结果写回内存
对于基本操作j = i++
执行过程如下:
int temp = i;
int temp2 = i;
temp2 = temp2 + 1;
i = temp2;
j = temp;
看了很多的博客,这里写的都有点乱,不能同时很好的解释为什么i++分为三步,同时i = i++的结果确是原来的值。我也不清楚自己写了点什么,不钻牛角尖可以直接跳过。深入讨论i = i++的过程在这里不是重点。总之:必须清楚在i++的实际执行是一个复合操作。
2.2 什么是原子性问题
有如下Java代码
public class Main {
public static void main(String[] args) {
A aa = new A();
for (int i=0; i<10; i++){
new Thread(aa).start();
}
}
}
class A implements Runnable {
private int i = 0;
public int getI() {
return i;
}
@Override
public void run() {
try {
// 将线程问题进行放大,其实不进行休眠,理论上也是必定会发生线程安全问题,但是由于i++的操作执行的极快,线程刚开始执行就结束了,十个线程几乎顺序执行
Thread.sleep(200);
} catch (InterruptedException e) {
}
System.out.println(i++);
}
}
程序的运行结果如下图:
小插曲:在写这部分代码的时候,是不能用jUnit写的,如下代码所示:
@Test public void testA(){ A aa = new A(); for (int i=0; i<10; i++){ new Thread(aa).start(); } }
用以上代码代替main方法,执行之后,控制台什么都没有,在子线程还没执行完的时候,主线程就结束了。对于JUnit来说,测试方法结束,执行就结束。
分析运行结果可以看出:十个线程进行i
的累加,结果并没有得到10。中间有多个线程同时对同样额值进行了自增操作,所以输出了相同的结果。这是因为对于操作i++
,线程在执行三步的过程中,有可能另一个线程也开始执行了。如果能确保i++
的过程能要么全部执行完毕,要么不执行,就不会产生问题了。从另一个角度说,也是因为对i
操作的线程不互斥。
- 原子性:一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- 原子操作:在整个操作过程中不会被线程调度器中断的操作,同一时刻只能有一个线程来执行。例如
a=1
是原子性操作,但是a++
和a +=1
就不是原子性操作。
引用类型的赋值是原子的。虽然虚拟机规范中说64位操作可以不是原子性的,可以分为两个32位的原子操作,但是目前商用的虚拟机几乎都实现了64位原子操作。——《深入理解JVM》
原子性问题和可见性问题的区别:
- 可见性问题:一个线程已经修改过的值(如上例,对flag的操作代码已经执行完) ,另一个线程不知道
- 原子性问题:一个线程执行的非原子操作的过程中,另一个线程也过来执行了(对 i 的操作还没执行完)
具体执行过程如图所示:
2.3 如何解决原子性问题
(1) synchronized
synchronized (this){
System.out.println(i++);
}
synchronized
关键字是从线程互斥角度解决的原子性问题,对i++
的操作,同一时间只能有一个线程进行,自然不会有问题。
(2) 原子变量
从JDK5开始,Java提供了JUC包(java.util.concurrent
),JUC包下的所有类的操作都是原子操作。
其中,Java在java.util.concurrent.atomic
包下提供了常用的原子变量:AtomicInteger
,AtomicReference
(对象的引用)等。还有一些原子变量的数组:AtomicIntegerArray
等
一个小型工具包,支持单个变量上的无锁线程安全编程。——《JDK8文档》
使用原子变量AtomicInteger
,如以下Java代码所示:
class A implements Runnable {
private AtomicInteger i = new AtomicInteger(0);
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
System.out.println(i.getAndIncrement());
}
}
有关于
AtomicInteger
类以及其他类的具体用法,参考JDK文档
原子变量通过两种手段确保线程安全:
- 使用volatile保证内存可见性
private volatile int value;// AtomicInteger维护的字段,是volatile的
// 构造方法
public AtomicInteger(int initialValue) {
value = initialValue;
}
- 用CAS算法保证原子性
2.4 CAS算法
CAS(CompareAndSwap),比较并替换。是一种非阻塞同步的解决方案,非阻塞同步对于阻塞同步而言主要解决了阻塞同步中线程阻塞和唤醒带来的性能问题。多个线程如果存在数据的争用冲突,那就才去补偿措施(CAS采用不断的重试,直到成功为止)。因为这种乐观的并发策略不需要把线程挂起,所以这种同步操作称为非阻塞同步。
“CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作”
“在JDK1.5之后,Java程序中才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了”
“由于Unsafe类不是提供给用户程序调用的类(Unsafe.getUnsafe()的代码中限制了只有启动类加载器(Bootstap ClassLoader)加载的Class才能访问它,因此,如果不采用反射字段,我们只能通过其他的Java API才能间接的使用它,如JUC包里的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都用了Unsafe的CAS操作。)”
——《深入理解Java虚拟机》
通过阅读以上段落,可以得到三个重要结论:
- CAS算法是计算机硬件对并发操作共享数据的支持
- CAS包含两个操作:读取内存值,比较和替换。两个过程是一个操作,并且这是一个原子操作(这是计算机硬件的支持,能确保这是一个原子操作)
- 当且仅当V等于预期值A时,就用B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值
如下Java代码为AtomicInteger.getAndIncrement
的调用过程源码:
// 获取value在堆内存中的偏移量 valueOffset,近似的认为获取到了value的实际存储地址
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// AtomicInteger.getAndIncrement
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
/**
* unsafe.getAndAddInt
* @param var1 需要更新的对象
* @param var2 value在内存中的地址
* @param var4 增量
*/
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 根据内存地址var2获取var1对象中value的值
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
// var1 需要更新的对象,var2 偏移量,var5 期望值,var5+var4 替换值
return var5;
}
如上代码可以看到,在一个无限循环体内,不断尝试将var5 + var4
的新值赋给自己,如果失败则说明在执行"比较并替换"操作的时已经被其它线程修改过了,于是便再次进入循环下一次操作,直到成功为止。
CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。
- 循环时间长开销很大。 从以上源码可以看到,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。
- 只能保证一个共享变量的原子操作。 对多个共享变量操作时,循环CAS就无法保证操作的原子性
- ABA问题。 如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那它的值就没有被其他线程改变过了吗?不见得。如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。
JUC包为了解决这个问题,提供了一个带有标记(stamp)的原子引用类“
AtomicStampedReference
”,它可以通过控制变量值的版本来保证CAS的正确性,因此,在使用CAS前要考虑清楚ABA问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
三、同步容器类和ConcurrentHashMap
在JDK5以前,Map的线程安全容器类是HashTable
,HashTable
实现线程安全的方式很简单,几乎所有的 public 的方法都是 synchronized
的,而有些方法也是在内部通过 synchronized 代码块来实现。多个线程对哈希表进行操作的时候,HashTable
的做法是对整个哈希表上锁,无论是读写都是单线程进行。HashTable
其实是Java的保留类,而对于其他的集合类,还可以通过Collections.synchronizedXXX()
的方式获得线程安全的容器,不过这种做法也是将所有的public方法做成同步的了。性能极低。
有关哈希表和Map的原理不是本文讨论的重点,不予深究,但仍有必要了解HashTable的原理
以下是HashTable的锁表操作,所有访问HashTable的线程都必须竞争同一把锁。
JUC包中提供的同步容器有:ConcurrentHashMap
,CopyOnWriteArrayList
等等,这些实现类都能保证容器的线程安全性。
ConcurrentHashMap
与HashTable不同的是
,采用了锁分段技术,锁分段技术将table划分为多个分段(Segment),每个分段都有自己独立的锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争。
有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。按顺序依次锁定能有效避免死锁的发生。
注:在JDK8之后,ConcurrentHashMap放弃锁分段而采用CAS算法。
四、ContDownLatch闭锁
4.1 为什么需要闭锁
需求:统计五个线程执行完毕一共耗费多少时间。以下Java代码不能完成:
public class Main {
public static void main(String[] args) throws InterruptedException {
LocalDateTime now1 = LocalDateTime.now();
for (int i = 0; i < 5; i++) {
new Thread(new A()).start();
}
LocalDateTime now2 = LocalDateTime.now();
long result = Duration.between(now1, now2).toMillis();
System.out.println("一共耗时:" + result);
}
}
class A implements Runnable {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
System.out.println(i);
}
}
}
一共五个线程,每个线程输出0—1000中间的数,要统计五个线程全部输出完毕一共所耗时间,上述程序并不能实现要求。在子线程还在执行的时候,主线程的输出语句就执行完了。这种需求的本质是,在子线程还没执行完的时候,主线程被锁定,直到所有线程执行完毕,主线程才能继续执行。诸如此类的操作,都可以通过闭锁来实现。
类似的场景比如说,在游戏中,需要所有玩家都加载进来,游戏才开始。
4.2 闭锁
- 闭锁:一种的同步辅助工具,允许一个或多个线程等待,直到在其他线程中执行的一组操作完成。
如以下Java代码所示:
public class Main {
public static void main(String[] args) throws InterruptedException {
// 当5个子线程全部执行完之后,main线程再继续执行。初始化值为5
CountDownLatch latch = new CountDownLatch(5);
LocalDateTime now1 = LocalDateTime.now();
for (int i = 0; i < 5; i++) {
new Thread(new A(latch)).start();
}
latch.await();// 执行此,线程处于休眠状态,计数达到零被唤醒
LocalDateTime now2 = LocalDateTime.now();
long result = Duration.between(now1, now2).toMillis();
System.out.println("一共耗时:"+result);
}
}
class A implements Runnable {
private CountDownLatch latch;
public A(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
if (i%2==0)
System.out.println(i);
}
// 减少锁存器的计数,如果计数达到零,释放所有等待的线程。
// 如果当前计数大于零,则它将递减。
// 如果当前计数等于零,那么没有任何反应。
latch.countDown();// 一个线程执行完,就将latch中的计数器-1
}
}
- 注意 一定要确保
latch.countDown()
的执行,以上示例只为说明CountDownLatch
的简单使用,代码并不严谨。如果由于异常导致latch.countDown()
没有执行,休眠中的线程有可能不会被唤醒。
再看一个例子,这次创建两个闭锁,用于子线程和主线程进行通信:
public class Main {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latchStart = new CountDownLatch(1);
CountDownLatch latchEnd = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
new Thread(()->{
try {
latchStart.await();// (1)子线程执行到此,休眠
} catch (InterruptedException e) {
}
for (int j = 0; j < 1000; j++) {
System.out.println(j);
}
latchEnd.countDown();// (4)子线程全部执行完,唤醒主线程,从(3)处继续执行
}).start();
}
latchStart.countDown();// (2)主线程将子线程全部唤醒,子线程从(1)处继续执行
latchEnd.await();// (3)主线程执行到此,休眠
System.out.println("执行完毕");
}
}
五、创建线程的第三种方式
5.1 JDK5以前创建线程的方式
- 继承
Thread
类,复写run()方法:
public class Main {
public static void main(String[] args) throws InterruptedException {
B bb = new B();
bb.start();
}
}
class B extends Thread{
@Override
public void run() {
}
}
注意:Thread类其实实现了
Runnable
接口
- 实现
Runnable
接口:
public class Main {
public static void main(String[] args) throws InterruptedException {
A aa = new A();
Thread t = new Thread(aa);
t.start();
}
}
class A implements Runnable {
@Override
public void run() {
}
}
5.2 JDK5创建线程的第三种方式
在JDK5的JUC包中,提供了一个Callable
接口:
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
Callable接口类似于Runnable ,因为它们都是为其实例可能由另一个线程执行的类设计的。 然而,Runnable不返回结果,也不能抛出被检查的异常。 ——JDK8 API
也就是说,Callable
接口与Runnable
接口不同之处在于:有返回值,能抛异常。然而创建的Callable实例对象并不能通过Thread的构造方法传参,Thread类接受一个Runnable实例对象,以下为Thread的构造方法:
Thread(Runnable target)
因此下列示例代码会编译报错:
A aa = new A();
Thread t = new Thread(aa);// 不能将一个Callable实例对象作为Runnable实例对象传参
t.start();
5.3 Callable创建的线程如何执行
在JUC包中提供了一个类:FutureTask
,以下为该类的声明和构造方法:
public class FutureTask<V> implements RunnableFuture<V>;
FutureTask(Callable<V> callable);
FutureTask(Runnable runnable, V result);// FutureTask既可以接收一个Callable,也可接收一个Runnable
RunnableFuture
接口继承了Runnable
,因此,FutureTask可以作为Thread(Runnable)
构造方法的参数。FutureTask
的构造方法接受一个Callable
实例对象,因此可以通过FutureTask
对象,进行Callable和Thread的关联,如以下Java代码所示:
public class Main {
public static void main(String[] args){
A aa = new A();
FutureTask<Object> futureTask = new FutureTask(aa);
Thread t = new Thread(futureTask);
t.start();
}
}
class A implements Callable{
@Override
public Object call() throws Exception {
return null;
}
}
5.4 Callable返回结果的获取
FutureTask.get()
方法用于接收返回结果。对于线程创建的第三种方式来说,Callable用于产生结果,Future用于获取结果。Future相对于普通线程的区别是可以把线程执行的结果传给调用者。
V get() 等待计算完成,然后检索其结果。——JDK8 API
需求:计算0-100的和,要求每个线程计算一部分,最后合并总和,一个完整的示例代码如下:
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 主线程和子线程对0-100的累加任务进行拆分,主线程计算0-50,子线程计算51-100,最后进行合并
A aa = new A();
FutureTask<Integer> futureTask = new FutureTask(aa);
Thread t = new Thread(futureTask);
t.start();
int sum = 0;
for (int i = 0; i <= 50; i++) {
sum += i;
}
Integer result = futureTask.get();// 接收子线程返回结果。线程执行到此进行休眠,直到子线程执行完毕
sum += result;
System.out.println(sum);
}
}
class A implements Callable<Integer>{//泛型代表call()返回的对象类型
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 51; i <= 100; i++) {
sum += i;
}
return sum;
}
}
- FutureTask.get() 方法是阻塞的,直到子线程执行完毕返回结果,在这一点上,类似于闭锁。
- 如上示例,对一个整体大任务进行拆分,由多个子线程进行,最后将结果进行合并。这在大数据量的时候,是很有意义的,这种叫做分支合并(ForkJoin),与之对应的实现类
ForkJoinTask
,也是Future
接口的实现类。关于这部分,之后进行详细讲解。
六、Lock同步锁
6.1 JDK5以前的锁方式
synchronized
同步代码块synchronized
同步方法(普通方法和静态方法)
synchronized
可以保证方法或者代码块在运行时,同一时刻只有一个线程可以进入到临界区,同时它还可以保证共享变量的内存可见性。在Java中,每个对象都可以作为同步锁,并且一个对象只能有一把锁。多个线程操作同一个对象,同一时刻只能有一个线程获得这个对象的锁,其他线程无法获取该对象的锁。synchronized
代码块即为:持有该对象锁的线程才能访问。
关于synchronized的实现原理,见参考资料《深入理解Java并发之synchronized实现原理》
6.2 JDK5的Lock锁
Lock.lock()
方法获得锁,Lock.unlock()
方法释放锁。使用时要确保在锁定时执行的所有代码由try-finally或try-catch保护,以确保在必要时释放锁定。
Lock.lock()
能获得锁就获得,不能则一直等待Lock.trylock()
能获得锁就返回 true,不能就立即返回 false。
ReentrantLock
(重入锁)是Lock
的一个实现类,是一个可重入的互斥锁,具有与synchronized
相同的基本行为和语义,但具有扩展功能。以下为JDK文档中推荐的写法:
class X {
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock();
try {
// do someting
} finally {
lock.unlock();
}
}
}
ReentrantLock
是可以重入的锁,当一个线程已经获取了锁时,还可以接着重复获取多次。但是获取锁的次数必须和释放锁的次数一样,否则可能导致其他线程无法获得该锁。以下Java代码示例:
class A implements Runnable {
private int i = 0;
private Lock lock = new ReentrantLock();
@Override
public void run() {
lock.lock();
lock.lock();
try {
Thread.sleep(200);
System.out.println(i++);
} catch (InterruptedException e) {
} finally {
lock.unlock();
lock.unlock();// 如果没有这行,程序只输出0,并且不会终止。
}
}
}
ReentrantLock
默认是一个非公平锁,通过构造函数指定,当为true时,创建一个公平锁,锁的分配有利于等待时间最长的线程,否则,该锁不保证任何特定的访问顺序。使用公平锁的程序可能会比使用默认设置的整体速度通常要慢得多。注意:锁的公平性不能保证线程调度的公平性。
七、Condition线程通信
7.1 生产者消费者问题
生产者消费者问题是线程通信,线程安全问题的典型案例,确实有难度。刚开始写程序并不会一次成功,本文将带着问题,一步步推导,逐渐改进,明确问题的原因,理解为什么要这么写。刚开始学习最好也能敲一遍,边想边写。最省事的办法就是第一次做事的时候就尽最大努力把事情做好。
生产者消费者问题:有一个固定大小的容器(存放数据的仓库),生产者不断对这个容器put,消费者不断对这个容器get。约束条件:容器满生产者不能再put,容器空消费者不能再get。生产者put之后要通知消费者可以进行get了,消费者get之后通知生产者可以进行put了
问题: 这个问题涉及三个对象,生产者、消费者和容器。
分析: 创建三个类分别代表这三个对象。生产者(Put)、消费者(Get)、容器(Store)。
问题: 生产者和消费者对同一个容器进行操作。如何确保生产者和消费者操作的是同一个容器?
分析: 在日常使用Java的时候有没有过类似的需求?“有两个线程,让两个线程争夺同一把锁,如何确保两个线程竞争的是同一把锁?”在创建线程的时候,new Thread(XXX)
传入的是同一个对象,一个对象只有一把锁,一个线程获得了这个锁,另一个线程就只能等待。如下例所示:通过构造方法传参的方式,确保Thread-1和Thread-2竞争的是同一个对象的锁。
public class MainTest {
public static void main(String[] args) {
A aa = new A();
new Thread(aa).start();
new Thread(aa).start();
}
}
class A implements Runnable{
@Override
public void run() {
}
}
对于生产者消费者问题,也可以通过构造方法传参的方式,确保生产者和消费者操作的是同一个容器。如下列Java代码所示:
public class Main {
public static void main(String[] args) {
Store store = new Store();
Put put = new Put(store);
Get get = new Get(store);
}
}
class Put{
private Store store;
public Put(Store store) {
this.store = store;
}
}
class Get{
private Store store;
public Get(Store store) {
this.store = store;
}
}
class Store{
}
问题: 哪个类应该实现Runnable接口?
分析: 生产者消费者问题,之所以是多线程问题,其实是多线程操作共享数据的问题。在该问题中,生产者和消费者同时在对容器进行操作,生产者不断往容器放数据的同时,消费者不断删除容器中的数据。就像同时开着进水阀和下水阀的洗澡池。你可以有多个进水阀同时进水,可以有多个下水阀同时排水,进水阀和下水阀也是同时工作的。他们之间都是并行关系,唯独洗澡池是唯一的,被共享的容器。因此,生产者和消费者是线程,容器是共享单元。
public class Main {
public static void main(String[] args) {
Store store = new Store();
Put put = new Put(store);
Get get = new Get(store);
}
}
class Put implements Runnable{
private Store store;
public Put(Store store) {
this.store = store;
}
@Override
public void run() {
// 生产者操作容器,只需要调用容器的put()方法
store.put();
}
}
class Get implements Runnable{
private Store store;
public Get(Store store) {
this.store = store;
}
@Override
public void run() {
// 消费者操作容器,只需要调用容器的get()方法
store.get();
}
}
class Store{
private int size = 1; // 固定大小的容器,假设该仓库容量大小为1
private int num = 0; // 实际存放数量
// 往容器放数据
public void put() {
if (num >= size){
// 仓库满了,生产者不能在放
}else {
// 仓库没满,可以生产
num++;
}
}
// 从容器拿数据
public void get(){
if (num <= 0){
// 仓库空了,消费者不能再取
} else{
// 仓库没空,进行消费
num--;
}
}
问题: 为什么get()或put()方法都是容器的方法?而不是生产者有put方法,消费者有get方法?像下面这样:
public class MainTest {
public static void main(String[] args) {
Store2 store2 = new Store2();
new Thread(new Get2(store2)).start();
new Thread(new Put2(store2)).start();
}
}
class Put2 implements Runnable{
private Store2 store;
public Put2(Store2 store) {
this.store = store;
}
@Override
public void run() {
if (store.num >= store.size){
// 仓库满了,不能生产
}else {
// 没满,可以生产
store.num++;
}
}
}
class Get2 implements Runnable{
private Store2 store;
public Get2(Store2 store) {
this.store = store;
}
@Override
public void run() {
if (store.num <= 0){
// 仓库空了,不能消费
}else {
// 没空,可以消费
store.num--;
}
}
}
class Store2{
public int size = 1; // 该仓库大小为1
public int num = 0; // 实际存放数量
}
分析: 我最开始真的有这样的疑惑,从面向对象角度出发,生产者可以生产,消费者可以消费,有自己的方法感觉没问题啊。对于之前的那种,由仓库(Store)类维护对自己的操作感觉也可以(JDK有大量的类都是这样,对外提供对自己操作的方法,比如String类,对外提供了很多对自己操作的方法)。经过我自己的实验,这样写真的确实没问题,可以,也能写出来。以下以第一种方式进行。
其实后来再次思考的时候,其实是不对的,虽然也能写出来。编码规范通常将类自身的数据做成private私有的,外部不能直接对类的数据进行操作,类对外只提供安全的操作方法供外部调用。还回到问题上来,假设这么写,在生产者或消费者类中出现这样的代码就是有风险的:store.num = 10; 虽然这行代码并不会产生编译问题。
问题: 容器满的时候生产者如何不生产,如何通知消费者消费?容器空的时候消费者如何不消费,如何通知生产者生产?
分析: Object.wait()
方法和Object.notifyAll()
方法,具体详解在这里不是重点。完善程序如下(为了更直观,我删除了异常部分和构造方法以减少篇幅,其实以下代码编译是有问题的):
public class Main {
public static void main(String[] args) {
Store store = new Store();
new Thread(new Put(store)).start();
new Thread(new Get(store)).start();
}
}
class Put implements Runnable{
private Store store;
// @此处省略有参构造方法
@Override
public void run() { // 生产者不断往容器放十个数据
for (int i = 0; i < 2; i++) {
store.put();
}
}
}
class Get implements Runnable{
private Store store;
// @此处省略有参构造方法
@Override
public void run() { // 消费者不断从容器取十个数据
for (int i = 0; i < 2; i++) {
store.get();
}
}
}
class Store{
private int size = 1; // 该仓库大小为1
private int num = 0; // 实际存放数量
public void put() {
if (num >= size){
System.out.println("满了"); // 仓库满了
this.wait(); // 生产者不能在放
this.notifyAll(); // 唤醒其他线程消费
}else {
System.out.println("put---" + num++); // 仓库没满,生产
}
}
public void get(){
if (num <= 0){
System.out.println("空了"); // 仓库空了
this.wait(); // 消费者不能再取
this.notifyAll(); // 通知其他线程生产
}else {
System.out.println("get---" + num--);
}
}
}
以上代码运行抛出异常:IllegalMonitorStateException
,这个异常会在三种情况下抛出:
- 当前线程不含有当前对象的锁资源的时候,调用obj.wait()方法
- 当前线程不含有当前对象的锁资源的时候,调用obj.notify()方法
- 当前线程不含有当前对象的锁资源的时候,调用obj.notifyAll()方法
对put和get方法分别加上synchronized
:
public synchronized void put(){}
public synchronized void get(){}
运行结果:
put---0
满了
get---1
空了
问题: 程序并没有终止,为什么输出这些之后,程序没有终止?
分析: 消费者wait()
方法执行之后,后面的代码没有执行,导致没有执行notifyAll()
唤醒生产者线程。最终导致一直挂起。消费者线程的执行并不是因为生产者notifyAll()
的唤醒,而是CPU调度的结果。执行流程如图所示:
因此,this.nodifyAll()
不能在wai()
方法之后,那如何通知另一个线程及时进行操作?实际上,生产者只要生产一个之后,消费者就可以进行消费。消费者只要消费一个,生产者就可以再次生产了。改进的代码如下:
class Store{
private int size = 1; // 该仓库大小为1
private int num = 0; // 实际存放数量
public synchronized void put() {
if (num >= size){
// 仓库满了,生产者不能在放
System.out.println("满了");
this.wait();
}else {
System.out.println("put---" + num++);
this.notifyAll(); // 只要生产者生产了,消费者新线程就可以被唤醒了
}
}
public synchronized void get(){
if (num <= 0){
// 仓库空了,消费者不能再取
System.out.println("空了");
this.wait();
}else {
System.out.println("get---" + num--);
this.notifyAll(); // 只要消费者消费了,生产者线程就可以被唤醒了
}
}
}
在上述示例代码中,生产者线程执行两次put方法,生产者生产的同时,消费者进行消费,消费者也执行两次get方法。改进后的代码运行如下:
put---0
满了
get---1
空了
生产者确实执行了两次put方法,而消费者确实执行了两次get方法,读者可以加大for循环的循环次数,可以验证生产者线程和消费者线程确实可以互相通信,到此为止能确保线程能被唤醒执行。但是程序并没有终止。
问题: 程序并没有终止,为什么输出这些之后,程序没有终止?
当for循环的次数为偶数的时候,线程不会终止。当循环次数为单数的时候,线程会正常停止。我到现在并没有想明白这是为什么,但是讲道理理论上并不会这样。如果读者知道,请告诉我。
分析: 下图描述了程序运行过程以及发生问题原因:
希望读者能静下心,对照代码和执行过程图,好好理解一下。有些时间的花费是值得的。程序没有停止的原因,是因为消费者线程执行wait()
方法而被挂起,但是生产者线程却执行完了,再不会被唤醒导致。如果到此能完全理解了,再继续讨论,上面的情况只是其中之一,同理,生产者线程也是可能被挂起的。
这个问题的关键点在于,最后一个生产者线程执行wait()
方法被挂起,切换到最后一个消费者线程执行的时候,从最后这个消费者线程的wait()
方法之后开始执行。但是由于if-else
块,导致if
块执行完之后,方法就结束了,没有再次执行notifyAll()
方法唤醒最后一个生产者。因此,应当去掉else
的逻辑。代码如下:
class Store {
private int size = 1; // 该仓库大小为1
private int num = 0; // 实际存放数量
public synchronized void put() {
if (num >= size) {
// 仓库满了,生产者不能在放
System.out.println("满了");
try {
this.wait();
} catch (InterruptedException e) {
}
}
System.out.println("put---" + num++);
this.notifyAll();
}
public synchronized void get() {
if (num <= 0) {
// 仓库空了,消费者不能再取
System.out.println("空了");
try {
this.wait();
} catch (InterruptedException e) {
}
}
System.out.println("get---" + num--);
this.notifyAll();
}
}
请读者思考,去掉else之后,对之前已经修改正确的部分是否有影响。
其实执行过程是有影响的,但是功能不影响。在之前的逻辑中,生产者线程第一次for循环执行时如果被挂起,被唤醒时必须要等到第二次for循环执行的时候才能再次生产。修改过后的代码,真正实现了循环两次,就能生产两次。
修改后的代码能正确运行,这里再次声明,为了缩短代码篇幅,突出重要部分,省略了异常和部分构造函数。
7.2 生产者消费者问题真的解决了吗
在上一节中,最终代码对于单个生产者和单个消费者线程的情况下,是没有问题的。现在,创建多个生产者和多个消费者:
new Thread(new Put(store)).start();
new Thread(new Get(store)).start();
new Thread(new Put(store)).start();
new Thread(new Get(store)).start();
运行结果如下:
空了
put:::::0
满了
get:::::1
put:::::0
get:::::1
空了
空了
put:::::0
满了
get:::::1
get:::::0
put:::::-1
注意: 该线程安全问题不一定会每次发生,但是确实存在且每次结果不一定相同,为了放大问题,读者可以自行在run()方法中使线程休眠一段时间,用于打破执行时间片。另外,除了会出现-1之外,也可能会出现2的情况
由于分析篇幅过长,而且中间大多都是正常执行流程,以下执行图示,只描述了问题发生前的执行过程:
理解问题的产生原因之后,就可以给出定义了。这叫做虚假唤醒,虚假唤醒是一种现象,不保证每次都能发生,但是多线程问题确实存在。这个问题的根本原因在于,仓库里只有一个数据,但是却又两个消费者线程都从wait()
方法之下继续执行,后执行的那个消费者输出了-1的数据。同理,也可能是第二个生产者多生产了一个,输出2。
- 虚假唤醒: 当一个条件满足时,很多线程都被唤醒了,但是只有其中部分是有用的唤醒,其它的唤醒都是无用功。比如说买货,如果商品本来没有货物,突然进了一件商品,这时所有的线程都被唤醒了,但是只能一个人买,所以其他人都是假唤醒,获取不到对象的锁。
如何解决虚假唤醒?参考JDK文档中,Object.wait()
方法:
中断和虚假唤醒是可能的,并且该方法应该始终在循环中使用。——JDK8 API
synchronized (obj) {
while (<condition does not hold>)
obj.wait();
}
JDK文档说,虚假唤醒的解决途径是:确保wait()
方法在循环中使用。以下给出生产者消费者问题的最终解决方案:
public class Main {
public static void main(String[] args) {
Store store = new Store();
new Thread(new Put(store)).start();
new Thread(new Get(store)).start();
new Thread(new Put(store)).start();
new Thread(new Get(store)).start();
}
}
class Put implements Runnable {
private Store store;
public Put(Store store) {
this.store = store;
}
@Override
public void run() {
for (int i = 0; i < 2; i++) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
store.put();
}
}
}
class Get implements Runnable {
private Store store;
public Get(Store store) {
this.store = store;
}
@Override
public void run() {
for (int i = 0; i < 2; i++) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
store.get();
}
}
}
class Store {
private int size = 1;
private int num = 0;
public synchronized void put() {
while (num >= size) {
System.out.println("满了");
try {
this.wait();
} catch (InterruptedException e) {
}
}
System.out.println("put:::::" + num++);
this.notifyAll();
}
public synchronized void get() {
while (num <= 0) {
System.out.println("空了");
try {
this.wait();
} catch (InterruptedException e) {
}
}
System.out.println("get:::::" + num--);
this.notifyAll();
}
}
到此为止,生产者消费者问题终于解决,代码问题的发现和改进经历了三个重要的关键点:
- 为了解决在执行过程中,生产者消费者线程均被挂起的问题。 更改
notifyAll()
方法位置,没有在wait()
方法的下一句。 - 为了解决执行到最后一次循环时,一个线程被唤醒后继续执行,但是从wait()之后却没有代码导致方法结束线当前程终止,另一个线程被永远无法唤醒的问题。 去掉
else
的逻辑。 - 为了解决多个生产者消费者线程下的虚假唤醒问题。 修改
if
逻辑为while
循环。
7.3 JUC中wait()和notify()的替代方案:Condition
在7.1节讨论过一个异常:IllegalMonitorStateException
,该异常抛出的原因是因为没有在synchronized
同步的情况下调用了wait()
或者notify()
方法。要想进行线程间的通信,必须在具备锁的情况下进行。在JUC中,这个条件依然必要。JUC提供了Lock
接口可以进行同步,提供了Condition
接口进行线程通信。这个接口依赖于Lock
接口。
Lock替换了synchronized方法和语句的使用, Condition取代了对象监视器( wait , notify和notifyAll )方法的使用。 一个Condition实例本质上绑定到一个锁,要获得特定的Condition实例,请使用锁的newCondition()方法。 ——JDK8 API
以下是Condition接口替换原始方法的对应关系:
Object | Condition |
---|---|
wait() | await() |
notify() | signal() |
notifyAll() | signalAll() |
生产者消费者问题使用JUC通信机制如下:
class Store {
private int size = 1;
private int num = 0;
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();//获取condition实例
public void put() {
lock.lock();
try {
while (num >= size) {
try {
System.out.println("满了");
condition.await();
} catch (InterruptedException e) {
}
}
System.out.println(num++);
condition.signalAll();
}finally {
lock.unlock();
}
}
public void get() {
lock.lock();
try {
while (num <= 0) {
try {
System.out.println("空了");
condition.await();
} catch (InterruptedException e) {
}
}
System.out.println(num--);
condition.signalAll();
}finally {
lock.unlock();
}
}
}
再次强调,Lock
需要手动关闭,一定要放在try-finally
中确保锁的关闭。
八、读写锁
8.1 什么是读写锁
无论是synchronized
还是ReentrantLock
,都是一种排它锁。多个线程竞争同一把锁,无论获得锁之后执行的是读操作还是写操作。但是,并发的读操作并不对数据的完整性造成破坏。读写锁其实维护了两个锁:一个读锁,一个写锁,它允许多个线程同时读,但只有一个线程可以写。在JUC中提供了一个读写锁:ReadWriteLock
ReadWriteLock维护了一对关联的locks ,一个用于只读操作,一个用于写入。 read lock可以由多个读线程同时进行,只要没有写的线程。 而write lock是排它的。——JDK8 API
8.2 读写锁的基本使用
ReadWriteLock
有且仅有一个实现类:ReentrantReadWriteLock
。其中,readLock()
返回读锁,writeLock()
返回写锁。基本使用如下所示:
class A {
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
public void read() {
readWriteLock.readLock().lock();
try {
// do something
} finally {
readWriteLock.readLock().unlock();
}
}
public void write() {
readWriteLock.writeLock().lock();
try {
// do something
} finally {
readWriteLock.writeLock().unlock();
}
}
}
此外,ReentrantReadWriteLock
的写锁支持Condition
,也就是说写锁支持线程通信。但是读锁不支持Condition
。使用readLock().newCondition()
将抛出UnsupportedOperationException
。
8.3 读锁和写锁之间是互斥的
在多个线程读的时候,读写锁不允许其他线程写。在一个线程写的时候,不允许其他线程读。也就是说读锁和写锁是互斥的。以下为源码:
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
同一时刻,只能有多个读线程或者一个写线程获得该对象的锁。
但是读写锁具有锁定降级的属性,在线程持有读锁的情况下,该线程不能取得写锁。在线程持有写锁的情况下,该线程可以继续获取读锁。——《ReentrantReadWriteLock读写锁详解》
8.4 读写锁的性能问题
读写锁允许访问共享数据时的并发性高于互斥锁允许的并发性。 从理论上讲,通过使用读写锁的性能将超过使用互斥锁。但实际上,并发性的增加只能在多处理器上完全实现,而且只有在共享数据的读操作数量是合适的时候才可以。
读写锁是否会提高性能取决于数据被读取的频率与被修改的频率之比,以及读取和写入操作的持续时间。 例如,最初初始化的数据,经常被频繁的搜索(例如某种目录)是使用读写锁的理想候选。 然而,如果更新变得频繁,那么数据的大部分时间将被专门锁定,这种情况下并发性增加很少。 此外,由于读写锁的锁定实现(其本身比互斥锁更复杂)开销更大,特别是因为许多读写锁的锁定实现是通过小部分代码序列化所有线程。 因此,如果读取操作太短,这是不值得的。总之,必须剖析和测量再确定使用读写锁是否适合您的应用程序。 ——JDK8 API
九、线程池
9.1 什么是线程池
线程是一种稀缺资源。线程的创建和销毁会增加系统资源的消耗,以下代码是没有使用线程池技术对线程的应用:
public class MainTest {
public static void main(String[] args) {
new Thread(new A()).start();
}
}
class A implements Runnable{
@Override
public void run() {
// do something
}
}
当需要使用多线程执行任务的时候,就显示的创建多个线程,这是传统方式的做法。假设现在有两个任务都需要多线程处理,以传统方式,执行完任务1时,线程就被销毁了,再执行任务2时还需要再次创建线程。如果线程预先都是准备好的,需要的时候只需要分配任务就可以执行,那么效率将提升很多。线程池,其实就是一个存放线程的容器。使用线程池的优势如下:
- 降低系统资源的消耗。通过重复利用已经被创建好的线程避免了线程创建和销毁造成的消耗。
- 提高执行效率。当任务被分配给线程时,线程就能立即执行,任务无需再等待线程的创建完毕。
9.2 线程池的基本使用和注意事项
JUC提供接口ExecutorService
代表一个线程池,并且提供了类Executors
用于更方便快速的创建线程池。使用Executors
创建线程池的方法有如下几种:
Executors.newCachedThreadPool()
,创建一个无固定容量的线程池,该线程池可灵活(60s)回收空闲线程。如果没有可用的线程,将创建一个新的线程并将其添加到该池中。因此,长时间保持闲置的池将不会消耗任何资源。Executors.newFixedThreadPool(int nThreads)
,创建一个有固定数量线程的线程池。Executors.newScheduledThreadPool(int corePoolSize)
,创建一个固定大小的线程池,可以调度命令在给定的延迟之后运行,或定期执行。Executors.newSingleThreadExecutor()
,创建一个单个工作线程的线程池,与newFixedThreadPool(1)
等效
在使用线程池的时候,除非服务器负载较轻,否则最好创建一个有固定大小的线程池,过多的线程会由于频繁的上下文切换导致整个系统的速度变缓。
使用ExecutorService
的如下方法进行任务分配:
Future submit(Runnable task)
,返回一个Future
但是由于Runnable
是无返回值的,因此Future.get()
将返回Null
。Future submit(Callable<T> task)
,可以使用Future.get()
获取线程执行的结果。T invokeAny(Collection<? extends Callable<T>> tasks)
,接收一个Callable
集合,但是只返回执行成功那个线程的执行结果(即,这个线程没有发生异常),如果多个线程执行成功,则返回结果不确定。List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
,接收一个Callable
集合,与invokeAny()
不同的是,返回所有线程的执行结果。void execute(Runnable command)
,这是接口Executor
的方法,只能接收一个Runnable
。
使用ExecutorService
的方法对线程池关闭:
ExecutorService.shutdown()
,启动有序关闭,其中先前提交的任务将被执行,但不会接受任何新任务。 这是以一种平滑的,安全的方式对线程池进行关闭。ExecutorService.shutdownNow()
,尝试停止所有主动执行的任务,停止等待任务的处理,并返回正在等待执行的任务列表。
以下代码实例了ScheduledExecutorService
的基本使用:
public class MainTest {
public static void main(String[] args) {
A aa = new A();
//1.创建线程池
ScheduledExecutorService pool = Executors.newScheduledThreadPool(5);
try {
//2.为线程池中的线程分配任务
// param: Callable/Runnable,delay,TimeUnit
Future future = pool.schedule(aa, 1, TimeUnit.SECONDS);// 该任务延时一秒执行
future.get();
}catch (Exception e){
e.printStackTrace();
}finally {
//3.关闭线程池
pool.shutdown();
}
}
}
class A implements Runnable{
@Override
public void run() {
System.out.println("aaa");
}
}
线程池的使用中一定要考虑到异常的问题,有以下Java代码:
public class MainTest {
public static void main(String[] args) {
A aa = new A();
//1.创建线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
try {
//2.为线程池中的线程分配任务
pool.submit(aa);
}finally {
//3.关闭线程池
pool.shutdown();
}
}
}
class A implements Runnable{
@Override
public void run() {
System.out.println(1/0);
}
}
以上代码什么也不输出,也不会抛出异常。 因此,无论对于Future submit(Runnable task)
还是Future submit(Callable<T> task)
,最好都使用Future.get()
获取一下线程的执行结果,如果线程抛出异常,在使用Future.get()
时候会将线程执行的异常抛出。
十、ForkJoin分支合并框架
注意:ForkJoin分支合并框架在JDK7之后才加入到JUC包中,并且作为JDK8的并行流的底层实现,有着重要的作用
10.1 什么是Fork/Join
在“5.4 Callable返回结果的获取”这一节中,有一个多线程计算0-100和的一个例子,一个线程计算0-50的和,另一个线程计算51-100的和,最后,将两个线程的计算结果合并得到0-100的和,这就是分支合并的思想。
- Fork/Join:把一个大任务分解(fork)成许多个独立的小任务,然后起多线程并行去处理这些小任务,处理完得到结果后再进行合并(join)从而得到原大任务的结果。
10.2 Fork/Join基本使用
Fork/Join的工作基于递归、线程池已经工作窃取模式。大任务通常通过递归拆分成多个小任务由多个线程去执行,而这些线程由ForkJoinPool
线程池维护,这个线程池专门用于执行ForkJoin
任务。这若干个线程将执行的结果作为Future
返回。ForkJoinPool
线程池只能执行ForkJoin
任务,JUC提供了抽象类ForkJoinTask
代表一个可执行的ForkJoin
任务,这个抽象类具有两个实现:RecursiveTask
和RecursiveAction
。以下以RecursiveTask为例,使用Fork/Join计算0-100的和,将任务分为0-50的和,51-100的和:
public class Main {
public static void main(String[] args) {
A aa = new A(0, 100);
ForkJoinPool pool = new ForkJoinPool();
Integer result = pool.invoke(aa);
System.out.println(result);
}
}
class A extends RecursiveTask<Integer> {
private int threshold = 50;// 临界值,每个线程将执行50个数的累加
private int fromIndex;
private int toIndex;
public A(int formIndex, int toIndex){
this.fromIndex = formIndex;
this.toIndex = toIndex;
}
@Override
protected Integer compute() {
// 50以内的直接计算
if((toIndex-fromIndex) <= threshold ){
int count = 0;
for(int i=fromIndex; i<=toIndex; i++){
count += i;
}
return count;
}else{
// 范围在50以上,进行大任务拆分
int mid = (fromIndex+toIndex) / 2;
A left = new A(fromIndex, mid);
left.fork();// 对left部分进行拆分
A right = new A(mid+1, toIndex);
right.fork();// 对right部分进行拆分
return left.join()+right.join();// 对拆分线程执行结果进行合并
}
}
}
可以看到,拆分的过程其实是递归的过程,其中left.fork()
其实仍然调用了compute()
方法进行递归。在看一个计算斐波那契数列的例子:
public class MainTest {
public static void main(String[] args) {
//斐波那契数列 1 1 2 3 5 8 13 21 34 55
Fibonacci f = new Fibonacci(10);// 求斐波那契第十个数
ForkJoinPool pool = new ForkJoinPool();
Integer reslut = pool.invoke(f);
System.out.println(reslut);
}
}
class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) {
this.n = n;
}
public Integer compute() {
if (n <= 1) return n;
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
f2.fork();
return f2.join() + f1.join();
}
}
RecursiveTask
和RecursiveAction
区别:RecursiveTask执行后有返回结果(Future<T>
)。RecursiveAction是没有返回结果的(Future<void>
)。
10.3 Fork/Join性能问题
Fork/Join的任务拆分基于递归,因此,进行大任务拆分的时候注意OutOfMemeryError。此外,任务的拆分和合并本身也是很耗费性能的,所以数据量不大的时候使用串行比使用多线程快。
任务应该执行超过100个和少于10000个基本的计算步骤,并且应该避免不确定的循环。 如果任务太大,则并行性不能提高吞吐量。如果太小,则内存和内部任务维护开销可能会压制处理。——JDK8 API
但是,在大数据量的情况下,使用Fork/Join借助了现代计算机多核的优势并行去处理数据,更重要的是,使用了工作窃取模式,极高的利用了CPU资源。
- 工作窃取:被分解的若干子任务,被放在了一个双端队列中(普通的队列,一头进,另一头出。双端队列,两头都能出)。当有线程把当前负责队列的任务处理完之后,它还可以从那些还没有处理完的队列的尾部窃取任务来处理。
若实现0-100的累加,将任务分解成0-20,20-40,40-60,60-80,80-100的子任务,假设分配到2个线程A,B中。A完成0-20,20-40的累加。B完成40-60,60-80,80-100。假设A将自己的任务全部执行完毕之后,B还剩下60-80,80-100。那么这时候A会从B的任务中取“80-100”来执行。
Fork/Join在JDK8的并行流中有至关重要的作用,以下为使用JDK8并行流实现0-100累加:
public class Main {
public static void main(String[] args) {
Long sum = LongStream.rangeClosed(0, 100)
.parallel()
.reduce(0, Long::sum);
System.out.println(sum);
}
}
问题总结
这篇文章中尚未解决的问题:
- 7.1节 当for循环的次数为偶数的时候,线程不会终止。当循环次数为单数的时候,线程会正常停止。
参考资料: