CLH自旋锁的简易实现

前言

我们知道,当一个线程想要申请的锁已经被其他线程持有时,那么该线程需要等待其释放锁。等待的方式有两种:一种是将线程状态置为等待状态(非RUNNABLE),另一种就是自旋了。自旋就是不断检测该锁是否被释放,而不是将线程挂起或休眠。这两种方式适用于不同的场景。

当线程持有锁的时间较长时(一般情况下,也可以理解为,临界区代码较长),适合使用将线程挂起或休眠的方式,因为自旋太久会消耗大量CPU计算资源;当线程持有锁的时间较短时,适合自旋等待的方式,因为线程被挂起或休眠后需要被唤醒,而频繁的唤醒与挂起线程会导致大量的线程上下文切换,会带来巨大的开销。

实现

自旋锁分有多种,有排队自旋锁、MCS锁、CLH锁。一般都是使用CAS操作实现的。

最简单的自旋锁就是使用CAS操作不断将锁记录设置为当前线程,当CAS成功后,说明当前线程获得了锁,其他线程会继续CAS操作,直到当前线程释放了锁。

// 锁的持有者
private AtomicReference<Thread> owner = new AtomicReference<Thread>();

   public void lock() {
       Thread currentThread = Thread.currentThread();
       // CAS设置当前线程为锁的拥有者,成功则说明获得了锁
       while (!owner.compareAndSet(null, currentThread)) {
       }
   }
   
   public void unlock() {
       Thread currentThread = Thread.currentThread();
       // 只有锁的拥有者才能释放锁,释放后将锁的持有者置为null,让其他线程争夺
       owner.compareAndSet(currentThread, null);
   }

这种实现方式的缺点很明显,每个线程频繁使用CAS对原子变量更新,而这个操作会对各个处理器的缓存进行缓存同步,缓存同步本来就会带来很大的开销,频繁的缓存同步的消耗更是巨大。而且上述方式无法保证公平性。

排队自旋锁做了一些改进,保证了公平性,但仍有上面频繁缓存同步的问题,会大大降低系统性能。
而MCS锁和CLH锁很好地改善了上述问题。他们都是基于链表的高性能、公平的自旋锁。他们在自旋时都是在本地变量上自旋,极大的减少了不必要的缓存同步操作,降低了系统和内存总线的开销。
使用了MCS锁的线程在自旋申请锁时,是由它的前驱节点通知它结束自旋的。其实现较为复杂,这里不做过多说明。接下来说下本文的主题:CLH锁。

CLH锁的自旋也是在本地变量上自旋,它不断轮询前驱节点的状态,如果发现前驱节点释放了锁,那么自己就结束自旋。可以说CLH锁是主动去看它前面的节点有没有释放锁,而MCS锁是被动等它前面的节点通知它释放锁。Java的AQS的实现就是借鉴了CLH锁的思想。

CLS锁虽说也是基于链表,但他的链表是隐式的,也就是说,它的链表节点并没有前驱指针,即并没有实际持有前驱节点。CLH锁里,每个节点就是一个线程。他通过一个属性tail来保存最新加入的节点(线程),每当有线程进来时,就会先把tail保存到本地变量preNode里,然后把tail设置成自己。这样其实就是隐式的拿到了前驱节点preNode。下个线程进来时也依次类推。这样也就保证了公平性,即会按照加入链表的先后依次唤醒线程。

接下来看具体代码实现:

public class CLHLock {
	// 每个节点代表一个线程
    static class CLHNode{
    	// volatile字段,默认当前线程是获得锁的
        volatile boolean isLocked = true;
    }
    // 记录尾节点
    volatile CLHNode tail;
    // 原子更新器,保证只有一个线程更新成功
    static final AtomicReferenceFieldUpdater UPDATER = AtomicReferenceFieldUpdater.newUpdater(
            CLHLock.class,CLHNode.class,"tail"
    );

    public void lock(CLHNode currentThread){
        // 只在本地变量上自旋,高性能缘由
        CLHNode preNode = (CLHNode) UPDATER.getAndSet(this,currentThread);
        if(preNode!=null){
        	// 方便调试,加了print语句
            System.out.println(Thread.currentThread().getName() +" is waiting...");
            // 等待前驱节点释放锁
            while (preNode.isLocked){
            }
        }
        System.out.println(Thread.currentThread().getName() +" get lock...");
    }

    public void unlock(CLHNode currentThread){
        // CAS失败,说明tail != currentThread ,也就是说后面还有线程申请锁
        if(!UPDATER.compareAndSet(this,currentThread,null)){
            // 释放锁。此时才会冲刷写缓冲器、清空无效化队列,进行缓存同步
            currentThread.isLocked = false;
        }
    }
}

测试


public class CLHLockDemo{
	// 使用ThreadLocal为每个线程新建一个特有的节点对象
    static ThreadLocal<CLHLock.CLHNode> currentThread = ThreadLocal.withInitial(() -> new CLHLock.CLHNode());
    // CLH锁
    CLHLock clhLock = new CLHLock();
    // 申请锁
    public void lock(){
        clhLock.lock(currentThread.get());
    }
	// 释放锁
    public void unlock(){
        clhLock.unlock(currentThread.get());
    }


    public static void main(String[] args){
        CLHLockDemo clhLockDemo = new CLHLockDemo();
        // 新建5个线程
        for(int i=0;i<5;i++) {
            new Thread(()->{
                clhLockDemo.lock();
                try {
                    Thread.sleep(new Random().nextInt(5000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() +" Completed.");
                clhLockDemo.unlock();
            },"Thread-"+i).start();
        }
    }

}

打印结果如下:

Thread-1 get lock...
Thread-3 is waiting...
Thread-2 is waiting...
Thread-0 is waiting...
Thread-4 is waiting...
Thread-1 Completed.
Thread-0 get lock...
Thread-0 Completed.
Thread-2 get lock...
Thread-2 Completed.
Thread-3 get lock...
Thread-3 Completed.
Thread-4 get lock...
Thread-4 Completed.

有人可能会说,这不公平呀,线程0最先启动,应该是线程0最先获得锁吧。但不是这样的,首先要注意公平性指的是什么。比如说,链表里有5个正在申请锁的线程,那么他们获得锁的顺序必须是他们加入的顺序,也就是先来先得锁。这就是公平性。而因为我们示例里的代码比较简单,线程启动很快,所以可能线程0在start之后没被分配到时间片,而线程1在start后刚好被分配到时间片,这时它去CAS操作就拿到锁了。

我们可以在启动一个线程后休眠一下,这样就能保证是按启动顺序拿到锁了。

for(int i=0;i<5;i++) {
            new Thread(()->{
                clhLockDemo.lock();
                try {
                    Thread.sleep(new Random().nextInt(2000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() +" Completed.");
                clhLockDemo.unlock();
            },"Thread-"+i).start();
            // 休眠一小会再启动下一个线程,保证当前线程先加入链表里
            Thread.sleep(100);
        }
Thread-0 get lock...
Thread-1 is waiting...
Thread-2 is waiting...
Thread-3 is waiting...
Thread-4 is waiting...
Thread-0 Completed.
Thread-1 get lock...
Thread-1 Completed.
Thread-2 get lock...
Thread-2 Completed.
Thread-3 get lock...
Thread-3 Completed.
Thread-4 get lock...
Thread-4 Completed.

参考

自旋锁、排队自旋锁、MCS锁、CLH锁

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值