- 在多线程并发编程中Synchronized一直是元老级角色,很多人都会称呼它为重量级锁, 但是随着Java SE1.6对Synchronized进行了各种优化之后,有些情况下它并不那么重了,Java SE1.6中 为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。
- 在Java SE1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。
- 这几个状态会随着锁的竞争情况逐渐升级。锁可以升级但是不能降级,这种策略是为了提高获得锁和释放锁的效率。
- 重量级锁是悲观锁的一种,自旋锁、轻量级锁与偏向锁属于乐观锁。
Java线程阻塞的代价
上下文切换
- 单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。
- 时间片:是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不断地切换线程执行,让我们觉得多个线程是同时进行的。
- CPU通过时间片分配算法来执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务的时候,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
代价
- java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
- 频繁的上下文切换很费时,如果同步代码执行所需时间比上下文切换时间都要短,那引入重量级锁切换上下文这种同步策略是失败的:所以synchronized从JKD1.6进行了改进,引入了偏向锁、轻量级锁。
偏向锁(Biased Locking)
- 偏向锁会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程竞争的情况,这时就会给该线程加一个偏向锁。
- 如果在运行的过程中,遇到了其他线程抢占锁,则持有偏向锁的线程将会被挂起,JVM会消除它身上的偏向锁,将锁膨胀成为轻量级锁。
加锁过程
- 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
- 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
- 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
- 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
- 执行同步代码。
释放
- 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,否则线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或膨胀成轻量级锁(标志位为“00”)的状态。
应用场景
- 始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作。在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用;
轻量级锁
- 轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
加锁过程
-
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图:
image -
拷贝对象头中的Mark Word复制到锁记录中;
-
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
-
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。
image -
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明锁已经被别的线程持有,此时有多个线程同时竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
轻量级锁的释放
- 释放锁线程视角:由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。因为重量级锁被修改了,所有display mark word和原来的markword不一样了。怎么补救,就是进入mutex前,compare一下obj的markword状态。确认该markword是否被其他线程持有。此时如果线程已经释放了markword,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用。
- 尝试获取锁线程视角:如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。还有一个注意点:等待轻量锁的线程不会阻塞,它会一直自旋等待锁,并如上所说修改markword。这就是自旋锁,尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起,获得锁,则执行代码。
自旋锁
- 自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
- 但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。
- 如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
应用场景
- 当锁的竞争不激烈,并且锁占用的时间非常短的时候,自旋锁就可以大幅度提高性能,因为自旋的消耗会小于线程阻塞挂起操作的消耗。但是如果锁的竞争激烈,或者锁占用时间长,这时就不适合用自旋锁。因为这样会导致大量的线程一直占着CPU资源长时间做无用功。此时线程自旋的消耗是大于线程阻塞挂起操作的消耗。
时间阈值
- 它是自旋锁的重要一部分,过长和过短都会对整体系统的性能产生负面影响。JVM对于自旋周期的选择,jdk1.5这个限度是写死的,在1.6引入了适应性自旋锁,它是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化:
- 如果平均负载小于CPU数则一直自旋
- 如果有超过(CPU数/2)个线程正在自旋,则后来线程直接阻塞
- 如果CPU处于节电模式则停止自旋
- 如果正在自旋的线程发现了进入临界区的线程变化则延迟自旋时间(自旋计数)或进入阻塞
- 自旋时会适当放弃线程优先级之间的差异
疑问和猜想
-
在网上又看到一副关于偏向锁,轻量级锁,自旋锁,重量级锁的流程图。图中和上述的内容的细节有些出入,特别是轻量级锁膨胀成重量级锁部分不同有些大。笔者无能,不知道哪个才是正确的,或者都不正确。希望自己以后可以把这篇文章完善,给出正确的答案。图如下:
锁分配和膨胀过程.jpg -
我觉得上图中在左边的灰色框后应该是把对象头的Mark Word记录当前线程的ID从原偏向线程ID改成新线程的ID。然后新线程就能获取到偏向锁,再访问临界区(代码块中的内容)
-
希望大牛可以评论告诉我这几种锁膨胀的过程,哪个才是正确的,还是有另外的版本。
网友评论