吃透Java锁
一、锁与锁的升级
在JDK1.6后java对锁做了很多优化,JDK最开始是synchronized属于重量级锁,即一个线程抢到锁后其余线程都处于阻塞状态,这样在线程状态切换过程或造成很大开销。优化之后有了四种状态:无锁状态、偏向锁、轻量级锁、重量级锁。升级顺序由偏向锁->轻量级锁->重量级锁过程不可逆,偏向锁和无锁状态可以相互转换。
二、CAS
cas及比较并交换(Compare-And-Swap),一个CAS过程涉及到的操作有:
1.内存原数据V,旧的预期值A,需要修改的值B。比较预期值A和原数据V是否相等(比较)
2.如果A=V,将B写入V(交换)。
3.返回操作是否成功。
当多个线程对数据做操作时,只有一个线程可以进行操作,其他的线程会不断尝试操作并不会被阻塞。Java中是用了jdk.internal.misc.Unsafe这个最终类调用本地方法实现。单纯的CAS操作如果不涉及到CPU层面其实并不是原子操作。CAS的真正实现依靠CPU指令Atomic:cmpxchg,使用CPU硬件提供的lock机制保证了操作的原子性。
三、挂起锁和自旋锁
自旋锁持续尝试获取锁,这个过程一直在占用CPU资源,抢到锁就继续执行下一步操作,但是如果锁长期被别的线程占有那么长时间的自旋或消耗CPU资源。挂起锁则是将线程挂起,这时候不消耗CPU资源。自旋锁是轻量级锁的一种实现。
四、公平锁和非公平锁
指的是阻塞或者自旋的多个线程获取锁的顺序问题,如果争夺锁得线程是随机获得锁的那么就是非公平锁,如果是按照一定顺序获得锁的那么就是公平锁。
五、可重入锁和不可重入锁
如果当前线程已经获取到锁,那么再次加锁不会阻塞自己就是可重入锁,反之如果会阻塞自己造成死锁现象那就是不可重入锁。
六、Synchronized
针对资源或者数据A开始肯定是无锁状态,因为我们程序还没有加锁,这时候不会产生线程锁竞争,当然也会发生线程污染问题如ABA问题。然后我们使用Synchronized来进行加锁,如果资源只是被同一个线程使用那么此时最开始会是一个偏向锁,因为加锁和释放锁会涉及到额外的修改更新数据如复制对象头信息等操作会产生额外的资源消耗,所以如果锁仅有一个线程访问这种情况会给线程加一个偏向锁,如果再有其他的线程前来竞争,持有偏向锁的线程会被挂起,JVM会消除偏向锁并恢复到轻量级锁。
当线程1访问代码块并获取锁对象时,会在java对象头(对象头的内容详见另外的一篇文章)和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
1.在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。拷贝对象头中的Mark Word复制到锁记录中;
2.使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态
3.如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制,**如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。**重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized 效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
偏向锁并不是真正加锁 ,只是给中做一个 "偏向锁的标记", 记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用进行后续加锁的操作了(节省加锁解锁的开销)如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态. 偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销。这个过程类似于单例模式中的懒汉模式,只在必要时加锁,节省开销。
七、其他优化操作
1.锁消除
2.锁粗化
3.锁细化
八、AQS与ReentrantLock
ReentrantLock依赖于AQS(AbstractQueuedSynchronizer)主要在java层面实现了锁的效果。sync主要包含几个量:state(用volatile修饰,标识资源状态默认为0,锁每重入一次+1,如果不等于0就是上锁状态)、exclusiveOwnerThread(持有锁的线程,AbstractQueuedSynchronizer继承AbstractOwnableSynchronizer,包含用来记录持有线程及方法)、Node(一个队列用来存放等待线程,等待被上一个线程唤醒)ReentrantLock其实是持有一个AbstractQueuedSynchronizer对象sync实例化ReentrantLock对象时可以选择公平锁或者非公平锁,默认非公平锁。
执行lock()方法发生的过程:线程先判断state的值如果为0说明现在处于无锁状态,将state置为1并将exclusiveOwnerThread置为当前线程,如果本线程再次执行加锁操作只会使stae加1,因为可以通过exclusiveOwnerThread来判断是否是自身。如果是另外的线程进入试图加锁那么也会判断state和exclusiveOwnerThread的值,如果都不满足说明此时锁还被别的线程占有,那么这个另外的线程就会加入Node并构造出prev线程和next线程(null),如果有多个线程都来加锁那么会形成一个前后关联的Node链,其实也就是队列,并且这些线程都会被终端。初始线程执行unlock方法后会首先将state置为性的新的状态,如果state>1说明重入多次,那么exclusiveOwnerThread还是初始线程,知道state=1,exclusiveOwnerThread才会被重新设为null,这样初始线程会被gc,通过初始线程获取它的下个节点Node(包含一个被中断的线程对象)通过调用unpark方法恢复线程的执行。JDK1.9之前操作会有不同主要的改变是变量句柄(VarHandler)的使用和一些方法内部操作的变化。代码就不列举了详见ReentrantLock类、AbstractQueuedSynchronizer类、VarHandle、LockSupport(封装了park和unpark,底层其实也是用了unsafe的park和unpark用来终端和恢复线程)。
盗一张图

九、自我理解
volatile和synchronized一个很大的不同是volatile直接通过内存屏障实现内存的线程可见性,可以阻止jvm指令重排,但是volatile修饰的变量可能发生ABA问题。而且volatile只能修饰变量,synchronized时synchronized可以防止发生ABA问题(因为是锁机制)但是jvm依然会进行优化指令重排序。synchronized底层是通过硬件层面的cas(Atomtic:cmqxchg指令)和mutex来加锁和锁升级的,加锁过程和升级过程涉及到java对象头(堆,Mark Word来存放线程栈帧的信息如ThreadID和偏向时间戳)和虚拟机栈(锁记录空间Lock Record 用来存放复制的对象头信息),不同线程争夺锁的过程其实是不断自旋(轻量级锁)查看、试图修改对象头信息和状态的过程(CAS),当然也可能是先挂起等待被唤醒(重量级锁)。synchronized的机制导致其是不公平的。synchronized嵌套使用可能会引起死锁问题。
ReentrantLock(JDK9后)主要是在java代码层面通过一个状态state一个owner线程和一个Node队列来实现锁的(AQS),有了队列(先后关系)后就可以实现公平锁了。AQS底层是用了varHandler和unsafe(park和unpark)实际也是CAS。ReentrantLock可以响应中断,并防止死锁。

浙公网安备 33010602011771号