参考:

http://blog.onlycatch.com/post/自旋锁

学习自旋锁之前,请先了解CAS概念,可以看上面博客,本文仅类似笔记

自旋锁与synchronized的比较

并发编程中,锁是保证线程安全的重要手段,我们熟悉的synchronized锁本质上属于一种互斥锁,当一个线程持有该锁,其余线程是无法获取的。

自旋锁在被一个线程持有的时候,其余线程也是无法获取到锁的,那么,为什么自旋锁效率要优于一般的synchronized(重量锁)锁呢?

获取到syn锁的线程得以执行同步代码块的内容,在锁外等待的线程则进入阻塞(block)状态并进入队列中,在持锁线程释放锁后,这些等待队列中的线程被唤醒,重新竞争锁的持有权。

问题就在这里,因为线程阻塞后进入排队队列和唤醒都需要CPU从用户态转为核心态,尤其频繁的阻塞和唤醒对CPU来说是负荷很重的工作。同时统计发现,很多对象锁的锁定状态只会持续很短的一段时间,这意味着在锁竞争激烈的时候阻塞唤醒将频繁被执行,效率自然就下去了。

自旋锁就是避免了这个被频繁执行的动作,它通过CAS操作,让未持有锁的线程也执行一些无意义的空循环,这样就会一直处于running状态,而不是不断地被阻塞唤醒,这样当锁被释放的时候就省去了中间的阻塞唤醒动作而是直接去竞争锁的持有权,这无疑增加了CPU的开销,因为等待线程都是处于running状态的。自旋锁也存在问题,当等待线程多点时候,大家都不是阻塞状态而是都是处于运行状态,这种情况下CPU开销很大,因此Java虚拟机内部一般对于自旋锁有一定的次数限制,可能是50或者100次循环后就放弃,直接挂起线程,让出CPU资源。

此外,synchronized属于可重入锁(这里指重量级锁),而借助CAS实现的简单自旋锁属于不可重入锁。

对于重入锁的定义,举例synchronized说明,多个线程争抢锁,线程A获得了锁开始执行同步代码块,如果此时线程A再次请求锁,仍旧是可以进入锁的,典型例子就是synchronized代码块中嵌套另一个synchronized代码块,线程是可以执行到内部的同步块的。

也就是说,synchronized可用于递归调用,对于不可重入锁,如果执行递归调用则会造成死锁。因为同步方法递归的时候调用的是自己,非重入锁递归调用无法获得锁。

这一段是我看资料找到的,具体可以应用的实例感觉不难想象,递归方法是在处理文件夹的时候常见的方式,假设我们现在启动若干个线程去处理若干文件夹,ABC线程处理文件1,DEF线程处理文件2,首先是有公共资源的,对于ABC线程,我们如果使用synchronized锁,假设某个时间点A线程正在处理,而递归相当于方法套方法,符合上面所说的同步内套同步的场景,换成自旋锁就不行了。

所以不难理解在下面的案例中,核心思想都是在请求获取锁的时候进行自旋,保持线程的"活跃状态"-running

基于CAS的简单自旋锁

结合CAS,我们可以实现简单的自旋锁

public class SpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}

分析代码,对于CAS操作,同一时间点只有一个线程能成功,假设线程A获取锁,此时CAS成功,compare方法返回true,不会进入循环,而其他线程执行CAS时则不符合CAS条件失败,进入循环,等线程A释放锁其余线程有机会执行成功获得锁,但线程A获取锁以后再次请求锁就无法在获取,所以这个锁是不可重入锁。

这个自旋锁的优缺点都很明显,CPU占有高但是吞吐量大,不是可重入锁所以不能递归调用,此外还有一点关于锁的重要特征,就是锁的公平性

synchronized锁的状态切换和锁的公平性

synchronized不是直接就变成重量级对象锁的,在线程竞争不激烈的时候,只是会使用偏向锁,在多线程竞争资源激烈的时候,会有偏向锁->轻量级锁->自旋锁->重量级锁的状态变化

锁的公平与否

公平的性质不难理解,就像是FIFO的队列一样,排队的线程先到的先执行,在不公平的状态下吞吐量会明显更大,公平锁可以保证创建的线程都能得到充分地利用,非公平锁可能会造成某些线程一直得不到调用,但是吞吐量会大很多。

对于之前不可重入的自旋锁的改进

之前说了,对于使用CAS实现的自旋锁,拥有锁的线程不能再次获取锁,因为CAS条件在第一次获取锁的时候就被改变了,当前获取锁的线程如果再次调用lock方法,已经不符合CAS的条件,会进入循环等待,那么,可不可以做出一些改进,是的这个自旋锁是可重入的呢

下面的代码是引用博客头部声明文章中的,实现方法,引入了一个计数器,已经获取锁的线程count+1,不再做CAS操作,同理,释放锁的方法也需要改动,获取锁的次数和释放锁的次数应该是相同的,对计数器的操作取代了原CAS,这样不会打乱CAS的条件,使得这个自旋锁变成可重入锁,已获取锁的线程再次调用lock方法仍旧可以持有锁,而释放锁的时候也会逐层释放,最后一次unlock会释放第一次CAS操作获得的锁。

public class ReentrantSpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    private int count;
    public void lock() {
        Thread current = Thread.currentThread();
        if (current == cas.get()) { // 如果当前线程已经获取到了锁,获取锁次数+1,直接返回
            count++;
            return;
        }
        // 如果没获取到锁,则通过CAS自旋
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread cur = Thread.currentThread();
        if (cur == cas.get()) {
            if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
                count--;
            } else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
                cas.compareAndSet(cur, null);
            }
        }
    }
}

然后可重入性解决了,那么改如何实现锁的公平性呢?

public class TicketLock {
    /**
     * 服务号
     */
    private AtomicInteger serviceNum = new AtomicInteger(1);
    /**
     * 排队号
     */
    private AtomicInteger ticketNum = new AtomicInteger();
    /**
     * lock:获取锁,如果获取成功,返回当前线程的排队号,获取排队号用于释放锁. <br/>
     *
     * @return
     */
    public int lock() {
        int currentTicketNum = ticketNum.incrementAndGet();
        while (currentTicketNum != serviceNum.get()) {
            // Do nothing
        }
        return currentTicketNum;
    }
    /**
     * unlock:释放锁,传入当前持有锁的线程的排队号 <br/>
     *
     * @param ticketnum
     */
    public void unlock(int ticketnum) {
        serviceNum.compareAndSet(ticketnum, ticketnum + 1);
    }
}

这里原博客代码可能稍微有点问题,两个变量都是初始化为0,第一次获取锁的时候不满足条件会导致一直循环。声明了两个原子变量的数字类型,原理类似排队按照序号等待,每个线程执行的时候要先让ticket+1,然后校验一下自己的ticket和锁的serviceNum是否相等,相等才能获取锁,然后释放锁的时候再令serviceNum+1,然后循环。

举例说明执行流程,线程A获取,将tickect+1,通过校验,返回1,然后释放锁的时候serviceNum=2,线程B获取锁的时候ticket = 1 + 1,然后通过校验,c线程进入ticket = 3 就不能通过(进入死循环),只能等B释放锁

这里是有问题的,lock方法多次被调用会导致ticket变化,最终结果就是释放锁的时候CAS条件被破坏导致锁无法释放

针对这个原博主提了一个优化方法

public class TicketLockV2 {
    /**
     * 服务号
     */
    private AtomicInteger serviceNum = new AtomicInteger(1);
    /**
     * 排队号
     */
    private AtomicInteger ticketNum = new AtomicInteger();
    /**
     * 新增一个ThreadLocal,用于存储每个线程的排队号
     */
    private ThreadLocal<Integer> ticketNumHolder = new ThreadLocal<Integer>();
    public void lock() {
        int currentTicketNum = ticketNum.incrementAndGet();
        // 获取锁的时候,将当前线程的排队号保存起来
        ticketNumHolder.set(currentTicketNum);
        while (currentTicketNum != serviceNum.get()) {
            // Do nothing
        }
    }
    public void unlock() {
        // 释放锁,从ThreadLocal中获取当前线程的排队号
        Integer currentTickNum = ticketNumHolder.get();
        serviceNum.compareAndSet(currentTickNum, currentTickNum + 1);
    }
}

如果每个线程判断的时候,用的都是自己的ticket,释放锁的时候用的也是自己的ticket,自然就不会有问题,利用ThreadLocal实现了线程间的数据隔断,同理,这里的serviceNum被我改成了1,原文两个数都是0

TicketLock存在的问题:

多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量serviceNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能

解决思路,让线程转变为在本地变量上自旋,而不是serviceNum这样的共享变量

CLHLock实现

import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
/**
 * CLH的发明人是:Craig,Landin and Hagersten。
*/
public class CLHLock {
    /**
     * 定义一个节点,默认的lock状态为true
     */
    public static class CLHNode {
        private volatile boolean isLocked = true;
    }
    /**
     * 尾部节点,只用一个节点即可
     */
    private volatile CLHNode tail;
    private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<CLHNode>();
    private static final AtomicReferenceFieldUpdater<CLHLock, CLHNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(CLHLock.class, CLHNode.class,
            "tail");
    public void lock() {
        // 新建节点并将节点与当前线程保存起来
        CLHNode node = new CLHNode();
        LOCAL.set(node);
        // 将新建的节点设置为尾部节点,并返回旧的节点(原子操作),这里旧的节点实际上就是当前节点的前驱节点
        CLHNode preNode = UPDATER.getAndSet(this, node);
        if (preNode != null) {
            // 前驱节点不为null表示当锁被其他线程占用,通过不断轮询判断前驱节点的锁标志位等待前驱节点释放锁
            while (preNode.isLocked) {
            }
            preNode = null;
            LOCAL.set(node);
        }
        // 如果不存在前驱节点,表示该锁没有被其他线程占用,则当前线程获得锁
    }
    public void unlock() {
        // 获取当前线程对应的节点
        CLHNode node = LOCAL.get();
        // 如果tail节点等于node,则将tail节点更新为null,同时将node的lock状态职位false,表示当前线程释放了锁
        if (!UPDATER.compareAndSet(this, node, null)) {
            node.isLocked = false;
        }
        node = null;
    }
}

MCSLock

/**
 * MCS:发明人名字John Mellor-Crummey和Michael Scott
 */
public class MCSLock {
    /**
     * 节点,记录当前节点的锁状态以及后驱节点
     */
    public static class MCSNode {
        volatile MCSNode next;
        volatile boolean isLocked = true;
    }
    private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<MCSNode>();
    // 队列
    @SuppressWarnings("unused")
    private volatile MCSNode queue;
    // queue更新器
    private static final AtomicReferenceFieldUpdater<MCSLock, MCSNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(MCSLock.class, MCSNode.class,
            "queue");
    public void lock() {
        // 创建节点并保存到ThreadLocal中
        MCSNode currentNode = new MCSNode();
        NODE.set(currentNode);
        // 将queue设置为当前节点,并且返回之前的节点
        MCSNode preNode = UPDATER.getAndSet(this, currentNode);
        if (preNode != null) {
            // 如果之前节点不为null,表示锁已经被其他线程持有
            preNode.next = currentNode;
            // 循环判断,直到当前节点的锁标志位为false
            while (currentNode.isLocked) {
            }
        }
    }
    //这个释放锁的方法加一点补充说明
    //这里只判断了,当前节点,是否是队列的尾巴节点,而没有判断当前节点是否有前驱节点
    //因为lock方法中,所有未获取锁的节点都会自旋,只有获取了锁的线程才能调用unlock
    public void unlock() {
        MCSNode currentNode = NODE.get();
        // next为null表示没有正在等待获取锁的线程
        if ((currentNode.next == null)&&
            (UPDATER.compareAndSet(this, currentNode, null))) {
            //更新状态并设置queue为null 如果成功表示queue==currentNode,
            //即当前节点后面没有节点了 不再需要while next==null这种无意义的阻塞
                return;         
        } else {
            //如果不为null或者不成功,表示有线程在等待获取锁queue!=currentNode,
            //即当前节点后面多了节点,将等待线程对应的节点锁状态更新为false,
            // 同时将当前线程设为null
            currentNode.next.isLocked = false;
            currentNode = null;
        }
    }
}

借助链表实现了公平性,这里原方法有点小问题,我引用了答复里大佬回复的代码

先补充说明下这里声明关键字的意义,因为是借助CAS实现自旋锁,所以涉及的变量需要使用volatile来声明,这个没什么好说的,可以保证内存可见性。

然后ThreadLocal和更新器都声明为了常量,ThreadLocal结合其实现原理可知,在只需要存一个变量到当前线程的ThreadLocalMap中的时候可以声明为常量,而更新器也只需要一个实例。

实现的思路不复杂,先整理一下设计思想

利用链表,和ThreadLocal线程隔离工具,如果锁已经被某个线程获取,那么后续请求锁的线程对应的ThreadLocal中的Node对象将拼接在链表后继节点,用以代表该线程,并进入在本地变量isLocked的自旋(无意义循环)状态中

梳理代码流程:

lock:线程获取锁时新建node,存入ThreadLocal代表该线程,执行CAS成功,将queue设置为当前线程对应Node并返回,再判断之前的queue是否持有锁,如果queue旧值不为null,则证明前驱节点对应线程持有锁,将当前线程置于链表后继节点并自旋,如果后续有线程继续调用lock,queue将不等于之前调用lock的线程,而是下次调用的(请求获取锁的)

unlock:如果当前线程Node无后继节点,或者queue == currentNode,说明没有后继节点(线程)继续请求获取锁,相反的,如果有后继节点,或者说queue!=currentNode,则说明后继有节点在请求锁,将当前节点的锁给到下一节点对应的线程,然后下一线程再次判断是否需要释放锁给自己的下一节点线程,以此类推。

方法中有些许不足,我想,在释放锁的时候,将当前节点置为null,此时的当前线程其实已经不再被使用,我们可以回收掉ThreadLocal中的value强引用,调用NODE.remove()方法避免内存溢出

最后:

不难发现自旋锁和synchronized锁都是独占锁,不能被多个线程同时持有

自旋锁的吞吐量一般会大于非自旋的锁,因为省去了CPU内核状态转换的过程,等待的线程也是running的转台

对于自旋锁,可以借助原子类和ThreadLocal,以链表的数据结构实现公平机制和可重入机制