要想构建高并发的应用,那么多线程就是绕不开的技术,而锁又是多线程中的重点。如何利用好锁,将很大程度决定程序的健壮性跟并发度。这次我们一起聊下锁相关的一些知识。
Mark Word
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
而对象头又包括以下三部分信息,其中Mark Word是synchronized锁的实现基础。
- Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit
- Klass Pointer:类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- 数组长度:只有数组对象有,用于记录数组的长度
Mark Word在32位跟64位虚拟机中存储结构是不同的,但是核心结构一致,我们就以32位为例进行介绍。
mark word- 锁标志位:区分锁状态,11时表示对象待GC回收状态
- 是否偏向锁:由于无锁和偏向锁的锁标识都是01,这里引入一位的偏向锁标识位
- 分代年龄:表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代
- 对象的hashcode:当对象加锁后,计算的结果32位不够表示,在偏向锁、轻量锁、重量锁,hashcode会被转移到monitor中
- 偏向锁的线程ID:偏向模式时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 再次进入时候,就无需再进行尝试获取锁的动作
- Epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁
- 指向栈中锁记录的指针:当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的Mark Word中设置指向栈中锁记录的指针
- 指向互斥锁(重量级锁)的指针:如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的Mark Word中设置指向monitor的指针
锁的实现
synchronized
synchronized的实现
public void addValue(int value) {
synchronized (lock) {
a += value;
}
}
上面这段代码编译后可以得到如下的字节码数据:
synchronized 字节码从字节码中可知同步语句块的实现使用的是monitorenter和monitorexi指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。
值得注意的是:一条指令monitorenter可以对应到多条monitorexit 指令。这是因为Java虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。也就是说:编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应monitorexit指令。
monitorenter,monitorexi指令其实都是对monitor对象的操作,monitor对象有个专门的名字:对象监视器(Object Monitor)。每个Java对象都存在一个monitor与之关联,在HotSpot中monitor是由ObjectMonitor实现(C++源码)。下面是ObjectMonitor的数据结构。
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;// 存储Monitor对象
_owner = NULL;// 持有此锁的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
- 线程执行monitorenter时候,会获取此monitor的所有权,计数器0->1,owner指向此线程
- 如果已经是monitor的owner,再次进入时候计数器加1
- 当执行monitorexit的时候,计数器将减1,直到为0的时候,代表锁已经释放,其他线程就可以来竞争此锁
synchronized的优化
在Java6之前synchronized锁进行状态切换都是依赖内核态指令进行,而内核态进行线程的阻塞、唤醒成本是比较昂贵的。而很多时候其实没有激烈的锁竞争,或者锁的持有只有很短的时间,那么让请求锁的线程“稍等一会”相比挂起、唤醒线程成本将更低。Java6之后于是引入了偏向锁跟轻量级锁来优化synchronized的性能,synchronized加锁的过程于是变成了无锁-->偏向锁-->轻量级锁-->重量级锁。
偏向锁
偏向锁目的是为了消除在无竞争情况(在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得)下的获取锁消耗。当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的锁标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。
如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。有时候使用参数-XX:- UseBiasedLocking来禁止偏向锁优化反而可以提升性能。
在JDK15中默认禁用了偏向锁,可以使用XX:+UseBiasedLocking开启。
轻量级锁
如果偏向锁宣告结束,将升级为轻量级锁。
执行轻量级锁的时候,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间。然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位将转变为“ 00”。
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行,否则就说明这个锁对象已经被其他线程抢占了。所以出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁。
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
自旋本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,否则那么自旋只会带来性能的浪费。因此自旋等待的时间必须有一定的限度,自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。
重量级锁
重量级锁就是我们大家印象中的锁,需要从用户态切换到内核态,依赖操作系统的内核态来完成线程之间的切换,这个成本非常高。
总的来说偏向锁是认为只有一个线程在使用锁,轻量级锁是认为获取锁的时候不存在竞争,自旋锁是认为锁住的代码将很快执行完成。如果不符合假设将向上膨胀。
Lock
自JDK5起Java类库中新提供了java.util.concurrent包,其中的java.util.concurrent.locks.Lock接口便成了Java的另一种全新的锁手段。基于Lock接口,用户能够以非块结构来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步。
java.util.concurrent中的锁都是基于AQS(AbstractQueuedSynchronizer)实现,AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。
主要原理图如下:
CLHAQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。
ReentrantLock
ReentrantLock(重入锁)是Lock接口最常见的一种实现,顾名思义,它与synchronized一样是可
重入的。在基本用法上,ReentrantLock也与synchronized很相似,只是代码写法上稍有区别而已。不过ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件。
ReentrantReadWriteLock
ReentrantReadWriteLock是Lock的另一种实现方式。ReentrantLock是一个排他锁,同一时间只允许一个线程访问,而ReentrantReadWriteLock允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。相对于排他锁,提高了并发性。在实际应用中,很多情况下对共享数据(如缓存)的访问都是读操作远多于写操作,这时ReentrantReadWriteLock能够提供比排他锁更好的并发性和吞吐量。
读写锁对于同步状态的实现是在一个整形变量上通过“按位切割使用”:将变量切割成两部分,高16位表示读,低16位表示写。
假设当前同步状态值为S,get和set的操作如下:
- 获取写状态:S&0x0000FFFF:将高16位全部抹去
- 获取读状态:S>>>16:无符号补0,右移16位
- 写状态加1: S+1
- 读状态加1: S+(1<<16)即S + 0x00010000
看起来Lock实现的锁性能不差于synchronized,功能又丰富,那么是不是可以弃用synchronized了呢?答案是否定的,从长远来看,Java虚拟机更容易针对synchronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized中锁的相关信息,也可以在编译器对其进行锁消除等优化。而Lock的话,Java虚拟机是很难对其进行监控、分析,也就难以对其进行优化。所以Java建议在能实现同样功能的时候优先选择使用synchronized。
其他
Mark Word中对象hashCode的说明
在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的Object Monitor类里有字段记录了非加锁状态(标志位为“01”)下的Mark Word。
自适应自旋
无论是自旋的默认值还是用户指定的自旋次数,将对所有的锁都将生效。在JDK6中引入了自适应的自旋。自适应意味着自旋的时间不再固,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间。如果自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准。
锁的一些名词
乐观锁/悲观锁
乐观锁认为使用共享数据的时候不会有别的线程来修改,所以只有在更新数据的时候才判定是否有别的线程将数据修改了,如果没有则正常更新,如果有则按需处理(重新计算或者报错),最经常使用的是CAS。典型案例如Java中AtomicInteger自增操作。
悲观锁则认为在使用共享数据的时候会有别的线程来修改,所以要先加锁,Java中的synchronized、Lock都属于悲观锁。
独享锁/共享锁
独享锁也叫互斥锁、排它锁,是指同一时间一个锁只能被一个线程持有,如synchronized、ReentrantLock都属于独享锁。
共享锁是指同一时间一个锁可以被多个线程同时持有,如ReentrantReadWriteLock中的ReadLock。
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程会先进入到队列中排队,只有队列中的第一个线程才能获取到锁。
非公平锁是多个线程加锁是直接尝试获取锁,获取不到的时候进入队列等待或者挂起。
ReentrantLock中的公平锁与非公平锁加锁的时候区别如下图所示,非公平锁会先尝试获取锁,而公平锁直接进入队列(当然acquire方法的实现是不同的)。一般来说非公平锁的吞吐会比公平锁的高。
非公平锁 | 公平锁 |
---|
深入理解Java虚拟机--周志明
大彻大悟synchronized原理,锁的升级
从ReentrantLock的实现看AQS的原理及应用
网友评论