java并发编程-Java中的锁


在并发编程中,经常遇到多个线程访问同一个 共享资源 ,这时候必须考虑如何维护数据一致性。在JVM中所有线程都共享堆内存的,因此Java中的同步都是针对堆中的对象。一般在Java中所说的锁就是指的内置锁,每个Java对象都可以作为一个实现同步的锁。虽说在Java中有句万物皆对象的名言,但Java中的同步锁仅仅是针对引用数据类型而言,基本数据类型不能作为同步的锁。在JDK1.5之前都是使用synchronized关键字保证同步的,Synchronized的作用相信大家都已经非常熟悉了,它是一把重量级锁,但随着人们对性能和体验的要求越来越高,JDK1.6以后对Synchronized做了优化处理。同时根据锁的状态,锁的设计等不同角度又衍生出各种类型的锁,如果不明白它们的关系,会让人云里雾里,摸不着头脑。下面就介绍各种锁的分类以及实现的原理

在java中提到锁、安全性、同步,首先想到的则是java提供synchronized。synchronized是这么的神奇而又强大,成为了我们解决多线程情况的百试不爽的良药。但是,随着我们学习的进行我们知道synchronized是一个重量级锁,相对于Lock,它会显得笨重无比,在如今人们对速度和性能极致要求的现在,现在此时并不能满足性能上的要求。诚然SUN公司也认识到了这一点,在Java SE 1.6对synchronized进行了各种优化后,有些情况下它就并不那么笨重了。在Java SE 1.6中为了减少获得锁和释放锁带来的性能开销而引入偏向锁和轻量级锁。synchronized关键字可以用在方法上,也可以用在对象上,但实际上都是锁住了JVM堆中的对象。Java中每一个对象都可以作为锁,这是synchronized实现同步的基础,普1.通同步方法:锁是当前实例对象,2.静态同步方法:锁是当前类的class对象,3.同步方法块:锁是括号里面的对象。当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,我们先看一段简单的代码:

public class SynchronizedTest {

    public synchronized void method1(){
        System.out.println("Hello World");
    }
    public void method2(){
        synchronized (SynchronizedTest.class){
            System.out.println("Hello World");
        }
    }
}

利用javap工具查看生成的class文件信息来分析Synchronize的实现

 public synchronized void method1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

  public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #5                  // class com/hollis/SynchronizedTest
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #3                  // String Hello World
        10: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return

从上面可以看出,同步代码块是使用monitorenter和monitorexit指令实现的,同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。因此加锁解锁的原理大致如下:同步方法通过ACC_SYNCHRONIZED关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法。同步代码块通过monitorentermonitorexit执行来进行加锁。当线程执行到monitorenter的时候要先获得所锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。每个对象自身维护这一个被加锁次数的计数器,当计数器数字为0时表示可以被任意线程获得锁。当计数器不为0时,只有获得锁的线程才能再次获得锁。即可重入锁。

synchronized

在jdk1.6后对synchronized做了很多优化,这里就需要深入了解一下synchronized的工作原理了。但是在深入之前我们需要了解两个重要的概念:Java对象头,Monitor。synchronized用的锁是存在Java对象头里的。otspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以下面将重点阐述

Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(64位虚拟机):

乐观锁与悲观锁

乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。

悲观锁

总是假设最坏的情况,每次去读数据的时候都认为别人会修改,所以每次在读数据的时候都会上锁,这样别人想读这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现

乐观锁

总是假设最好的情况,每次去读数据的时候都认为别人不会修改,所以不会上锁,但是在写数据的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。如果仅仅简单的使用CAS会出现ABA的问题。

CAS

CAS 的含义为 compare and swap,目前绝大多数 CPU 都原生支持 CAS 原子指令,例如在 IA64、x86的指令集中,就有 cmpxchg 这样的指令来完成 CAS 功能,它的原子性要求是在硬件层面上得到保证的。CAS 指令一般需要有三个参数,分别是值的内存地址、期望中的旧值和新值。CAS 指令执行时,如果该内存地址上的值符合期望中的旧值,处理器会用新值更新该内存地址上的值,否则就不更新。这个操作在 CPU 内部保证了是原子性的。在 Java 中有许多 CAS 相关的 API,我们常见的有 java.util.concurrent 包下的各种原子类,例如AtomicIntegerAtomicReference等等。这些类都支持 CAS 操作,其内部实际上也依赖于 sun.misc.Unsafe 这个类里的 compareAndSwapInt() 和 compareAndSwapLong() 方法。CAS 并非是完美无缺的,尽管它能保证原子性,但它存在一个著名的 ABA 问题。一个变量初次读取的时候值为 A,再一次读取的时候也为 A,那么我们是否能说明这个变量在两次读取中间没有发生过变化?不能。在这期间,变量可能由 A 变为 B,再由 B 变为 A,第二次读取的时候看到的是 A,但实际上这个变量发生了变化。一般的代码逻辑不会在意这个 ABA 问题,因为根据代码逻辑它不会影响并发的安全性,但如果在意的话,可能考虑采用阻塞同步的方式而不是 CAS。实际上 JDK 本身也对这个 ABA 问题解决方案,提供了 AtomicStampedReference 这个类,为变量加上版本来解决 ABA 问题。

偏向锁

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。偏向锁有一些特点

  • 偏向锁的Bulk Revoke机制:Bulk Revoke即偏向锁的批量撤销,偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
  • 偏向锁的Bulk Rebias机制:偏向锁的批量重偏向,这个机制很特殊, 别的锁在执行完同步代码块后, 都会有释放锁的操作, 而偏向锁并没有直观意义上的“释放锁”操作。bulk rebias机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。其逻辑如下,引入一个概念 epoch, 其本质是一个时间戳 , 代表了偏向锁的有效性。从前文描述的对象头结构中可以看到, epoch 存储在可偏向对象的 MarkWord 中。除了对象中的 epoch, 对象所属的类 class 信息中, 也会保存一个 epoch 值,每当遇到一个全局安全点时, 如果要对 class C 进行批量再偏向, 则首先对 class C 中保存的 epoch 进行增加操作, 得到一个新的 epoch_0。然后扫描所有持有 class C 实例的线程栈, 根据线程栈的信息判断出该线程是否锁定了该对象, 仅将 epoch_0 的值赋给被锁定的对象中。退出安全点后, 当有线程需要尝试获取偏向锁时, 直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等, 则说明该对象的偏向锁已经无效了, 可以尝试对此对象重新进行偏向操作。
  • 关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

批量重偏向与批量撤销渊源:从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。

轻量级锁

轻量级锁,也是JDK 1.6加入的新机制,之所以成为“轻量级”,是因为它不是使用操作系统互斥量来实现锁, 而是通过CAS操作,来实现锁。当线程获得轻量级锁后,可以再次进入锁,即锁是可重入(Reentrance Lock)的。

  • 轻量级锁的加锁过程:轻量级锁加锁过程分几个步骤。步骤1:在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。步骤2:拷贝对象头中的Mark Word复制到锁记录中。步骤3:拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。步骤4:如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。步骤5:如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
  • 释放锁线程视角:由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。
  • 尝试获取锁线程视角:如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。还有一个注意点:等待轻量锁的线程不会阻塞,它会一直自旋等待锁,当然这也有次数限制,不会一直自旋。得到锁后会修改markword。这就是自旋锁,尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起,获得锁,则执行代码。
自旋锁

自旋锁原理非常简单,实际上是一种冒险的思想,假设当前持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,那么它们只需要等一等(自旋),自旋其实就是一个空循环,什么都不做,但是占用CPU的资源。等持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程和内核的切换的消耗。这样做有利有弊,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。但相比在大量线程并发情况下进入线程调度,这么做确实值得。自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就会当挂起线程。自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

  • 自适应自旋:自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

正如前面所说,自旋锁一般发生在获取轻量级锁的过程中,当获取轻量级锁时会使用自旋锁等待。并升级为重量级锁

重量级锁

重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。在Java中,重量级锁的实现就是Synchronized关键字。它可以把任意一个非null的对象当作锁。Synchronized的实现如下图

当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中:

  • Contention List:争队列,所有请求锁的线程首先被放在这个竞争队列中
  • Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中
  • Waiting Set:调用wait方法被阻塞的线程被放置在这里
  • OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck
  • Owner:当前已经获取到所资源的线程被称为Owner

JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,Contention List会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到Entry List中作为候选竞争线程。Owner线程会在unlock时,将Contention List中的部分线程迁移到Entry List中,并指定Entry List中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在Entry List中。如果Owner线程被wait方法阻塞,则转移到Wait Set队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去Entry List中。处于Contention List、Entry List、Wait Set中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。Synchronized是非公平锁, Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

Synchronized的执行过程
  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作。在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们。偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁。

Lock

我们已经知道,synchronized 是Java的关键字,是Java的内置特性,在JVM层面实现了对临界资源的同步互斥访问,但 synchronized 粒度有些大,在处理实际问题时存在诸多局限性,比如响应中断等。Lock 提供了比 synchronized更广泛的锁操作,它能以更优雅的方式处理线程同步问题。Lock锁是Java代码级别来实现的,相对于synchronizedd在功能性上,有所加强,主要是,公平锁,轮询锁,定时锁,可中断锁等,还增加了多路通知机制(Condition),可以用一个锁来管理多个同步块。另外在使用的时候,必须手动的释放锁。

Lock接口的基本思想

需要实现锁的功能,有两个必备元素:

  1. 一个表示状态的变量(我们假设0表示没有线程获取锁,1表示已有线程占有锁),并把该变量声明为voaltile类型
  2. 一个FIFO队列,队列中的节点表示因未能获取锁而阻塞的线程。

在java.util.concurrent.locks包中有很多Lock的实现类,如ReentrantLock、ReadWriteLock(实现类ReentrantReadWriteLock),其实现都依赖java.util.concurrent.AbstractQueuedSynchronizer类,实现思路大同小异,下面主要讲ReentrantLock。ReentrantLock把所有Lock接口的操作都委派到Sync类上,该类继承了AbstractQueuedSynchronizer,多数需要覆盖的方法都是在Sync类实现的,而NonfairSync、FairSync只是在tryAcquire方法和lock方法上有所不同。那么来看看AbstractQueuedSynchronizer(下面简称AQS)到底干了什么吧。类关系图如下:

其中ReentrantLock中包含FairSync和NonfairSync,FairSync 与 NonfairSync的区别在于,是不是保证获取锁的公平性。在源代码中的体现如下:

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

     private final Sync sync;

     static final class NonfairSync extends Sync{...}
     static final class FairSync extends Sync{...}

     //构造函数-默认是非公平锁
     public ReentrantLock() {
        sync = new NonfairSync();
    }
    //构造函数-带参可设置公平与非公平锁
     public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
}

ReentrantLock加锁原理

简单说来,AbstractQueuedSynchronizer会把所有的请求线程构成一个CLH队列,当一个线程执行完毕(lock.unlock())时会激活自己的后继节点,但正在执行的线程并不在队列中,而那些等待执行的线程全部处于阻塞状态,经过调查线程的显式阻塞是通过调用LockSupport.park()完成,而LockSupport.park()则调用sun.misc.Unsafe.park()本地方法,再进一步,HotSpot在Linux中中通过调用pthread_mutex_lock函数把线程交给系统内核进行阻塞。首先来看加锁操作的lock()方法,源码如下:

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

if 条件的第一个分支是使用CAS操作,将锁状态更新为1,1表示当前线程成功占有锁。如果返回true的话是正常流程,表示当前线程成功的抢到了锁,然后就会执行业务代码了。如果已经有别的线程占有了锁,CAS会失败,返回false,则会进入第二个分支,重点来看看这个分支的逻辑。acquire源码如下

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

我们看到这个if条件中有两个条件,代码比较简洁,但做的事情并不简单。这里主要做了三件事情:

  1. tryAcquire:会尝试再次通过CAS获取一次锁。
  2. addWaiter:将当前线程加入上面锁的双向链表(等待队列)中
  3. acquireQueued:通过自旋,判断当前队列节点是否可以获取锁。

下面重点分析一下这三件事情是怎么做的,tryAcquire方法比较简单,就是通过CAS再做一次获取锁的尝试,就不多说了。尝试获取锁失败后,返回false,这里取了反,判断为真,因此会进入acquireQueued方法,先来看一下addWaiter方法,该方法源码如下:

    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;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

这个方法第一步首先把当前线程包装成了一个Node对象。假设当前没有线程获得锁,当Thread1获取锁执行到这个方法,这时肯定是没有头节点的,因此第5行代码的 if 分支不会进去,直接执行 enq 方法。如此继续看enq方法的源码

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;
                }
            }
        }
    }

从这里代码可以看出,在没有线程获得锁,第一次循环时,tail肯定是null,因此会进入第4行的分支,这里代码很简单创建了一个头节点,注意这个头节点没有任何参数,也就是说 HEAD 节点不关联线程。然后进行第二次循环,进入第7行的分支,把当前节点的prev指向刚刚创建的头节点,同时把头节点的next指向当前节点,形成一个双向链表,并返回当前线程的Node对象。接着来看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)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

首先获取了当前节点的前节点,判断前节点是否为HEAD节点,注意前面说过HEAD节点是不与任务线程关联的节点,仅仅只是做链表头的作用。因此当前节点的前节点为HEAD时,说明当前节点排在链表的最前面了。因此当前节点会通过tryAcquire方法尝试去获取锁,如果成功了直接返回false。如果失败则会进入第14行的分支代码,源码如下

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            return true;
        if (ws > 0) {
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

举个例子假设Thread1已经获得了锁,Thread2和Thread3要去竞争锁。对于 Thread2 来说,它的 prev 指向 HEAD,因此会首先再尝试获取锁一次,如果失败,则会将 HEAD 的 waitStatus 值为 SIGNAL,下次循环的时候再去尝试获取锁,如果还是失败,且这个时候 prev 节点的 waitStatus 已经是 SIGNAL,则这个时候线程会被通过 LockSupport 挂起。对于 Thread3 来说,它的 prev 指向 Thread2,因此直接看看 Thread2 对应的节点的 waitStatus 是否为 SIGNAL,如果不是则将它设置为 SIGNAL,再给自己一次去看看自己有没有资格获取锁,如果 Thread2 还是挡在前面,且它的 waitStatus 是 SIGNAL,则将自己挂起。如果 Thread1 死死的握住锁不放,那么 Thread2 和 Thread3 现在的状态就是挂起状态啦,而且 HEAD,以及 Thread 的 waitStatus 都是 SIGNAL,尽管他们在整个过程中曾经数次去尝试获取锁,但是都失败了,失败了不能死循环呀,所以就被挂起了。挂起的方法是通过parkAndCheckInterrupt做的

    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

其中LockSupport.park(this)就会调用系统级别的函数将当前线程挂起。此时线程状态图如下:

重入锁

重入锁指的是当前线成功获取锁后,如果再次访问该临界区,则不会对自己产生互斥行为。Java中对ReentrantLock和synchronized都是可重入锁,synchronized由jvm实现可重入,ReentrantLock都可重入性基于AQS实现。同时,ReentrantLock还提供公平锁非公平锁两种模式。重入锁的基本原理是判断上次获取锁的线程是否为当前线程,如果是则可再次进入临界区,如果不是,则阻塞。由于ReentrantLock是基于AQS实现的,底层通过操作同步状态来获取锁,下面看一下非公平锁的实现逻辑:

final boolean nonfairTryAcquire(int acquires) {
            //获取当前线程
            final Thread current = Thread.currentThread();
            //通过AQS获取同步状态
            int c = getState();
            //同步状态为0,说明临界区处于无锁状态,
            if (c == 0) {
                //修改同步状态,即加锁
                if (compareAndSetState(0, acquires)) {
                    //将当前线程设置为锁的owner
                    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;
        }

重入锁的最主要逻辑就锁判断上次获取锁的线程是否为当前线程。


Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
面试总结 面试总结
又到一年金三银四的黄金跳槽季,相信很多人都在这期间蠢蠢欲动,不是正在离职,就是在纠结要不要跳槽。各大公司呢,也开始招兵买马为今年公司的发展储备人才。2019年的金三银四可以说比较特殊了,人们都说,互联网寒冬来了。各大互联网公司裁员,在互联网
2019-05-15
Next 
java并发编程-JVM架构与GC java并发编程-JVM架构与GC
作为一名Java开发者,掌握JVM的体系结构也是很有必要的,了解底层的东西,有助于更好的理解和掌握程序运行中的原理。JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的
2019-04-13
  TOC