CAS、Synchronized、ReentrantLock原理

1,569 阅读18分钟

前言

锁相关概念、Synchronized的一些优化、CAS实现(AtomicInteger为例)、ReentrantLock实现原理、AQS

目录

一、锁相关概念

1、AQS(AbstractQueuedSynchronizer)

java.util.concurrent类的许多阻塞类,例如ReentrantLockSemaphoreReentrantRead-WriteLockCountDownLatchSynchronousQueueFultureTask等都是基于AQS构建的。AQS内部维护了一个等待线程队列,其中记录了某个线程请求的是独占访问还是共享访问(condition)。

2、独占锁

独占锁是一种悲观技术,比较并交换(CAS)是乐观技术,大多数处理器架构使用的是CASCAS包括3个操作数----需要读写的内存位置V、进行比较的值A和拟写入的新值B当且仅当V的值等于ACAS``才会通过原子方式用新值``B来更新V的值,否则不执行任何操作,无论位置V的值是否等于A,都将返回V的值。

3、乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发的可能性低。Java中的乐观锁基本是通过CAS操作实现的。AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到才会转为悲观锁,如ReentrantLock

4、自旋锁

自旋锁原理很简单,如果持有锁的线程能在很短时间内释放资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程和内核线程的切换消耗。

注意:线程自旋是需要消耗CPU的,如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。适用于锁的竞争不激烈场景,减少线程阻塞。

5、偏向锁

偏向锁,顾名思义,它会偏向第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况下,就会给线程加一个偏向锁。适用场景为只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行,也就是在锁无竞争的情况下使用。一旦有了竞争就会升级为轻量级锁,升级为轻量级锁的时候就需要撤销偏向锁,撤销偏向锁的时候就会导致stop the world操作。

6、轻量级锁

轻量级锁,由偏向锁升级而来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用时,就会升级为轻量锁。

7、Synchronized

Synchronized或导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级锁,为了缓解性能问题,JVM1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,这些都属于乐观锁。

8、对象头

对象内存结构,由三部分构成,分别是对象头、对象实例、对齐填充。

对象头包括两部分,第一部分是markword,用于存储对象自身运行时数据,如哈希码(HashCode)、GC分代年龄锁状态标志线程持有的锁偏向线程ID偏向时间戳等。这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit。第二部分是类类型指针,即对象指向它的元数据指针,虚拟机通过这个指针来确定这个对象是哪个实例的。**如果是数组对象的话,那么对象头中还必须有一块数据记录数组长度。**对象实例这部分则是对象真正存储的有效信息,页时程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的还是在子类中定义的,都需要记录下来。对齐填充不是必须的,仅仅起着占位符的作用,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

markword

9、锁的分类

二、CAS原理

1、原理

本质是利用了处理器支持的CAS指令,循环指令,直到成功

我们看一下 Java JDK中相关原子操作类

基本类型:AtomicBooleanAtomicIntegerAtomicLong

数组类型:AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray

引用类型:AtomicReferenceAtomicMarkableReferenceAtomicStampedReference

我平时用到得大范围内只有基本类型,这里通过AtomicInteger举例,主要是其 getAndAddgetAndSet方法

     public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(1);
        new Thread(new Runnable() {
            @Override
            public void run() {
                atomicInteger.getAndSet(4);
                System.out.println(atomicInteger.get());
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                atomicInteger.getAndAdd(1);
                System.out.println(atomicInteger.get());
            }
        }).start();

    }

输出 4、5

getAndAdd 方法

    public final int getAndAdd(int delta) {
        return unsafe.getAndAddInt(this, valueOffset, delta);
    }
    //# UnSafe类
    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;
    }
    
    public native int getIntVolatile(Object var1, long var2);
    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

最后都是通过调用native方法实现的

getAndAddInt 中,通过 getIntVolatile 方法 拿到当前 AtomicInteger 在内存中的 value 值,然后通过调用 UnSafe 类的compareAndSwapInt 尝试将 AtomicInteger 的值修改为内存中的值 + 新增加的值,var4 就是通过getAndAdd的参数值,var2valueOffsetvalueOffset是如何获取的?

    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

valueOffst 代表着 value 变量在内存中的偏移地址,每次修改只能对内存中同一个地址进行修改,保证一个变量的原子操作。

所以CAS操作可以理解为如下

多线程并发情况下,线程1、线程2、线程3同时执行 count ++ 操作,

执行步骤(对应于getAndAddInt方法的do-while语句逻辑):

从内存中取出count的值,为0

然后都执行count ++ ,count在每个线程内值都为1

然后线程1会 判断当前count值是否是0,如果是0,则count值由0变为1(假设这里线程1执行成功)

然后线程2会判断当前count值是否为0,不是,则从内存中继续重新取出count值,此时为1,再执行count++,然后再会比较内存中count值是否为1,为1,则count值由1变成2

线程3也是如此,从内存中取得count值不是0,则重新取,此时内存中count值为2,再执行count++操作,再看内存中的count值是否为2,是则count值由2变成3

虽然说通过加锁也能实现原子操作,但是加锁的方式涉及到线程切换上下文,而CAS不存在上下文切换,这一点比加锁好。(CPU执行一条指令时间大概0.6纳秒,上下文切换一次,在500020000个时间周期内,大约为35毫秒,也就是CAS0.6纳秒与加锁耗费的35毫秒对比)

2、问题

那么这种CAS操作会不会有什么问题呢

假设线程1需要将值从A修改为B,它会判断变量值是否为A,是就设置为B

线程2此时的操作为,将值从A修改为C然后快速修改为A,假如线程2先执行compareAndSet线程1中将会认为变量值没有被修改过,还是A,然后直接修改为B

上面这种问题,是CAS中典型的ABA问题。解决思路就是加一个版本号,每次变量变化就新加一个版本号,如果版本号不一致,就说明修改过。

还有一个问题就是开销问题,就是在CAS操作中,线程不会休眠,没有修改值成功的线程,将会一直执行do-while循环

最后一个问题是CAS只能保证一个共享变量的原子操作,多个共享变量操作时,循环CAS无法保证原子性了,除非把国歌共享变量合成一个,例如AtomicReference(可以原子读写的对象引用变量),或者利用

三、Synchronized

Synchronized 是通过 monitorentermonitorexit指令来实现的,monitorenter在编译后插入到同步代码块开始位置,monitorexit插入到方法结束处和异常处。上面截图方法b字节码没有对应的monitorentermonitorexit指令,但在实际执行过程中,原理一样,只是这两个指令加在了我们看不到的地方。

还记得之前提到过对象头么,对象头持有ObjectMonitor,即任何一个对象都有对应的monitor与这两个指令关联获取锁和释放锁其实都是对这个monitor的持有权获取和释放的一个过程

如果是重量级锁,没有获取到锁的线程,将会入队,加入到ObjectMonitorEnterList队列中,调用PostEvent.park方法阻塞,内部对应于C++muter_lock。区别于 C++spin_lock,这个是一个自旋锁,拿不到锁会一直去获取。

Synchronized 对某个方法加锁,那么任一时刻只能有一个线程能访问这个方法,其它线程就会被操作系统挂起,发生两次上下文切换(挂起时、重新争夺锁),锁对象都是同一个ObjectMonitor

JDK1.5后对此做了一些优化

  • 结合统计,一个锁大概率总是由同一个线程获得。在线程A访问该方法时,虚拟机会直接测试一下,看下是否是当前线程获得锁,具体做法就是通过对象头的markworld中存储的线程ID来判断,是的话直接执行方法内代码,这时锁状态称为偏向锁,偏向当前线程。不是的话,通过CAS替换Markword信息,将其中存储的线程ID指向自己。

  • 此时其它线程也来访问同一个方法,早期做法是其它线程直接挂起,现在优化成了,为了避免上下文切换,其它挂起的线程不挂起,新来访问的线程B也会通过线程ID来判断是否是当前线程持有该锁,不是,就会撤销偏向锁,通过CAS自旋来尝试获取锁和解锁(CAS替换markword),此时锁的状态就变成了自旋锁,为了防止过度消耗CPU,大概自旋10次左右,这个值由JVM动态计算,自旋时间约等于一个上下文切换时间,此时锁的状态变成了 适应性自旋锁。这些都属于轻量级锁。因为线程A持有偏向锁时,线程B将锁改成了轻量级锁,即修改了对象头的信息,需要通知线程A同步修改线程上对象头的堆栈内容,此时线程A会暂停线程,会出现类似GC过程中的Stop the world现象,修改完后恢复线程。

  • 线程B自旋次数超过一定次数后,轻量级锁就会膨胀为重量级锁,释放CPU,阻塞线程。追求吞吐量一般会用重量级锁。

四、ReentrantLock

ReentrantLock  reentrantLock   =  new ReentrantLock();
reentrantLock.lock();
reentrantLock.unlock();

构造函数

    public ReentrantLock() {
        sync = new NonfairSync();
    }
    //fair true公平锁还是非公平锁
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

之前一篇文章介绍过,公平锁可以理解为排队拿锁,先来后到,非公平锁则靠CPU调度决定

1、获取锁原理

lock()

    public void lock() {
        sync.lock();
    }

Sync是继承自AbstractQueuedSynchronizer 的静态抽象类,FairSyncNonfairSync都是继承自Sync

NonfairSync.lock

	final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

这个方法内先判断是否能将父类 AbstractQueuedSynchronizer 中定义的state,默认是0,从0改成1CAS操作如果返回true,说明已经拿到锁了,然后记录一下当前拿到所得线程。如果没有拿到锁(锁被占用了),则调用acquire方法

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

这个方法内 通过 tryAcquire 方法尝试拿锁,拿不到会执行 acquireQueuedaddWaiter方法,将当前节点入队(双向链表)处理,入队失败就进入真正意义上的线程阻塞。

NonfairSync.tryAcquire 内调用的是nonfairTryAcquire 方法,主要是尝试拿锁并修改state状态值,一般自定义锁,都会重写tryAcquire方法和tryRelease方法

        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

我们先看AbstractQueuedSynchronizer.acquireQueued方法,分析在代码内

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
            //当前Node的前一个节点
                final Node p = node.predecessor();
                //头节点,尝试拿锁
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;//不需要中断
                }
                //否则通过CAS不断尝试拿锁,如果还是拿不到锁,线程中断执行,进入阻塞,这里是重点
                //如果线程进入阻塞条件成功,那么failed == true
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt()) // 这个方法就是让线程进入阻塞
                    interrupted = true;
            }
        } finally {
            if (failed)
            	//放弃尝试获取锁,这里也是重点
                cancelAcquire(node);
        }
    }

AbstractQueuedSynchronizer.addWaiter(Node.EXCLUSIVE) 方法

    private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred; //当前Node的前一个节点指向队列中的尾节点
            if (compareAndSetTail(pred, node)) {//CAS操作,将尾节点变成当前Node
                pred.next = node;
                return node;
            }
        }
        //如果之前入队没成功,则不断循环,通过CAS操作,不断入队直到入队成功
        enq(node);
        return node;
    }
 
    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

从上面大概能了解到,拿锁的过程中,会根据当前线程会创建新的Node,加入到链表中,主要方法是以下这两个方法

shouldParkAfterFailedAcquire(p, node)
cancelAcquire(node)

shouldParkAfterFailedAcquire 注释写在代码中

     private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus; //获取前一个节点的状态
        if (ws == Node.SIGNAL) //前一个节点的状态是 -1,可以停止各种自旋操作进入阻塞
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        if (ws > 0) {//如果前一个大于0,代表前一个等待超时或者被中断了,需要从队列中往下继续寻找在等待的节点
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);//小于0的状态,那就设置成-1,到阻塞等待状态,对于SIGNAL源码介绍是指后续线程需要取消unparking
        }
        return false;
    }

节点大概长这样

其中waitStatus状态值有 ,源码中有解释

  • CANCELLED:值为1,结束状态,进入该状态后的结点将不会再变化

  • SIGNAL:值为-1,阻塞等待唤醒阶段,只有前一个节点释放锁。

  • CONDITION:值为-2,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。

  • PROPAGATE:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态

  • 0状态:值为0,初始状态

首先这里Node节点的waitStatus默认是0,然后线程B获取锁时,(一个节点对应于一个线程)

1、如果前一个节点没有释放锁,也就是waitStatus没有变成-1,会通过CAS操作,不断轮询,将前一个节点的waitStataus变成-1,代表前一个线程释放了锁,线程B就不用进入阻塞等待获取锁。

2、如果前一个节点释放了锁,即waitStatus = 1, 就需要从双向链表中不断往后寻找到没有获取锁的节点,然后将当前节点插入到寻找到的节点的后面,这种就不用阻塞,理解为我前面的节点,谁持有锁,插入到谁的后面。

3、如果前一个节点waitStatus = -1,那当前节点不用考虑了,直接进入阻塞吧。

其实就是通过前一个节点的状态来判断当前节点对应的线程是否需要进行阻塞。

AQS这个思想叫做CLH队列锁,是CLH的变体实现

CLH具体思想我们可以理解为一个线程对应于一个Node节点,Node节点中假想有一个locked标志是否获取了锁(true没有,false释放了锁),每次线程获取锁,都需要加入到链表队列末尾,然后通过CAS操作,不断判断前一个线程的locked是否变成false,释放了锁,然后线程将自己的locked变成true,前一个节点的变成falseCAS操作一定次数后,会判断需不需要进入阻塞等待唤醒阶段。(公平锁实现)

AQS中是双向链表结构,并且新增加了公平和非公平的实现,上面分析的都是非公平锁,我们对比一下公平锁和非公平锁的lock方法,你就知道了区别了,非公平锁多了一个CAS尝试去拿锁,线程对应的节点完全可能先于其它线程先插入链表后面

FairSync  公平锁
final void lock() {
            acquire(1);
}
NonfairSync 非公平锁
final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
 }

2、释放锁原理

我们再来看一下如何释放锁的,即ReentrantLockunlock方法

    public void unlock() {
        sync.release(1);
    }
    
   public final boolean release(int arg) {
        if (tryRelease(arg)) {//这里就是通过CAS将state值递减
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

唤醒其它节点方法 unparkSuccessor

    private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        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);
    }

上面方法可以简单概括为,寻找下一个有效节点,waitStatus变成CANCEL就是无效了(锁释放了),然后唤醒它所对应的线程。从这里可以看到AQS的前后节点指针,一个是用来向前寻找非阻塞的,有效的节点,另一个是在释放锁时,寻找有效的节点,来唤醒它所对应的线程。

五、经典问题系列

多个线程能否共享一把锁

ReentrantLock你应该很了解,那么ReentrantReadWriteLock读写锁呢?前者是排它锁,后者是共享锁。

CAS的原理

每次都会判断内存中的值和旧值是否相等,相等说明其它线程没有改变,然后直接将内存中的值改成新值。不相等,则将这个过程进行轮询。

然后CAS中有三个典型问题,ABA问题开销问题只能保证一个共享变量原子操作

ReentrantLock实现原理

显示锁的实现,线程锁一次,计数器就会增加一次,重入一次增加一次,释放锁一次就累减一次,计数器为0,则代表当前线程已经释放该锁了。

内部实现是基于GUC包下的AQS来实现的。

AQS是啥

并发锁的基本构建,局限不包括ReentrantLockCountDownLatch、信号量、读写锁等,独占锁、共享锁等。一般自定义锁,都会继承自AQS,重写它的 tryAcquiretryRelease 方法来重置计数器。

内部使用了 int state来表示同步状态,内部还维护了一个队列,来完成线程获取资源的一个排队工作,AQSCLH队列锁的一种变体实现。 如果自定义一个类似的锁,我们一般会写一个子类继承自AQS,实现AQS的抽象方法来管理同步状态,例如tryAcquiretryRelease

Synchronized的原理以及与ReentrantLock的区别

Synchronized 关键字,内置锁

从之前的分析来看,实现上涉及到字节码方面就是两条指令,monitorentermonitorexit,同步块能看到这两个指令,同步方法反编译会多一个ACC_Synchronized关键字。JVM实现加锁,主要利用这两个指令,访问到 monitorenter,会去相关联的monitor上获取锁,获取成功 计数器+1, 访问到moitorexit,也是会在monitor上释放锁,计数器减1。 显示锁,ReentrantLock,提供了Synchronized没有的一些功能,例如锁中断、尝试获取锁,公平锁和非公平锁两种。

volatile 能否保证线程安全?它在DCL上的作用是什么?

根据操作系统和处理器的不同来选择对应的调用代码,以 WindowsX86 处理器为例,如果是多处理器,通过带 lock 前缀的 cmpxchg 指令对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作;如果是单处理器,通过 cmpxchg 指令完成原子操作。

总之有volatile变量修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令,作用是将当前处理器缓存的数据写回到系统内存,写回内存时会导致在其它CPU里缓存了该内存地址的数据无效,可以理解为通知其它线程取新数据。

具有原子性和可见性。一般配合锁使用。

DCL上的作用,上篇文章分析过**,主要是禁止指令重排**,按照顺序来构建对象,分配内存空间、初始化对象,将空间地址赋值给引用,保证这三步按顺序进行,防止其它线程判断引用不为空时,直接使用,导致业务出错。

volatile和synchronize有什么区别?

这个很常见。

volatile 最轻量级的同步机制,保证了线程间的可见性,不保证操作原子性。 synchronize保证了线程间的可见性和排他性,内置锁机制。

Sleep 、wait、yield 的区别,wait 的线程如何唤醒它?

 sleepyield不会释放锁

 sleep 暂停线程的执行,休眠,可打断,等休眠时间一过,才有执行权资格,但是只有又有了资格,不代表马上就能被执行,什么时候执行取决于操作系统的调用

 wait 线程间的交互,消费者观察者常用,等待别人来唤醒,唤醒后,才有执行权的资格 

yield 让出CPU执行权,进入到可执行状态

涉及多线程操作相关的Android类ThreadLocal

ThreadLocal是什么?

分析可见 常用集合类相关知识点总结

线程本地变量,特殊的变量,ThreadLocal为每个线程提供了一个变量的副本,使得每一个线程同一时间访问到的是不同的对象,隔离了线程间对数据的共享访问。内部实现上,每个线程内部都有一个ThreadLocalMap,用来保存每一个线程所拥有的变量的副本。