自旋锁-JUC系列

公众号原文:自旋锁-JUC系列

前言

2022!这个年份现在看起来都觉得有那么些恍惚的未来感,然而现在已在脚下。

无边落木萧萧下, 不尽长江滚滚来!

人生如白驹过隙!

本来计划最近把AQS源码分析做了,然后自下而上把JUC整个探一遍,集合成文记录下来。但是因为前期没有很好的笔记准备,加上内容涉及的广度比较大,只好把目标降低:控制好涉及面情况下,能有输出就行。主要还是想把以前养成的写博客习惯捡起来。

所以,就先收集和总结一些内容作为分析源码学习需要的基础知识储备,在这个过程中,发现了两个经验:

  • 连成网的知识结构更容易发挥出能量,有时候理解了一个事物后,做一些延展性的思考,总结抽象规律,以及相似性的类比都是很有必要的,以前自己总在需要做这些事的时候中断了。
  • 凡事都是从功利的角度去衡量投入回报总是会让人容易懈怠,因为世界上大部分的事情并不是为了什么而做什么可以去驱动的,如果真是如此,这也太无趣了。

主文

CAS

Compare-and-Swap,即比较并交换

是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。wiki

CAS是一条CPU的原子指令(cmpxchg指令),不会造成数据不一致问题,而在JAVA世界里,这个原子操作的能力是通过Unsafe类来提供的,这个类提供的API是低级别也就是底层操作的,比如内存操作线程调度对象操作等等。Unsafe提供的CAS方法底层实现就是CPU指令cmpxchg:

public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

再详细一点,CAS操作分成以下步骤:

  • 1,读取内存上的当前值X
  • 2,进行比较传入的期望值A和当前值X,相同则只需第三步
  • 3,写入新值B

并发累加例子

举一个场景,现在两个线程对x同时进行累加5000次,结束后打印出结果,我们期望是10000。

public class IntIncr implements Runnable{

    private static int x;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 6; i++) {
            IntIncr intIncr = new IntIncr();
            Thread t1 = new Thread(intIncr);
            t1.start();
            
            Thread t2 = new Thread(intIncr);
            t2.start();

            t1.join();
            t2.join();

            System.out.println("x:" + x);
            x = 0;
        }

    }

    @Override
    public void run() {
        for (int i = 0; i < 5000; i++) {
            x++;
        }
    }
}

结果:

x:10000
x:9130
x:10000
x:9038
x:10000
x:7391

结果并不如我们期望那样,原因是累加的操作并不是一个原子操作,所以是不保证线程安全的。

Atomic包

在JDK中的java.util.concurrent.atomic包中的类以CAS操作为基石构建出原子操作的封装类的功能,下面AtomicInteger的累加方法代码:

实现原子变量的能力不仅需要CAS,还有volatile,这部分下一篇单独展开。

// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 内存地址偏移量
private static final long valueOffset;

static {
  try {
    // class加载时计算在对象占用的内存中value的位置
    valueOffset = unsafe.objectFieldOffset
      (AtomicInteger.class.getDeclaredField("value"));
  } catch (Exception ex) { throw new Error(ex); }
}

// volatile修饰的int值
private volatile int value;

/**
 * Atomically adds the given value to the current value.
 *
 * @param delta the value to add
 * @return the updated value
 */
public final int addAndGet(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}

Unsafe类中的getAndAddInt方法实际是一个while循环使用compareAndSwapInt把累加值赋值到指定内存,能够赋值的判断条件是刚刚getIntVolatile读取的值没有变化。

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

自旋锁

内存数据同步

关于JMM(java内存模型)这里不做展开,会在下一篇中详细解释,但是需要理解内存数据的同步机制

JMM对内存进行了抽象划分:

  • 本地内存:线程执行时使用的工作内存,线程私有
  • 主内存:存储共享变量存储,线程共享

我们想一个问题,如果有一个共享变量在A线程改成了X值,在B线程看到的还是老的值,会发生什么呢?

前面的并发累加例子就是要发生的事情!

一个需要频繁修改的值,需要及时的同步给各个需要使用的线程本地内存中才行,我们先知道是有一个机制来保证的,并且重点需要明白把一个最新的值从一个本地内存同步到主内存,再同步到另一个本地内存是需要有消耗的。

自旋锁实现

从前面的Atomic包的代码实现中就看到通过一个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();
        // 只需要设置回null,就释放锁了
        cas.compareAndSet(current, null);
    }

}

这就是传说中的自旋锁,通过一个while循环,就轻松实现了。

用一句话总结自旋锁的好处,那就是自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。

考虑一下,自旋锁用在什么场景比较合适?

优化

Spin on READ

我们知道多线程的本地内存能够及时同步,执行compareAndSet会把其他线程本地内存上的锁值置成过期,当这些线程需要用到的时候会向主内存上同步。上面自旋的代码可以发现,所有没拿到锁的并发线程都在不停执行compareAndSet,如此你可以想象得到CPU需要花费精力不停的做内存数据同步,也就是CPU缓存一致性总线风暴。

那么就有了spin on READ的优化,也就是在进入compareAndSet抢锁前,先读取数据检查锁是不是已经释放,这样就大大减少了在compareAndSet上的自旋,把自旋迁移到读取数据上。实现如下:

public class TTASLock {

    private AtomicReference<Thread> cas = new AtomicReference<Thread>();

    public void lock() {
        Thread current = Thread.currentThread();
        while (true){
            // 判断未解锁 就在这里通过get自旋
            while(cas.get() != null){}
            // 使用CAS抢锁,抢到锁的推出执行加锁后逻辑
            if (cas.compareAndSet(null, current)) {
                // 抢到锁则退出外部循环
                break;
            }
        }

    }

    public void unlock() {
        Thread current = Thread.currentThread();
        // 只需要设置回null,就释放锁了
        cas.compareAndSet(current, null);
    }
}
Spin and Sleep

再想一下,如果为了减少没有获取锁的线程自旋的消耗,能不能在没获取锁的while循环里sleep一会儿,这样也可以减少compareAndSet的消耗吧,但是因为没有机制让我们知道sleep多久是合适的,sleep久了也是极大的浪费,不过这也是可以想到的思路。

TicketLock(Fair Lock)

锁有公平和不公平之分,公平锁就是保证多线程并发抢锁时能按顺序获得,先到的线程先获得锁,不公平锁就是不保证这个顺序了。那么前面写的自旋锁实现是不公平的,就有人想怎么改造一下,获得公平锁的能力。

public class TicketLock {

    // 记录票的号数,初始化是0,记号从1号开始
    private AtomicInteger ticketer = new AtomicInteger(0);
    // 叫号从1号开始
    private AtomicInteger callNumber = new AtomicInteger(1);

    public void lock() {
        // 每次触发抢锁就原子操作记号器加1,隐含顺序意义
        int num = ticketer.incrementAndGet();
        // 叫号和记录票的号数相同则获取到锁,否则自旋
        while (num != callNumber.get()){}
    }

    public void unlock() {
        // 释放锁就是把比对号加1
        callNumber.incrementAndGet();
    }
}

这个实现可以简单比对成一个生活的场景,我们去银行办理业务都是需要取号(ticketer),每次取出号码规律是累加,假设只有一个客户经理在上班,她一次只能和一个人对接,对接期间其他人就要排队,她处理完一个就叫号(callNumber),号码按顺序累加。下一位能被接待的人就是手上的号和叫号相同的人。

MCS

MCS论文

这篇论文详细解释了MCS的来源,其中这段就像在向世界介绍一个新发明:

它第一次把队列引入了进来解决问题,算是一个创新。

MCS自旋锁是一种基于单向链表的高性能、公平的自旋锁,申请加锁的线程只需要在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。

关键描述:

  • 单向链表
  • 本地变量上自旋

单向链表是数据结构,java实现一个就行,本地变量就是线程本地变量,java中就可以用ThreadLocal来实现。

public class MCSLock {

    // 链表尾部指向
    private final AtomicReference<Node> tail;
    // 每个线程维护的内部变量
    private final ThreadLocal<Node> myNode;
  
    /**
     * 节点定义
     */
    class Node {
        volatile boolean locked = false;
        Node next = null;
    }

    public MCSLock() {
        // 队尾指向 使用AtomicReference保证原子操作
        tail = new AtomicReference<>();
        // 线程本地变量维护各自线程的Node
        myNode = ThreadLocal.withInitial(() -> new Node());
    }

    public void lock() {
        Node node = myNode.get();
        // 把队尾指向到自己 注意这里是有cas 原子操作,确保了多线程并发下依次执行,保证了并发场景下链表的形成
        Node pred = tail.getAndSet(node); // step1
        // 如果原先有队尾node存在,说明已经有线程占用了锁
        if (pred != null) {
            // 把自己的节点设置成锁状态
            node.locked = true;
            // 并且把前后两个node用next连接起来
            pred.next = node; // step2
            // 自旋自己node的locked字段
            while (node.locked) {
            }
        }

    }

    // 释放锁
    public void unLock() {
        // 操作本线程的本地变量维护的node
        Node node = myNode.get();
        // 判断自己后面还有没有等待的节点
        if (node.next == null) {
            // 如果自己是最后一个节点,那么就把尾部指向改成null,这里仍然是需要使用cas操作来进行
            if (tail.compareAndSet(node, null)) {
                // 操作成功就结束了
                return;
            }
            // 前面的compareAndSet执行失败,就是说去设置尾部指向的时候被别人抢先了
            // 只有一种可能那就是有其他线程执行lock了,这里需要在自旋等待一下node.next被lock操作设置
            while (node.next == null) {
            }
        }
        // 相当于通知自己后面的节点释放锁
        node.next.locked = false;
        // 把自己设置成null,释放内存
        node.next = null;
    }
}

代码中的注释已经解释得比较清楚了,它使用一个单向链表实现线程等待的顺序效果,每个线程自旋本地变量维护的节点中的boolean字段,从而达到监听锁状态的效果。

其中,在释放锁的代码中有一个自旋判断node.next == null,要理解这点需要现理解到这里能执行到这个判断是在tail.compareAndSet(node, null)执行返回false,也就意味着必然是有线程抢占了tail的指向,再看上面lock代码中抢占tail指向是step1代码,所以这里可以确定有线程已经执行到step1了。

然后在继续看到lock操作中的step1先设置tail指向, 然后到step2才把老节点的next指向到自己,这两步分开意味着第一步完成后第二步还没执行的时候,pred.nextnull,所以去执行释放锁中的 node.next.locked = false;就直接空指针了。

CLH
public class CLHLock {
    // 尾部指向
    private final AtomicReference<Node> tail;
    // 当前线程持有节点
    private final ThreadLocal<Node> myNode;
    // 当前线程的前面节点
    private final ThreadLocal<Node> myPred;

    /**
     * 节点定义
     */
    static class Node {
        volatile boolean locked = false;
    }

    public CLHLock() {
        // 初始化尾部指向一个Node对象
        tail = new AtomicReference<>(new Node());
        myNode = ThreadLocal.withInitial(() -> new Node());
        // 初始化前面节点为空
        myPred = ThreadLocal.withInitial(() -> null);
    }

    public void lock(){
        Node node = myNode.get();
        // 设置成获得锁状态
        node.locked = true;
        // 尾部指向自己 刚开始的第一个执行的返回pred就是前面CLHLock初始化时产生的Node对象
        Node pred = tail.getAndSet(node);
        // 自己维护前面节点
        myPred.set(pred);
        // 自旋前面节点的锁状态
        while (pred.locked){}
    }

    public void unLock(){
        Node node = myNode.get();
        // 释放锁
        node.locked=false;
        // 把自己设置成那个CLHLock初始化时产生的Node对象
        myNode.set(myPred.get());
    }
}

和前面的MCS锁对比,有几个关键点的区别:

  • 1,MCS自旋的是自己节点上的状态属性,变更是有前面节点用过next引用来更改的,而CLH是自旋自己前面节点的状态属性,锁状态更新就是由前面节点释放自己节点的锁状态来达成的。
  • 2,MCS使用的是显示链表,因为它在Node定义里有next指向,而CLH是隐式链表

第一点不同和两个锁设计的起因有关,CLH的缺点是在NUMA系统结构下性能很差,但是在SMP系统结构下非常有效的。解决NUMA系统结构的思路是MCS队列锁。关于这两种结构的区别可以查看参考资料。

参考资料

1,//www.cc.gatech.edu/classes/AY2010/cs4210_fall/papers/anderson-spinlock.pdf

2,//www.eecg.utoronto.ca/~amza/ece1747h/papers/readings/tocs91.pdf

3,//en.wikipedia.org/wiki/Spinlock

4,//funzzz.fun/2021/05/19/CLH锁/

5,//www.quora.com/What-is-the-difference-between-SMP-and-NUMA-architectures