【Java并发编程实战】5.5.3 信号量Semaphore总结

1. 什么是Semaphore

Semaphore,计数信号量,用来控制同时访问某个特定资源的线程数量,需要我们设定它的最大访问数量。 Semaphore 管理着一组虚拟许可,许可的初始数量可以通过构造函数来指定。在执行操作时可以首先获取许可,并在使用后释放许可。如果没有许可,那么获取操作将阻塞直到有可用的许可。

简单来说,就是一个资源计数器,先定义上限,每取一个资源,这个计数器就减一,直至为0,此时,申请资源的线程A就会阻塞,直至其他线程释放资源,计数器加一,线程A会从阻塞态恢复,并申请资源。

Semaphore 可以用于实现一个资源池,也可以将任何一个容器变成一个有界的阻塞容器,他在限制资源访问量上有很大的用处。

2. 用法模板

// 创建一个计数阈值为5的信号量对象, 只能5个线程同时访问
Semaphore semp = new Semaphore(5);

try {
	// 申请许可,计数器,可用数变成4了
	semp.acquire();   
	try {
		// 业务逻辑
	} catch (Exception e) {

	} finally {
		// 释放许可,可用数变为5
		semp.release();
	}
} catch (InterruptedException e) {

}

3. 使用场景

3.1 当许可证的数量大于1时,Semaphore就变成了一把共享锁

在实际工作中,我们经常会接触到池化技术,例如数据库连接池、redis连接池等等,这些池化技术出现的根本原因是,池中的资源是有限的,不能无限创建,当出现高并发的场景是,我们必须保证同一时刻最大不能超过指定数量的线程来得到这些资源,那么这个时候Semaphore就派上用场了。

在如下Demo示例中,创建了一个拥有2个许可证的信号量,表示同一时刻只允许两个线程访问数据库,然后启动了10个线程去模拟获取数据库的连接,然后对数据库进行操作,在demo中,为了模拟对数据库的操作,让线程休眠了两秒钟。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Semaphore;

public class SemaphoreDemo {
    public static void main(String[] args) {
        // 创建2个许可证,表示同一时刻只允许两个线程访问数据库
        Semaphore semaphore = new Semaphore(2);
        List<Thread> threads = new ArrayList<>(10);
        Long start = System.currentTimeMillis(); // 开始时间

        for (int i = 0; i < 10; i++) {
            int index = i;
            threads.add(new Thread(() -> {
                try {
                    // 在获取数据库连接之前先要获取到许可,这样就能保证统一时刻最大允许鬼固定的线程获取到数据库资源
                    semaphore.acquire();

                    Long end = System.currentTimeMillis();
                    long offset = (end - start) / 1000;// 除以1000是为了转换成秒

                    System.out.println("在第" + offset + "秒" + "线程T" + index + "开始操作");
                    // 模拟耗时操作,比如获取数据链接并进行 保存数据
                    // 让当前线程睡眠两秒,模拟业务处理的时间
                    Thread.sleep(2000);
                    System.out.println("线程T" + index + "操作结束");

                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    semaphore.release();
                }

            }, "T" + i));
        }
        for (Thread thread : threads) {
            thread.start();
        }

    }
}

执行结果:


在第0秒线程T1开始操作     //`刚开始只有2个线程能获得执行机会`
在第0秒线程T0开始操作
线程T0操作结束       //`释放资源`
线程T1操作结束
在第`2`秒线程`T3`开始操作   //`直至2s后,另外2个线程获得执行机会,T3和T2都是在T0和T1结束后,才开始运行`
在第`2`秒线程`T2`开始操作
线程T3操作结束
线程T2操作结束
在第4秒线程T4开始操作   //`T4和T5在重现之前的逻辑`
在第4秒线程T5开始操作
线程T4操作结束
在第6秒线程T6开始操作
线程T5操作结束
在第6秒线程T7开始操作
线程T6操作结束
在第8秒线程T8开始操作
线程T7操作结束
在第8秒线程T9开始操作
线程T8操作结束
线程T9操作结束

结果分析:从控制台中我们可以发现,同一大约每隔2s最多只会有两个线程在打印,说明其他的线程都阻塞了。

根据Semaphore的特点,还可以用它来做简易版的限流器。当某一时刻系统的并发量较大的时候,可以简单的使用Semaphore来实现流量控制,只有从Semaphore中获取到许可证的连接,才让它继续访问系统,否则返回系统繁忙等提示。当然了,Semaphore的性能当然满足不了双十一这种高并发的场景,关于高性能的限流器,市面上有更好的解决方法,那就是Guava RateLimiter。

3.2 当许可证的数量等于1时,排他锁

因为Semaphore的构造方法中,我们可以传入一个int类型的参数,用来表示许可证的数量。当我们将这个参数传为1的时候,此时只有一个许可证,那么同一时刻就会只允许一个线程去访问共享资源,这个时候Semaphore就是一个排他锁了。不过与ReentrantLock不一样的地方是,Semaphore不支持重入,这一点我们从tryAcquireShared()方法的源码中就能看到。

4. Semaphore源码分析

先给个框架流程图:

在这里插入图片描述

Semaphore其实是一个共享锁,它的底层实现是队列同步器(AQS),所以Semaphore也是通过组合一个同步组件来实现具体逻辑,这个组件需要继承AQS

在Semaphore中定义了一个内部类Sync,Sync继承了AQS。

    /** All mechanics via AbstractQueuedSynchronizer subclass */
    private final Sync sync;   //Sync 成员变量

4.1 构造函数

Semaphore也区分公平性和非公平性,它通过两个内部类FairSync和NonfairSync来是实现,这两个类继承了Sync。

Semaphore提供了两个构造方法。当使用一个参数的构造方法时,创建的是非公平的Semaphore,参数permits表示许可证的数量。当使用两个参数的构造方法时,参数fair决定创建的是公平还是非公平的Semaphore,参数permits表示许可证的数量。

public Semaphore(int permits) {
    sync = new NonfairSync(permits);  //默认非公平模式
}

public Semaphore(int permits, boolean fair) {
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);  //自定义公平或非公平模式
}

在这里插入图片描述

4.2 Semaphore.acquire()

由于Semaphore同时允许多个线程访问共享资源,因此它是共享锁,所以它最终调用的是AQS中和共享式锁相关的方法。

  1. 当调用Semaphore.acquire()方法时,会先调用到成员变量syncacquireSharedInterruptibly()方法:
 public void acquire() throws InterruptedException {
        sync.acquireSharedInterruptibly(1); //调用AQS的方法
    }

在这里插入图片描述

支持中断,如果获取资源过程中阻塞,那么会响应中断,抛出中断异常

  1. 实际上是调用sync的父类AbstractQueuedSynchronizeracquireSharedInterruptibly()方法处理:
public abstract class AbstractQueuedSynchronizer{   //父类
    public final void acquireSharedInterruptibly(int arg)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();   //中断提前结束
       // tryAcquireShared()方法是尝试获取锁,方法逻辑由AQS的子类实现
       // 若返回值小于0则表示获取锁(许可证)失败。
        if (tryAcquireShared(arg) < 0) 
            doAcquireSharedInterruptibly(arg);  //加入等待队列
    }

通过方法名就能猜出,acquireSharedInterruptibly()方法是响应中断的,从而acquire()方法能响应中断。

  1. 当前线程未中断的情况下先调用tryAcquireShared()方法尝试获取许可,未获取到则调用doAcquireSharedInterruptibly()方法将当前线程加入等待队列。

在这里插入图片描述

  1. tryAcquireShared()由子类实现,公平的Semaphore(也就是FairSync)和非公共平的Samaphore(也就是NonfairSync),tryAcquireShared()方法的具体实现不一样。

下面以FairSync的源码为例,看tryAcquireShared()实现。

static final class FairSync extends Sync {
    
    protected int tryAcquireShared(int acquires) {
        for (;;) {
            // 判断同步队列中是否有线程在排队,如果队列中有线程排队,就直接返回-1,表示获取锁失败
            if (hasQueuedPredecessors())
                return -1;
            // 获取当前同步变量的值
            int available = getState();
            // 将当前同步变量的值减去即将获取的许可证数量
            /**
             * 如果remaining小于0,就表示当前线程获取锁失败,因为许可证不够了,所以直接返回remaining,此时remaining是一个负数,负数表示获取共享锁失败
             * 如果remianing大于等于0,然后将进行CAS操作,修改成功,就表示当前线程获取锁成功,返回remaining,此时remaining是一个非负数。如果修改失败,就进入下一次循环
             */
            int remaining = available - acquires;
            if (remaining < 0 ||
                compareAndSetState(available, remaining))
                return remaining;
        }
    }
}


  • 对于公平锁而言,在获取许可证之前,会先判断同步队列中是否有线程在排队,如果有,那么就直接返回-1,表示获取许可失败。因为前面有线程排队,为了保证公平性,所以此时当前线程不能插队,因此会返回-1。
  • 如果没有线程排队,那么就让此时同步变量state的值减去即将获取许可的数量,如果相减的结果小于0,表示此时许可证的数量不够了,那么就会返回一个负数,表示当前线程获取许可失败。如果相减的结果大于等于0,表示许可证数量足够,因此进行CAS操作,将state设置为剩余许可证的数量,最后如果CAS操作成功,就返回一个大于等于0的数,表示当前线程获取许可证成功。
  1. 当从子类的tryAcquireShared()方法返回后,就会回到AQS的acquireSharedInterruptibly()方法中。
  • 如果线程获取到了许可证,那么就会直接返回。
  • 如果没有获取到许可证,就会执行doAcquireSharedInterruptibly()方法。剩下的就和其他共享锁的操作一模一样了,将当前线程加入到同步队列,然后将当前线程park
  1. 对于非公平的Semaphore而言,在NonFairSync的tryAcquireShared()方法中会直接调用父类Sync的nonfairTryAcquireShared()方法。nonfairTryAcquireShared()方法的源码如下。

    final int nonfairTryAcquireShared(int acquires) {
        for (;;) {
            // 与公平锁的区别就是,不会判断同步队列中是否有线程在排队
            int available = getState();
            int remaining = available - acquires;
            if (remaining < 0 ||
                compareAndSetState(available, remaining))
                return remaining;
        }
    }
    
    

    它的逻辑几乎与公平锁一样,唯一的区别就是非公平锁在减少许可证之前,没有调用hasQueuedPredecessors()方法判断队列中是否有线程排队。

    1.先获取state的值,并执行减法操作,得到remaining值,
    2.如果remaining大于等于0,那么线程获取同步状态成功,可访问共享资源,并更新state的值,
    3.如果remaining小于0,那么线程获取同步状态失败,将被加入同步队列(通过doAcquireSharedInterruptibly(arg))
    
    4.采用无锁(CAS)并发的操作保证对state值修改的安全
    
4.2.1 doAcquireSharedInterruptibly()

负责把阻塞的线程加入队列。

注意:是以共享模式加入队列的!ReentrantLock是独占锁模式。

    private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

在这里插入图片描述

4.3 Semaphore.release()

当调用Semaphore.release()方法时,会调用到AQS的releaseShared()方法。releaseShared()方法的作用就是释放共享锁,其源码如下:

public final boolean releaseShared(int arg) {
    // 尝试释放共享锁
    if (tryReleaseShared(arg)) {
        /**
         * 当释放锁完成后,同步状态state=0,此时说明后面的线程可以获取锁了
         * 如果此时同步队列中有人的等待,就唤醒后面的线程
         * 如果无人等待,就将首节点的waitStatus设置为-3,表示同步状态可以无条件的传播下去,即后面的线程都可以直接获取锁了
         */
        doReleaseShared();
        return true;
    }
    return false;
}

和其他类型的共享锁的释放一样,也是先调用子类的tryReleaseShared()。对于Semaphore而言,会调用Semaphore的内部类Sync的tryReleaseShared()方法,源码如下:

protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        // 利用CAS操作,将同步变量的值加release
        int current = getState();
        int next = current + releases;
        if (next < current) // overflow
            throw new Error("Maximum permit count exceeded");
        if (compareAndSetState(current, next))
            return true;
    }
}

tryReleaseShared()方法的逻辑比较简单,就是将state的值加releases,也就是添加许可证,然后通过for的死循环和CAS操作来保证原子性。当返回true时,表示释放许可证成功。回到AQS中的releaseShared()方法中,剩下的逻辑就和其他类型共享锁的释放一模一样了,唤醒同步队列中正在等待的线程。

从整体来看,相对而言,Semaphore的源码相对比较简单,尤其是明白AQS的设计原理和源码实现后,Semaphore的实现非常简单。





参考:Java并发之Semaphore详解 引用使用模板
Semaphore的源码分析以及使用场景 参考场景及例子

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值