如何让多线程步调一致
上面的几篇文章主要介绍Java SDK中提供互斥的工具类:管程synchronized,Lock,ReadWriteLock,StampedLock,LockSupport。下面我们将分几篇文章介绍Java SDK中提供线程协作的工具类,今天讲解CountDownLatch和CyclicBarrier两个工具类。
下面介绍一个场景,全文围绕该场景介绍这两个工具类
业务介绍
1.创建线程优化业务
while(存在未对账订单){
// 查询未对账订单
Thread T1 = new Thread(()->{ pos = getPOrders(); });
T1.start();
// 查询派送单
Thread T2 = new Thread(()->{ dos = getDOrders(); });
T2.start();
// 等待T1、T2结束
T1.join();
T2.join();
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
main主线程调用线程T1,T2的join方法等待线程执行结束退出,再执行对账和写入库的操作。
2. java的线程和操作系统的线程之间的关系:
操作系统实现的线程是稳定极好的,所以java的线程调度交给操作系统了,而线程的创建、销毁、调度和维护,都是靠操作系统(准确的说是内核)来实现,程序员只需要使用线程即可,而不需要自己设计线程的调度算法和线程对CPU资源的抢占使用。
3.线程池方式实现业务
上面我们知道线程的创建和销毁是重量级的,所以我们开发中经常使用线程池,重复使用线程,避免线程的创建和销毁。
下面我们用线程池方式实现上面的业务:
// 创建2个线程的线程池
Executor executor = Executors.newFixedThreadPool(2);
while(存在未对账订单){
// 查询未对账订单
executor.execute(()-> { pos = getPOrders(); });
// 查询派送单
executor.execute(()-> { dos = getDOrders();});
/* ??如何实现等待??*/
// 执行对账操作
diff = check(pos, dos);
// 差异写入差异库
save(diff);
}
因为线程池的线程是不会退出的,所以Thread的join方法失效了,怎么办?使用Java SDK工具包的CountDownLatch。
4 CountDownLatch
4.1 CountDownLatch适用场景
适用场景:CountDownLatch 主要用来解决一个线程等待多个线程的场景。
4.2 用 CountDownLatch 实现线程等待
// 创建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(); // 等待两个查询操作结束,实现对计数器=0的等待。
diff = check(pos, dos); // 执行对账操作
save(diff); // 差异写入差异库
}
首先我们创建了一个CountDownLatch,计数器初始值是2,当执行完getPOrders方法,调用latch.countDown(),计数器-1,等到计数器=0时,会唤醒执行latch.await()继续向下执行。
5.进一步优化性能
上面我们通过开启两个线程分别执行查询未对账订单和查询派送单,然后等待查询结束再执行对账操作。
其实还有优化地步,就是在执行对账操作的时候,可以同时再去查询未对账订单和查询派送单。
上面这幅图可以表达我们的意图:当线程T1和线程T2都生产完一条数据(两者需要相互等待),才能继续往下走(查询订单和拍送单),并且执行线程3执行对账。
其实就是生产者消费者模式,线程T1,T2都生产出一条数据,放入队列中,消费者T3消费。
实现难点有两个:
- 线程T1,T2都生产了一条数据,才能执行下一轮的查询。
- 另一方面要通知到T3.
因为CountDownLatch在=0之后不会重置计数器,所以下面就使用CyclicBarrier来实现该操作。
6.CyclicBarrier
6.1 CyclicBarrier适用场景
适用场景:CyclicBarrier 是一组线程之间互相等待。
除此之外 CountDownLatch 的计数器是不能循环利用的,也就是说一旦计数器减到 0,再有线程调用 await(),该线程会直接通过。但 CyclicBarrier 的计数器是可以循环利用的,当计数器=0时,调用回调函数,重置计数器的值。
6.2 用 CyclicBarrier 实现线程同步(协作)
线程同步:就是控制线程之间协作。下面使用CyclicBarrier 实现上面的优化。
// 订单队列
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();
}
- 创建CyclicBarrier,计数器初始值为2,=0时执行回调函数check()方法。
- 当执行checkall()方法,开启两个线程分别查询订单和派送单,查询结束,计数器-1。
- 当计数器=0时,使用线程池中的线程执行check()方法,同时重置计数器,开启下一轮的查询订单和派送单。
三个问题:
- CyclicBarrier的回调函数在哪个线程执行啊?
CyclicBarrier的回调函数执行在一个回合中最后执行barrier.await()的线程上,而且同步执行check()方法,调用完check()方法,才能开启下一回合。所以check()如果开启另外一个线程异步执行,就起不到性能优化的作用了。 - 为啥要用线程池,而不是在回调函数中直接调用?
相信你通过上一个问题也差不多明白了,对就是为了异步。 - 线程池为啥要使用单线程的?
线程数量固定为1,防止了多线程并发导致的数据不一致,因为订单和派送单是两个队列,只有单线程去两个队列中取消息才不会出现消息不匹配的问题。(其实可以把每一回合的两个数据封装成一个数据放到一个队列中,多线程取就不会有这种问题了)