常见并发工具类

Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程

1、Lock:实现互斥

1.再造管程的理由

a、Java 语言本身提供的 synchronized 已经实现管程,为什么还要在 SDK 里提供另外一种实现

b、synchronized互斥锁的缺点

i.导致死锁问题

ii.解决死锁问题的破坏不可抢占条件方案,synchronized 没有办法解决

iii.原因: synchronized 申请不到资源,线程直接进入阻塞状态,啥也干不了,也不会释放线程已经占有的资源

c、全面弥补 synchronized 问题的三种方案:

i.能够响应中断:阻塞状态的线程能够响应中断信号,中断信号唤醒阻塞线程释放已占有的资源

ii.支持超时:线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误

iii.非阻塞地获取锁:尝试获取锁失败,并不进入阻塞状态,而是直接返回

d、再造管程的原因:

i.全面弥补 synchronized 问题

ii.体现在 API 上,就是 Lock 接口的三个方法:

1)支持中断的 API:

void lockInterruptibly()
   throws InterruptedException;

2)支持超时的 API:

boolean tryLock(long time, TimeUnit unit)
  throws InterruptedException;

3)支持非阻塞获取锁的 API:

boolean tryLock();

2.如何保证可见性

a、Java 里多线程的可见性是通过 Happens-Before 规则

b、synchronized保证可见性的原因:

i.有一条 synchronized 相关的Happens-Before规则:synchronized 的解锁 Happens-Before 于后续对这个锁的加锁

c、Java SDK 里 Lock 可见性的保证:

i.原理:利用了 volatile 相关的 Happens-Before 规则

ii.Java SDK里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写state 的值;解锁的时候,也会读写 state 的值(volatile写操作对读操作可见)

iii.传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作

3.可重入锁

a、创建的锁的具体类名是 ReentrantLock,翻译为可重入锁

b、概念:指的是线程可以重复获取同一把锁

c、具体过程:

i.当线程 T1 执行到 ① 处时,已经获取到了锁 rtl ,当在① 处调用 get() 方法时,会在 ② 再次对锁 rtl 执行加锁操作

ii.如果锁 rtl 是可重入的,那么线程 T1 可以再次加锁成功;如果锁 rtl 是不可重入的,那么线程 T1 此时会被阻塞

d、可重入函数:指的是多个线程可以同时调用该函数,每个线程都能得到正确结果(同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。多线程可以同时执行,还支持线程切换(可重入函数是线程安全的))

4.公平锁与非公平锁

a、ReentrantLock 类有两个构造函数:

i.无参构造函数:默认非公平锁

public ReentrantLock() {
    sync = new NonfairSync();
}

ii.传入 fair 参数的构造函数

fair 参数代表的是锁的公平策略,传入 true 表示需要构造一个公平锁,反之表示要构造一个非公平锁

public ReentrantLock(boolean fair){
    sync = fair ? new FairSync()
                : new NonfairSync();
}

b、锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程

i.公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁

ii.非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒

5.用锁的最佳实践

a、永远只在更新对象的成员变量时加锁

b、永远只在访问可变的成员变量时加锁

c、永远不在调用其他对象的方法时加锁

d、减少锁的持有时间

e、减小锁的粒度

2、Condition:实现同步

Condition 实现了管程模型里面的条件变量,条件变量和等待队列的作用就是解决线程同步的问题

1.Java 语言内置的管程与Lock&Condition 实现的管程区别

a、Java 语言内置的管程里只有一个条件变量

b、Lock&Condition 实现的管程是支持多个条件变量

2.利用两个条件变量快速实现阻塞队列

a、一个阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队)

b、实现:

i. 对于入队操作,如果队列已满,就需要等待直到队列不满,用(notFull.await()、notFull.wait())

ii.对于出队操作,如果队列为空,就需要等待直到队列不空,用(notEmpty.await()、notEmpty.wait())

iii.如果入队成功,那么队列就不空了,就需要通知条件变量:队列不空notEmpty对应的等待队列(notEmpty.singalAll()、notEmpty.noityAll())

iv.如果出队成功,那就队列就不满了,就需要通知条件变量:队列不满notFull对应的等待队列(notFull.singalAll()、notFull.noityAll())

c、Lock 和 Condition 实现的管程,线程等待和通知需要调用 await()、signal()、signalAll();synchronized 实现的管程用wait()、notify()、notifyAll()

3.同步与异步

a、区别:

i.同步:调用方需要等待结果(Java 代码默认的处理方式)

ii.异步:调用方不需要等待结果

b、异步的实现:

i.异步调用:调用方创建一个子线程,在子线程中执行方法调用

ii.异步方法:方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接 return

4.Dubbo 源码分析

a、TCP 协议本身就是异步,在 TCP 协议层面,发送完 RPC 请求后,线程是不会等待 RPC 的响应结果的;RPC 框架 Dubbo做了异步转同步的事情

b、一个简单的 RPC 调用:

i.默认情况下 sayHello() 方法,是个同步方法

ii.执行 service.sayHello(“dubbo”),线程会停下来等结果;调用线程阻塞

iii.Dubbo 异步转同步的功能应该是通过DefaultFuture 这个类实现的

c、Dubbo 的实现:

i.需求:当 RPC 返回结果之前,阻塞调用线程,让调用线程等待;当 RPC 返回结果后,唤醒调用线程,让调用线程重新执行。

ii.代码实现:

public class DubboInvoker{
  Result doInvoke(Invocation inv){
    // 下面这行就是源码中 108 行
    // 为了便于展示,做了修改
    return currentClient
       .request(inv, timeout)
       .get();
   }
}
​
// 创建锁与条件变量
private final Lock lock
   = new ReentrantLock();
private final Condition done
   = lock.newCondition();
// 调用线程通过调用 get() 方法等待 RPC 返回结果
Object get(int timeout){
  long start = System.nanoTime();
  lock.lock(); //调用 lock() 获取锁
  try {
        while (!isDone()) {
          done.await(timeout); //获取锁后,经典的在循环中调用 await() 方法来实现等待
      long cur=System.nanoTime();
          if (isDone() ||
          cur-start > timeout){
            break;
          }
        }
  } finally {
       lock.unlock(); //在finally里面调用 unlock()释放锁
  }
  if (!isDone()) {
       throw new TimeoutException();
  }
  return returnFromResponse();
}
// RPC 结果是否已经返回
boolean isDone() {
   return response != null;
}
// 当 RPC 结果返回时,会调用 doReceived()方法
private void doReceived(Response res) {
   lock.lock(); //调用 lock() 获取锁
   try {
     response = res;
     if (done != null) {
      done.signal();//获取锁后通过调用 signal() 来通知调用线程,结果已经返回,不用继续等待了
      }
     } finally {
       lock.unlock(); //finally 里面调用unlock()释放锁
     }
 }

d、Lock&Condition 实现的管程相对于 synchronized 实现的管程来说更加灵活、功能也更丰富。

3、Semaphore:信号量

信号量和管程是等价的,目前几乎所有支持并发编程的语言都支持信号量机制

1.信号量模型

a、信号量模型:

i.一个计数器、一个等待队列(对外透明,只能通过信号量模型的三个方法来访问)

ii.三个方法:

1)init():设置计数器的初始值

2)down():计数器的值减 1;如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行

3)up():计数器的值加 1;如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除

b、信号量模型是由 java.util.concurrent.Semaphore实现的,Semaphore 这个类能够保证这三个方法都是原子操作

c、在 Java SDK 并发包里,down() 和 up() 对应的则是 acquire() 和 release()。

d、信号量模型代码实现:

class Semaphore{
  // 计数器
  int count;
  // 等待队列
  Queue queue;
  // 初始化操作
  Semaphore(int c){
    this.count=c;
  }
  //
  void down(){
    this.count--;
    if(this.count<0){
      // 将当前线程插入等待队列
      // 阻塞当前线程
    }
  }
  void up(){
    this.count++;
    if(this.count<=0) {
      // 移除等待队列中的某个线程 T
      // 唤醒线程 T
     }
  }
}

2.如何使用信号量(用累加器的例子来说明信号量的使用 )

a、像用互斥锁一样,只需要在进入临界区之前执行一下 down() 操作,退出临界区之前执行一下 up() 操作就可以了

b、acquire() 就是信号量里的 down() 操作,release() 就是信号量里的 up() 操作

c、信号量使用代码实现:

static int count;
// 初始化信号量
static final Semaphore s
   = new Semaphore(1);
// 用信号量保证互斥
static void addOne() {
  s.acquire();//原子操作,只能同时对一个线程进行操作
  try {
   count+=1;
  } finally {
    s.release();
  }
}

d、信号量是如何保证互斥的:

两个线程 T1 和 T2 同时访问addOne() 方法,当它们同时调用 acquire() 的时候,由于 acquire() 是一个原子操作,所以只能有一个线程(假设 T1)把信号量里的计数器减为 0,另外一个线程(T2)则是将计数器减为 -1。对于线程 T1,信号量里面的计数器的值是 0,大于等于 0,所以线程 T1 会继续执行;对于线程 T2,信号量里面的计数器的值是 -1,小于 0,按照信号量模型里对down() 操作的描述,线程 T2 将被阻塞。所以此时只有线程 T1 会进入临界区执行count+=1;。

线程 T1 执行 release() 操作,也就是 up() 操作的时候,信号量里计数器的值是 -1,加1 之后的值是 0,小于等于 0,按照信号量模型里对 up() 操作的描述,此时等待队列中的T2 将会被唤醒。于是 T2 在 T1 执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性

3.快速实现一个限流器(??)

a、Semaphore的功能:

i.实现一个互斥锁

ii.Semaphore 可以允许多个线程访问一个临界区

b、需求:工作中遇到的各种池化资源,例如连接池、对象池、线程池等等

c、在同一时刻,一定是允许多个线程同时使用连接池的,当然,每个连接在被释放前,是不允许其他线程使用的。

d、对象池:指的是一次性创建出N 个对象,之后所有的线程重复利用这 N 个对象,当然对象在被释放前,也是不允许其他线程使用的

e、限流:指的是不允许多于 N 个线程同时进入临界区

f、信号量实现限流器:

i.信号量的计数器,设置成 1, 表示只允许一个线程进入临界区

ii.把计数器的值设置成对象池里对象的个数 N,就能完美解决对象池的限流问题

iii.代码实现:

class ObjPool<T, R> {
  //用一个 List来保存对象实例
  final List<T> pool; 
  //用 Semaphore 实现限流器
  final Semaphore sem;
  // 构造函数
  ObjPool(int size, T t){
   pool = new Vector<T>(){};
   for(int i=0; i<size; i++){
    pool.add(t);
  }
   sem = new Semaphore(size);
 }
 //利用对象池的对象,调用 func
 R exec(Function<T,R> func) { //exec()方法实现了限流的功能
  T t = null;
  sem.acquire();
  try {
    t = pool.remove(0);//pool.remove(0)实现为每个线程分配了一个对象t
    return func.apply(t);
  } finally {
    pool.add(t); //释放对象
    sem.release();//调用release()方法来更新信号量的计数器
  }
 }
}
//创建对象池
ObjPool<Long, String> pool =
  new ObjPool<Long, String>(10, 2);
//通过对象池获取 t,之后执行
pool.exec(t -> {
     System.out.println(t);
     return t.toString();
});

4、ReadWriteLock:读多写少场景

用管程和信号量中任何一个都可以解决所有的并发问题, Java SDK 并发包里其他的工具类用来分场景优化性能,提升易用性

1.什么是读写锁

a、读多写少场景:使用缓存之所以能提升性能 ,例如缓存元数据、缓存基础数据等一定是读多写少的

b、针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReadWriteLock

c、读写锁和三条基本原则:

i.允许多个线程同时读共享变量

ii.只允许一个线程写共享变量

iii. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量

d、读写锁与互斥锁的一个重要区别: 读写锁允许多个线程同时读共享变量,读写锁的写操作是互斥的;互斥锁读写都互斥。

2.快速实现一个缓存

a、用 ReadWriteLock 快速实现一个通用的缓存工具类

b、代码实现:

 class Cache<K,V> { //类型参数 K 代表缓存里 key 的类型,V 代表缓存里 value 的类型
  final Map<K, V> m =
     new HashMap<>(); //缓存的数据保存在 Cache 类内部的 HashMap里面
  final ReadWriteLock rwl =
     new ReentrantReadWriteLock();
 // 读锁
 final Lock r = rwl.readLock();
 // 写锁
 final Lock w = rwl.writeLock();
 // 读缓存
 V get(K key) {
  r.lock();
  try { return m.get(key); }
  finally { r.unlock(); }
 }
 // 写缓存
 V put(String key, Data v) {
  w.lock();
  try { return m.put(key, v); }
  finally { w.unlock(); }
 }
}

c、HashMap 不是线程安全的,这里我们使用读写锁 ReadWriteLock 来保证其线程安全;ReadWriteLock 是一个接口,它的实现类是 ReentrantReadWriteLock。

d、Cache 这个工具类,我们提供了两个方法,一个是读缓存方法 get(),另一个是写缓存方法put()。读缓存需要用到读锁,读锁的使用和前面我们介绍的 Lock 的使用是相同的,都是try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的

3.实现缓存的按需加载??

a、使用缓存首先要解决缓存数据的初始化问题

b、缓存数据的初始化:

i. 一次性加载

ii. 按需加载

c、一次性加载:源头数据的数据量不大;只需在应用启动的时候把源头数据查询出来,依次调用类似上面示例代码中的 put()方法就可以了

d、按需加载(懒加载): 源头数据量非常大;只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作

e、利用ReadWriteLock 来实现缓存的按需加载 :(??)

class Cache<K,V> { //类型参数 K 代表缓存里 key 的类型,V 代表缓存里 value 的类型
  final Map<K, V> m = //缓存的数据保存在 Cache 类内部的 HashMap里面
     new HashMap<>();
  final ReadWriteLock rwl =
     new ReentrantReadWriteLock();
  final Lock r = rwl.readLock(); //读锁
  final Lock w = rwl.writeLock(); //写锁
  V get(K key) {
    V v = null; //缓存中不存在 
    // 读缓存
    r.lock(); ① //读锁
    try {
      v = m.get(key); ② 
    } finally{
      r.unlock(); ③
    }
    // 缓存中存在,返回
    if(v != null) { ④
       return v;
     }
     // 缓存中不存在,查询数据库
     w.lock(); ⑤ //写锁
     try {
       // 再次验证
       // 其他线程可能已经查询过数据库
       v = m.get(key); ⑥
       if(v == null){ ⑦
         // 查询数据库
         v= 省略代码无数
         m.put(key, v);
        }
      } finally{
        w.unlock();
      }
      return v;
    }
}

4.读写锁的升级与降级

a、锁的升级: 先获取读锁,然后再升级为写锁;ReadWriteLock 并不支持这种升级(读写锁互斥,写锁之间互斥,读锁之间不互斥)

b、锁的降级:先获取写锁,然后再降级为读锁;ReadWriteLock允许降级

c、读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。

d、只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出UnsupportedOperationException 异常

e、数据同步:指的是保证缓存数据和源头数据的一致性

f、解决数据同步问题的一个最简单的方案就是超时机制

g、超时机制:指的是加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存

5、StampedLock:比读写锁性能更好

读多写少的场景中, StampedLock 的锁的性能就比读写锁还要好

1.StampedLock 支持的三种锁模式

a、 StampedLock和ReadWriteLock 的区别:

i.ReadWriteLock 支持两种模式:一种是读锁,一种是写锁

ii.StampedLock 支持三种模式:写锁、悲观读锁和乐观读

iii.写锁、悲观读锁的语义和 ReadWriteLock的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的

iv.不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp

相关代码:

final StampedLock sl =
  new StampedLock();
// 获取 / 释放悲观读锁示意代码
long stamp = sl.readLock();
try {
  // 省略业务相关代码
} finally {
  sl.unlockRead(stamp);
}
// 获取 / 释放写锁示意代码
long stamp = sl.writeLock();
try {
  // 省略业务相关代码
} finally {
  sl.unlockWrite(stamp);
}

b、StampedLock 的性能比 ReadWriteLock 好的原因:

i.StampedLock 支持乐观读

ii.StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞

iii.乐观读这个操作是无锁的

c、执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁

class Point {
  private int x, y;
  final StampedLock sl =
    new StampedLock();
  // 计算到原点的距离
  int distanceFromOrigin() {
    // 乐观读
    long stamp =
      sl.tryOptimisticRead();
     // 读入局部变量,
     // 读的过程数据可能被修改
     int curX = x, curY = y;
     // 判断执行读操作期间,
     // 是否存在写操作,如果存在,
     // 则 sl.validate 返回 false
     if (!sl.validate(stamp)){
        // 升级为悲观读锁
        stamp = sl.readLock();
        try {
          curX = x;
          curY = y;
        } finally {
          // 释放悲观读锁
          sl.unlockRead(stamp);
        }
     }
     return Math.sqrt(
        curX * curX + curY * curY);
  }
}

2.进一步理解乐观读

a、数据库的乐观锁的原理:在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。

b、数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用version 字段做验证。这个 version 字段就类似于 StampedLock 里面的 stamp

3.StampedLock 使用注意事项

a、对于读多写少的场景 StampedLock 性能很好,简单的应用场景基本上可以替代ReadWriteLock,但是StampedLock 的功能仅仅是 ReadWriteLock 的子集

b、StampedLock 在命名上并没有增加 Reentrant ,StampedLock 不支持重入

c、StampedLock 的悲观读锁、写锁都不支持条件变量

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

6、CountDownLatch和CyclicBarrier:多线程步调一致

核心代码:在一个单线程里面循环查询订单、派送单,然后执行对账,最后将写入差异库

while(存在未对账订单){
  // 查询未对账订单
  pos = getPOrders();
  // 查询派送单
  dos = getDOrders();
  // 执行对账操作
  diff = check(pos, dos);
  // 差异写入差异库
  save(diff);
}

1.利用并行优化对账系统

a、对账系统里的瓶颈:查询未对账订单 getPOrders() 和查询派送单 getDOrders() 是否可以并行处理呢

b、代码实现:

while(存在未对账订单){
  // 查询未对账订单
  Thread T1 = new Thread(()->{
     pos = getPOrders();
  }); //创建了线程 T1执行查询未对账订单getPOrders()操作
  T1.start();
  // 查询派送单
  Thread T2 = new Thread(()->{
    dos = getDOrders();
  }); //创建了线程 T2执行查询未对账订单getDOrders()操作
  T2.start();
  // 等待 T1、T2 结束
  T1.join(); //调用 T1.join()实现等待
  T2.join(); //调用 T2.join()实现等待,主线程会从阻塞态被唤醒
  // 执行对账操作
  diff = check(pos, dos);
  // 差异写入差异库
  save(diff);
}

2.用 CountDownLatch 实现线程等待

CountDownLatch 主要用来解决一个线程等待多个线程的场景,CountDownLatch 的计数器是不能循环利用

a、代码实现:

// 创建 2 个线程的线程池
Executor executor =
   Executors.newFixedThreadPool(2);
while(存在未对账订单){
   // 计数器初始化为 2
   CountDownLatch latch =
      new CountDownLatch(2);
   // 查询未对账订单
   executor.execute(()-> {
       pos = getPOrders();
       latch.countDown();
   });
   // 查询派送单
   executor.execute(()-> {
      dos = getDOrders();
      latch.countDown();
   });
   // 等待两个查询操作结束
   latch.await();
   // 执行对账操作
   diff = check(pos, dos);
   // 差异写入差异库
   save(diff);
}

b、在while 循环里面,我们首先创建了一个 CountDownLatch,计数器的初始值等于 2,之后在pos = getPOrders();和dos = getDOrders();两条语句的后面对计数器执行减 1操作,这个对计数器减 1 的操作是通过调用 latch.countDown(); 来实现的。在主线程中,我们通过调用 latch.await() 来实现对计数器等于 0 的等待

3.进一步优化性能

a、将 getPOrders() 和 getDOrders() 这两个查询操作并行了,但这两个查询操作和对账操作 check()、save() 之间还是串行的。但是在执行对账操作的时候,可以同时去执行下一轮的查询操作

b、两次查询操作是生产者,对账操作是消费者。既然是生产者 - 消费者模型,那就需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据

c、订单查询操作将订单查询结果插入订单队列,派送单查询操作将派送单插入派送单队列,这两个队列的元素之间是有一一对应的关系

d、用双队列来实现完全的并行,必须满足线程 T1 和线程 T2 要互相等待,步调要一致 ;当线程T1 和 T2 都生产完一条数据的时候,还要能够通知线程 T3 执行对账操作

4.用 CyclicBarrier 实现线程同步

CyclicBarrier 是一组线程之间互相等待,CyclicBarrier 的计数器是可以循环利用的

a、 CyclicBarrier实现原理:利用一个计数器来解决这两个难点,计数器初始化为 2,线程 T1 和 T2 生产完一条数据都将计数器减 1,如果计数器大于 0 则线程 T1 或者 T2 等待。如果计数器等于0,则通知线程 T3,并唤醒等待的线程 T1 或者 T2,与此同时,将计数器重置为 2,这样线程 T1 和线程 T2 生产下一条数据的时候就可以继续使用这个计数器了

b、CyclicBarrier实现:创建了一个计数器初始值为 2 的CyclicBarrier,同时传入一个回调函数,当计数器减到 0 的时候,会调用这个回调函数

c、代码实现:

// 订单队列
Vector<P> pos;
// 派送单队列
Vector<D> dos;
// 执行回调的线程池
Executor executor =
   Executors.newFixedThreadPool(1);
final CyclicBarrier barrier =
  new CyclicBarrier(2, ()->{
    executor.execute(()->check());
  });
void check(){
  P p = pos.remove(0);
  D d = dos.remove(0);
  // 执行对账操作
  diff = check(p, d);
  // 差异写入差异库
  save(diff);
}
void checkAll(){
  // 循环查询订单库
  Thread T1 = new Thread(()->{
     while(存在未对账订单){
       // 查询订单库
       pos.add(getPOrders());
       // 等待
       barrier.await();
     }
  });
  T1.start();
  // 循环查询运单库
  Thread T2 = new Thread(()->{
     while(存在未对账订单){
       // 查询运单库
       dos.add(getDOrders());
       // 等待
       barrier.await();
      }
   });
   T2.start();
}

d、线程 T1 负责查询订单,当查出一条时,调用 barrier.await() 来将计数器减 1,同时等待计数器变成 0;线程 T2 负责查询派送单,当查出一条时,也调用 barrier.await()来将计数器减 1,同时等待计数器变成 0;当 T1 和 T2 都调用 barrier.await() 的时候,计数器会减到 0,此时 T1 和 T2 就可以执行下一条语句了,同时会调用 barrier 的回调函数来执行对账操作。CyclicBarrier 的计数器有自动重置的功能,当减到 0 的时候,会自动重置你设置的初始值

7、并发容器

1.同步容器及其注意事项

a、同步容器的概念:Java 在 1.5 版本之前所谓的线程安全的容器(经过包装后线程安全容器,都是基于 synchronized 这个同步关键字实现的,串行度高,性能差)

b、Java 中的容器主要可以分为四个大类: 分别是 List、Map、Set 和 Queue,但并不是所有的 Java 容器都是线程安全的。常用的 ArrayList、HashMap 就不是线程安全的

c、将非线程安全的容器变成线程安全的容器的方法:

i.思路:把非线程安全的容器封装在对象内部,然后控制好访问路径就可以了

ii.实例:以 ArrayList 为例, SafeArrayList 内部持有一个 ArrayList 的实例 c,所有访问 c 的方法我们都增加了synchronized 关键字,需要注意的是我们还增加了一个 addIfNotExist() 方法,这个方法也是用 synchronized 来保证原子性的

代码实现:

SafeArrayList<T>{
   // 封装 ArrayList
   List<T> c = new ArrayList<>();
   // 控制访问路径
   synchronized
   T get(int idx){
     return c.get(idx);
   }
   synchronized
   void add(int idx, T t) {
     c.add(idx, t);
   }
   synchronized
   boolean addIfNotExist(T t){
     if(!c.contains(t)) {
       c.add(t);
       return true;
   }
   return false;
  }
}

iii. Java SDK Collections 类提供了一套完备的包装类,分别把ArrayList、HashSet 和 HashMap 包装成了线程安全的 List、Set 和Map

代码实现:

List list = Collections.
synchronizedList(new ArrayList());
Set set = Collections.
synchronizedSet(new HashSet());
Map map = Collections.
synchronizedMap(new HashMap());

iv.组合操作需要注意竞态条件问题。addIfNotExist() 方法就包含组合操作。即便每个操作都能保证原子性,也并不能保证组合操作的原子性

d、在容器领域一个容易被忽视的“坑”是用迭代器遍历容器,通过迭代 器遍历容器 list,对每个元素调用 foo() 方法,这就存在并发问题,这些组合的操作不具备原子性。

错误代码实现:

List list = Collections.
  synchronizedList(new ArrayList());
Iterator i = list.iterator();
while (i.hasNext())
  foo(i.next());

正确代码:

List list = Collections.
  synchronizedList(new ArrayList());
synchronized (list) { //锁住 list 之后再执行遍历操作
  Iterator i = list.iterator();
  while (i.hasNext())
    foo(i.next());
}

d、Java 提供的同步容器还有 Vector、Stack 和 Hashtable,这三个容器不是基于包装类实现的,但同样是基于 synchronized 实现的,对这三个容器的遍历,同样要加锁保证互斥。

2.并发容器及其注意事项

a、并发容器概念:Java 在 1.5 及之后版本提供了性能更高的容器

b、List:

i. List 里面只有一个实现类就是 CopyOnWriteArrayList。CopyOnWrite,顾名思义就是写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁

ii. CopyOnWriteArrayList 的实现原理:

1)CopyOnWriteArrayList 内部维护了一个数组,成员变量 array 就指向这个内部数组,所有的读操作都是基于 array 进行的,如下图所示,迭代器 Iterator 遍历的就是 array 数组。

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

iii.使用 CopyOnWriteArrayList 需要注意的“坑” :

1)应用场景,CopyOnWriteArrayList 仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致

2)CopyOnWriteArrayList 迭代器是只读的,不支持增删改。因为迭代器遍历的仅仅是一个快照,而对快照进行增删改是没有意义的

c、Map:

i.Map 接口的两个实现是 ConcurrentHashMap 和ConcurrentSkipListMap;ConcurrentHashMap 的 key 是无序的,而ConcurrentSkipListMap 的 key 是有序的

ii.使用 ConcurrentHashMap 和 ConcurrentSkipListMap 需要注意的地方: 它们的 key和 value 都不能为空,否则会抛出NullPointerException这个运行时异常

iii.Map 相关的实现类对于 key 和 value 的要求 :

iv. ConcurrentSkipListMap 里面的 SkipList 本身就是一种数据结构,翻译为“跳表”。跳表插入、删除、查询操作平均的时间复杂度是 O(log n),理论上和并发线程数没有关系

v.在并发度非常高的情况下,若你对 ConcurrentHashMap 的性能还不满意,可以尝试一下 ConcurrentSkipListMap

d、Set:

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

e、Queue:

i.两个维度来分类:阻塞与非阻塞,单端与双端

ii.阻塞与非阻塞:阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞

iii.单端与双端:单端指的是只能队尾入队,队首出队;而双端指的是 队首队尾皆可入队出队

iv. Java 并发包里阻塞队列都用 Blocking 关键字标识,单端队列使 用 Queue 标识,双端队列使用 Deque 标识

v. Queue 细分为四大类:

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

2)双端阻塞队列:其实现是 LinkedBlockingDeque

3)单端非阻塞队列:其实现是 ConcurrentLinkedQueue

4)双端非阻塞队列:其实现是 ConcurrentLinkedDeque

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

8、原子类

1.无锁方案的实现原理

a、累加器的例子中add10K() 这个方法不是线程安全的,问题就出在变量 count 的可见性和 count+=1 的原子性上。可见性问题可以用 volatile 来解决,而原子性问题我们前面一直都是采用的互斥锁方案。

public class Test {
  long count = 0;
  void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
}

b、简单的原子性问题,还有一种无锁方案。Java SDK 并发包将这种无锁方案封装提炼之后,实现了一系列的原子类

c、用原子类解决累加器问题 :将原来的 long 型变量 count 替换为了原子类 AtomicLong,原来的 count +=1 替换成了count.getAndIncrement()

public class Test {
  AtomicLong count =
    new AtomicLong(0);
  void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
       count.getAndIncrement();
    }
  }
}

d、原子性优点:无锁方案则完全没有加锁、解锁的性能消耗,同时还能保证互斥性

e、原子性实现原理:硬件支持

i. CAS指令:CPU 为了解决并发问题,提供了 CAS 指令(CAS,全称是 Compare And Swap,即“比较并交换”)。CAS 指令包含 3 个参数:共享变量的内存地址 A、用于比较的值 B 和共享变量的新值 C;并且只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值 C。 (作为一条 CPU 指令,CAS 指令本身是能够保证原子性的 )

ii. CAS指令原理:只有当目前 count 的值和期望值 expect 相等时,才会将 count 更新为 newValue

class SimulatedCAS{
  int count;
  synchronized int cas(
     int expect, int newValue){
     // 读目前 count 的值
     int curValue = count;
     // 比较目前 count 值是否 == 期望值
     if(curValue == expect){
       // 如果是,则更新 count 的值
        count = newValue;
      }
      // 返回写入前的值
      return curValue;
  }
}

iii. count += 1 的一个核心问题是:基于内存中 count 的当前值 A 计算出来的 count+=1 为 A+1,在将 A+1 写入内存的时候,很可能此时内存中count 已经被其他线程更新过了,这样就会导致错误地覆盖其他线程写入的值(只有当内存中 count 的值等于期望值 A 时,才能将内存中 count 的值更新为计算结果 A+1,刚好符合CAS语义)

iv.使用 CAS 来解决并发问题,一般都会伴随着自旋,而所谓自旋,其实就是循环尝试

v.实现一个线程安全的count += 1操作,“CAS+ 自旋”的实现方案: 首先计算 newValue = count+1,如果 cas(count,newValue) 返回的值不等于 count,则意味着线程在执行完代码①处之后,执行代码②处之前,count 的值被其他线程更新过; 可以采用自旋方案,可以重新读 count 最新的值来计算 newValue 并尝试再次更新,直到成功

vi. CAS 方案的缺点ABA的问题 : 如果cas(count,newValue) 返回的值等于count,也不能认为 count 的值没有被其他线程更新过;假设 count 原本是 A,线程 T1 在执行完代码①处之后,执行代码②处之前,有可能 count 被线程 T2 更新成了 B,之后又被 T3 更新回了 A,这样线程T1 虽然看到的一直是 A,但是其实已经被其他线程更新过了,这就是 ABA 问题

vii。在使用 CAS 方案的时候,一定要先 check 一下

2.看 Java 如何实现原子化的 count += 1

a、原子类 AtomicLong 的 getAndIncrement() 方法替代了count += 1,从而实现了线程安全。原子类 AtomicLong 的 getAndIncrement() 方法内部就是基于 CAS 实现的

b、CAS实现原子化count+= 1的原理:

i.getAndIncrement() 方法会转调 unsafe.getAndAddLong() 方法。 这里 this 和 valueOffset 两个参数可以唯一确定共享变量的内存地址

final long getAndIncrement() {
  return unsafe.getAndAddLong(
     this, valueOffset, 1L);
}

ii.unsafe.getAndAddLong() 方法的源码:

public final long getAndAddLong(
   Object o, long offset, long delta){
   long v;
   do {
      // 在内存中读取共享变量的值
      v = getLongVolatile(o, offset);
   } while (!compareAndSwapLong( //循环调用 compareAndSwapLong() 方法来尝试设置共享变量的值,直到成功为止
       o, offset, v, v + delta));
   return v;
}
// 原子性地将变量更新为 x
// 条件是内存中的值等于 expected
// 更新成功则返回 true
native boolean compareAndSwapLong(//compareAndSwapLong() 是一个 native 方法,只有当内存中共享变量的值等于expected 时,才会将共享变量的值更新为 x,并且返回 true;否则返回 fasle
  Object o, long offset,
  long expected,
  long x);

iii. compareAndSwapLong 的语义和 CAS 指令的语义的差别仅仅是返回值不同而已

c、Java提供的原子类里面 CAS 一般被实现为 compareAndSet(),compareAndSet() 的语义和CAS 指令的语义的差别仅仅是返回值不同而已,compareAndSet() 里面如果更新成功,则会返回 true,否则返回 false

do {
   // 获取当前值
   oldV = xxxx;
   // 根据当前值计算新值
   newV = ...oldV...
}while(!compareAndSet(oldV,newV);

3.原子类概览

a、Java SDK 并发包里提供的原子类五个类别:原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器

b、原子化的基本数据类型:

i.相关实现:AtomicBoolean、AtomicInteger 和 AtomicLong

ii.方法:

getAndIncrement() // 原子化 i++
getAndDecrement() // 原子化的 i--
incrementAndGet() // 原子化的 ++i
decrementAndGet() // 原子化的 --i
// 当前值 +=delta,返回 += 前的值
getAndAdd(delta)
// 当前值 +=delta,返回 += 后的值
addAndGet(delta)
//CAS 操作,返回是否成功
compareAndSet(expect, update)
// 以下四个方法
// 新值可以通过传入 func 函数来计算
getAndUpdate(func)
updateAndGet(func)
getAndAccumulate(x,func)
accumulateAndGet(x,func)

c、原子化的对象引用类型:

i.相关实现:AtomicReference、AtomicStampedReference 和 AtomicMarkableReference

ii.作用:可以实现对象引用的原子化更新

iii.注意:

1)AtomicReference 提供的方法和原子化的基本数据类型差不多

2)对象引用的更新需要重点关注 ABA 问题,AtomicStampedReference 和AtomicMarkableReference 这两个原子类可以解决 ABA 问题

3)解决 ABA 问题的思路其实很简单,增加一个版本号维度,与乐观锁机制很类似:每次执行 CAS操作,附加再更新一个版本号,只要保证版本号是递增的,那么即便 A 变成 B 之后再变回A,版本号也不会变回来(版本号递增的)

4)AtomicStampedReference 实现的 CAS 方法就增加了版本号参数,方法签名如下:

boolean compareAndSet(
   V expectedReference,
   V newReference,
   int expectedStamp,
   int newStamp)

5)AtomicMarkableReference 的实现机制则更简单,将版本号简化成了一个 Boolean 值,方法签名如下:

boolean compareAndSet(
   V expectedReference,
   V newReference,
   boolean expectedMark,
   boolean newMark)

d、原子化数组:

i.相关实现:AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray

ii.作用:可以原子化地更新数组里面的每一个元素

iii.这些类提供的方法和原子化的基本数据类型的区别仅仅是:每个方法多了一个数组的索引参数

e、原子化对象属性更新器:

i.相关实现:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和AtomicReferenceFieldUpdater

ii.作用:可以原子化地更新对象的属性

iii.这三个方法都是利用反射机制实现的,创建更新器的方法如下:

public static <U>
AtomicXXXFieldUpdater<U>
newUpdater(Class<U> tclass,
   String fieldName)

iv.对象属性必须是 volatile 类型的,只有这样才能保证可见性;如果对象属性不是 volatile 类型的,newUpdater() 方法会抛出IllegalArgumentException 这个运行时异常。

v. newUpdater() 的方法参数只有类的信息,没有对象的引用,而更新对象的属性,一定需要对象的引用, 这个参数是在原子操作的方法参数中传入的。例如 compareAndSet() 这个原子操作,相比原子化的基本数据类型多了一个对象引用obj

vi.原子化对象属性更新器相关的方法,相比原子化的基本数据类型仅仅是多了对象引用参数

boolean compareAndSet(
   T obj,
   int expect,
   int update)

f、原子化的累加器:

i.相关实现:DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder

ii.作用:相比原子化的基本数据类型,速度更快,但是不支持compareAndSet() 方法。如果你仅仅需要累加操作,使用原子化的累加器性能会更好

4.总结

a、无锁方案的优点:性能好,其次是基本不会出现死锁问题(但可能出现饥饿和活锁问题,因为自旋会反复重试)

b、使用建议:Java 提供的原子类的方法都是针对一个共享变量的;需要解决多个变量的原子性问题,建议还是使用互斥锁方案。原子类虽好,但使用要慎之又慎。

9、Executor与线程池

1.线程池是一种生产者 - 消费者模式

a、创建对象,仅仅是在 JVM 的堆里分配一块内存而已;而创建一个线程(new Thread()),却需要调用操作系统内核的 API,然后操作系统要为线程分配一系列的资源,这个成本就很高了,所以线程是一个重量级的对象,应该避免频繁创建和销毁

b、Java SDK 并发包里线程池相关的工具类和一般意义上的池化资源的不同:

i.一般池化资源调用 acquire() 方法来申请资源,用完之后就调用 release() 释放资源

class XXXPool{
  // 获取池化资源
  XXX acquire() {
  }
  // 释放池化资源
  void release(XXX x){
  }
}

ii. Java 提供的线程池里面压根就没有申请线程和释放线程的方法

c、目前业界线程池的设计,普遍采用的都是生产者 - 消费者模式。线程池的使用方是生产者,线程池本身是消费者

d、线程池的工作原理:

i.实现代码:

// 简化的线程池,仅用来说明工作原理
class MyThreadPool{
  // 利用阻塞队列实现生产者 - 消费者模式
  BlockingQueue<Runnable> workQueue;
  // 保存内部工作线程
  List<WorkerThread> threads
    = new ArrayList<>();
  // 构造方法
  MyThreadPool(int poolSize,
     BlockingQueue<Runnable> workQueue){
     this.workQueue = workQueue;
     // 创建工作线程
     for(int idx=0; idx<poolSize; idx++){
       WorkerThread work = new WorkerThread();
       work.start();
       threads.add(work);
     }
   }
   // 提交任务
   void execute(Runnable command){
    workQueue.put(command);
   }
   // 工作线程负责消费任务,并执行任务
   class WorkerThread extends Thread{
     public void run() {
       // 循环取任务并执行
       while(true){ ①
         Runnable task = workQueue.take();
         task.run();
       }
      }
    }
}
/** 下面是使用示例 **/
// 创建有界阻塞队列
BlockingQueue<Runnable> workQueue =
  new LinkedBlockingQueue<>(2);
// 创建线程池
MyThreadPool pool = new MyThreadPool(
  10, workQueue);
// 提交任务
pool.execute(()->{
     System.out.println("hello");
}); 

ii.原理:在 MyThreadPool 的内部,我们维护了一个阻塞队列 workQueue 和一组工作线程,工作线程的个数由构造函数中的 poolSize 来指定。用户通过调用 execute() 方法来提交Runnable 任务,execute() 方法的内部实现仅仅是将任务加入到 workQueue 中。MyThreadPool 内部维护的工作线程会消费 workQueue 中的任务并执行任务,相关的代码就是代码①处的 while 循环

2.如何使用 Java 中的线程池

a、Java提供的线程池相关的工具类中,最核心的是ThreadPoolExecutor,强调的是 Executor,而不是一般意义上的池化资源

b、ThreadPoolExecutor 的构造函数有 7个参数:

ThreadPoolExecutor(
  int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime,
  TimeUnit unit,
  BlockingQueue<Runnable> workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler)  

c、可以把线程池类比为一个项目组,而线程就是项目组的成员

d、corePoolSize:表示线程池保有的最小线程数

e、maximumPoolSize:表示线程池创建的最大线程数

f、keepAliveTime & unit:如果一个线 程空闲了keepAliveTime & unit这么久,而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收了。

g、workQueue:工作队列,和上面示例代码的工作队列同义。

h、threadFactory:通过这个参数你可以自定义如何创建线程,例如你可以给线程指定一个有意义的名字。

i、handler:通过这个参数你可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌,并且工作队列也满了(前提是工作队列是有界队列),那么此时提交任务,线程池就会拒绝接收。至于拒绝的策略,你可以通过 handler 这个参数来指定

j、ThreadPoolExecutor 已经提供了以下 4 种策略:

1)CallerRunsPolicy:提交任务的线程自己去执行该任务

2)AbortPolicy:默认的拒绝策略,会 throws RejectedExecutionException。

3)DiscardPolicy:直接丢弃任务,没有任何异常抛出。

4) DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列

k、1.6 版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时,这意味着如果项目很闲,就会将项目组的成员都撤走

3.使用线程池要注意些什么

a、ThreadPoolExecutor 的构造函数实在是有些复杂,所以 Java 并发包里提供了一个线程池的静态工厂类 Executors,利用 Executors 你可以快速创建线程池

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

c、使用有界队列,当任务过多时,线程池会触发执行拒绝策略,线程池默认的拒绝策略会throw RejectedExecutionException 这是个运行时异常,对于运行时异常编译器并不强制catch 它,所以开发人员很容易忽略。因此默认拒绝策略要慎重使用

d、如果线程池处理的任务非常重要,建议自定义自己的拒绝策略;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用

e、使用线程池,还要注意异常处理的问题,例如通过ThreadPoolExecutor 对象的 execute()方法提交任务时,如果任务在执行的过程中出现运行时异常,会导致执行任务的线程终止;不过,最致命的是任务虽然异常了,但是你却获取不到任何通知,这会让你误以为任务都执行得很正常。虽然线程池提供了很多用于异常处理的方法,但是最稳妥和简单的方案还是捕获所有异常并按需处理

try {
   // 业务逻辑
} catch (RuntimeException x) {
   // 按需处理
} catch (Throwable x) {
   // 按需处理
}

10、Future:最优多线程

1.如何获取任务执行结果

a、ThreadPoolExecutor 的 void execute(Runnable command) 方法,利用这个方法虽然可以提交任务,但是却没有办法获取任务的执行结果(execute() 方法没有返回值),使用ThreadPoolExecutor 的时候,如何获取任务执行结果

b、获取任务执行结果:Java 通过 ThreadPoolExecutor 提供的 3 个 submit() 方法和 1 个 FutureTask 工具类来支持获得任务执行结果的需求

c、3 个 submit() 方法:

// 提交 Runnable 任务
Future<?>
submit(Runnable task);
// 提交 Callable 任务
<T> Future<T>
submit(Callable<T> task);
// 提交 Runnable 任务及结果引用
<T> Future<T>
submit(Runnable task, T result);

d、返回值都是 Future 接口,Future 接口有 5 个方法:取消任务的方法 cancel()、判断任务是否已取消的方法 isCancelled()、判断任务是否已结束的方法 isDone()以及2 个获得任务执行结果的 get() 和 get(timeout, unit),其中最后一个 get(timeout, unit) 支持超时机制

e、通过 Future 接口的这 5 个方法你会发现,我们提交的任务不但能够获取任务执行结果,还可以取消任务;两个 get() 方法都是阻塞式的,如果被调用的时候,任务还没有执行完,那么调用 get() 方 法的线程会阻塞,直到任务执行完才会被唤醒

// 取消任务
boolean cancel(
boolean mayInterruptIfRunning);
// 判断任务是否已取消
boolean isCancelled();
// 判断任务是否已结束
boolean isDone();
// 获得任务执行结果
get();
// 获得任务执行结果,支持超时
get(long timeout, TimeUnit unit);

f、3 个 submit() 方法之间的区别在于方法参数不同:

i.提交 Runnable 任务 submit(Runnable task) :这个方法的参数是一个 Runnable接口,Runnable 接口的 run() 方法是没有返回值的,所以 submit(Runnable task)这个方法返回的 Future 仅可以用来断言任务已经结束了,类似于 Thread.join()。

ii.提交 Callable 任务 submit(Callable<T> task):这个方法的参数是一个 Callable接口,它只有一个 call() 方法,并且这个方法是有返回值的,所以这个方法返回的Future 对象可以通过调用其 get() 方法来获取任务的执行结果。

iii.提交 Runnable 任务及结果引用 submit(Runnable task, T result):这个方法很有意思,假设这个方法返回的 Future 对象是 f,f.get() 的返回值就是传给 submit()方法的参数 result。这个方法该怎么用呢?下面这段示例代码展示了它的经典用法。需要你注意的是 Runnable 接口的实现类 Task 声明了一个有参构造函数 Task(Resultr) ,创建 Task 对象的时候传入了 result 对象,这样就能在类 Task 的 run() 方法中对result 进行各种操作了。result 相当于主线程和子线程之间的桥梁,通过它主子线程可以共享数据

g、FutureTask 工具类,有两个构造函数,它们的参数和前面介 绍的 submit() 方法类似:

FutureTask(Callable<V> callable);
FutureTask(Runnable runnable, V result);

h、如何使用 FutureTask:FutureTask 实现了 Runnable 和 Future 接口,由于实现了 Runnable 接口,所以可以将 FutureTask 对象作为任务提交给ThreadPoolExecutor 去执行,也可以直接被 Thread 执行;又因为实现了 Future 接口,所以也能用来获得任务的执行结果。

i、将 FutureTask 对象提交给ThreadPoolExecutor 去执行 :

// 创建 FutureTask
FutureTask<Integer> futureTask
= new FutureTask<>(()-> 1+2);
// 创建线程池
ExecutorService es =
Executors.newCachedThreadPool();
// 提交 FutureTask
es.submit(futureTask);
// 获取计算结果
Integer result = futureTask.get();

j、FutureTask 对象直接被 Thread 执行:

// 创建 FutureTask
FutureTask<Integer> futureTask
= new FutureTask<>(()-> 1+2);
// 创建并启动线程
Thread T1 = new Thread(futureTask);
T1.start();
// 获取计算结果
Integer result = futureTask.get();

2.实现最优的“烧水泡茶”程序

a、对于烧水泡茶这个程序,一种最优的分工方案可以是下图所示的这样:用两个线程 T1 和 T2 来完成烧水泡茶程序,T1 负责洗水壶、烧开水、泡茶这三道工序,T2 负责洗茶壶、洗茶杯、拿茶叶三道工序,其中 T1 在执行泡茶这道工序时需要等待 T2 完成拿茶叶的工序。对于 T1 的这个等待动作,你应该可以想出很多种办法,例 如 Thread.join()、CountDownLatch,甚至阻塞队列都可以解决,不过今天我们用 Future特性来实现。

b、Future 特性实现: 创建了两个FutureTask——ft1 和 ft2,ft1 完成洗水壶、烧开水、泡茶的任务,ft2 完成洗茶壶、洗茶杯、拿茶叶的任务; ft1 这个任务在执行泡茶任务前,需要等待 ft2 把茶叶拿来,所以 ft1 内部需要引用 ft2,并在执行泡茶之前,调用 ft2 的 get() 方法实现等待

代码实现:

// 创建任务 T2 的 FutureTask
FutureTask<String> ft2
= new FutureTask<>(new T2Task());
// 创建任务 T1 的 FutureTask
FutureTask<String> ft1
= new FutureTask<>(new T1Task(ft2));
// 线程 T1 执行任务 ft1
Thread T1 = new Thread(ft1);
T1.start();
// 线程 T2 执行任务 ft2
Thread T2 = new Thread(ft2);
T2.start();
// 等待线程 T1 执行结果
System.out.println(ft1.get());
// T1Task 需要执行的任务:
// 洗水壶、烧开水、泡茶
class T1Task implements Callable<String>{
FutureTask<String> ft2;
// T1 任务需要 T2 任务的 FutureTask
T1Task(FutureTask<String> ft2){
this.ft2 = ft2;
}
@Override
String call() throws Exception {
System.out.println("T1: 洗水壶...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T1: 烧开水...");
TimeUnit.SECONDS.sleep(15);
// 获取 T2 线程的茶叶
String tf = ft2.get();
System.out.println("T1: 拿到茶叶:"+tf);
System.out.println("T1: 泡茶...");
return " 上茶:" + tf;
}
}
// T2Task 需要执行的任务:
// 洗茶壶、洗茶杯、拿茶叶
class T2Task implements Callable<String> {
@Override
String call() throws Exception {
System.out.println("T2: 洗茶壶...");
TimeUnit.SECONDS.sleep(1);
System.out.println("T2: 洗茶杯...");
TimeUnit.SECONDS.sleep(2);
System.out.println("T2: 拿茶叶...");
TimeUnit.SECONDS.sleep(1);
return " 龙井 ";
}
}
// 一次执行结果:
T1: 洗水壶...
T2: 洗茶壶...
T1: 烧开水...
T2: 洗茶杯...
T2: 拿茶叶...
T1: 拿到茶叶: 龙井
T1: 泡茶...
上茶: 龙井

3.总结

a、利用 Java 并发包提供的 Future 可以很容易获得异步任务的执行结果,无论异步任务是通过线程池 ThreadPoolExecutor 执行的,还是通过手工创建子线程来执行的

b、利用多线程可以快速将一些串行的任务并行化,从而提高性能;如果任务之间有依赖关系,比如当前任务依赖前一个任务的执行结果,这种问题基本上都可以用 Future 来解决

11、CompletableFuture:异步编程

1.CompletableFuture 的核心优势

a、用多线程优化性能,其实不过就是将串行操作变成并行操作;在串行转换成并行的过程中,一定会涉及到异步化

代码实现:

//串行
// 以下两个方法都是耗时操作
doBizA();
doBizB();
​
//创建两个子线程将串行改为并行
new Thread(()->doBizA())
  .start();
new Thread(()->doBizB())
  .start();

b、异步(调用方不需要等结果):利用多线程优化性能这个核心方案得以实施的基础

c、Java 在 1.8 版本提供了CompletableFuture 来支持异步编程

d、用CompletableFuture重新实现烧水泡茶程序:

思路:分工方案:分了3 个任务:任务 1 负责洗水壶、烧开水,任务 2 负责洗茶壶、洗茶杯和拿茶叶,任务 3 负责泡茶。其中任务 3 要等待任务 1 和任务 2 都完成后才能开始

代码实现:

// 任务 1:洗水壶 -> 烧开水
CompletableFuture<Void> f1 =
   CompletableFuture.runAsync(()->{
   System.out.println("T1: 洗水壶...");
   sleep(1, TimeUnit.SECONDS);
   
   System.out.println("T1: 烧开水...");
   sleep(15, TimeUnit.SECONDS);
});
// 任务 2:洗茶壶 -> 洗茶杯 -> 拿茶叶
CompletableFuture<String> f2 =
   CompletableFuture.supplyAsync(()->{
   System.out.println("T2: 洗茶壶...");
   sleep(1, TimeUnit.SECONDS);
   
   System.out.println("T2: 洗茶杯...");
   sleep(2, TimeUnit.SECONDS);
    System.out.println("T2: 拿茶叶...");
   sleep(1, TimeUnit.SECONDS);
   return " 龙井 ";
});
// 任务 3:任务 1 和任务 2 完成后执行:泡茶
CompletableFuture<String> f3 =
  f1.thenCombine(f2, (__, tf)->{
    System.out.println("T1: 拿到茶叶:" + tf);
    System.out.println("T1: 泡茶...");
    return " 上茶:" + tf;
});
// 等待任务 3 执行结果
System.out.println(f3.join());
    void sleep(int t, TimeUnit u) {
    try {
      u.sleep(t);
    }catch(InterruptedException e){}
}
// 一次执行结果:
T1: 洗水壶...
T2: 洗茶壶...
T1: 烧开水...
T2: 洗茶杯...
T2: 拿茶叶...
T1: 拿到茶叶: 龙井
T1: 泡茶...
上茶: 龙井

e、CompletableFuture 的核心优势:

i.无需手工维护线程,没有繁琐的手工维护线程的工作,给任务分配线程的工作也不需要我们关注;

ii.语义更清晰,例如 f3 = f1.thenCombine(f2, ()->{}) 能够清晰地表述“任务 3要等待任务 1 和任务 2 都完成后才能开始”

iii. 代码更简练并且专注于业务逻辑,几乎所有代码都是业务逻辑相关的

2.创建 CompletableFuture 对象

a、创建 CompletableFuture 对象的 4 个静态方法:

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

b、runAsync(Runnable runnable)和supplyAsync(Supplier<U> supplier),它们之间的区别是:Runnable 接口的run() 方法没有返回值,而 Supplier 接口的 get() 方法是有返回值的

c、前两个方法和后两个方法的区别在于:后两个方法可以指定线程池参数

d、根据不同的业务类型创建不同的线程池,以避免互相干扰

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

f、一个异步操作的两个问题:一个是异步操作什么时候结束,另一个是如何获取异步操作的执行结果;CompletableFuture 类实现了 Future 接口,这两个问题都可以通过 Future 接口来解决

3.如何理解 CompletionStage 接口

a、站在分工的角度类比一下工作流。任务是有时序关系的,比如有串行关系、并行关系、汇聚关系

b、CompletionStage 接口可以清晰地描述任务之间的这种时序关系: f3 = f1.thenCombine(f2, ()->{}) 描述的就是一种汇聚关系

c、AND 聚合关系:所有依赖的任务(烧开水和拿茶叶)都完成后 才开始执行当前任务(泡茶)

d、OR 聚合关系: OR 指的是依赖的任务只要有一个完成就可以执行当前任务

e、CompletionStage 接口也可以方便地描述异常处理

f、CompletionStage 接口描述串行关系:

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

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

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

iv. thenRun系列方法: action 的参数是 Runnable,所以 action 既不能接收参数也不支持返回值,所以 thenRun 系列方法返回的也是CompletionStage<Void>

v. 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);

vi.这些方法里面 Async 代表的是异步执行 fn、consumer 或者 action

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

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

g、CompletionStage 接口描述 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);

h、CompletionStage 接口描述OR 汇聚关系 :

i.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);

ii.使用 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());

i、CompletionStage 接口描述异常处理 :

i. fn、consumer、action 它们的核心方法都不允许抛出可检查异常,但是却无法限制它们抛出运行时异常

ii. 非异步编程里面:我们可以使用 try{}catch{}来捕获并处理异常

iii.在异步编程里面异常的处理:

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

iv.使用 exceptionally() 方法来处理异常: exceptionally() 的使用 非常类似于 try{}catch{}中的 catch{}

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

v. whenComplete() 和 handle() 系列方法就类似于 try{}finally{}中的 finally{},无论是否发生异常都会执行 whenComplete() 中的回调函数 consumer 和 handle() 中的回调函数 fn。whenComplete() 和 handle() 的区别在于whenComplete() 不支持返回结果,而 handle() 是支持返回结果的

12、CompletionService:批量执行异步任务

1.利用 CompletionService 实现询价系统

a、Java SDK 并发包里已经提供了设计精良的CompletionService。利用 CompletionService 不但能解决先获取到的报价先保存到数据库的问题,而且还能让代码更简练。

b、CompletionService 的实现原理: 内部维护了一个阻塞队列,当任务执行结束就把任务的执行结果加入到阻塞队列中,不同的是 CompletionService 是把任务执行结果的 Future对象加入到阻塞队列中

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

1. ExecutorCompletionService(Executor executor);
2. ExecutorCompletionService(Executor executor,
BlockingQueue<Future<V>> completionQueue)。

d、这两个构造方法都需要传入一个线程池,如果不指定 completionQueue,那么默认会使用无界的 LinkedBlockingQueue。任务执行结果的 Future 对象就是加入到 completionQueue 中

e、如何利用 CompletionService 来实现高性能的询价系统:

// 创建线程池
ExecutorService executor =
  Executors.newFixedThreadPool(3);
// 创建 CompletionService
CompletionService<Integer> cs = new
  ExecutorCompletionService<>(executor);
// 异步向电商 S1 询价
cs.submit(()->getPriceByS1());
// 异步向电商 S2 询价
cs.submit(()->getPriceByS2());
// 异步向电商 S3 询价
cs.submit(()->getPriceByS3());
// 将询价结果异步保存到数据库
for (int i=0; i<3; i++) {
  Integer r = cs.take().get();
  executor.execute(()->save(r));
}

没有指定 completionQueue,默认使用无界的LinkedBlockingQueue ,通过 CompletionService 接口提供的 submit() 方法提交了三个询价操作,这三个询价操作将会被 CompletionService 异步执行。最后通过 CompletionService 接口提供的 take() 方法获取一个 Future 对象(前面我们提到过,加入到阻塞队列中的是任务执行结果的 Future 对象),调用 Future 对象的 get() 方法就能返回询价操作的执行结果了。

2.CompletionService 接口说明

a、CompletionService 接口提供的方法有 5 个:

i.submit() 相关的方法有两个。一个方法参数是Callable<V> task,前面利用CompletionService 实现询价系统的示例代码中,我们提交任务就是用的它。另外一个方法有两个参数,分别是Runnable task和V result,这个方法类似于ThreadPoolExecutor 的 <T> Future<T> submit(Runnable task, T result) ,

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

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;

3.利用 CompletionService 实现 Dubbo 中的 Forking Cluster

a、Dubbo 中有一种叫做Forking 的集群模式,这种集群模式下,支持并行地调用多个查询服务,只要有一个成功返回结果,整个服务就可以返回了

b、需要提供一个地址转坐标的服务,为了保证该服务的高可用和性能,你可以并行地调用 3 个地图服务商的 API,然后只要有 1 个正确返回了结果 r,那么地址转坐标这个服务就可以直接返回 r 了。这种集群模式可以容忍 2 个地图服务商服务异常,但缺点是消耗的资源偏多

geocoder(addr) {
  // 并行执行以下 3 个查询服务,
  r1=geocoderByS1(addr);
  r2=geocoderByS2(addr);
  r3=geocoderByS3(addr);
  // 只要 r1,r2,r3 有一个返回
  // 则返回
  return r1|r2|r3;
}

c、利用 CompletionService 可以快速实现 Forking 这种集群模式:

过程:创建了一个线程池 executor 、一个CompletionService 对象 cs 和一个Future<Integer>类型的列表 futures,每次通过调 用 CompletionService 的 submit() 方法提交一个异步任务,会返回一个 Future 对象,我们把这些 Future 对象保存在列表 futures中。通过调用 cs.take().get(),我们能够拿到最快返回的任务执行结果,只要我们拿到一个正确返回的结果,就可以取消所有任务并且 返回最终结果了。

代码实现:

// 创建线程池
ExecutorService executor =
  Executors.newFixedThreadPool(3);
// 创建 CompletionService
CompletionService<Integer> cs =
  new ExecutorCompletionService<>(executor);
// 用于保存 Future 对象
List<Future<Integer>> futures =
  new ArrayList<>(3);
// 提交异步任务,并保存 future 到 futures
futures.add(
  cs.submit(()->geocoderByS1()));
futures.add(
  cs.submit(()->geocoderByS2()));
futures.add(
  cs.submit(()->geocoderByS3()));
// 获取最快返回的任务执行结果
Integer r = 0;
try {
  // 只要有一个成功返回,则 break
  for (int i = 0; i < 3; ++i) {
    r = cs.take().get();
    // 简单地通过判空来检查是否成功返回
    if (r != null) {
     break;
    }
  }
} finally {
// 取消所有任务
for(Future<Integer> f : futures)
  f.cancel(true);
}
// 返回结果
return r;

4.总结

a、当需要批量提交异步任务的时候建议你使用CompletionService。CompletionService 将线程池 Executor 和阻塞队列 BlockingQueue 的功能融合在了一起,能够让批量异步任务 的管理更简单。除此之外,CompletionService 能够让异步任务的执行结果有序化,先执行完的先进入阻塞队列,利用这个特性,你可以轻松实现后续处理的有序性,避免无谓的等待,同时还可以快速实现诸如 Forking Cluster 这样的需求

b、CompletionService 的实现类 ExecutorCompletionService,需要你自己创建线程池,虽看上去有些啰嗦,但好处是你可以让多个 ExecutorCompletionService 的线程池隔离,这种隔离性能避免几个特别耗时的任务拖垮整个应用的风险

13、Fork/Join:单机版的MapReduce

1.分治任务模型

a、线程池、Future、CompletableFuture 和 CompletionService这些工具类都是在帮助我们站在任务的视角来解决并发问题,而不是让我们纠缠在线程之间如何协作的细节上(比如线程之间如何实现等待、通知等)

b、对于简单的并行任务,你可以通过“线程池 +Future”的方案来解决;如果任务之间有聚合关系,无论是 AND 聚合还是 OR 聚合,都可以通过 CompletableFuture 来解决;而批量的并行任务,则可以通过 CompletionService 来解决。

c、并发编程可以分为三个层面的问题,分别是分工、协作和互斥,当你关注于任务的时候,你会发现你的视角已经从并发编程的细节中跳出来了,你应用的更多的是现实世界的思维模式,类比的往往是现实世界里的分工,所以我把线程池、Future、CompletableFuture 和 CompletionService 都列到了分工里面。

d、分治:指的是把一个复杂的问题分解成多个相似的子问题,然后再把子问题分解成更小的子问题,直到子问题简单到可以直接求解

e、Java 并发包里提供了一种叫做 Fork/Join 的并行计算框架,就是用来支持分治这种任务模型的。

f、分治任务模型:

i.两个阶段:一个阶段是任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果。

ii.模型图:

iii.分治任务模型里,任务和分解后的子任务具有相似性,这种相似性往往体现在任务和子任务的算法是相同的,但是计算的数据规模是不同的。具备这种相似性的问题,我们往往都采用递归算法

2.Fork/Join 的使用

a、Fork/Join 是一个并行计算的框架,主要就是用来支持分治任务模型的,这个计算框架里的Fork 对应的是分治任务模型里的任务分解,Join 对应的是结果合并

b、Fork/Join 计算框架主要包含两部分:一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务ForkJoinTask。这两部分的关系类似于 ThreadPoolExecutor 和 Runnable 的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型 ForkJoinTask

c、ForkJoinTask 是一个抽象类,最核心的方法: fork() 方法和 join() 方法 ;fork() 方法会异步地执行一个子任务,join() 方法则会阻塞当前线程来等待子任务的执行结果

d、ForkJoinTask 有两个子类——RecursiveAction 和RecursiveTask: 用递归的方式来处理分治任务的。这两个子类都定义了抽象方法compute(),不过区别是 RecursiveAction 定义的 compute() 没有返回值,而RecursiveTask 定义的 compute() 方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。

e、如何用 Fork/Join 这个并行计算框架计算斐波那契数列:

i.创建一个分治任务线程池以及计算斐波那契数列的分治任务

ii.通过调用分治任务线程池的 invoke() 方法来启动分治任务

iii.计算斐波那契数列需要有返回值,所以 Fibonacci 继承自 RecursiveTask

iv.分治任务 Fibonacci 需要实现 compute() 方法,这个方法里面的逻辑和普通计算斐波那契数列非常类似,区别之处在于计算 Fibonacci(n - 1) 使用了异步子任务,这是通过 f1.fork()这条语句实现的。

static void main(String[] args){
  // 创建分治任务线程池
  ForkJoinPool fjp =
    new ForkJoinPool(4);
  // 创建分治任务
  Fibonacci fib =
    new Fibonacci(30);
  // 启动分治任务
  Integer result =
    fjp.invoke(fib);
  // 输出结果
  System.out.println(result);
}
// 递归任务
static class Fibonacci extends
    RecursiveTask<Integer>{
  final int n;
  Fibonacci(int n){this.n = n;}
  protected Integer compute(){
   if (n <= 1)
     return n;
   Fibonacci f1 =
     new Fibonacci(n - 1);
   // 创建子任务
   f1.fork();
   Fibonacci f2 =
     new Fibonacci(n - 2);
   // 等待子任务结果,并合并结果
   return f2.compute() + f1.join();
  }
}

3.ForkJoinPool 工作原理

a、ThreadPoolExecutor 本质上是一个生产者 -消费者模式的实现,内部有一个任务队列,这个任务队列是生产者和消费者通信的媒介;ThreadPoolExecutor 可以有多个工作线程,但是这些工作线程都共享一个任务队列

b、ForkJoinPool 本质上也是一个生产者 - 消费者的实现,ThreadPoolExecutor 内部只有一个任务队列,而 ForkJoinPool 内部有多个任务队列,当我们通过 ForkJoinPool 的 invoke() 或者 submit() 方法提交任务时,ForkJoinPool 根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中

c、如果工作线程对应的任务队列空了,ForkJoinPool 支持一种叫做“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务,例如下图中,线程 T2 对应的任务队列已经空了,它可以“窃取”线程 T1 对应的任务队列的任务。如此一来,所有的工作线程都不会闲下来了

d、ForkJoinPool 中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争

4.模拟 MapReduce 统计单词数量

a、用 Fork/Join 并行计算框架来实现模拟 MapReduce 统计单词数量思路: 先用二分法递归地将一个文件拆分成更小的文件,直到文件里只有一行数据,然后统计这一行数据里单词的数量,最后再逐级汇总结果,你可以对照前面的简版分治任务模型图来理解这个过程

b、代码实现:用一个字符串数组 String[] fc 来模拟文件内容,fc 里面的元素与文件里面的行数据一一对应。关键的代码在 compute() 这个方法里面,这是一个递归方法,前半部分数据 fork 一个递归任务去处理(关键代码mr1.fork()),后半部分数据则在当前任务中递归处理(mr2.compute())。

static void main(String[] args){
  String[] fc = {"hello world",
           "hello me",
           "hello fork",
           "hello join",
           "fork join in world"};
// 创建 ForkJoin 线程池
ForkJoinPool fjp =
    new ForkJoinPool(3);
// 创建任务
MR mr = new MR(
    fc, 0, fc.length);
// 启动任务
Map<String, Long> result =
    fjp.invoke(mr);
// 输出结果
result.forEach((k, v)->
  System.out.println(k+":"+v));
}
//MR 模拟类
static class MR extends
  RecursiveTask<Map<String, Long>> {
  private String[] fc;
  private int start, end;
  // 构造函数
  MR(String[] fc, int fr, int to){
    this.fc = fc;
    this.start = fr;
    this.end = to;
}
@Override protected
Map<String, Long> compute(){
   if (end - start == 1) {
      return calc(fc[start]);
   } else {
     int mid = (start+end)/2;
     MR mr1 = new MR(
         fc, start, mid);
     mr1.fork();
     MR mr2 = new MR(
         fc, mid, end);
     // 计算子任务,并返回合并的结果
     return merge(mr2.compute(),
         mr1.join());
   }
}
// 合并结果
private Map<String, Long> merge(
    Map<String, Long> r1,
    Map<String, Long> r2) {
  Map<String, Long> result =
      new HashMap<>();
  result.putAll(r1);
  // 合并结果
  r2.forEach((k, v) -> {
     Long c = result.get(k);
     if (c != null)
        result.put(k, c+v);
     else
        result.put(k, v);
  });
  return result;
}
// 统计单词数量
private Map<String, Long>
   calc(String line) {
  Map<String, Long> result =
      new HashMap<>();
  // 分割单词
  String [] words =
     line.split("\\s+");
// 统计单词数量
  for (String w : words) {
   Long v = result.get(w);
   if (v != null)
     result.put(w, v+1);
   else
     result.put(w, 1L);
  }
  return result;
 }
}

5.总结

a、Fork/Join 并行计算框架主要解决的是分治任务。分治的核心思想是“分而治之”:将一个大的任务拆分成小的子任务去解决,然后再把子任务的结果聚合起来从而得到最终结果。这个过程非常类似于大数据处理中的 MapReduce,所以你可以把 Fork/Join 看作单机版的MapReduce

b、Fork/Join 并行计算框架的核心组件是 ForkJoinPool。ForkJoinPool 支持任务窃取机制,能够让所有线程的工作量基本均衡,不会出现有的线程很忙,而有的线程很闲的状况,所以性能很好。Java 1.8 提供的 Stream API 里面并行流也是以 ForkJoinPool 为基础的。不过需要你注意的是,默认情况下所有的并行流计算都共享一个 ForkJoinPool,这个共享的ForkJoinPool 默认的线程数是 CPU 的核数;如果所有的并行流计算都是 CPU 密集型计算的话,完全没有问题,但是如果存在 I/O 密集型的并行流计算,那么很可能会因为一个很慢的 I/O 计算而拖慢整个系统的性能。所以建议用不同的 ForkJoinPool 执行不同类型的计算任务。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值