简单的非公平自旋锁以及基于排队的公平自旋锁的实现

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/dm_vincent/article/details/79677891

基础

什么是自旋锁

由于本文主要讨论的都是自旋锁,所以首先就需要弄明白什么是自旋锁。

自旋锁最大的特征,就是它会一直循环检测锁的状态,当锁处于被占用的状态时,不会将当前线程阻塞住,而是任由它继续消耗CPU Cycles,直到发现需要的锁处于可用状态。

有了这一层了解,自旋锁的优势和劣势,以及其适用场景也就一目了然了。

优势:

  1. 没有线程阻塞,也就没有了线程上下文切换带来的开销
  2. 自旋操作更加直观,无需分析什么情况下会导致线程阻塞

劣势:

最大的问题就是由于需要一直循环检测锁的状态,因此会浪费CPU Cycles

适用场景:

结合上述的优劣,自旋锁在锁的临界区很小并且锁争抢排队不是非常严重的情况下是非常合适的:

  1. 临界区小,因此每个使用锁的线程占用锁的时间不会很长,自旋等待的线程能够快速地获取到锁。
  2. 所争抢排队不严重,因此锁的自旋时间也是可控的,不会有大量线程处于自旋等待的状态中。

自旋锁只是一种加锁和释放的实现策略。它也能够分为非公平和公平两种情况。这一点很好理解,每个需要加锁的线程没有先来后到的概念,完全根据当时运行时的情况来决定哪个线程能够成功加锁。而公平锁则通过使用一个队列对线程进行排队来保证线程的先来后到。

核心机制

对于加锁和释放锁的操作,需要是原子性的。这是能够继续讨论的基石。对于现代处理器,一般通过CAS(Compare And Set)操作来保证原子性。它的原理其实很简单,就是将“对比-设置”这一个流程原子化,保证在符合某种预期的前提下,完成一次写操作。

对应到Java语言层面,就是那一大票的AtomicXXX类型。比如在下面的非公平自旋锁的实现中,会借助AtomicReference类型提供的CAS操作来完成加锁和释放锁的操作。

非公平自旋锁

实现比较简单,直接上代码:

public class SimpleSpinLock {

    /**
     * 维护当前拥有锁的线程对象
     */
    private AtomicReference<Thread> owner = new AtomicReference<>();

    public void lock() {
        Thread currentThread = Thread.currentThread();

        // 只有owner没有被加锁的时候,才能够加锁成功,否则自旋等待
        while (!owner.compareAndSet(null, currentThread)) {

        }
    }

    public void unlock() {
        Thread currentThread = Thread.currentThread();

        // 只有锁的owner才能够释放锁,其它的线程因为无法满足Compare,因此不会Set成功
        owner.compareAndSet(currentThread, null);
    }

}

这里的关键就是加锁和释放锁中的两个CAS操作:

  1. 加锁过程。将CAS操作置于一个while循环中,来实现自旋的语义。由于CAS操作成功与否是成功取决于它的boolean返回值,因此当CAS操作失败的情况下,while循环将不会退出,会一直尝试CAS操作直到成功为止,此即所谓的自旋(忙等待)。
  2. 释放锁过程。此时不需要循环操作,但是仍然会考虑到只有当前拥有锁的线程才有资格释放锁。这一点还是通过CAS操作来保证。

这个锁的实现是比较简单的,关键需要了解自旋锁的原理和实现层面的CAS操作。

从加锁的实现来看,加锁过程并没有考虑到先来后到,因此也就不是一个公平的加锁策略。下面介绍一种基于排队的公平自旋锁的实现,它类似于我们在日常生活中的各种服务场景下的排队。比如你去银行办理业务,需要首先在叫号机上拿一个号码,然后你就处于等待状态,然后时不时地看一下当前叫到哪个号码了(自旋等待),直到你的号码被柜台呼叫(加锁成功)。服务完成后,柜台会呼叫下一个号码(释放锁)。

基于排队的公平自旋锁

我们可以使用两个原子整型变量来分别模拟当前排队号和当前服务号。

加锁和释放锁两个操作的过程如下:

  1. 加锁过程。获取一个排队号,当排队号和当前的服务号不相等时自旋等待。
  2. 释放锁过程。当前正被服务的线程释放锁,计算下一个服务号并设置。

相应的代码如下所示:

public class TicketLock {

    /**
     * 当前正在接受服务的号码
     */
    private AtomicInteger serviceNum = new AtomicInteger(0);

    /**
     * 希望得到服务的排队号码
     */
    private AtomicInteger ticketNum  = new AtomicInteger(0);

    /**
     * 尝试获取锁
     *
     * @return
     */
    public int lock() {
        // 获取排队号
        int acquiredTicketNum = ticketNum.getAndIncrement();

        // 当排队号不等于服务号的时候开始自旋等待
        while (acquiredTicketNum != serviceNum.get()) {

        }

        return acquiredTicketNum;
    }

    /**
     * 释放锁
     *
     * @param ticketNum
     */
    public void unlock(int ticketNum) {
        // 服务号增加,准备服务下一位
        int nextServiceNum = serviceNum.get() + 1;

        // 只有当前线程拥有者才能释放锁
        serviceNum.compareAndSet(ticketNum, nextServiceNum);
    }

}

这里需要注意的是:

  1. 加锁过程。lock方法会返回一个排队号,这个排队号在后面释放锁的过程中会被用到。
  2. 释放锁过程。接受希望释放锁的线程的排队号。在CAS增加服务号的过程中会首先验证排队号的合法性。

总结

本文首先讨论了什么是自旋锁,以及它的优劣和对应的应用场景。

然后给出了两种简单的自旋锁的实现,分别对应非公平和公平两种策略。

自旋锁在Java并发包中扮演着很重要的角色,下一篇文章会分析MCS和CLH这两种更加高级的自旋锁原理和相应实现,为后续分析Java并发包中的基石AbstractQueuedSynchronizer扫清障碍。

参考资料

  1. https://coderbee.net/index.php/concurrent/20131115/577
  2. https://en.wikipedia.org/wiki/Spinlock
  3. https://en.wikipedia.org/wiki/Ticket_lock

猜你喜欢

转载自blog.csdn.net/dm_vincent/article/details/79677891
今日推荐