CAS、AQS、ReentrantLock机制以原理

时间:2024-03-30 22:25:39

1、CAS

1.1 基本概念

CAS 是 compare and swap 的简写,即比较并交换。它是指一种操作机制,而不是某个具体的类或方法。在 Java 平台上对这种操作进行了包装。在 Unsafe 类中,调用代码如下

 这里无法用Unsafe类看,我使用的是AtomicInteger查看的源码

 这里是三个分别是内存位置 V,旧的预期值 A 和新的值 B。操作时,先从内存位置读取到值,然后和预期值A比较。如果相等,则将此内存位置的值改为新值 B,返回 true。如果不相等,说明和其他线程冲突了,则不做任何改变,返回 false。

这种机制在不阻塞其他线程的情况下避免了并发冲突,比独占锁的性能高很多。 CAS 在 Java 的原子类和并发包中有大量使用。

1.2底层实现

 CAS 主要分三步,读取-比较-修改。其中比较是在检测是否有冲突,如果检测到没有冲突后,其他线程还能修改这个值,那么 CAS 还是无法保证正确性。所以最关键的是要保证比较-修改这两步操作的原子性。

CAS 底层是靠调用 CPU 指令集的 cmpxchg 完成的,它是 x86 和 Intel 架构中的 compare and exchange 指令。在多核的情况下,这个指令也不能保证原子性,需要在前面加上 lock 指令。lock 指令可以保证一个 CPU 核心在操作期间独占一片内存区域。那么 这又是如何实现的呢?

在处理器中,一般有两种方式来实现上述效果:

总线锁和缓存锁。

在多核处理器的结构中,CPU 核心并不能直接访问内存,而是统一通过一条总线访问。总线锁就是锁住这条总线,使其他核心无法访问内存。这种方式代价太大了,会导致其他核心停止工作。

而缓存锁并不锁定总线,只是锁定某部分内存区域。当一个 CPU 核心将内存区域的数据读取到自己的缓存区后,它会锁定缓存对应的内存区域。锁住期间,其他核心无法操作这块内存区域。

CAS 就是通过这种方式实现比较和交换操作的原子性的。值得注意的是, CAS 只是保证了操作的原子性,并不保证变量的可见性,因此变量需要加上 volatile 关键字。

1.3 ABA问题

上面提到,CAS 保证了比较和交换的原子性。但是从读取到开始比较这段期间,其他核心仍然是可以修改这个值的。如果核心将 A 修改为 B,CAS 可以判断出来。但是如果核心将 A 修改为 B 再修改回 A。那么 CAS 会认为这个值并没有被改变,从而继续操作。这是和实际情况不符的。解决方案是加一个版本号。

2.AQS

2.1基本概念 

 全称 AbstractQueuedSynchronizer。AQS 中有两个重要的成员:

  • 成员变量 state。用于表示锁现在的状态,用 volatile 修饰,保证内存一致性。同时所用对 state 的操作都是使用 CAS 进行的。state 为0表示没有任何线程持有这个锁,线程持有该锁后将 state 加1,释放时减1。多次持有释放则多次加减。
  • 还有一个双向链表,链表除了头结点外,每一个节点都记录了线程的信息,代表一个等待线程。这是一个 FIFO 的链表

3. ReentrantLock

 3.1 基本概念

Lock和 java.io.Serializable接口,并提供了与synchronized相同的互斥性和内存可见性

public class ReentrantLock implements Lock, java.io.Serializable {
.....
}

他是非公平(默认)也可以是非公平的,是支持重入的锁

3.2 底层实现 

 ReentrantLock与AQS的关系非常密切

  abstract static class Sync extends AbstractQueuedSynchronizer {
。。。。
}

 ReentrantLock的使用则是这样子

Lock lock = new ReentrantLock();

       lock.lock();
       try{
           //更新对象状态
           //捕获异常,并在必须时恢复不变性条件

       }catch (Exception e){
           e.printStackTrace();
       } finally {
           lock.unlock();
       }

 因为他继承了Lock的接口i,我们也要讲下Lock的方法

方法名称 方法描述
lock 用来获取锁,如果锁已被其他线程获取,则进行等待
tryLock 表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待
tryLock(long time, TimeUnit unit) 和tryLock()类似,区别在于它在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true
lockInterruptibly 获取锁,如果获取锁失败则进行等到,如果等待的线程被中断会相应中断信息
unlock 释放锁的操作
newCondition 获取Condition对象,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件wait()方法,而调用后,当前线程释放锁

 ReentrantLock 也实现了上面接口的内容,同时 ReentrantLock 提供了 公平锁和 非公平锁两种模式,如果没有特别的去指定使用何种方式,那么 ReentrantLock 会默认为 非公平锁,首先我们来看一下 ReentrantLock 的构造函数:

 /**
     * 无参的构造函数
     */
 public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * 有参构造函数
     * 参数为布尔类型
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

我们会发现,在默认无参构造的是非公平锁,即对应了我上文所说的默认是非公平的锁。

我们这里就要说下非公平锁是怎么个东西了。

3.2.1 非公平锁NonfairSync.lock()

 3.2.1.1 lock方法详解

当我们调用 ReentrantLock 的 lock() 方法的时候,实际上是调用了 NonfairSync 的 lock() 方法,代码如下:

static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        /**
         * Performs lock.  Try immediate barge, backing up to normal
         * acquire on failure.
         */
        final void lock() {
         //这个方法先用CAS操作,去尝试抢占该锁
         // 快速尝试将state从0设置成1,如果state=0代表当前没有任何一个线程获得了锁
            if (compareAndSetState(0, 1))
             //state设置成1代表获得锁成功
             //如果成功,就把当前线程设置在这个锁上,表示抢占成功,在重入锁的时候需要
                setExclusiveOwnerThread(Thread.currentThread());
            else
             //如果失败,则调用 AbstractQueuedSynchronizer.acquire() 模板方法,等待抢占。
                acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }

 调用 acquire(1) 实际上使用的是 AbstractQueuedSynchronizer 的 acquire() 方法,它是一套锁抢占的模板,acquire() 代码比较简单:

public final void acquire(int arg) {
     //先去尝试获取锁,如果没有获取成功,就在CLH队列中增加一个当前线程的节点,表示等待抢占。
     //然后进入CLH队列的抢占模式,进入的时候也会去执行一次获取锁的操作,如果还是获取不到,
     //就调用LockSupport.park() 将当前线程挂起。那么当前线程什么时候会被唤醒呢?当
     //持有锁的那个线程调用 unlock() 的时候,会将CLH队列的头节点的下一个节点上的线程
     //唤醒,调用的是 LockSupport.unpark() 方法。
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

acquire() 会先调用 tryAcquire() 这个钩子方法去尝试获取锁,这个方法就是在 NonfairSync.tryAcquire()下的 **nonfairTryAcquire()**,源码如下:

//一个尝试插队的过程
  final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            //获取state值
            int c = getState();
            //比较锁的状态是否为 0,如果是0,当前没有任何一个线程获取锁
            if (c == 0) {
             //则尝试去原子抢占这个锁(设置状态为1,然后把当前线程设置成独占线程)
                if (compareAndSetState(0, acquires)) {
                 // 设置成功标识独占锁
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //如果当前锁的状态不是0 state!=0,就去比较当前线程和占用锁的线程是不是一个线程
            else if (current == getExclusiveOwnerThread()) {
             //如果是,增加状态变量的值,从这里看出可重入锁之所以可重入,就是同一个线程可以反复使用它占用的锁
                int nextc = c + acquires;
                //重入次数太多,大过Integer.MAX
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            //如果以上两种情况都不通过,则返回失败false
            return false;
        }

tryAcquire() 一旦返回 false,就会则进入 acquireQueued() 流程,也就是基于CLH队列的抢占模式,在CLH锁队列尾部增加一个等待节点,这个节点保存了当前线程,通过调用 addWaiter() 实现,这里需要考虑初始化的情况,在第一个等待节点进入的时候,需要初始化一个头节点然后把当前节点加入到尾部,后续则直接在尾部加入节点。

//AbstractQueuedSynchronizer.addWaiter()
  private Node addWaiter(Node mode) {
    // 初始化一个节点,用于保存当前线程
        Node node = new Node(Thread.currentThread(), mode);
        // 当CLH队列不为空的视乎,直接在队列尾部插入一个节点
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            //如果pred还是尾部(即没有被其他线程更新),则将尾部更新为node节点(即当前线程快速设置成了队尾)
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        // 当CLH队列为空的时候,调用enq方法初始化队列
        enq(node);
        return node;
    }
    
        private Node enq(final Node node) {
        //在一个循环里不停的尝试将node节点插入到队尾里
        for (;;) {
            Node t = tail;
            if (t == null) { // 初始化节点,头尾都指向一个空节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

 将节点增加到CLH队列后,进入 acquireQueued() 方法

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            //在一个循环里不断等待前驱节点执行完毕
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {// 通过tryAcquire获得锁,如果获取到锁,说明头节点已经释放了锁
                    setHead(node);//将当前节点设置成头节点
                    p.next = null; // help GC//将上一个节点的next变量被设置为null,在下次GC的时候会清理掉
                    failed = false;//将failed标记设置成false
                    return interrupted;
                }
                //中断
                if (shouldParkAfterFailedAcquire(p, node) && // 是否需要阻塞
                    parkAndCheckInterrupt())// 阻塞,返回线程是否被中断
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

 如果尝试获取锁失败,就会进入 shouldParkAfterFailedAcquire() 方法,会判断当前线程是否阻塞

/**
 * 确保当前结点的前驱结点的状态为SIGNAL
 * SIGNAL意味着线程释放锁后会唤醒后面阻塞的线程
 * 只有确保能够被唤醒,当前线程才能放心的阻塞。
 */
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
           //如果前驱节点状态为SIGNAL
           //表明当前线程需要阻塞,因为前置节点承诺执行完之后会通知唤醒当前节点
            return true;
        if (ws > 0) {//ws > 0 代表前驱节点取消了
           
            do {
                node.prev = pred = pred.prev;//不断的把前驱取消了的节点移除队列
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
         //初始化状态,将前驱节点的状态设置成SIGNAL
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

进入阻塞阶段,会进入 parkAndCheckInterrupt() 方法,则会调用 LockSupport.park(this) 将当前线程挂起。代码如下:

// 从方法名可以看出这个方法做了两件事
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);//挂起当前的线程
    // 如果当前线程已经被中断了,返回true,否则返回false
    // 有可能在挂起阶段被中断了
    return Thread.interrupted();
}
 3.2.1.2 NonfairSync.unlock()
  1. 调用 unlock() 方法,其实是直接调用 AbstractQueuedSynchronizer.release() 操作。
  2. 进入 release() 方法,内部先尝试 tryRelease() 操作,主要是去除锁的独占线程,然后将状态减一,这里减一主要是考虑到可重入锁可能自身会多次占用锁,只有当状态变成0,才表示完全释放了锁。
  3. 如果 tryRelease 成功,则将CHL队列的头节点的状态设置为0,然后唤醒下一个非取消的节点线程。
  4. 一旦下一个节点的线程被唤醒,被唤醒的线程就会进入 acquireQueued() 代码流程中,去获取锁。
  5. public void unlock() {
            sync.release(1);
    }
    
    
    public final boolean release(int arg) {
      //尝试在当前锁的锁定计数(state)值上减1,
            if (tryRelease(arg)) {
                Node h = head;
                if (h != null && h.waitStatus != 0)//waitStatus!=0表明或者处于CANCEL状态,或者是SIGNAL表示下一个线程在等待其唤醒。也就是说waitStatus不为零表示它的后继在等待唤醒。
                    unparkSuccessor(h);
                   //成功返回true
                return true;
            }
            //否则返回false
            return false;
    }
    
    private void unparkSuccessor(Node node) {
            int ws = node.waitStatus;
             //如果waitStatus < 0 则将当前节点清零
            if (ws < 0)
                compareAndSetWaitStatus(node, ws, 0);
      //若后续节点为空或已被cancel,则从尾部开始找到队列中第一个waitStatus<=0,即未被cancel的节点
            Node s = node.next;
            if (s == null || s.waitStatus > 0) {
                s = null;
                for (Node t = tail; t != null && t != node; t = t.prev)
                    if (t.waitStatus <= 0)
                        s = t;
            }
            if (s != null)
                LockSupport.unpark(s.thread);
        }
    

    当然在 release() 方法中不仅仅只是将 state - 1 这么简单,**-1** 之后还需要进行一番处理,如果 -1 之后的 新state = 0 ,则表示当前锁已经被线程释放了,同时会唤醒线程等待队列中的下一个线程。

protected final boolean tryRelease(int releases) {
     int c = getState() - releases;
    //判断是否为当前线程在调用,不是抛出IllegalMonitorStateException异常
    if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
    boolean free = false;
   //c == 0,释放该锁,同时将当前所持有线程设置为null
    if (c == 0) {
          free = true;
          setExclusiveOwnerThread(null);
    }
    //设置state
    setState(c);
    return free;
 }

private void unparkSuccessor(Node node) {
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            // 从后往前找到离head最近,而且waitStatus <= 0 的节点
            // 其实在ReentrantLock中,waitStatus应该只能为0和-1,需要唤醒的都是-1(Node.SIGNAL)
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null) 
            LockSupport.unpark(s.thread);// 唤醒挂起线程
}

重点:unlock最好放在finally中,因为如果没有使用finally来释放Lock,那么相当于启动了一个定时炸弹,如果发生错误,我们很难追踪到最初发生错误的位置,因为没有记录应该释放锁的位置和时间,这也就是 ReentrantLock 不能完全替代 synchronized 的原因,因为当程序执行控制离开被保护的代码块时,不会自动清除锁

3.2.2 公平锁

FairSync相对来说就简单很多,只有重写的两个方法跟NonfairSync不同

inal void lock() {
    acquire(1);
}

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&// 没有前驱节点了
            compareAndSetState(0, acquires)) {// 而且没有锁
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

3.2.2、公平锁和非公平锁的区别

  • 锁的公平性是相对于获取锁的顺序而言的。
  • 如果是一个公平锁,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO,线程获取锁的顺序和调用lock的顺序一样,能够保证老的线程排队使用锁,新线程仍然排队使用锁。
  • 非公平锁只要CAS设置同步状态成功,则表示当前线程获取了锁,线程获取锁的顺序和调用lock的顺序无关,全凭运气,也就是老的线程排队使用锁,但是无法保证新线程抢占已经在排队的线程的锁。
  • ReentrantLock默认使用非公平锁是基于性能考虑,公平锁为了保证线程规规矩矩地排队,需要增加阻塞和唤醒的时间开销。如果直接插队获取非公平锁,跳过了对队列的处理,速度会更快。