Java并发编程学习十:线程协作

一、Semaphore

Semaphore,即信号量,可以用于控制那些需要限制并发访问量的资源。

信号量会维护“许可证”的计数,而线程去访问共享资源前,必须先拿到许可证。线程可以从信号量中去“获取”一个许可证,一旦线程获取之后,信号量持有的许可证就转移过去了,所以信号量手中剩余的许可证要减一。

线程也可以“释放”一个许可证,如果线程释放了许可证,这个许可证相当于被归还给信号量了,于是信号量中的许可证的可用数量加一。

当信号量拥有的许可证数量减到 0 时,如果下个线程还想要获得许可证,那么这个线程就必须等待,直到之前得到许可证的线程释放,它才能获取。

1. 使用流程

Semaphore的使用流程可以分为三步:

  • 初始化信号量,Semaphore的构造函数:public Semaphore(int permits, boolean fair),permits用于指定许可证的数量,fair代表是否公平,传入true代表公平策略,传入false代表非公平策略(允许插队)。
  • 让线程调用 acquire 方法或者 acquireUninterruptibly方法获取许可证,只有这个方法能顺利执行下去的话,线程才能进一步访问后面的调用服务的方法。如果此时信号量已经没有剩余的许可证了,那么线程就会等在 acquire 方法的这一行代码中。
  • 调用 release() 来释放许可证,将许可证还给信号量
2. 方法介绍

a. acquire() & acquireUninterruptibly()

两个方法都是获取许可证的方法,功能相似,但是acquire() 是可以支持中断的,也就是说,它在获取信号量的期间,假设这个线程被中断了,那么它就会跳出 acquire() 方法,不再继续尝试获取了。而 acquireUninterruptibly() 方法是不会被中断的。

b. tryAcquire()

该方法和之前介绍锁的 trylock 思维是一致的,是尝试获取许可证,相当于看看现在有没有空闲的许可证,如果有就获取,如果现在获取不到也没关系,不必陷入阻塞,可以去做别的事。

c. tryAcquire(long timeout, TimeUnit unit)

tryAcquire的重载方法,传入了超时时间。在超时时间内获取到了许可证,则往下继续执行;如果超时时间到,依然获取不到许可证,它就认为获取失败,且返回 false。

d. availablePermits()

该方法用来查询可用许可证的数量,返回一个整型的结果。

3. 实际使用

以下是Semaphore的使用示例:

public class SemaphoreDemo2 {

    static Semaphore semaphore = new Semaphore(3);

    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(50);
        for (int i = 0; i < 1000; i++) {
            service.submit(new Task());
        }
        service.shutdown();
    }

    static class Task implements Runnable {

        @Override
        public void run() {
            try {
                semaphore.acquire();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "拿到了许可证,花费2秒执行慢服务");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("慢服务执行完毕," + Thread.currentThread().getName() + "释放了许可证");
            semaphore.release();
        }
    }
}

新建一个数量为 3 的信号量,新建一个固定 50 线程的线程池,并且往里面放入 1000 个任务。每个任务在执行模拟慢服务之前,会先用信号量的 acquire 方法获取到信号量,然后再去执行这 2 秒钟的慢服务,最后利用 release() 方法来释放许可证。

执行结果如下:

pool-1-thread-1拿到了许可证,花费2秒执行慢服务
pool-1-thread-2拿到了许可证,花费2秒执行慢服务
pool-1-thread-3拿到了许可证,花费2秒执行慢服务
慢服务执行完毕,pool-1-thread-1释放了许可证
慢服务执行完毕,pool-1-thread-2释放了许可证
慢服务执行完毕,pool-1-thread-3释放了许可证
pool-1-thread-4拿到了许可证,花费2秒执行慢服务
pool-1-thread-5拿到了许可证,花费2秒执行慢服务
pool-1-thread-6拿到了许可证,花费2秒执行慢服务
慢服务执行完毕,pool-1-thread-4释放了许可证
慢服务执行完毕,pool-1-thread-5释放了许可证
慢服务执行完毕,pool-1-thread-6释放了许可证
...

实际使用中,Semaphore还可以一次性释放或者获取多个许可证,如semaphore.acquire(2)会一次性获取两个许可证,semaphore.release(3) 会一次性释放三个许可证。

为什么有这种特殊用法呢?假如有两个任务,任务 A(Task A )会调用很耗资源的方法一 method1(),任务 B (Task B )调用的是方法二 method 2,但这个方法不是特别消耗资源。现在又5个许可证,要求只能同时有 1 个线程调用方法一,或者同时最多有 5 个线程调用方法二,但是方法一和方法二不能同时被调用。

这种情况下,可以要求Task A 在执行之前要一次性获取到 5 个许可证才能执行,而 Task B 只需要获取一个许可证就可以执行了。这样的话避免了任务 A 和 B 同时运行,同时又很好的兼顾了效率,保证Task A 只会有1个线程调用,而Task B最多可以被5个线程调用,那样的话也存在浪费资源的情况。

4. Semaphore vs FixedThreadPool

信号量Semaphore 可以限制同时访问资源的编程数量,但是如果是这个目的的话,为什么不直接用固定数量线程池FixedThreadPool去限制呢?如果可以,为什么不用FixedThreadPool取代Semaphore呢?

答案是两者并不矛盾,也不能互相取代,甚至可以互相配合。

下面用两个场景说明:

场景一,假如有一个慢服务,同时只想在每天的零点附近去访问这个慢服务时受到最大线程数的限制(比如 3 个线程),而在除了每天零点附近的其他大部分时间,希望让更多的线程去访问的。这个时候,可以将线程池内的线程设置的足够多,然后在慢服务前加一个 if 判断,如果符合时间限制了(比如零点附近),再用信号量去额外限制。

场景二,实际业务中,调用慢服务的是不同的线程池,如果采用固定线程池,是没办法做到跨线程池限制。而Semaphore则不同,它可以跨线程、跨线程池,设置信号量的许可证数量,可以保证不同的线程池调用慢服务的线程不超过许可证数量。

二、CountDownLatch

CountDownLatch能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。其内部使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为0时,表示所有的线程都已经完成一些任务,然后在CountDownLatch上等待的线程就可以恢复执行接下来的任务。

如下图所示, CountDownLatch 设置的初始值为 3,T0线程调用await 方法进入等待,T1、T2、T3每一次调用 countDown 方法,内部的计数器依次减1,计数器为0时,T0线程恢复运行。

1. 方法介绍

a. 构造函数:CountDownLatch(int count)

传入count,代表计数器的初始值。

b. await()

调用 await() 方法的线程开始等待,直到倒数结束,也就是 count 值为 0 的时候才会继续执行。

c. await(long timeout, TimeUnit unit)

await()的重载方法,传入超时参数,一旦超时不再等待。

d. countDown()

将计数器减1,直到减为 0 时,之前等待的线程会被唤起

2. 实际使用

CountDownLatch 有两个典型的用法

a. 一个线程等待多个线程执行完毕,继续自己的工作

假如有5个运动员参赛,只有一个裁判。裁判员只有等5个运动员都到达了终点,才能宣布比赛结束。

public class RunDemo1 {

    public static void main(String[] args) throws InterruptedException {
        // 新建一个初始值为 5 的 CountDownLatch
        CountDownLatch latch = new CountDownLatch(5);
        // 新建一个固定 5 线程的线程池
        ExecutorService service = Executors.newFixedThreadPool(5);
        // for 循环往线程池中提交 5 个任务,每个任务代表一个运动员
        for (int i = 0; i < 5; i++) {
            final int no = i + 1;
            Runnable runnable = new Runnable() {

                @Override
                public void run() {
                    try {
                    	// 运动员随机等待一段时间,代表在跑步
                        Thread.sleep((long) (Math.random() * 10000));
                        System.out.println(no + "号运动员完成了比赛。");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        latch.countDown();
                    }
                }
            };
            service.submit(runnable);
        }
        System.out.println("等待5个运动员都跑完.....");
        // 主线程代表裁判,等待所有运动员跑完
        latch.await();
        System.out.println("所有人都跑完了,比赛结束。");
    }
}

执行结果如下:

等待5个运动员都跑完.....
4号运动员完成了比赛。
3号运动员完成了比赛。
1号运动员完成了比赛。
5号运动员完成了比赛。
2号运动员完成了比赛。
所有人都跑完了,比赛结束。

b. 多个线程等待一个线程的信号,同时开始执行

还是5个运动员和1个裁判,这次是5个运动员在起跑线上,等待裁判鸣抢才能起跑。

public class RunDemo2 {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("运动员有5秒的准备时间");
         // 新建一个初始值为 1 的 CountDownLatch
        CountDownLatch latch = new CountDownLatch(1);
        // 新建一个固定 5 线程的线程池
        ExecutorService service = Executors.newFixedThreadPool(5);
        // for 循环往线程池中提交 5 个任务,每个任务代表一个运动员
        for (int i = 0; i < 5; i++) {
            final int no = i + 1;
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    System.out.println(no + "号运动员准备完毕,等待裁判员的发令枪");
                    try {
                    	// 运动员等待裁判的鸣枪
                        countDownLatch.await();
                        System.out.println(no + "号运动员开始跑步了");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };
            service.submit(runnable);
        }
        Thread.sleep(5000);
        System.out.println("5秒准备时间已过,发令枪响,比赛开始!");
        // 裁判员鸣枪
        countDownLatch.countDown();
    }
}

执行结果如下:

运动员有5秒的准备时间
2号运动员准备完毕,等待裁判员的发令枪
1号运动员准备完毕,等待裁判员的发令枪
3号运动员准备完毕,等待裁判员的发令枪
4号运动员准备完毕,等待裁判员的发令枪
5号运动员准备完毕,等待裁判员的发令枪
5秒准备时间已过,发令枪响,比赛开始!
2号运动员开始跑步了
1号运动员开始跑步了
5号运动员开始跑步了
4号运动员开始跑步了
3号运动员开始跑步了
3. 注意点
  • CountDownLatch 的两种用法并不孤立,结合这两种用法可以应对更为复杂的业务场景
  • CountDownLatch 是不能够重用的,当完成了倒数,就不能在下一次继续使用。如果需要,可以考虑使用 CyclicBarrier 或者创建一个新的 CountDownLatch 实例。

三、CyclicBarrier

CyclicBarrier 和 CountDownLatch 确实有一定的相似性,它们都能阻塞一个或者一组线程,直到某种预定的条件达到之后,这些之前在等待的线程才会统一出发,继续向下执行。

CyclicBarrier 可以构造出一个集结点,当某一个线程执行 await() 的时候,它就会到这个集结点开始等待,等待这个栅栏被撤销。直到预定数量的线程都到了这个集结点之后,这个栅栏就会被撤销,之前等待的线程就在此刻统一出发,继续去执行剩下的任务。

1. 实际使用

考虑这样一个场景,一个班级去公园春游,公园里有一种只能三个人才能骑行的自行车,而从公园大门走到自行车驿站需要一段时间。

public class CyclicBarrierDemo {

    public static void main(String[] args) {
    	// 新建参数为 3 的 CyclicBarrier,代表需要等待 3 个线程到达这个集结点才统一放行
        CyclicBarrier cyclicBarrier = new CyclicBarrier(3);
        // for 循环中开启 6 个线程,每个线程代表一个同学
        for (int i = 0; i < 6; i++) {
            new Thread(new Task(i + 1, cyclicBarrier)).start();
        }
    }

    static class Task implements Runnable {

        private int id;
        private CyclicBarrier cyclicBarrier;

        public Task(int id, CyclicBarrier cyclicBarrier) {
            this.id = id;
            this.cyclicBarrier = cyclicBarrier;
        }

        @Override
        public void run() {
            System.out.println("同学" + id + "现在从大门出发,前往自行车驿站");
            try {
            	// 随机时间的睡眠,代表着从大门开始步行走到自行车驿站的时间
                Thread.sleep((long) (Math.random() * 10000));
                System.out.println("同学" + id + "到了自行车驿站,开始等待其他人到达");
                // 先到的同学需要等待,直到凑齐三个同学
                cyclicBarrier.await();
                System.out.println("同学" + id + "开始骑车");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
        }
    }
}

执行结果如下:

同学1现在从大门出发,前往自行车驿站
同学3现在从大门出发,前往自行车驿站
同学2现在从大门出发,前往自行车驿站
同学4现在从大门出发,前往自行车驿站
同学5现在从大门出发,前往自行车驿站
同学6现在从大门出发,前往自行车驿站
同学5到了自行车驿站,开始等待其他人到达
同学2到了自行车驿站,开始等待其他人到达
同学3到了自行车驿站,开始等待其他人到达
同学3开始骑车
同学5开始骑车
同学2开始骑车
同学6到了自行车驿站,开始等待其他人到达
同学4到了自行车驿站,开始等待其他人到达
同学1到了自行车驿站,开始等待其他人到达
同学1开始骑车
同学6开始骑车
同学4开始骑车

上面的场景中,使用了CyclicBarrier的构造函数:public CyclicBarrier(int parties),CyclicBarrier还有另外一个构造函数:public CyclicBarrier(int parties, Runnable barrierAction),表示当 parties 个线程到达集结点时,继续往下执行前,会执行这一次这个动作barrierAction。

对上面的代码进行改造如下:

CyclicBarrier cyclicBarrier = new CyclicBarrier(3, new Runnable() {
    @Override
    public void run() {
        System.out.println("凑齐3人了,出发!");
    }
});

最终的执行结果如下:

同学1现在从大门出发,前往自行车驿站
同学3现在从大门出发,前往自行车驿站
同学2现在从大门出发,前往自行车驿站
同学4现在从大门出发,前往自行车驿站
同学5现在从大门出发,前往自行车驿站
同学6现在从大门出发,前往自行车驿站
同学2到了自行车驿站,开始等待其他人到达
同学4到了自行车驿站,开始等待其他人到达
同学6到了自行车驿站,开始等待其他人到达
凑齐3人了,出发!
同学6开始骑车
同学2开始骑车
同学4开始骑车
同学1到了自行车驿站,开始等待其他人到达
同学3到了自行车驿站,开始等待其他人到达
同学5到了自行车驿站,开始等待其他人到达
凑齐3人了,出发!
同学5开始骑车
同学1开始骑车
同学3开始骑车

可以看到,barrierAction只会在每个周期内打印一次,而不是有几个线程等待就打印几次。

2. CyclicBarrier vs CountDownLatch

前面提到,这两个类的作用相似,都是阻塞一个或者一组线程,但两者之间实际还是有区别的,具体体现在以下几点:

  • 作用对象不同:CyclicBarrier 要等固定数量的线程都到达了栅栏位置才能继续执行,而 CountDownLatch 只需等待数字倒数到 0,也就是说 CountDownLatch 作用于事件,但 CyclicBarrier 作用于线程;CountDownLatch 是在调用了 countDown 方法之后把数字倒数减 1,而 CyclicBarrier 是在某线程开始等待后把计数减 1。
  • 可重用性不同:CountDownLatch 在倒数到 0 并且触发门闩打开后,就不能再次使用了,除非新建一个新的实例;而 CyclicBarrier 可以重复使用。CyclicBarrier 还可以随时调用 reset 方法进行重置,如果重置时有线程已经调用了 await 方法并开始等待,那么这些线程则会抛出 BrokenBarrierException 异常。
  • 执行动作不同:CyclicBarrier 有执行动作 barrierAction,而 CountDownLatch 没这个功能

四、Condition

Condition 这个接口的作用是等待某些条件满足后,才让线程继续运行。比如线程1需要等待特定条件才能继续执行,就可以执行 Condition 的 await 方法进入等待。此时,线程 2去达成对应的条件,再调用 Condition 的 signal 方法 [或 signalAll 方法],JVM 就会找到等待该 Condition 的线程,并予以唤醒,根据调用的是 signal 方法或 signalAll 方法,会唤醒 1 个或所有的线程。

1. 实际使用
public class ConditionDemo {
    private ReentrantLock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    void method1() throws InterruptedException {
        lock.lock();
        try{
            System.out.println(Thread.currentThread().getName()+":条件不满足,开始await");
            condition.await();
            System.out.println(Thread.currentThread().getName()+":条件满足了,开始执行后续的任务");
        }finally {
            lock.unlock();
        }
    }

    void method2() throws InterruptedException {
        lock.lock();
        try{
            System.out.println(Thread.currentThread().getName()+":需要5秒钟的准备时间");
            Thread.sleep(5000);
            System.out.println(Thread.currentThread().getName()+":准备工作完成,唤醒其他的线程");
            condition.signal();
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ConditionDemo conditionDemo = new ConditionDemo();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    conditionDemo.method2();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
        conditionDemo.method1();
    }
}

上述代码中:

  • method1代表主线程将要执行的内容,首先获取到锁,然后调用 condition.await() 方法,直到条件满足之后,才可以继续向下执行,最终在finally 中解锁
  • method2先获得锁,再用 sleep 来模拟准备时间,时间到了之后调用 condition.signal() 方法,把之前已经等待的线程唤醒
  • main 方法执行上面这两个方法,先用子线程去调用这个类的 method2 方法,接着用主线程去调用 method1 方法。

执行结果如下:

main:条件不满足,开始 await
Thread-0:需要 5 秒钟的准备时间
Thread-0:准备工作完成,唤醒其他的线程
main:条件满足了,开始执行后续的任务
2. 注意事项

使用Condition 需要注意:

  • 对应上面的案例,只有等线程 2 解锁后,线程 1 才能获得锁并继续执行。也就是说,并不是线程 2 调用了 signal 之后,主线程就可以立刻被唤醒去执行下面的代码。而是线程 2执行 unlock 之后,这个主线程才有可能去获取到这把锁,并且当获取锁成功之后才能继续执行后面的任务。
  • signalAll() 会唤醒所有正在等待的线程,而 signal() 只会唤醒一个线程。
3. Condition vs wait/notify/notifyAll

先说结论,这两种方式的效果基本一致,只是Condition 把 Object 的 wait/notify/notifyAll 转化为了一种相应的对象,更为直观可控。

在之前学到的线程基础部分,这两种方式都可以实现阻塞队列。

和Object 的 wait 一样,Condition 的await 方法会自动释放持有的 Lock 锁。同时调用 await 的时候必须持有锁,否则会抛出异常,这一点也和 Object 的 wait 一样。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值