@Catyee
2021-08-14T20:00:01.000000Z
字数 19887
阅读 500
面试
保证线程安全最基本的方式就是互斥同步,实现互斥同步就少不了锁,下面将介绍java中的各种锁以及原理。在阅读文章之前需要先明白公平锁、非公平锁、独占锁、共享锁、可重入锁等基础概念,这些概念在文中将不再进行讲解。
乐观锁与悲观锁并不是具体的某两种锁,而是一种广义上的概念。在Java和数据库中都有此概念对应的实际应用。
悲观锁认为自己在操作共享资源的时候一定有别的线程来修改数据,因此在操作共享资源的时候会先加锁,确保共享资源不会被别的线程修改。Java中synchronized关键字和Lock的实现类都是悲观锁。
乐观锁则认为自己在操作共享资源时不会有别的线程修改数据,所以不会添加锁,只是在更新共享资源的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的,就是一种乐观锁的实现方式
根据从上面的概念描述我们可以发现:
悲观锁适合写操作多的场景(共享资源写竞争激烈的场景),先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
synchronized是一种块结构(Block Structured)的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一,一旦计数器的值为零,锁随即就被释放。
monitorenter和monitorexit这两个字节码指令都一定需要一个reference类型的参数来指明要锁定和解锁的对象,即synchornied总是要与一个对象相关联。如果Java源码中的synchronized明确指定了锁定的对象(即:synchornied(user)),那就以这个对象的引用作为reference;如果没有明确指定,将根据synchronized修饰的方法类型(实例方法还是类方法),来决定是使用代码所在的对象实例还是类对应的Class对象来作为线程要持有的锁,如果synchornied修饰的是静态方法,锁住的对象是该类的class对象,如果synchornied修饰的是普通方法,锁住的是这个类的当前实例
被synchronized修饰的同步块对同一个线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着synchronized无法强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
synchornized是非公平锁。
持有同步锁是一种重量级的操作。原因是在主流Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,会在用户态到核心态来回转换,这种状态转换需要耗费很多的处理器时间。
所以为了提升synchronized的性能,jdk1.5之后对synchronized做了很多优化,优化之后可以认为其性能基本与ReentranLock持平。
锁消除是指虚拟机即时编译器在运行时,一些代码要求同步,但是虚拟机根据逃逸分析分析出的结果发现这部分代码根本不可能存在共享数据竞争,虚拟机将对这一部分代码进行锁消除,虚拟机在解释执行时这些代码仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。比如StringBuffer的append方法,该方法使用Synchornied关键字修饰,当我们进行连续的append操作(sb.apeend().append()...)时,原本应该每次执行都进行加锁和释放,但是经过锁粗化之后,就只在第一个append()操作前加锁,最后一个append()操作之后释放。
在讲述锁升级之前有几个概念要先明确:
a、对象头和MarkWord:java对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针(klass pointer),另外对于数组而言还会有一段内存记录数组长度。类型指针是指向该对象所属类元信息的指针,mark word则用于存储对象的HashCode、GC分代年龄、锁状态等信息。但是,为了能在有限的空间里存储下更多的数据,其存储格式是不固定的。 下图展示了32位虚拟机中对象头的MarkWord,需要注意的是,根据锁状态的不同,markword中存储的是不一样的信息:
可以看到当对象状态为偏向锁(biasable)时,markword存储的是偏向的线程ID;
当状态为轻量级锁(lightweight locked)时,markword存储的是指向线程栈中LockRecord的指针;
当状态为重量级锁(inflated)时,markword存储的指向堆中的monitor对象的指针。
b、LockRecord:LockRecord结构包括两部分,一部分用于存储锁对象的对象头中的mark word的拷贝(官方称之为Displaced Mark Word)另一部分是一个指向锁对象的指针(obj引用),在执行到同步代码块的时候,如果锁对象是01状态(未锁定状态),就会构建一个LockRecord。
c、无锁状态:感觉有不同理解,有人认为标识为01(表示无锁定)即为无锁状态,但是偏向锁(101)标志位也是01,所以我个人认为(001)才是无锁状态,准确说应该是不可偏向无锁定状态。以下的无锁状态,都指这种状态。
偏向锁是为了解决使用了synchornied关键字,但是实际上总是只有一个线程去获取锁的场景。(其实不存在竞争)
当新创建一个对象的时候,如果该对象所属的类没有关闭偏向锁模式,那新创建对象的mark word是可偏向状态(这里要特别注意,一个对象默认情况下创建出来就处于偏向锁模式,在符合某些条件(指批量撤销)后,类的可偏向模式被关闭,创建出来的对象将处于无锁状态,更准确的描述应该为不可偏向无锁定状态,即001状态),此时mark word中偏向状态为101,thread id为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。
MarkWord如下:
线程id | epoch | 分代年龄 | 偏向模式 | 锁标志位 |
---|---|---|---|---|
0 | epoch | age | 1 | 01 |
如果该对象被选作为同步锁的对象,当一个线程去尝试获取锁的时候,发现是匿名偏向状态(即还未偏向任何线程),则会用CAS指令,尝试将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。此时MarkWord如下:
线程id | epoch | 分代年龄 | 偏向模式 | 锁标志位 |
---|---|---|---|---|
thread id | epoch | age | 1 | 01 |
之后当被偏向的线程再次准备进入同步块时,会先判断锁对象是否是偏向状态(101),如果是还会比较自己的线程id和锁对象中记录的线程id,如果一致,这时会在线程的栈帧中从高位到低位找到一个空闲的LockRecord(空闲指obj引用为null),将其obj引用指向锁对象(重入也是如此),然后线程就会直接进入到同步代码块,退出同步块也就是释放同步锁的时候只需要将obj引用置为null,整个过程没有自旋等其它操作,开销非常小,如果之后该锁一直没有其它线程参与竞争,那持有这个偏向锁的线程每次很小的代价即可直接执行同步代码块。这里要注意偏向锁不会主动释放(也就是说偏向锁的解锁步骤中并不会修改对象头中的thread id)。
偏向锁升级的时机:当锁已经发生偏向后(即锁的对象头的markword处于偏向状态,且记录了线程id),只要有另一个线程尝试获得偏向锁,无论此时markword中记录的线程是否存活(可能存活并仍然在同步代码块中,可能存活但是不在同步代码块中,也可能已经消亡),该偏向锁都会先撤销,然后升级成轻量级锁,(有一种情况例外,就是符合批量重偏向的情况),区别在于如果原来的线程还存活且持有偏向锁,升级之后原来的线程依然持有锁,只不过已经是轻量级锁。如果原来的线程已消亡或者不在同步代码块,新的线程会持有轻量级锁。
批量重偏向:
重偏向意思是重新更改偏向线程,批量重偏向则指同一个类的多个实例,都作为锁对象,在符合某种条件的情况下都更改为新的偏向线程。什么时候会发生批量重偏向呢?每一个类都有一个偏向锁撤销计数器,这个类任何一个实例发生偏向锁撤销时都会使计数器加1,当这个值达到重偏向阈值(默认20)时,虚拟机就认为这个类的实例的偏向锁的偏向有问题,就会进行批量重偏向。
具体实现:每一个类都有一个epoch值(并不是偏向撤销计数器),这个类的每一个实例也有一个epoch值,初始和类的epoch值相等,如果虚拟机检测到一个类的实例需要进行批量重定向的时候,会将类的epoch值加1,如果该类的部分对象仍然处于偏向锁的状态,下次另一个线程来获取锁的时候,本来按照正常流程应该将偏向锁升级为轻量锁,但是它发现锁对象的epoch值和类的epoch值不相等,就不会进行锁升级,也不会撤销偏向锁,而是直接将锁偏向当前线程(重偏向),并把epoch更改为与类的epoch值相等。(对于发生批量重偏向时正处于使用中的偏向锁,只更改epoch值,不进行重偏向)
举例说明,如果一个线程1,执行的时候创建了A的30个实例,并且用synchornied锁定,然后加入到一个list中,此时这30个实例都处于偏向锁状态,偏向线程1。然后启另一个线程2,遍历list获取对象,并用synchornied锁定,这时遍历前20个对象的时候,都会进行偏向锁撤销,然后升级为轻量锁,但是第21个对象及之后,就会发生批量重偏向,就不会升级为轻量锁了,而是直接偏向线程2。参考:https://www.jianshu.com/p/2a25e9954527
批量撤销:
当偏向撤销次数达到重偏向阈值后,假设这个类的偏向锁撤销计数器仍然在增长,当其达到批量撤销的阈值后(默认40),JVM就认为这个类的对象的使用场景存在多线程竞争,会标记该类为不可偏向状态,同时之后这个类的所有对象,如果仍然处于偏向锁的状态,就会关闭其偏向模式,将不会再进行批量重偏向,而是撤销偏向之后升级为轻量级锁。(对于发生批量撤销时正处于使用中的偏向锁,会撤销其偏向锁状态)。批量撤销之后这个类新建的实例也将处于不可偏向无锁定状态(001)。
批量撤销后,锁对象的MarkWord如下,偏向模式被置为0:
hashcode | 分代年龄 | 偏向模式 | 锁标志位 |
---|---|---|---|
hashcode | age | 0 | 01 |
轻量级锁是为了解决使用了synchornied关键字,但是实际上线程总是交替去获取锁的场景,此时有多个线程在执行过程中都需要获取锁,但刚好交替获取锁。(其实不存在竞争)
有两种情况会使用轻量级锁:1、当前线程获取锁的时候发现锁处于不可偏向模式,说明该锁已经发生过批量撤销,就会使用轻量级锁。2、当前线程获取锁的时候发现锁处于偏向模式,但是偏向的线程并不是自己,则会撤销偏向锁并升级至轻量级锁。
偏向锁升级为轻量锁的时候原来偏向的线程可能已经消亡或者不在同步代码块中,也可能存活而且正在同步代码块中,jvm会在safepoint(安全点,此时处于线程暂停状态)中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,具体过程为在原线程栈帧中最高位的LockRecord中构造一个无锁状态的DisplacedMarkWord,然后将对象头指向最高位的Lock Record,这里不需要用CAS指令,因为是在safepoint,也就是原偏向的线程继续拥有锁,当前线程则继续进入到锁升级的逻辑里(进入升级为重量级锁的流程,待查证);如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(偏向锁撤销),之后将其升级为轻量级锁,自己将持有锁。
轻量级锁的MarkWord:
指向线程栈帧中DisplacedMarkWord的指针 | 锁标志位 |
---|---|
30位指针 | 00 |
如何判断偏向的线程是否还存活:
JVM维护了一个集合存放所有存活的线程,通过遍历该集合判断某个线程是否存活。
如何判断偏向的线程是否还在同步代码块中:
回顾一下偏向锁的加锁流程:每次进入同步块(即执行monitorenter)的时候都会以从高往低的顺序在栈中找到第一个可用的LockRecord,将其obj字段指向锁对象。每次解锁(即执行monitorexit)的时候都会将最低的一个相关LockRecord中obj引用置为null。所以可以通过遍历线程栈中的Lock Record来判断线程是否还在同步块中。
加锁过程:
虚拟机首先将在当前线程的栈帧中创建一个LockRecord。
如果是第一次加轻量级锁,会在线程的LockRecord中去构建一个无锁状态的markword,(设置markword是无锁状态的原因是:轻量级锁解锁时是将对象头的markword设置为LockRecord中的DisplacedMarkWord,所以创建时设置为无锁状态,解锁时直接用CAS替换就好了)虚拟机将使用CAS操作尝试把锁对象的MarkWord更新为指向LockRecord的指针,如果成功,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位将转变为“00”,表示此对象处于轻量级锁定状态, 如果失败则会膨化为重量级锁。
如果是重入,则直接将LockRecord的DisplacedMarkWord设置为null,LockRecord的obj引用依然指向锁对象。
解锁过程:
1.遍历线程栈,找到最低一个obj字段等于当前锁对象的LockRecord。
2.如果LockRecord的DisplacedMarkWord为null,代表这是一次重入的解锁,将obj设置为null后continue。
3.如果LockRecord的DisplacedMarkWord不为null,则利用CAS指令将对象头的mark word恢复成为DisplacedMarkWord。如果成功,则continue,否则膨胀为重量级锁(轻量级锁解锁过程也可能发生锁升级)。
可以看到轻量锁加锁解锁都有cas操作,相比偏向锁开销要稍微大一些。轻量锁解锁的时候会用无锁状态的DisplacedMarkWord替换锁对象的markword,也就是说替换结束之后锁对象又将进入无锁状态,下次有线程需要获取锁的时候就会走重新获取轻量锁的逻辑。
下图位偏向锁、轻量级锁的状态转化及对象Mark Word的关系:
轻量锁膨化为重量锁的时机:
轻量锁加锁时用cas将markword更新为指向LockReord的指针,如果cas失败,说明已经被其它线程占有了轻量锁,也说明同一时刻有两个线程竞争锁,就会进入膨化重量锁的流程。
轻量锁解锁时会用cas将LockRecord中的DisplayedMarkword替换锁对象的markword,如果失败(之所以失败是可能已被其它线程膨化),也会进入膨化重量锁的流程,该流程中会检测是否已经膨化完成,已膨化完成则退出膨化流程,如果正在膨化会进行忙等待。总结起来,只要同一时刻有两个线程竞争锁,就会膨化为重量锁。
其它注意点:
当调用锁对象的hashcode()方法或者调用System.identityHashCode()方法时会导致该对象的偏向锁或轻量级锁膨化为重量锁。这是因为一个对象的hashcode是在调用这两个方法时才生成的,如果是无锁状态生成的hashcode会存放在mark word中,如果是重量级锁则存放在对应的monitor对象中,但是偏向锁和轻量级锁的markword中没有地方能存放hashcode,所以必须升级。
重量锁的MarkWord,monitor对象中会存储对象的hashcode:
指向monitor对象的指针 | 锁标志位 |
---|---|
30位指针 | 10 |
重量锁即有至少两个线程同时竞争锁资源的时候就会使用重量锁。重量锁是基于对象的monitor对象来实现,monitor结构如下:
其中ContentionList,EntryList,WaitSet都是由ObjectWaiter元素构成的链表结构。
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到ContentionList的队首,然后调用park函数挂起当前线程。在linux系统上,park函数底层调用的是POSIX线程库的pthread_cond_wait,JDK的ReentrantLock底层也是用该方法挂起线程的。当线程释放锁时,会从ContentionList或EntryList中挑选一个线程唤醒,即图中的Ready Thread,这个线程被唤醒后会尝试获得锁,但synchronized是非公平的,所以这个线程也并不是一定能获得锁。
如果线程获得锁后调用Object.wait()方法,则会将线程加入到WaitSet中,并释放锁,而当调用Object.notify()方法进行唤醒后,会将线程从WaitSet移动到ContentionList或EntryList中去,重新排队等锁。
可以看到重量锁的实现和Reentranlock的实现是非常相似的。
重量级锁需要操作链表,并且需要挂起和唤醒线程,所以代价要比偏向锁和轻量级锁都高,这也是叫重量锁的原因。(从挂起线程的角度来说ReentranLock也是重量级锁)
其它注意点:
如果同步锁处于偏向锁或者轻量锁的状态下,调用wait()或notify()方法时,需要先膨胀成重量级锁
主要区别有以下几点:
1、Synchornied是非公平的,ReentrantLock可以是公平的也可以是非公平的
2、Synchornied无法响应中断,使用ReentrantLock的lockInterruptibly()方法,调用interrupt()即可响应中断
3、Synchornied无法设置超时时间,使用ReentrantLock的tryLock()方法可以设置超时时间,如果超过超时时间则会放弃等待。
4、ReentranLock可以通过Condition进行更细粒度更复杂的锁控制。
5、ReentranLock可以通过tryLock()方法先尝试获取锁,如果获取不到并不会阻塞,而是返回false,这个时候线程可以去做别的事情。
5、总结起来Synchornied是jvm层面实现的锁,使用起来更简单;ReentranLock是jdk层面实现的锁,更加灵活,但是使用起来也更加复杂,加锁、解锁都需要程序员调用方法,而且unlock解锁方法还必须写在finally代码块中,以防止不释放锁的情况。
同步锁原理参考文章:
https://blog.51cto.com/janephp/2429937
https://www.jianshu.com/p/2a25e9954527
自旋锁的目的是尽量不进行线程的挂起和唤醒,用以减少挂起唤醒线程的代价。
java中主流虚拟机中的线程都是基于内核线程实现的,阻塞或唤醒一个Java线程需要操作系统用户态切换内核态来完成,这种状态转换需要耗费处理器时间,并发性能因此受到影响。其实在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如何解决这个问题呢?自然而然就会想到可以不让线程挂起,而仅仅只是让线程等一会,现在的物理机一般都有多个处理器,也就是说能够让两个或以上的线程同时并行执行,所以就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。如何让线程不放弃cpu又能进行等待呢?答案就是自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。
自旋锁一般都使用cas算法来实现的。
CAS全称Compare And Swap(比较与交换),是一种无锁算法。CAS算法涉及到三个操作数:
当前的内存值V、旧值A、将要写入的新值B
当且仅当当前内存值V和旧值A相等时,CAS用新值B来更新V的值,否则进行重试(自旋)整个比较更新是原子操作。
ABA问题: CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,而实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就co从“A -> B -> A”变成了“1A -> 2B -> 3A”。JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
循环时间长开销大: 自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。所以一般的做法是限定自旋的次数,(默认是10次,可以使用-XX:PreBlockSpin来更改,1.6及以后的jdk该设置已经不起作用,自1.6之后都是是自适应的自旋锁)超过了次数仍然没有成功获得锁,就会挂起线程。
只能保证一个共享变量的原子操作: 对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
自适应即自旋的次数不再固定,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
TicketLock:给每个请求资源的线程一个编号,锁有一个服务号,线程自旋去判断自己的编号和锁的服务号是否相同,如果相同则获取锁,不同则继续自旋。线程释放锁的时候,服务号会自增。
缺点:在多处理器系统上,每个线程占用的处理器都在读写同一个服务号,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会降低系统整体的性能。
CLHLock:使用队列,每个请求资源的线程都入队,每个节点自旋获取前一个节点的状态,前一个节点如果占有锁就继续自旋,前一个节点没有占有锁就自己获取锁,并让前一个节点出队。
缺点:这种方式实际上是自旋获取前一个节点的状态,队列的每个节点的物理内存不是连续的,在某些系统结构中可能两个节点的物理内存很远,寻址的代价会更高。
MCSLock:同样使用队列,每个请求资源的线程都入队,但是每个节点都自旋自己节点的状态,自己节点的状态由自己的前驱节点改变,自己释放锁的时候将自己的后驱节点的状态改为true,这样每次自旋不用去获取其它节点的状态,只有释放锁的时候需要去访问其它节点。
以下代码都是1.8的源码
// Unsafe是本地方法类,1.8及之前可以用于原子操作以及直接修改内存值
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 用于存储value字段相对对象“起始地址”的偏移量
private static final long valueOffset;
static {
try {
// 用于获取某个字段相对Java对象的“起始地址”的偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// 存储当前的int值,用volatile修饰来保证可见性并禁止重排序
private volatile int value;
在AtomicInteger的静态代码块中获取了value相对于初始地址的偏移量,之后会频繁直接去获取这个地址存储的内存值。
以下是AtomicInteger的getAndIncrement()方法,可以看到调用Unsafe的getAndAddInt()方法
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
下面是Unsafe的getAndAddInt()方法
// 从上面getAndIncrement()方法中传递的参数可以看到var1就是AtomicInteger对象本身
// var2就是valueOffset,而var4就是要增加的数量
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 获取该偏移量内存值的旧值,用var5记录
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
var5 = this.getIntVolatile(var1, var2); 这一行的意思就是获取AtomicInteger对象在valueOffset偏移出的旧值,并赋值给var5。
this.compareAndSwapInt(var1, var2, var5, var5 + var4),这一行中var2即valueOffset,var5即旧值,var5 + var4即新值,意思就是比较valueOffset偏移处记录的内存值和旧值的大小,如果相等就将新值更新上去,并返回true。否则返回false继续自旋。
Java并发编程的核心在于java.util.concurrent包。而juc包当中大多数同步器实现都是围绕着共同的基础行为,比如等待队列、条件队列、独占获取、共享获取等,而这个行为的抽象就是基于AbstractQueuedSynchronizer,简称AQS。AQS定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(state)的同步器。
AQS内部用一个state变量来表示锁的状态:volatile int state(32位)
默认为0,在不同实现中有不同含义。在ReentranLock中,如果为1代表已经获取锁,如果大于1代表重入次数。
AQS定义了两种资源共享方式:即独占式和共享式,反应在同步队列中节点的类型:
Exclusive独占,只有一个线程能执行,如ReetrantLock
Share共享,多个线程可以同时执行,如Semaphore/CountDownLatch
AQS定义了两种队列
● 同步等待队列:双向链表,尾插法
● 条件等待队列:单向队列,尾插法
ReentranLock内部实现了两种锁,非公平锁(NonfairSync)和公平锁(FairSync),两种锁最终都继承自AQS。根据构造器的不同可以使用不同锁,默认是非公平锁。
加锁解锁过程:
第一个线程会去用cas尝试将state从0改为1,由于还没有其它线程竞争,修改可以成功,修改成功即代表获取了锁,会在当前ReentranLock锁对象中记录自己的线程号,然后开始执行业务代码。这时第二个线程也尝试获取锁,即用cas尝试将state从0改为1,由于已经被第一个线程修改过,第二个线程会cas失败,这时就会准备入队,由于是第一个准备入队的线程,此时还没有队列,会去先构造一个双向队列(实际上就是构造一个head节点,这个head节点模式为独占模式,thread是null,waitStatus为-1,代表希望获取锁的线程释锁的时候将自己唤醒,其实这个head节点就代表了拥有锁的线程,头节点后面排队的节点则是被阻塞的节点),然后将自己入队,入队之后将自己的线程挂起(LockSupport.park()),等待已经获取锁的线程在释放锁的时候将自己唤醒,之后进入等待队列排队的每个节点都会把它前一个节点的waitStatus设为-1,代表自己需要被前一个节点唤醒。
第一个线程执行完业务代码之后开始释放锁的过程,它先是计算state减一的值(只是减一,未更新state),如果减1之后的值大于0,代表当前只是退出了一次重入;如果减一之后的值变为了0,这个时候才会真正的释放锁,它会把当前ReentranLock锁对象中记录的自己的线程ID原子性改为null,然后cas更新state,更新完之后检查等待队列,如果头节点不为空并且头节点的waitStatus为-1,代表有节点需要被自己唤醒,它就会去找头节点的下一个节点,并调用LockSupport.unPark()方法去唤醒这个节点的线程,这个节点的线程被唤醒后会再次尝试获取锁,由于是非公平的,有可能再次获取锁失败,失败会再次被阻塞。但如果线程2被线程1唤醒之后能成功获取锁,线程2就会进行出队操作,实际上就是删除head节点,并且将自身节点的thread置为null(自身节点变成了头节点,所以头节点总是代表着获取到锁的线程)
等待队列的waitStatus:
每个节点都有一个waitStatus变量,用于这个节点状态的一些标志,初始状态是0。
如果被取消了, 节点就是1(CANCELLED),那么他就会被AQS清理。
如果是-1(SIGNAL)则表示当前节点释放锁的时候,需要唤醒下一个节点。
如果是-2(CONDITION)则表示当前节点在条件队列中,
如果是-3(PROPAGATE)则表示释放锁的时候,进行传播唤醒(用于共享锁)
每个线程在进入队列前,都需要将前置节点的waitStatus设置成-1,否则自己永远无法被唤醒。
由于每个节点都必须设置前置节点的waitStatus为-1,所以就必须要一个前置节点,这个前置节点实际上就是当前持有锁的节点。
非公平锁如何实现:
非公平锁在加锁的时候就会尝试获取锁,即插队,如果获取失败才会进入队列排队,在进入队列之前会再次尝试获取锁,仍然是插队。而公平锁是直接进入队列排队。
// 非公平锁的NonfairSync的lock方法
final void lock() {
// 尝试获取锁,即插队
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
ReentranLock如何响应中断
ReentrantLock.lockInterruptibly()方法允许在等待时由其它线程调用等待线程的Thread.interrupt方法来中断等待线程的等待而直接返回,这时不用获取锁,而会抛出一个InterruptedException。当然如果线程已经处于挂起的状态是没办法立即抛异常的,而是被唤醒之后判断线程是否被中断,中断了就不会在尝试获取锁,也不会再次进入等待队列,而是直接抛出异常。
ReentrantLock.lock()方法不允许Thread.interrupt中断,即使检测到Thread.isInterrupted,一样会继续尝试获取锁,失败则继续休眠。只是在最后获取锁成功后再把当前线程置为interrupted状态。
concurrent包中阻塞线程都是使用的LockSupport类的方法。来详细了解一下这个类:
public static void park(Object blocker); // 阻塞当前线程
public static void parkNanos(Object blocker, long nanos); // 阻塞当前线程,设置超时时间
public static void parkUntil(Object blocker, long deadline); // 阻塞当前线程,直到某个时间
public static void park(); // 阻塞当前线程
public static void parkNanos(long nanos); // 阻塞当前线程,设置超时时间
public static void parkUntil(long deadline); // 阻塞当前线程,直到某个时间
public static void unpark(Thread thread); // 唤醒指定线程
public static Object getBlocker(Thread t);
blocker的作用其实就是方便在线程dump的时候看到具体的阻塞对象的信息,诊断问题的时候能够知道park的原因。
其实park/unpark的设计原理核心是许可:park是等待一个许可,unpark是为某线程提供一个许可。许可是不可累加的,相当于一个二元信号量。
所以unpark方法可以在park之前被调用,之后再调用park方法的时候发现已经有许可了,就会消费掉这个许可,然后继续执行。unpark方法可以调用多次但是许可只有一个,被park消费一次之后,再次调用park方法将会阻塞(等待许可)。
LockSupport最终会调用Unsafe类中的方法,这是一个与平台相关的实现,在linux下实际上最终是用的Posix线程库pthread中的mutex(互斥量)和condition(条件变量)来实现的,位于glibc库中。这和synchornied的阻塞线程时用到的库是一样的。
所以synchornied和ReentranLock最终实现上有很多相似之处,阻塞和唤醒线程都会在用户态和核心态中切换,从这个角度来说,这两种锁都是重量锁。
使用场景:Semaphore是一个计数信号量, 用于限制可以访问某些资源的线程数量。
实现方式:Semaphore在构造时候传入一个int值(实际就是给AQS的state赋值),也就是指明信号数量。主要方法有两个:acquire()和release()。acquire()用于请求信号,每调用一次,信号量便少一个,在state值为0的时候再调用acquire()方法就会被阻塞;release()用于释放信号,每调用一次信号量就会增加一个,但是要注意release方法可以随意调用,而且state值会一直增加,甚至可以超过初始值,比如state初始值设置为5,然后直接调用5次release方法,state值将变为10,这个时候就可以通过acquire方法申请到10个资源。
Semaphore内部也实现了两种锁,即非公平锁(NonfairSync)和公平锁(FairSync),都是基于AQS实现的,和ReentranLock不一样的是Semaphore的同步队列中的节点是共享模式,而ReentranLock的同步队列中的节点是独占模式。对于独占模式,当获取到锁的线程释放锁的时候会唤醒头节点的下一个节点,唤醒的这个节点会去尝试获取锁,如果获取到锁就会出队。共享模式不太一样,当获取锁的线程释放锁的时候也会唤醒头节点的下一个节点,唤醒的节点会去尝试获取锁,如果获取到锁将会出队,出队的时候这个节点会去唤醒它的下一个节点,唤醒的这个节点也会尝试去获取锁,之后会循环这个过程(传播唤醒)。如果尝试获取锁失败就会再次阻塞,就不会再唤醒下一个节点了。(共享模式是获取到锁就唤醒下一个节点,而独占模式是释放锁的时候唤醒下一个节点)
使用场景:CountDownLatch定义了一个计数器,当计数器的值递减为0之前,使队列里面的线程处于阻塞状态,值减为0之后,则唤醒所有被阻塞的队列。所以可以实现让一个或多个线程等待其他线程执行完毕后再执行的场景。
实现方式:在构造CountDownLatch时会传入一个int值,即为计数值(实际就是给AQS的state赋值),当计数值没有减到0,则会将线程构造为共享节点放入到同步队列,如果减到0,就会唤醒所有同步队列的的节点(因为是共享模式的节点,实际上是一种传播的形式的方式唤醒所有节点,即一个节点获取到锁之后出队的时候去唤醒下一个节点)
使用场景:CyclicBarrier即循环栅栏,可以使线程都到齐了再执行某个动作,可以循环使用。主要方法是await()方法,当线程调用await()方法代表当前线程已经来到了栅栏的位置,如果其它线程还没到齐,当前线程会阻塞,如果当前线程是最后一个到齐的线程,会唤醒所有阻塞的线程,然后重新复位栅栏。
实现方式:CyclicBarrier内部使用ReeentrantLock和Condition来实现,构造CyclicBarrier的时候需要传入一个计数值,即需要等待到齐的线程数量。当一个线程来到屏障点(调用CyclicBarrier.await方法的地方),先进行加锁,然后将计数减一,如果计数没有到0,会将当前线程构造成条件节点并加入到条件队列,然后阻塞该线程,这个过程就是使用了Condition的await()方法,由于当前线程被挂起,所以finally代码块中的unlock()方法没有执行(之后会有大用)。在计数器没有减到0之前,所有线程都是上述流程,进入到条件队列并阻塞。直到某一个线程来到屏障点的时候,先加锁,然后将计数减1,这个时候减到0了,该线程就不会入队了,会去调用Condition的signAll()方法,signAll()的逻辑是,将条件队列的节点依次出队,边出队边构造成同步队列的双向链表,这个同步队列就是前面用到的ReeentrantLock的同步队列。到这一步就完全是ReeentranLock的逻辑了,当前线程就是已经获取到锁的线程,之前到达屏障点的线程都在同步队列中等待唤醒,当前线程释放锁的时候会唤醒同步队列头节点的下一个节点的线程,这个线程可以立即获取到锁,中间没有其它逻辑,所以直接来到finally的代码块中执行unlock方法,unlock释放锁的时候再去唤醒下一个节点,直到所有节点被唤醒。
Condition的await()方法会临时释放锁资源(将status原子性改为0,之前的状态值会进行记录,用于重新获取锁的时候恢复原有的状态),让其它线程能够获得锁(会去同步队列中唤醒下一个节点,注意,不是条件队列),并阻塞当前线程,当在其它地方调用Condition.signl()方法的时候,线程会被唤醒,然后重新尝试获取锁,获取锁的时候会恢复之前的锁状态值。和Object类中的wait()方法类似,await()方法也有两个作用,一是临时释放锁资源,让其它阻塞的线程能获取锁,另一个是作用是阻塞当前线程。
读写锁主要用于读操作频繁,而写操作不怎么频繁的场景。
ReentrantReadWriteLock是可重入的读写锁,可以是公平模式也可以是非公平模式。ReentrantReadWriteLock维护了一对锁:WriteLock和ReadLock,ReadLock是共享锁,WriteLock是排它锁,总结起来读读不互斥,读写互斥,写写互斥
读写锁用AQS的state值来记录读写锁的次数,state默认为0,其低16位记录写锁次数,高16位记录读锁的次数。
所以增加一个写锁只需要将state原子加1就可以了,而增加一个读锁需要将state原子加65536(即2的16次方,也就是SHARED_UNIT的值)。 以下代码从ReentrantReadWriteLock中摘录了计算读锁次数和写锁次数的逻辑,并模仿增加一个写锁和一个读锁,然后打印当前的state的值,以及读锁和写锁的次数:
public class ReadWriteLockTest {
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT); // 2的16次方
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 2的16次方减1
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 2的16次方减1
// 获取读锁的次数,将state右移16位
/** Returns the number of shared holds represented in count */
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
// 获取写锁的次数,state和(2^16 - 1)做与运算,其等效于将state模上2^16
/** Returns the number of exclusive holds represented in count */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
@Test
public void testGetReadWriteLockCount() {
int state = 0; // ReentrantReadWriteLock初始的state值为0
System.out.println("==========add write lock==================");
// 模仿增加一个写锁
int writeState = state + 1;
System.out.println("current state is:" + writeState);
// 获取读锁和写锁的次数
System.out.println("read lock count:" + sharedCount(writeState));
System.out.println("write lock count:" + exclusiveCount(writeState));
System.out.println("==========add read lock==================");
// 模仿增加一个读锁
int readState = state + SHARED_UNIT; // SHARED_UNIT = 65536
System.out.println("current state is:" + readState);
// 获取读锁和写锁的次数
System.out.println("read lock count:" + sharedCount(readState));
System.out.println("write lock count:" + exclusiveCount(readState));
}
}
结果如下:
==========add write lock==================
current state is:1
read lock count:0
write lock count:1
==========add read lock==================
current state is:65536
read lock count:1
write lock count:0
获取写锁:
首先获取state值,如果state值为0,代表读锁和写锁都没有被其它线程获取过,写锁获取成功,state加1。如果state不为0,代表已经有线程获取过锁了(一定有一种锁被获取了,可能是读锁也可能是写锁),这时候需要从state的低16位获取写锁的次数来做进一步的判断,如果写锁的次数为0,代表已经被其他线程获取了读锁,则获取写锁失败;如果写锁次数不为0,就会判断当前获取写锁的线程是不是自己,如果不是自己则获取写锁失败,如果是自己则代表是重入,获取写锁成功,state加1。
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
// 1. 获取写锁当前的同步状态
int c = getState();
// 2. 获取写锁的次数
int w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 3.1 当读锁已被其它读线程获取或者当前线程不是已经获取写锁的线程
// 则当前线程获取写锁失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
// 3.2 当前线程获取写锁,支持可重复加锁
setState(c + acquires);
return true;
}
// 3.3 写锁未被任何线程获取,当前线程可获取写锁
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
释放写锁:
释放写锁比较简单,将state减1,判断state是否为0,如果为0则表示写锁空闲了,将锁的持有线程设置为null,如果不为0则说明只是退出了一次重入。
获取读锁
首先获取state值,然后从state值中获取读锁和写锁的次数,如果发现写锁次数不为0,说明有线程占有了写锁,判断这个线程是不是自己,如果不是自己,则读锁获取失败。其它情况都将获取读锁成功,修改state的值(原子增加2的16次方,即65536)
思考:如果有多个线程,每个线程都只获取读锁,那么ReentranReadWriteLock如何记录当前线程的重入数呢?
答案就是ThreadLocal,每个线程自己在ThreadLocal中保存自己的重入度。
读写锁支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁。ReentranReadWriteLock不支持锁升级。