《并发编程实战》摘要-极客时间

最近看完了极客时间的另一个专栏《并发编程实战》,这个专栏看下来总感觉作者有些言犹未尽,一个点没法展开深入分析,阅读过程中我经常会感觉这篇就这样结束了?不过也有学习到一些之前自己的知识盲点,这个笔记可以说相当简单,所以我仅仅备注为摘要,不成一篇文章,后面的章节我也是一带而过因此也没有记录在这里,仅仅作为文章内容备忘录吧,感兴趣的可以看看原文。

可见性、原子性、有序性

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性

一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

CPU,内存,IO设备三者之间的速度差异一直是计算机系统努力平衡的点,三者速度由快到慢。为了合理利用CPU的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:
1.CPU增加了缓存,以均衡与内存的速度差异;

2.操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;

3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

然而与此同时也带来了相应的问题:

缓存导致了可见性问题,线程切换带来了原子性问题,编译优化带来有序性问题,这也是并发问题的根源

java如何解决可见性和有序性问题

为了解决可见性和有序性问题,java内存模型做了很多规定,也引入了一些机制,包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。

Happens-Before并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的

Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens Before规则。

1-程序的顺序性规则

在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。

2-volatile 变量规则

对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作

3-传递性

如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens Before C。

4-管程中锁的规则

对一个锁的解锁 Happens-Before 于后续对这个锁的加锁

5- 线程 start() 规则

主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

6-线程 join() 规则

指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

互斥锁-解决原子性问题

原子性问题的源头是线程切换。如果是单核CPU,我们只要保证禁止CPU中断就能解决这个问题,但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行

“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。

java中的synchronized

当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就 是 Class X; 当修饰非静态方法的时候,锁定的是当前实例对象 this

管程(synchronized)中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程,就是我们这里的synchronized(至于为什么叫管程,我们后面介绍),我们知道synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁Happens-Before后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。

使用细粒度锁可以提高并行度,是性能优化的一个重要手段。但是这是有代价的,这个代价就是可能会导致死锁

如何预防死锁

死锁的发生有下面四个条件:

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用;
  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。

其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

  • 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。

  • 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

  • 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

安全性,活跃性及性能问题

竞态条件,指的是程序的执行结果依赖线程执行的顺序

有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”

解决“活锁”的方案很简单,尝试等待一个随机的时间就可以了。

饥饿指的是线程因无法访问所需资源而无法执行下去的情况

在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源

性能方面的度量指标有很多,有三个指标非常重要,就是:吞吐量、延迟和并发量。

1.吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。

2.延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。

3.并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是1000的时候,延迟是50毫秒。

管程:并发编程的万能钥匙

管程,对应的英文是Monitor,很多Java领域的同学都喜欢将其翻译成“监视器”,这是直译。操作系统领域一般都翻译成“管程”,这个是意译,管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。

wait() 的正确姿势

有一点,需要再次提醒,对于MESA管程(java中锁synchronized参考的模型)来说,有一个编程范式,就是需要在一个 while 循环里面调用 wait()。这个是 MESA 管程特有的。

while(条件不满足) { wait(); }

线程的生命周期

操作系统层面线程的生命周期

通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态

这“五态模型”的详细情况如下所示。
1.初始状态,指的是线程已经被创建,但是还不允许分配CPU执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。

2.可运行状态,指的是线程可以分配CPU执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配CPU执行。

3.当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就转换成了运行状态。

4.运行状态的线程如果调用一个阻塞的API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。

5.线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
这五种状态在不同编程语言里会有简化合并。例如,C语言的POSIX Threads规范,就把初始状态和可运行状态合并了;Java语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而JVM层面不关心这两个状态,因为JVM把线程调度交给操作系统处理了。

java中线程的生命周期

Java 语言中线程共有六种状态,分别是:

  1. NEW(初始化状态) 2. RUNNABLE(可运行 / 运行状态) 3. BLOCKED(阻塞状态) 4. WAITING(无时限等待) 5. TIMED_WAITING(有时限等待) 6. TERMINATED(终止状态)

其实在操作系统层面,Java 线程中的 BLOCKEDWAITINGTIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。

java中各线程状态的转换:

  • RUNNABLE与BLOCKED的状态转换
    只有一种场景会触发这种转换,就是线程等待synchronized的隐式锁。synchronized修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从RUNNABLE转换到BLOCKED状态。而当等待的线程获得synchronized隐式锁时,就又会从BLOCKED转换到RUNNABLE状态。
    如果你熟悉操作系统线程的生命周期的话,可能会有个疑问:线程调用阻塞式API时,是否会转换到BLOCKED状态呢?在操作系统层面,线程是会转换到休眠状态的,但是在JVM层面,Java 线程的状态不会发生变化,也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。JVM 层面并不关心操作系统调度相关的状态,因为在 JVM 看来,等待 CPU 使用权(操作系统层面此 时处于可执行状态)与等待 I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了 RUNNABLE 状态。

  • RUNNABLE 与 WAITING 的状态转换

    总体来说,有三种场景会触发这种转换。
    第一种场景,获得synchronized隐式锁的线程,调用无参数的Object.wait()方法。
    第二种场景,调用无参数的Thread.join()方法。其中的join()是一种线程同步方法,例如有一个线程对象threadA,当调用A.join()的时候,执行这条语句的线程会等待threadA执行完,而等待中的这个线程,其状态会从RUNNABLE转换到WAITING。当线程threadA执行完,原来等待它的线程又会从WAITING状态转换到RUNNABLE。
    第三种场景,调用LockSupport.park()方法。其中的LockSupport对象,Java并发包中的锁,都是基于它实现的。调用LockSupport.park()方法,当前线程会阻塞,线程的状态会从RUNNABLE转换到WAITING。调用LockSupport.unpark(Threadthread)可唤醒目标线程,目标线程的状态又会从WAITING状态转换到RUNNABLE。

  • RUNNABLE 与 TIMED_WAITING 的状态转换

有五种场景会触发这种转换:
1.调用带超时参数的Thread.sleep(longmillis)方法;

2.获得synchronized隐式锁的线程,调用带超时参数的Object.wait(longtimeout)方法;

3.调用带超时参数的Thread.join(longmillis)方法;

4.调用带超时参数的LockSupport.parkNanos(Objectblocker,longdeadline)方法;

5.调用带超时参数的LockSupport.parkUntil(longdeadline)方法。
这里你会发现TIMED_WAITING和WAITING状态的区别,仅仅是触发条件多了超时参数。

  • 从 NEW 到 RUNNABLE 状态

    Java 刚创建出来的 Thread 对象就是 NEW 状态,NEW状态的线程,不会被操作系统调度,因此不会执行。Java线程要执行,就必须转换到RUNNABLE状态。从NEW状态转换到RUNNABLE状态很简单,只要调用线程对象的start()方法就可以了

  • 从RUNNABLE到TERMINATED状态
    线程执行完run()方法后,会自动转换到TERMINATED状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断run()方法的执行,例如run()方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java的Thread类里面倒是有个stop()方法,不过已经标记为@Deprecated,所以不建议使用了。正确的姿势其实是调用interrupt()方法。

创建多少个线程才是合适的

我们所谓提升性能,从度量的角度,主要是降低延迟,提高吞吐量。这也是我们使用多线程的主要目的

创建多少线程合适

对于CPU密集型计算,多线程本质上是提升多核CPU的利用率,所以对于一个4核的CPU,每个核一个线程,理论上创建4个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于CPU密集型的计算场景,理论上“线程的数量=CPU核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU核数+1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证CPU的利用率。

对于I/O密集型计算场景,最佳的线程数是与程序中CPU计算和I/O操作的耗时比相关的,我们可以总结出这样一个公式:

最佳线程数 =1 +(I/O 耗时 / CPU 耗时)

不过上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了, 计算公式如下:

最佳线程数=CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接Connection,在JDBC规范里并没有要求这个Connection必须是线程安全的。数据库连接池通过线程封闭技术,保证一个Connection一旦被一个线程获取之后,在这个线程关闭Connection之前的这段时间里,不会再分配给其他线程,从而保证了Connection不会有并发问题。

Lock&Condition

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程 访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决 的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。

Lock 有别于 synchronized 隐式锁的三个特性:能够响应中断、支持超时和非阻塞地获取锁,而Condition 实现了管程模型里面的条件变量

Semaphore:如何快速实现一个限流器

信号量模型

信号量模型还是很简单的,可以简单概括为:一个计数器,一个等待队列,三个方法。在信号量模型里,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()、down()和up()。

三个方法详细的语义具体如下:

init():设置计数器的初始值。
down():计数器的值减1;如果此时计数器的值小于0,则当前线程将被阻塞,否则当前线程可以继续执行。
up():计数器的值加1;如果此时计数器的值小于或者等于0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。

这里提到的init()、down()和up()三个方法都是原子性的,并且这个原子性是由信号量模型的实现方保证的。在JavaSDK里面,信号量模型是由java.util.concurrent.Semaphore实现的,Semaphore这个类能够保证这三个方法都是原子操作。

ReadWriteLock

什么是读写锁

读写锁,并不是Java语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下三条基本原则:
1.允许多个线程同时读共享变量;

2.只允许一个线程写共享变量;3.如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。

读写锁在获取了读锁之后无法升级为写锁,也就是在锁定的代码块中再次获取写锁,但是支持锁降级,即在写锁锁定的区域内再次获取读锁。

StampedLock

StampedLock 支持的三种锁模式

先来看看在使用上StampedLock和上一篇文章讲的ReadWriteLock有哪些区别。

ReadWriteLock支持两种模式:一种是读锁,一种是写锁。而StampedLock支持三种模式,分别是:写锁、悲观读锁和乐观读。其中,写锁、悲观读锁的语义和ReadWriteLock的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是:StampedLock里的写锁和悲观读锁加锁成功之后,都会返回一个stamp;然后解锁的时候,需要传入这个stamp。

StampedLock的性能之所以比ReadWriteLock还要好,其关键是StampedLock支持乐观读的方式。ReadWriteLock支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而StampedLock提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。

注意这里,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,乐观读这个操作是无锁的,所以相比较ReadWriteLock的读锁,乐观读的性能更好一些。

StampedLock 使用注意事项

对于读多写少的场景StampedLock性能很好,简单的应用场景基本上可以替代ReadWriteLock,但是StampedLock的功能仅仅是ReadWriteLock的子集,在使用的时候,还是有几个地方需要注意一下。

StampedLock在命名上并没有增加Reentrant,StampedLock不支持重入。这个是在使用中必须要特别注意的。

另外,StampedLock的悲观读锁、写锁都不支持条件变量,这个也需要注意。

还有一点需要特别注意,那就是:如果线程阻塞在StampedLock的readLock()或者writeLock()上时,此时调用该阻塞线程的interrupt()方法,会导致CPU飙升

所以,使用StampedLock一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly()

CountDownLatch和CyclicBarrier

CountDownLatch和CyclicBarrier是Java并发包提供的两个非常易用的线程同步工具类,这两个工具类用法的区别如下:CountDownLatch主要用来解决一个线程等待多个线程的场景,可以类比旅游团团长要等待所有的游客到齐才能去下一个景点;而CyclicBarrier是一组线程之间互相等待,更像是几个驴友之间不离不弃。除此之外CountDownLatch的计数器是不能循环利用的,也就是说一旦计数器减到0,再有线程调用await(),该线程会直接通过。但CyclicBarrier的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到0会自动重置到你设置的初始值。除此之外,CyclicBarrier还可以设置回调函数,可以说是功能丰富。

并发容器及其注意事项

并发容器主要有四大类:List、Map、Set 和Queue

List

List里面只有一个实现类就是CopyOnWriteArrayList。CopyOnWrite,顾名思义就是写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁。CopyOnWriteArrayList内部维护了一个数组,成员变量array就指向这个内部数组,所有的读操作都是基于array 进行的

如果在遍历array的同时,还有一个写操作,例如增加元素,CopyOnWriteArrayList是如何处理的呢?CopyOnWriteArrayList会将 array复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将array指向这个新的数组。读写是可以并行的,遍历操作一直都是基于原array执行,而写操作则是基于新array进行。

Map

Map接口的两个实现是ConcurrentHashMap和ConcurrentSkipListMap,它们从应用的角度来看,主要区别在于ConcurrentHashMap的key是无序的,而ConcurrentSkipListMap的key是有序的。所以如果你需要保证key的顺序,就只能使用ConcurrentSkipListMap。
使用ConcurrentHashMap和ConcurrentSkipListMap需要注意的地方是,它们的key和value都不能为空,否则会抛出NullPointerException.

Set

Set接口的两个实现是CopyOnWriteArraySet和ConcurrentSkipListSet,使用场景可以参考前面 讲述的CopyOnWriteArrayList和 ConcurrentSkipListMap,它们的原理都是一样的

Queue

Java并发包里面Queue这类并发容器是最复杂的,你可以从以下两个维度来分类。一个维度是阻塞与非阻塞,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。 另一个维度是单端与双端,单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。Java并发包里阻塞队列都用Blocking关键字标识,单端队列使用Queue标识,双端队Deque 标识 列使用 Deque标识。

这两个维度组合后,可以将Queue细分为四大类,分别是:

  • 单端阻塞队列 :其实现有 ArrayBlockingQueue、LinkedBlockingQueue、 SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue和DelayQueue。内部一 般会持有一个队列,这个队列可以是数组(其实现是ArrayBlockingQueue)也可以是链表(其实 现是LinkedBlockingQueue);甚至还可以不持有队列(其实现是SynchronousQueue),此时生产者线程的入队操作必须等待消费者线程的出队操作。而LinkedTransferQueue融合 LinkedBlockingQueue和SynchronousQueue的功能,性能比 LinkedBlockingQueue更好; PriorityBlockingQueue支持按照优先级出队;DelayQueue支持延时出队。
  • 双端阻塞队列:其实现是LinkedBlockingDeque。
  • 单端非阻塞队列:其实现是ConcurrentLinkedQueue。
  • 双端非阻塞队列:其实现是ConcurrentLinkedDeque。

另外,使用队列时,需要格外注意队列是否支持有界(所谓有界指的是内部的队列是否有容量限制)。实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致OOM。上 面我们提到的这些Queue中,只有ArrayBlockingQueue和LinkedBlockingQueue是支持有界的,所以在使用其他无界队列时,一定要充分考虑是否存在导致OOM的隐患。

Excutors与线程池

不建议使用Executors的最重要的原因是:Executors提供的很多方法默认使用的都是无界的 LinkedBlockingQueue,高负载情境下,无界队列很容易导致OOM,而OOM会导致所有请求都无法处理, 这是致命问题。所以强烈建议使用有界队列

使用有界队列,当任务过多时,线程池会触发执行拒绝策略,线程池默认的拒绝策略会throw RejectedExecutionException 这是个运行时异常,对于运行时异常编译器并不强制catch它,所以开发人员很容易忽略。因此默认拒绝策略要慎重使用默认拒绝策略要慎重使用。如果线程池处理的任务非常重要,建议自定义自己的拒绝策略;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用

Future

ThreadPoolExecutor的 void execute(Runnable command) 方法,利用这个方法虽然可以提交任务,但是却没有办法获取任务的执行结果(execute()方法没有返回值)。而很多场景下,我们又都是需要获取任务的执行结果的。Java通过ThreadPoolExecutor提供的3个submit()方法和1个FutureTask工具类来支持获得任务执行结果的需求。

Future接口有5个方法,我都列在下面了,它们分别是取消任务的 取消任务的方法cancel()、判断任务是否已取消的方法isCancelled()、判断任务是否已结束的方法isDone() 方法、判断任务是否已取消的方法isCancelled()、判断任务是否已结束的方法isDone()以及2个获得任务执行结果的get()和get(timeout,unit) 其中最后一个get(timeout,unit)支持超时机制。不过需要注意的是:这两个get()方法都是阻塞式的,如果被调用的时候,任务还没有执行完,那么调用get()方法的线程会阻塞,直到任务执行完才会被唤醒。

CompletableFuture

创建CompletableFuture对象

创建CompletableFuture对象主要靠下面代码中展示的这4个静态方法。runAsync(Runnable runnable)和supplyAsync(Supplier< U > supplier),它们之间的区别是:Runnable接口的run()方法没有返回值,而Supplier接口的get()方法是有返回值的。

//使用默认线程池
public static CompletableFuture<Void> runAsync(Runnable runnable) {}
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) {}
//可以指定线程池
public static CompletableFuture<Void> runAsync(Runnable runnable,Executor executor) {}
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier,Executor executor) {}

创建完CompletableFuture对象之后,会自动地异步执行runnable.run()方法或者supplier.get()方法

CompletionStage接口

CompletableFuture类还实现了CompletionStage接口,这个接口可以用来描述任务的串行关系、并行关系、汇聚关系等。

描述串行关系

CompletionStage接口里面描述串行关系,主要是thenApply、thenAccept、thenRun和thenCompose这四个系列的接口

thenApply系列函数里参数fn的类型是接口Function<T, R>,这个接口里与CompletionStage相关的方法是R apply(T t),这个方法既能接收参数也支持返回值,所以thenApply系列方法返回的是CompletionStage。

而thenAccept系列方法里参数consumer的类型是接口Consumer,这个接口里与CompletionStage相关的方法是void accept(T t),这个方法虽然支持参数,但却不支持回值,所以thenAccept系列方法返回 的是CompletionStage。

thenRun系列方法里action的参数是Runnable,所以action既不能接收参数也不支持返回值,所以thenRun 系列方法返回的也是CompletionStage

这些方法里面Async代表的是异步执行fn、consumer或者action。其中,需要你注意的是thenCompose系列 方法,这个系列的方法会新创建出一个子流程,最终结果和thenApply系列是相同的。

CompletionStage<R>	thenApply(fn); 
CompletionStage<R>	thenApplyAsync(fn); 
CompletionStage<Void>	thenAccept(consumer); 
CompletionStage<Void>	thenAcceptAsync(consumer); 
CompletionStage<Void>	thenRun(action); 
CompletionStage<Void>	thenRunAsync(action); 
CompletionStage<R>	thenCompose(fn); 
CompletionStage<R>	thenComposeAsync(fn);

通过下面的示例代码,你可以看一下thenApply()方法是如何使用的。首先通过supplyAsync()启动一个异步流程,之后是两个串行操作,整体看起来还是挺简单的。不过,虽然这是一个异步流程,但任务①②③却是串行执行的,②依赖①的执行结果,③依赖②的执行结果

CompletableFuture<String>	f0	=			
    CompletableFuture.supplyAsync( 
    ()	->	"Hello	World")	//①		
    .thenApply(s->s	+"QQ")		//②		
    .thenApply(String::toUpperCase);//③
System.out.println(f0.join()); //输出结果 HELLO	WORLD	QQ
描述AND汇聚关系

CompletionStage接口里面描述AND汇聚关系,主要是thenCombine、thenAcceptBoth和runAfterBoth系列的接口,这些接口的区别也是源自fn、consumer、action这三个核心参数不同。

CompletionStage<R>	thenCombine(other,	fn); 
CompletionStage<R>	thenCombineAsync(other,	fn); 
CompletionStage<Void>	thenAcceptBoth(other,	consumer); 
CompletionStage<Void>	thenAcceptBothAsync(other,	consumer); 
CompletionStage<Void>	runAfterBoth(other,	action); 
CompletionStage<Void>	runAfterBothAsync(other,	action);
描述OR汇聚关系

CompletionStage接口里面描述OR汇聚关系,主要是applyToEither、acceptEither和runAfterEither系列的接口,这些接口的区别也是源自fn、consumer、action这三个核心参数不同。

CompletionStage	applyToEither(other,	fn); 
CompletionStage	applyToEitherAsync(other,	fn); 
CompletionStage	acceptEither(other,	consumer); 
CompletionStage	acceptEitherAsync(other,	consumer); 
CompletionStage	runAfterEither(other,	action); 
CompletionStage	runAfterEitherAsync(other,	action);

下面的示例代码展示了如何使用applyToEither()方法来描述一个OR汇聚关系。

CompletableFuture<String>	f1	=			
    CompletableFuture.supplyAsync(()->{				
        int	t=getRandom(5,10);				
        sleep(t,TimeUnit.SECONDS);				
        return	String.valueOf(t); });
CompletableFuture<String>	f2	=			
    CompletableFuture.supplyAsync(()->{				
        int	t=getRandom(5,10);				
        sleep(t,TimeUnit.SECONDS);				
        return	String.valueOf(t); });
CompletableFuture<String>	f3	=			
    f1.applyToEither(f2,s->	s);
System.out.println(f3.join());
异常处理

在异步编程里面,异常该如何处理呢?CompletionStage接口给我们提供的方案非常简单,比try{}catch{}还要简单,下面是相关的方法,使用这些方法进行异常处理和串行操作是一样的,都支持链式编程方式。

CompletionStage	exceptionally(fn); 
CompletionStage<R>	whenComplete(consumer);
CompletionStage<R>	whenCompleteAsync(consumer); 
CompletionStage<R>	handle(fn); 
CompletionStage<R>	handleAsync(fn)

下面的示例代码展示了如何使用exceptionally()方法来处理异常,exceptionally()的使用非常类似于 try{}catch{}中的catch{},但是由于支持链式编程方式,所以相对更简单。既然有try{}catch{},那就一定还 有try{}finally{},whenComplete()和handle()系列方法就类似于try{}finally{}中的finally{},无论是否发生异 常都会执行whenComplete()中的回调函数consumer和handle()中的回调函数fn。whenComplete()和 handle()的区别在于whenComplete()不支持返回结果,而handle()是支持返回结果的。

CompletableFuture<Integer>			
    f0	=	CompletableFuture				
    .supplyAsync(()->7/0))				
    .thenApply(r->r*10)				
    .exceptionally(e->0); 
System.out.println(f0.join());

CompletionService

如何创建CompletionService

CompletionService接口的实现类是ExecutorCompletionService,这个实现类的构造方法有两个,分别是:

ExecutorCompletionService(Executor	executor);
ExecutorCompletionService(Executor	executor,	BlockingQueue<Future<V>> completionQueue)

这两个构造方法都需要传入一个线程池,如果不指定completionQueue,那么默认会使用无界的 LinkedBlockingQueue。任务执行结果的Future对象就是加入到completionQueue中。CompletionService的实现原理也是内部维护了一个阻塞队列,当任务执行结束就把任务的执行结果加入到 阻塞队列中,不同的是CompletionService是把任务执行结果的Future对象加入到阻塞队列中

CompletionService接口提供的方法有5个, 这5个方法的方法签名如下所示。

Future<V>	submit(Callable<V>	task); 
Future<V>	submit(Runnable	task,	V result); 
Future<V>	take()	throws	InterruptedException; 
Future<V>	poll(); 
Future<V>	poll(long timeout,TimeUnit unit) throws	InterruptedException;

其中,submit()相关的方法有两个。一个方法参数是Callable task,另外一个方法有两个参数,分别是Runnable task 和V result,这个方法类似于ThreadPoolExecutor的 Future submit(Runnable task, T result)

CompletionService接口其余的3个方法,都是和阻塞队列相关的,take()、poll()都是从阻塞队列中获取并移除一个元素;它们的区别在于如果阻塞队列是空的,那么调用take()方法的线程会被阻塞,而poll()方法会 返回null值。poll(long timeout, TimeUnit unit)方法支持以超时的方式获取并移除阻塞队列头部的一个元素,如果等待了timeout unit时间,阻塞队列还是空的,那么该方法会返回null值。

ForkJoin:单机版的MapReduce

Fork/Join的使用

Fork/Join是一个并行计算的框架,主要就是用来支持分治任务模型的,这个计算框架里的Fork对应的是分治 Fork对应的是分治 任务模型里的任务分解,Join对应的是结果合并任务模型里的任务分解,Join对应的是结果合并。Fork/Join计算框架主要包含两部分,一部分是分治任务的分治任务的 线程池ForkJoinPool 线程池ForkJoinPool,另一部分是分治任务ForkJoinTask 分治任务ForkJoinTask。这两部分的关系类似于ThreadPoolExecutor和Runnable的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型ForkJoinTask。

ForkJoinTask是一个抽象类,它的方法有很多,最核心的是fork()方法和join()方法,其中fork()方法会异步地 执行一个子任务,而join()方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask有两个子类—— RecursiveAction和RecursiveTask,通过名字你就应该能知道,它们都是用递归的方式来处理分治任务的。 这两个子类都定义了抽象方法compute(),不过区别是RecursiveAction定义的compute()没有返回值,而 RecursiveTask定义的compute()方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。

Immutability模式:如何利用不变性解决并发问题

解决并发问题,其实最简单的办法就是让共享变量只有读操作,而没有写操作。这个办法如此重要,以至于被上升到了一种解决并发问题的设计模式:不变性(Immutability)模式 不变性(Immutability)模式。所谓不变性,简单来讲,就是对不变性,简单来讲,就是对 象一旦被创建之后,状态就不再发生变化。换句话说,就是变量一旦被赋值,就不允许修改了(没有写操 作);没有修改操作,也就是保持了不变性。

快速实现具备不可变性的类

将一个类所有的属性都设置成final的,并且只允许存在只读方 将一个类所有的属性都设置成final的,并且只允许存在只读方法,那么这个类基本上就具备不可变性了。更严格的做法是这个类本身也是final的,也就是不允许继承。因 为子类可以覆盖父类的方法,有可能改变不可变性。如果具备不可变性的类,需要提供类似修改的功能,具体该怎么操作呢?做法很简单,那就是创建一个新的不可变对象创建一个新的不可变对象,这是与可变对象的一个重要区别,可变对象往往是修改自己的属性。

所有的修改操作都创建一个新的不可变对象,你可能会有这种担心:是不是创建的对象太多了,有点太浪费内存呢?是的,这样做的确有些浪费,那如何解决呢?可以利用享元模式。

使用Immutability模式的注意事项

在使用Immutability模式的时候,需要注意以下两点:

  • 对象的所有属性都是final的,并不能保证不可变性;
  • 不可变对象也需要正确发布。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值