谈谈Java中锁机制(自旋锁)

什么是自旋锁

自旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁已经被另外一个线程占有了,那么此线程就无法获取这把锁,该线程会等待,间隔一段时间后再次尝试获取。这种采用循环加锁,等待锁释放的机制就称为自旋锁(spinlock)

为什么需要自旋锁

由于在多处理器的环境中某些资源的有限性,有时需要互斥访问,这时候就需要引入锁的概念,只有获取到锁的线程才能对临界资源进行访问,由于多线程的核心是CPU的时分片,所以同一时刻只能又一个线程获取到锁。但是那些没有获取到锁的线程该怎么办呢?

通常有两种做法:一种是没有获取到锁的线程就一直等待判断该资源是否已经释放了锁,这种锁叫做自旋锁,它不会引起线程阻塞(本质上是一种忙等待机制,避免线程切换带来的系统开销)。还有一种是,没有获取到锁的线程把自己阻塞起来,重新等待CPU的调度,这种锁称为互斥锁

自旋锁的原理

自旋锁实现的原理比较简单,当那些不能立马获取到锁资源的线程,它们不会像互斥锁那样直接将自己挂起进入阻塞状态,而是等一会(自旋)不判断的去判断锁资源是否被释放了,如果释放了那么就去获取锁资源。这样做避免了那些锁竞争不激烈的情况下,从核心态到用户态的切换,避免了系统上下文切换的开销。

因为自旋锁避免了操作系统进程调度的和线程的切换,所以自旋锁通常适用于在时间比较短的情况下。但是如果长时间上锁的话,自旋锁是非常消耗性能的,因为它阻止了其他线程调度。如果线程持有锁的时间很长,那么其他线程将一直保持旋转状态(不断的去判断锁资源是否被释放了,并没有让出CPU)。

为了解决上面的问题,可以给自旋锁加一个自旋的时间,等到时间一到立即释放自旋锁。自旋锁的目的是占有CPU资源,等到获取锁立即进行处理,但是如何去选择自旋的时间呢。如果自旋时间太长,会有大量的线程占用CPU,导致系统性能降低。在JDK1.6之后,引入了适应性自旋锁,意味这自旋的时间是不固定的,而是由前一次在同一个锁上自旋的时间以及锁拥有的状态来决定,基本认为线程上下文切换的时间是最佳的一个自旋时间。

自旋锁的优缺点

在线程竞争不激烈的时候,自旋锁是需要等待前面一个获的锁的线程释放锁后就可以在很短的时间内获取到锁从而继续执行,避免了直接将线程阻塞再去重新等待CPU调度所浪费的两次上下文切换的系统开销,这可以极大的提升系统的性能。

在线程竞争很激烈的时候,自旋锁就显得有一点笨拙了。因为自旋锁在获取到锁资源之前CPU是一直在做无用功,同时大量的线程去竞争一个锁资源,会导致获取锁的时间很长。这种情况下,就白白的浪费了许多CPU资源。

自旋锁的几种实现方式

最简单的自旋锁实现

public class SpinLock {
    //默认锁没有被占有
    private AtomicBoolean  available = new AtomicBoolean(false);

    private static Integer nums = 0;
    
    public void lock(){
        while (!tryLock()){
        }

    }
    //获取锁
    public boolean tryLock(){
        return available.compareAndSet(false,true);
    }
    //释放锁
    public void unLock(){
        if(!available.compareAndSet(true,false)){
            throw new RuntimeException("Failed to reverse lock");
        }
    }
    public static void main(String[] args) {
        ExecutorService exec = Executors.newFixedThreadPool(100);
        SpinLock spinLock = new SpinLock();
        for(int i=0;i<100;i++){
           exec.execute(new Runnable() {
               @Override
               public void run() {
                   spinLock.lock();
                   ++nums;
                   System.out.println(nums);
                   spinLock.unLock();
               }
           });
        }
        exec.shutdown();
    }
}

测试结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KPLB2g0V-1585036077593)(en-resource://database/506:1)]
可以看到不管怎么运行最后都是100,同学们有兴趣可以去尝试着运行一下代码。并且可以试一试将线程的数量和执行的次数i改一改,看看是否有不一样的效果。(这里笔者已经证实了当线程竞争激烈的时候,自旋锁的效率是很低的(●’◡’●),大家可以亲自再去试一试哦)**

上面这个简单的实现方式,存在这一个问题无法保证线程竞争的公平性,举个例子:同时又5个线程进入方法,线程1最先抢到了锁,执行完,释放,本来当线程2要去抢锁的时候,他发现WC,线程3你咋这么快呢,比它先抢到一步,同理线程2还有可能抢不过线程4和线程5,这对线程2来说是不公平的。可能它比线程3,4,5,都要早来一步,可是偏偏又抢不到,这不就很委屈吗?联系到生活中,遇到这种先来后到的情况,我们现实什么是怎么做的呢?没错就是排队去!!

所以为了实现公平的去竞争锁资源,引入了下面这种实现自旋锁的方式

TicketLock

在计算机中,TicketLock是一种同步机制或锁定算法,它是一种自旋锁,它是使用ticket来控制线程的执行顺序。

就像票据队列管理系统一样。面包店服务机构都会使用这种方式来为每个先到达的顾客记录其到达的顺序,而不用每次都进行排队。通常这种地点都会有一个分配器,先到的人需要在这个机器上面取出自己现在排队的号码,这个号码是按照自增的顺序进行的,旁边有一个标牌显示正在服务的标志,这通常是代表目前正在服务的队列号,当前号码完成服务后,标志牌会显示下一个号码可以去服务了。

像上面系统一样,TicketLock是基于先进先出(FIFO)队列的机制。它增加了锁的公平性,其设计原则如下:TicketLock中有两个int 类型的数值,开始都是0,第一个值是队列ticket(队列票据),第二个值是出队(票据)。队列票据是线程在队列中的位置,而出队票据是现在持有锁的票证队列位置。简单来说就是队列票据是你取票号的位置,出队票据是你距离叫号的位置

public class TicketLock {
    
    private AtomicInteger queueNum = new AtomicInteger();

    private AtomicInteger dueueNum = new AtomicInteger();

    private static int nums = 0;

    public int lock(){
        int currentTickNum = dueueNum.incrementAndGet();
        while(currentTickNum !=queueNum.get()){

        }
        return currentTickNum;
    }
    
    public void unLock(int ticketNum){
        queueNum.compareAndSet(ticketNum,ticketNum+1);
    }
    
}

每次叫号的时候,都会判断自己是不是被叫的号,并且每个人在办完业务的时候,叫号机根据当前的号码的基础上+1,让队列在往前走。

但是上面的设计是有问题的,因为在自己获得号码以后是可以修改的,这就造成了系统的紊乱,锁不能及时释放。这时候就需要有一个人能确保每个人能够按照自己的号码排队办理业务的角色,在得知这一点后,我们重新设计一下这个逻辑。

public class TicketLock2 {

    // 队列票据(当前排队号码)
    private AtomicInteger queueNum = new AtomicInteger();

    // 出队票据(当前需等待号码)
    private AtomicInteger dueueNum = new AtomicInteger();

    private ThreadLocal ticketLocal = new ThreadLocal<>();

    public void lock(){
        int currentTicketNum = dueueNum.incrementAndGet();

        // 获取锁的时候,将当前线程的排队号保存起来
        ticketLocal.set(currentTicketNum);
        while (currentTicketNum != queueNum.get()){
            // doSomething...
        }
    }

    // 释放锁:从排队缓冲池中取
    public void unLock(){
        Integer currentTicket = ticketLocal.get();
        queueNum.compareAndSet(currentTicket,currentTicket + 1);
    }

}

这次就不再需要返回值,办理业务的时候,要将当前的一个号码缓存起来,再办理玩业务以后需要释放存的这条票据。

缺点:

TicketLock虽然解决了公平性的问题,但是在多处理器的系统上,每个进程和线程占用处理器都在读写一个变量queueNum,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低了系统整体的性能。

Java锁中的自旋锁就先讲到这里了哦,文中有不足的地方,欢迎各位评论其留言指正交流。

你可能感兴趣的:(Java基础)