【Java八股文之进阶篇(七)】多线程之并发工具类

并发工具类

计数器锁CountDownLatch

多任务同步神器。它允许一个或多个线程,等待其他线程完成工作,比如现在我们有这样的一个需求:

  • 有20个计算任务,我们需要先将这些任务的结果全部计算出来,每个任务的执行时间未知
  • 当所有任务结束之后,立即整合统计最终结果

要实现这个需求,那么有一个很麻烦的地方,我们不知道任务到底什么时候执行完毕,那么可否将最终统计延迟一定时间进行呢?但是最终统计无论延迟多久进行,要么不能保证所有任务都完成,要么可能所有任务都完成了而这里还在等。

所以说,我们需要一个能够实现子任务同步的工具。

public static void main(String[] args) throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(20);  //创建一个初始值为20的计数器锁
    for (int i = 0; i < 20; i++) {
        int finalI = i;
        new Thread(() -> {
            try {
                Thread.sleep((long) (2000 * new Random().nextDouble()));
                System.out.println("子任务"+ finalI +"执行完成!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            latch.countDown();   //每执行一次计数器都会-1
        }).start();
    }

    //开始等待所有的线程完成,当计数器为0时,恢复运行
    latch.await();   //这个操作可以同时被多个线程执行,一起等待,这里只演示了一个
    System.out.println("所有子任务都完成!任务完成!!!");
  
  	//注意这个计数器只能使用一次,用完只能重新创一个,没有重置的说法
}

我们在调用await()方法之后,实际上就是一个等待计数器衰减为0的过程,而进行自减操作则由各个子线程来完成,当子线程完成工作后,那么就将计数器-1,所有的子线程完成之后,计数器为0,结束等待。

循环屏障 CyclicBarrier

好比一场游戏,我们必须等待房间内人数足够之后才能开始,并且游戏开始之后玩家需要同时进入游戏以保证公平性。

假如现在游戏房间内一共5人,但是游戏开始需要10人,所以我们必须等待剩下5人到来之后才能开始游戏,并且保证游戏开始时所有玩家都是同时进入,那么怎么实现这个功能呢?我们可以使用CyclicBarrier,翻译过来就是循环屏障,那么这个屏障正式为了解决这个问题而出现的。

public static void main(String[] args) {
    CyclicBarrier barrier = new CyclicBarrier(10,   //创建一个初始值为10的循环屏障
                () -> System.out.println("飞机马上就要起飞了,各位特种兵请准备!"));   //人等够之后执行的任务
    for (int i = 0; i < 10; i++) {
        int finalI = i;
        new Thread(() -> {
            try {
                Thread.sleep((long) (2000 * new Random().nextDouble()));
                System.out.println("玩家 "+ finalI +" 进入房间进行等待... ("+barrier.getNumberWaiting()+"/10)");

                barrier.await();    //调用await方法进行等待,直到等待的线程足够多为止

                //开始游戏,所有玩家一起进入游戏
                System.out.println("玩家 "+ finalI +" 进入游戏!");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

运行结果:

玩家 7 进入房间进行等待... (0/10)
玩家 6 进入房间进行等待... (1/10)
玩家 5 进入房间进行等待... (2/10)
玩家 3 进入房间进行等待... (3/10)
玩家 2 进入房间进行等待... (4/10)
玩家 1 进入房间进行等待... (5/10)
玩家 8 进入房间进行等待... (6/10)
玩家 4 进入房间进行等待... (7/10)
玩家 9 进入房间进行等待... (8/10)
玩家 0 进入房间进行等待... (9/10)
飞机马上就要起飞了,各位特种兵请准备!
玩家 0 进入游戏!
玩家 7 进入游戏!
玩家 6 进入游戏!
玩家 5 进入游戏!
玩家 2 进入游戏!
玩家 3 进入游戏!
玩家 1 进入游戏!
玩家 8 进入游戏!
玩家 9 进入游戏!
玩家 4 进入游戏!

Process finished with exit code 0

可以看到,循环屏障会不断阻挡线程,直到被阻挡的线程足够多时,才能一起冲破屏障,并且在冲破屏障时,我们也可以做一些其他的任务。这和人多力量大的道理是差不多的,当人足够多时方能冲破阻碍,到达美好的明天。当然,屏障由于是可循环的,所以它在被冲破后,会重新开始计数,继续阻挡后续的线程:

public static void main(String[] args) {
    CyclicBarrier barrier = new CyclicBarrier(5);  //创建一个初始值为5的循环屏障

    for (int i = 0; i < 10; i++) {   //创建10个线程
        int finalI = i;
        new Thread(() -> {
            try {
                Thread.sleep((long) (2000 * new Random().nextDouble()));
                System.out.println("玩家 "+ finalI +" 进入房间进行等待... ("+barrier.getNumberWaiting()+"/5)");

                barrier.await();    //调用await方法进行等待,直到等待线程到达5才会一起继续执行

                //人数到齐之后,可以开始游戏了
                System.out.println("玩家 "+ finalI +" 进入游戏!");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        }).start();
    }
}
玩家 3 进入房间进行等待... (0/5)
玩家 0 进入房间进行等待... (1/5)
玩家 5 进入房间进行等待... (2/5)
玩家 4 进入房间进行等待... (3/5)
玩家 8 进入房间进行等待... (4/5)
玩家 8 进入游戏!
玩家 3 进入游戏!
玩家 0 进入游戏!
玩家 5 进入游戏!
玩家 4 进入游戏!
玩家 9 进入房间进行等待... (0/5)
玩家 7 进入房间进行等待... (1/5)
玩家 6 进入房间进行等待... (2/5)
玩家 1 进入房间进行等待... (3/5)
玩家 2 进入房间进行等待... (4/5)
玩家 2 进入游戏!
玩家 9 进入游戏!
玩家 7 进入游戏!
玩家 1 进入游戏!
玩家 6 进入游戏!

可以看到,通过使用循环屏障,我们可以对线程进行一波一波地放行,每一波都放行5个线程,当然除了自动重置之外,我们也可以调用reset()方法来手动进行重置操作,同样会重新计数

public static void main(String[] args) throws InterruptedException {
    CyclicBarrier barrier = new CyclicBarrier(5);  //创建一个初始值为5的计数器锁

    for (int i = 0; i < 3; i++)
        new Thread(() -> {
            try {
                barrier.await();
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        }).start();

    Thread.sleep(500);   //等一下上面的线程开始运行
    System.out.println("当前屏障前的等待线程数:"+barrier.getNumberWaiting());

    barrier.reset();
    System.out.println("重置后屏障前的等待线程数:"+barrier.getNumberWaiting());
}

运行结果:

当前屏障前的等待线程数:3
重置后屏障前的等待线程数:0
java.util.concurrent.BrokenBarrierException
	at java.util.concurrent.CyclicBarrier.dowait(CyclicBarrier.java:250)
	at java.util.concurrent.CyclicBarrier.await(CyclicBarrier.java:362)
	at javase.day15.ThreadTest01.lambda$main$0(ThreadTest01.java:20)
	at java.lang.Thread.run(Thread.java:748)
java.util.concurrent.BrokenBarrierException
	at java.util.concurrent.CyclicBarrier.dowait(CyclicBarrier.java:250)
	at java.util.concurrent.CyclicBarrier.await(CyclicBarrier.java:362)
	at javase.day15.ThreadTest01.lambda$main$0(ThreadTest01.java:20)
	at java.lang.Thread.run(Thread.java:748)
java.util.concurrent.BrokenBarrierException
	at java.util.concurrent.CyclicBarrier.dowait(CyclicBarrier.java:250)
	at java.util.concurrent.CyclicBarrier.await(CyclicBarrier.java:362)
	at javase.day15.ThreadTest01.lambda$main$0(ThreadTest01.java:20)
	at java.lang.Thread.run(Thread.java:748)

Process finished with exit code 0

可以看到,在调用reset()之后,处于等待状态下的线程,全部被中断并且抛出BrokenBarrierException异常,循环屏障等待线程数归零。那么要是处于等待状态下的线程被中断了呢?屏障的线程等待数量会不会自动减少?从运行结果看,不会减少,这个时候只能重置开启下一轮了。

public static void main(String[] args) throws InterruptedException {
    CyclicBarrier barrier = new CyclicBarrier(10);
        Runnable r = () -> {
            try {
                barrier.await();
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        };
        Thread t = new Thread(r);
        t.start();
        TimeUnit.SECONDS.sleep(1);
        System.out.println("当前屏障前的等待线程数:"+barrier.getNumberWaiting());
        t.interrupt();
        System.out.println("中断后的等待线程数:"+barrier.getNumberWaiting());
        barrier.reset();
        for (int i = 0; i < 10; i++) {
            new Thread(r).start();
        }

    }

运行结果:

当前屏障前的等待线程数:1
中断后的等待线程数:1
java.util.concurrent.BrokenBarrierException
	at java.util.concurrent.CyclicBarrier.dowait(CyclicBarrier.java:250)
	at java.util.concurrent.CyclicBarrier.await(CyclicBarrier.java:362)
	at javase.day15.ThreadTest01.lambda$main$0(ThreadTest01.java:18)
	at java.lang.Thread.run(Thread.java:748)

Process finished with exit code 0

可以看到,当await()状态下的线程被中断,那么屏障会直接变成损坏状态,一旦屏障损坏,那么这一轮就无法再做任何等待操作了。也就是说,本来大家计划一起合力冲破屏障,结果有一个人摆烂中途退出了,那么所有人的努力都前功尽弃,这一轮的屏障也不可能再被冲破了(所以CyclicBarrier告诉我们,不要做那个害群之马,要相信你的团队,不然没有好果汁吃),只能进行reset()重置操作进行重置才能恢复正常。

乍一看,怎么感觉和之前讲的CountDownLatch有点像,好了,这里就得区分一下了,千万别搞混:

  • CountDownLatch:
    1. 它只能使用一次,是一个一次性的工具
    2. 它是一个或多个线程用于等待其他线程完成的同步工具
  • CyclicBarrier
    1. 它可以反复使用,允许自动或手动重置计数
    2. 它是让一定数量的线程在同一时间开始运行的同步工具

至于这两个工具类的底层实现细节先不管了。

信号量 Semaphore

还记得我们在《操作系统》中学习的信号量机制吗?它在解决进程之间的同步问题中起着非常大的作用。

信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。

通过使用信号量,我们可以决定某个资源同一时间能够被访问的最大线程数,它相当于对某个资源的访问进行了流量控制。简单来说,它就是一个可以被N个线程占用的排它锁(因此也支持公平和非公平模式),我们可以在最开始设定Semaphore的许可证数量,每个线程都可以获得1个或n个许可证,当许可证耗尽或不足以供其他线程获取时,其他线程将被阻塞。

public static void main(String[] args) throws ExecutionException, InterruptedException {
    //每一个Semaphore都会在一开始获得指定的许可证数数量,也就是许可证配额
    Semaphore semaphore = new Semaphore(2);   //许可证配额设定为2

    for (int i = 0; i < 3; i++) {
        new Thread(() -> {
            try {
                semaphore.acquire();   //申请一个许可证
                System.out.println("许可证申请成功!");//中间的代码其实就是被限流的资源
                semaphore.release();   //归还一个许可证
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

运行结果:

许可证申请成功!
许可证申请成功!
许可证申请成功!

Process finished with exit code 0

public static void main(String[] args) throws ExecutionException, InterruptedException {
    //每一个Semaphore都会在一开始获得指定的许可证数数量,也就是许可证配额
    Semaphore semaphore = new Semaphore(3);   //许可证配额设定为3

    for (int i = 0; i < 2; i++)
        new Thread(() -> {
            try {
                semaphore.acquire(2);    //一次性申请两个许可证
                System.out.println("许可证申请成功!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    
}

运行结果:

许可证申请成功!

Process finished with exit code 130

我们也可以通过Semaphore获取一些常规信息:

public static void main(String[] args) throws InterruptedException {
    Semaphore semaphore = new Semaphore(1);   //只配置一个许可证,5个线程进行争抢,不内卷还想要许可证?
    for (int i = 0; i < 5; i++)
        new Thread(semaphore::acquireUninterruptibly).start();   //可以以不响应中断(主要是能简写一行,方便)
    Thread.sleep(500);
    System.out.println("剩余许可证数量:"+semaphore.availablePermits());
    System.out.println("是否存在线程等待许可证:"+(semaphore.hasQueuedThreads() ? "是" : "否"));
    System.out.println("等待许可证线程数量:"+semaphore.getQueueLength());
}

运行结果:

剩余许可证数量:0
是否存在线程等待许可证:是
等待许可证线程数量:4

程序不会结束,因为还有四个线程试图获取信号量。
我们可以手动回收掉所有的许可证:

public static void main(String[] args) throws InterruptedException {
    Semaphore semaphore = new Semaphore(3);
    new Thread(semaphore::acquireUninterruptibly).start();
    Thread.sleep(500);
    System.out.println("收回剩余许可数量:"+semaphore.drainPermits());   //直接回收掉剩余的许可证
}

运行结果:

收回剩余许可数量:2

这里我们模拟一下,比如现在有10个线程同时进行任务,任务要求是执行某个方法,但是这个方法最多同时只能由5个线程执行,这里我们使用信号量就非常合适。具体操作就是:在执行方法前,先去获取信号量。

数据交换Exchanger

线程之间的数据传递也可以这么简单。

使用Exchanger,它能够实现线程之间的数据交换:

public static void main(String[] args) throws InterruptedException {
    Exchanger<String> exchanger = new Exchanger<>();
    new Thread(() -> {
        try {
            System.out.println("收到主线程传递的交换数据:"+exchanger.exchange("AAAA"));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
    System.out.println("收到子线程传递的交换数据:"+exchanger.exchange("BBBB"));
}

运行结果:

收到主线程传递的交换数据:BBBB
收到子线程传递的交换数据:AAAA

Process finished with exit code 0

在调用exchange方法后,当前线程会等待其他线程调用同一个exchanger对象的exchange方法,当另一个线程也调用之后,方法会返回对方线程传入的参数。

可见功能还是比较简单的。

Fork/Join框架

在JDK7时,出现了一个新的框架用于并行执行任务,它的目的是为了把大型任务拆分为多个小任务,最后汇总多个小任务的结果,得到整大任务的结果,并且这些小任务都是同时在进行,大大提高运算效率。Fork就是拆分,Join就是合并

我们来演示一下实际的情况,比如一个算式:18x7+36x8+9x77+8x53,可以拆分为四个小任务:18x7、36x8、9x77、8x53,最后我们只需要将这四个任务的结果加起来,就是我们原本算式的结果了,有点归并排序的味道。

image-20220316225312840

不仅仅只是拆分任务并使用多线程,而且还可以利用工作窃取算法,提高线程的利用率

工作窃取算法:是指某个线程从其他队列里窃取任务来执行。一个大任务分割为若干个互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务待处理。干完活的线程与其等着,不如帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。

image-20220316230928072

现在我们来看看如何使用它,这里以计算1-1000的和为例,我们可以将其拆分为8个小段的数相加,比如1-125、126-250… ,最后再汇总即可,它也是依靠线程池来实现的

public class Main {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        ForkJoinPool pool = new ForkJoinPool();
        System.out.println(pool.submit(new SubTask(1, 1000)).get());
    }


  	//继承RecursiveTask,这样才可以作为一个任务,泛型就是计算结果类型
    private static class SubTask extends RecursiveTask<Integer> {
        private final int start;   //比如我们要计算一个范围内所有数的和,那么就需要限定一下范围,这里用了两个int存放
        private final int end;

        public SubTask(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        protected Integer compute() {
            if(end - start > 125) {    //每个任务最多计算125个数的和,如果大于继续拆分,小于就可以开始算了
                SubTask subTask1 = new SubTask(start, (end + start) / 2);
                subTask1.fork();    //会继续划分子任务执行
                SubTask subTask2 = new SubTask((end + start) / 2 + 1, end);
                subTask2.fork();   //会继续划分子任务执行
                return subTask1.join() + subTask2.join();   //越玩越有递归那味了
            } else {
                System.out.println(Thread.currentThread().getName()+" 开始计算 "+start+"-"+end+" 的值!");
                int res = 0;
                for (int i = start; i <= end; i++) {
                    res += i;
                }
                return res;   //返回的结果会作为join的结果
            }
        }
    }
}

运行结果:

ForkJoinPool-1-worker-2 开始计算 1-125 的值!
ForkJoinPool-1-worker-2 开始计算 126-250 的值!
ForkJoinPool-1-worker-0 开始计算 376-500 的值!
ForkJoinPool-1-worker-6 开始计算 751-875 的值!
ForkJoinPool-1-worker-3 开始计算 626-750 的值!
ForkJoinPool-1-worker-5 开始计算 501-625 的值!
ForkJoinPool-1-worker-4 开始计算 251-375 的值!
ForkJoinPool-1-worker-7 开始计算 876-1000 的值!
500500

可以看到,结果非常正确,但是整个计算任务实际上是拆分为了8个子任务同时完成的,结合多线程,原本的单线程任务,在多线程的加持下速度成倍提升。

包括Arrays工具类提供的并行排序也是利用了ForkJoinPool来实现(ForkJoin适合处理数据量大的问题):

public static void parallelSort(byte[] a) {
    int n = a.length, p, g;
    if (n <= MIN_ARRAY_SORT_GRAN ||
        (p = ForkJoinPool.getCommonPoolParallelism()) == 1)
        DualPivotQuicksort.sort(a, 0, n - 1);
    else
        new ArraysParallelSortHelpers.FJByte.Sorter
            (null, a, new byte[n], 0, n, 0,
             ((g = n / (p << 2)) <= MIN_ARRAY_SORT_GRAN) ?
             MIN_ARRAY_SORT_GRAN : g).invoke();
}

并行排序的性能在多核心CPU环境下,肯定是优于普通排序的,并且排序规模越大优势越显著。

至此,并发编程篇完结。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kplusone

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值