白话Java锁--synchronized关键字

本文详细介绍了Java中的synchronized关键字及其实现原理,包括锁的升级、轻量级锁、偏向锁的概念与工作流程,以及锁的重入性和锁降级等内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在看了看Disruptor这个东西之后,感觉要是想要了解其中的原理还是需要了解一下java中的锁体系。

说到锁第一个浮现在我脑海中的就是synchronized关键字,可能也是我在项目中常用的吧,因为比较方便。

在现在大数据量时代下,如何提高系统的运行效率是现在大多数程序员所考虑的,大多数做法无非就是开启多线程或者异步请求调用,异步就不用了多说了(说白了就是将耗时的操作放在一个队列里面,然后再从队列里面慢慢拿取进行后续耗时操作),但是多线程就有很多说道了,如何保证在多线程提高系统效率的同时,并发下,保证多线程操作之后达到预期效果预期的效果,这就是多线程所考虑的问题,所以会有java中锁的体系,选用适当的结构达到最高的运行效率,这应该是我们所考虑最多的。

synchronized

那么如何保证多线程下的并发问题呢,一个浮现在我脑海中的就是synchronized关键字,可能也是我在项目中常用的吧,因为比较方便。synchronized实际上使用的就是锁机制,就是多个线程同时访问共享资源时,只能有一个线程操作,其他线程都得干等着,只能一个一个操作。

synchronized使用

分类具体分类被锁的对象
方法普通方法对象
静态方法
代码块this此对象
.class
任意对象任意对象

这里就不多说synchronized的使用与含义了

synchronized实现原理

如果想要了解synchronized的底层原理,只需要了解两部分的内容足以:

  • Monitor Object 模式
  • 之前还是得了解一下java中对象在内存中存储的格式。

就如其他例子一样首先看一下加上了synchronized关键字之后,编译器为我们做了什么

public class Test {
    public static void main(String[] args) {
        synchronized (Test.class){}
    }
}

使用命令查看编译后的class文件

javap -v Test.class

在这里插入图片描述
可以看到编译后自动加上了monitorenter和monitorexit这两条指令,可以通过名称知道一个是进入,一个是退出,那么进入的是什么退出的又是什么呢?

Monitor

实际上这个进入和退出的东西就是Monitor。这就要说jvm在设计之初的时候,会对在创建对象和创建类的时候,隐式的创建一个与对象或者类相对应的Monitor“对象”(为什么要加引号呢,因为严格意义上来说不是java对象)

由这个Monitor负责处理多线程并发的问题。类似一个Manager负责调度多线程并发访问,通过这种调度保证在同一时刻只有个个线程独占执行。

java会为每个object对象分配一个monitor,当某个对象的同步方法(synchronized methods )被多个线程调用时,该对象的monitor将负责处理这些访问的并发独占要求。
当一个线程调用一个对象的同步方法时,JVM会检查该对象的monitor。如果monitor没有被占用,那么这个线程就得到了monitor的占有权,可以继续执行该对象的同步方法;如果monitor被其他线程所占用,那么该线程将被挂起,直到monitor被释放。
当线程退出同步方法调用时,该线程会释放monitor,这将允许其他等待的线程获得monitor以使对同步方法的调用执行下去。
注意:Java对象的monitor机制和传统的临界检查代码区技术不一样。java的一个同步方法并不意味着同时只有一个线程独占执行,但临界检查代码区技术确实会保证同步方法在一个时刻只被一个线程独占执行。Java的monitor机制的准确含义是:任何时刻,对一个指定object对象的某同步方法只能由一个线程来调用。
java对象的monitor是跟随object实例来使用的,而不是跟随程序代码。两个线程可以同时执行相同的同步方法,比如:一个类的同步方法是xMethod(),有a,b两个对象实例,一个线程执行a.xMethod(),另一个线程执行b.xMethod(). 互不冲突。
其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。
任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态

Monitor 设计初衷

那么为什么要设计成在执行同步方法时候拿取Monitor的这种模式呢?而不是在调用同步方法的时候在调用前加锁,在调用后解锁。

首先,如果由程序员手动进行加锁和解锁会导致程序员在书写代码的时候异常困难,要不停的去书写与业务无关的代码
其次,无法控制如果在加锁后,执行方法内的代码抛出异常应该如何处理,也就是不可控性,即使你是高级程序员也有可能处理不好对锁的控制
最后,如果将锁的控制交给一个锁“对象”去处理,而不是由业务代码控制,将极大提高开发效率,因为全部交由锁“对象”处理的了,所以增强了对锁的控制,而且可以对通过对锁“对象”的优化以提高程序的运行效率

Monitor 结构

在hotSpot虚拟机中,monitor是由ObjectMonitor实现的。其源码是用c++来实现的,位于hotSpot虚拟机源码ObjectMonitor.hpp文件中。ObjectMonitor主要数据结构如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //monitor进入数
    _waiters      = 0,
    _recursions   = 0;  //线程的重入次数
    _object       = NULL;
    _owner        = NULL; //标识拥有该monitor的线程
    _WaitSet      = NULL; //等待线程组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争锁进入时的单项链表
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

会发现这里面的东西还真不少,简单的通过图片了解一下

在这里插入图片描述

  • CXQ队列(_cxq):竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)
  • EntryList:CXQ队列中有资格成为候选资源的线程会被移动到该队列中
  • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
  • Owner:获得锁的线程称为Owner。初始时为NULL,当有线程占有该monitor的锁的时候,Owner标记为该线程的唯一标识,当线程释放monitor时,Owner又恢复为NULL。
  • WaitSet:如果Owner线程被wait方法阻塞,则转移到WaitSet队列

注:每个等待锁的线程都会被封装成ObjectWaiter对象,保存了Thread(当前线程)以及当前的状态ThreadState等数据。

CXQ队列

这个CXQ队列是一个临界资源,并不是一个真正的Queue,只是一个虚拟队列,是由Node及其next指针逻辑构成,是一个后进先出(LIFO)的队列,每次新加入Node时都会在队头进行,通过CAS改变第一个节点的的指针为新增节点(新线程),同时设置新增节点的next指向后续节点,而取数据则发生在队尾。通过这种方式减轻了队列取数据时的争用问题。而且该结构是个Lock-Free的队列无锁队列(实际上就是通过CAS不断的尝试来实现的)。
在这里插入图片描述

EntryList

获得锁得到执行权力的Owner线程在释放锁时会从CXQ队列或EntryList中挑选一个线程唤醒,到底唤醒哪个取决于Monitor的策略(1.可以直接绕过EntryList直接将线程放到OnDeck中。2.将线程插入到EntryList尾部。3.将线程插入到EntryList头部),并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程(被选中的线程叫做Heir presumptive即假定继承人)。但是并不会把锁传递给 OnDeck线程,只是把竞争锁的权利交给OnDeck(synchronized是非公平的,所以不一定能获得锁),OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在 Hotspot中把OnDeck的选择行为称之为“竞争切换”。

这里扩展一下,在这里存储的线程对应java线程状态的Blocked状态

Owner

OnDeck线程获得锁后即变为Owner线程,获得执行的权限,无法获得锁则会依然留在EntryList中,在EntryList中的位置不 发生变化。

WaitSet

如果Owner线程被wait方法阻塞,则转移到WaitSet队列。当wait的线程在某个时刻被notify/notifyAll之后,会将对应的ObjectWaiter从WaitSet移动到EntryList或CXQ队列中(到底如何移动同样取决于Monitor的策略,1.可能将WaitSet队列中的对象头插入EntryList队列中,2.可能将WaitSet队列中的对象尾插入EntryList队列中,3.可能将WaitSet队列中的对象头插入CXQ队列中,4.可能将WaitSet队列中的对象尾插入cxq队列中)

顺便说一下,WaitSet存放的是处于等待状态的线程,这些线程在等待某种特定的条件变成真,所以又称为条件队列。这个Monitor的wait/notify/notifyAll方法实际上是为上层提供的操作API。所以要调用这个条件队列的方法,必须先拿到这个Monitor,相应的,对于同步方法或者同步代码块中,就会有一个推论就是“wait/notify/notifyAll方法只能出现在相应的同步块或同步方法中”。如果不在同步方法或同步块中,运行时会报IllegalMonitorStateException。

Monitor 设计缺陷

由于java的线程是映射到操作系统原生线程之上的,所以线程在放入队列里面的时候是需要阻塞的,在WaitSet里面的线程是需要唤醒的,这时阻塞/唤醒就会产生问题,因为阻塞/唤醒是需要调用Linux内核的命令,但是运行的线程是在jvm虚拟机上,这个时候就会存在操作系统用户态和内核态的转换

扩展:

Linux操作系统的体系架构分为:内核和用户空间(应用程序的活动空间,例如jvm)

内核:本质上可以理解为一种软件,控制计算机的硬件资源(CPU,硬盘,网络等),并提供上层应用程序运行的环境

用户空间:上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等

系统调用:为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口

- 用户态 :  所有进程初始都运行于用户空间,此时即为用户运行状态,简称用户态。例如jvm虚拟机运行在用户态

- 内核态 : 用户态的进程通过系统调用执行某些操作时,例如 I/O调用,此时就需要陷入内核中运行,简称内核态。例如线程阻塞/唤醒

当用户态程序在执行系统调用的时候会切换到内核态, 并跳到位于内存指定位置的指令(这些指令是操作系统的一部分, 他们具有内存保护, 不可被用户态程序访问),内核会执行程序请求的服务,系统调用完成后, 操作系统会返回系统调用的结果

因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递许多变量、参数给内核,同时内核还需要保护好用户态在切换时的一些寄存器值、变量等,以备内核态切换回用户态继续工作。而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。这就是说为什么synchronized未优化之前,效率低的原因。

所以synchronized从JKD1.6进行了改进,引入了偏向锁、轻量级锁

synchronized优化

在说优化之前我们先了解一下对象在内存是如何存储的以便了解synchronized是如何实现优化的

对象在内存中的格式

在这里插入图片描述
包括三个部分:

  • 对象头:存储对象的基本信息
  • 实例数据:存放类的属性数据信息,包括父类的属性信息(按4字节对齐)
  • 填充数据:虚拟机要求对象起始地址必须是8字节的整数倍(填充数据不是必须存在的,仅仅是为了字节对齐)(有点和CPU中cache line获取内存数据类似)

对象头信息包含两部分(或者三部分,数组情况下):

  • Mark Word:存储对象的hashCode、锁信息或分代年龄或GC标志等信息
  • Class Metadata Address:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例
  • Array length:数组长度,只有数组类型才有

其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构

锁状态25bit4bit1bit(是否是偏向锁)2bit(锁标志位)
无锁状态对象HashCode对象分代年龄001

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便节省存储空间,它会根据对象本身的状态刷新自己的对象头信息,不同的状态下结构会有所不同,可能变化的结构:

锁状态25bit4bit1bit2bit
23bit2bit是否是偏向锁锁标志位
无锁状态hashCode对象分代年龄001
轻量级锁执行栈中锁记录的指针00
重量级锁执行栈中锁记录的指针10
GC标记11
偏向锁线程IDEpoch对象分代年龄101

从上面的表就可以看到涉及synchronized优化的轻量锁和偏向锁。当多个线程同时请求同一个对象的监视器时,Monitor会设置线程的对象头上面几种状态用来区分不同的请求

一开始看到这个图的时候不要慌,接下来我会一一说明每种锁的状态

首先创建对象的时候,会根据jvm设置的参数来设置是否开启偏向锁,根据是否开启偏向锁的设置创建初始化对象时对应的对象头格式会有不同,执行的逻辑也会稍有不同。

默认偏向锁是开启的,如果需要关闭, 在jvm启动时加上如下参数即可

-XX:-UseBiasedLocking

先从简单的逻辑开始入手理解,关闭偏向锁机制(偏向锁机制稍微复杂一些,在说偏向锁的时候再打开)时,先看看轻量锁是如何工作的。

轻量锁

轻量锁产生背景

其实轻量锁就是为了解决上面synchronized中Monitor那种模式下,每次进入synchronized都要创建一个MonitorObject对象,这在已经确定是多线程访问的条件下是没有什么问题的,但是在真实生产环境下,并不是一直处于多线程竞争的,而是处于低竞争的状态,所以就发明出了轻量锁这种机制,大多数情况下线程A会先访问同步代码块,线程A访问完毕后线程B才会访问同步代码块,他们之间的访问类似于交替访问,并没有竞争问题,如果有竞争也只是轻微的竞争,只是几个线程之间竞争进入同步代码块,这时如果使用Monitor机制会显得大材小用,浪费空间。

轻量锁简述

实际上轻量锁的获取和释放都是通过CAS指令实现的,在低竞争的条件下如果一个线程已经拿到锁了,那另外一个线程会通过CAS去获取锁,虽然CAS会占用一定的CPU资源,但是相对Monitor来说不会有内核态和用户态转换的浪费。但是如果CAS也不是一直去获取,而是在达到一定的阈值之后,判定为这种情况下竞争比较激烈,轻量锁就会膨胀为上面的Monitor(也就是重量锁)。

轻量锁流程

当线程进入到同步代码块的时候,会先判断这个锁对象的对象头,因为上面我说了先关闭偏向锁模式。所以初始化对象的时候锁对象的对象头为如图所示:
在这里插入图片描述
当前线程会在栈帧中创建一个锁记录(Lock Record)。
这里面涉及两个概念一个是栈帧,一个是锁记录(Lock Record)

栈帧

栈帧的概念我就直接饮用网上的一段话了:

jvm为每个新创建的线程都分配一个堆栈。堆栈以帧为单位保存线程的状态。jvm对堆栈只进行两种操作:以帧为单位的压栈和出栈操作。

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。第一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈 都已经完全确定了,并且写入到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实 现

一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧 (Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧的概念结构如下图所示:
在这里插入图片描述

锁记录(Lock Record)

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象Mark Word的拷贝,官方称之为 Displaced Mark Word。
在这里插入图片描述
了解了栈帧和锁记录(Lock Record),对后面轻量锁的理解会更加透彻。

轻量锁加锁过程

1.首先在进入同步块的线程会在当前栈帧中创建一个锁记录(Lock Record)
2.拷贝锁对象头中的Mark Word到当前栈帧中的锁记录(Lock Record)中

在这里插入图片描述
3.将Mark Word拷贝到Lock Record中完成后,尝试使用CAS将Mark Word更新为指向锁记录的指针,并将Lock record里的owner指针指向object mark word,如果更新成功,当前线程就获得了锁,同时更新锁标志位为00,表示当前对象处于轻量级锁状态
在这里插入图片描述
4.如果更新失败,说明这时有两个线程,线程A在进行拷贝并修改锁对象的Mark Word的指针的时候,有另外一个线程B也想修改Mark Word的指针为线程B的Lock Record,所以只会有一个成功的,另外一个就会失败,成功的就是获取到轻量锁的线程,失败的就是没有获取到锁的线程。失败的时候,jvm会先检查这个轻量锁的Mark Word的指针是否指向当前线程的某个锁记录,如果是则说明当前线程已经拥有了这个轻量锁,可以直接执行同步块,这是重入锁特性,不是则说明其有其它线程抢占了锁,存在竞争。

在这里插入图片描述

5.在存在竞争的条件下,轻量锁并不会立即膨胀(发现存在大量线程竞争,需要增加锁的强度,保证多线程同步)为重量锁,而是不断的进行CAS操作,进行自旋,如果在多个自旋之后还是无法修改,那么就会膨胀为重量锁。目的是对于那些执行时间很短的代码来说,尽量减少线程的阻塞,通过乐观锁的方式提高并发线程执行效率。

轻量锁解锁过程

1.在没有竞争的情况下,当持有轻量锁的线程执行完同步方法后,会通过一次CAS操作将当前栈帧中的Lock Record重置,置回到之前的轻量锁的对象头的Mark Word中,如果重置成功,则释放锁完成。

2.但是如果有其他线程在竞争该轻量锁,而且其他竞争的线程在多次自旋后依然无法对象头的指针,那么就会将这个轻量锁膨胀为重量锁。竞争的线程会修改Mark Word的锁标识为重量锁的10,然后这个竞争线程就会被阻塞。

在这里插入图片描述

3.在持有这个轻量锁的线程执行完同步方法后,在通过CAS重置轻量锁的对象头的时候发现,自己的Lock Record和轻量锁的对象头不同,因为轻量锁的锁标识已经被其他线程的修改为重量锁标识,所以在释放锁的同时,会唤醒正在等待该轻量锁的而阻塞的线程。
在这里插入图片描述

轻量锁优点

重量级锁会涉及到有用户态切换到内核态进行线程的阻塞和唤醒操作,然后再切换到用户态,这些操作给系统的并发性能带来了很大的压力,有些时候锁定状态可能只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,在申请锁资源时通过一个CAS操作即可获取,释放锁资源时也是通过一个CAS操作即可完成,CAS是一种乐观锁的实现,其开销显然要比互斥开销小很多,自旋会对CPU造成资源浪费,特别是长时间无法获取锁的情况下,所以自旋次数一定要设置成一个合理的值,而不能无限自旋下去,造成CPU资源的浪费。

偏向锁

偏向锁产生背景

轻量级锁优化轻微并发情况下共享数据的访问。但实际生产环境下,一段时间内同步方法只会被同一个线程多次访问,从总体看同步方法是在单线程环境中运行。如果使用轻量级锁,每次调用同步方法要通过一次CAS操作申请锁,执行完后同样通过一次CAS操作释放锁,这种CAS操作毕竟会占用CPU资源,所以对这种场景进行了优化,在线程A调用同步方法获取锁时,执行完成后不去释放,线程A再次进入时不需要再次获取锁,直接进入,只有当其它线程申请锁时才会释放。
在这里插入图片描述

偏向锁简述

直接上网上找的一张图和一张对应的中文图,看到这张图不用慌,我会一点一点说明每种状态。

在这里插入图片描述在这里插入图片描述

偏向锁流程

上面在说轻量锁的时候,我说先关闭偏向锁模式,因为开启偏向锁模式和关闭偏向锁模式的时候初始化创建对的对象是不同的。开启的时候创建对象的Mark Word如图所示:
在这里插入图片描述
也就是上面图片上对应的未锁定、未偏向但是可偏向的对象。(因为没有同步代码块执行,所以是未锁定的)(因为锁对象在初始后他也不知道要偏向谁)(因为在jvm启动的时候添加了偏向锁模式,所以是可偏向的)
在这里插入图片描述

当一个线程开始执行同步代码块,获取这个偏向锁的时候,会先对这个对象的Mark Word进行判断,判断是否处于可偏向的状态(匿名偏向状态)。用 CAS 操作, 将自己的线程 ID 写入Mark Word,如果操作成功, 则认为这个线程获取到了这个偏向锁, 执行同步块代码。

匿名偏向状态:
	> Mark Word 的是否是偏向锁的标志位为 1 
	>锁的标志位为 01
	>Thread Id 为空。

在这里插入图片描述

如果下一个线程要获取锁的时候,会检测对象头的Mark Word,如果对象是可偏向的,并且偏向线程的ID和当前要获取到偏向锁的线程ID相同,而立马获取锁,没有任何其他操作(不会做其他判断)。

在当前线程解锁的时候,判断是否还是偏向锁的状态,如果还是偏向锁的状态,同样不会做任何其他操作(下次在加锁的时候直接判断Thread Id就可以了)。

在这里插入图片描述

如果下一个线程要获取锁的时候,会检测对象头的Mark Word,如果对象是可偏向的,但是偏向线程的ID和当前要获取到偏向锁的线程ID不同,则证明这个偏向锁对象目前偏向于其他线程, 需要撤销偏向锁模式。(Revoke Rebias)(注意这里是撤销,而不是解锁,因为现在需要将偏向锁的模式进行膨胀)
在这里插入图片描述

偏向锁的撤销 是一个很特殊的操作, 为了执行撤销操作, 需要等待全局安全点(Safe Point), 此时间点所有的工作线程都停止了字节码的执行。在进入到膨胀流程的时候,会有一个判断,判断持有这个偏向锁的线程是否正在执行同步代码块,如果持有偏向锁的线程已经执行完了,就应该将偏向锁对象的Mark Word置为不可偏向的无锁状态(因为判定现在存在多线程的竞争,应该将这种偏向模式禁用)。
在这里插入图片描述
但是,如果持有偏向锁的线程还没有执行完同步代码块,应该直接将这个偏向锁的膨胀为轻量锁。
在这里插入图片描述

后面的就是走轻量锁的逻辑了。

这里补充一下,为什么需要等待全局安全点(Safe Point)的时候才能进行膨胀?
首先这个此时间点所有的工作线程都停止了字节码的执行。
其次就是线程A在占用偏向锁执行同步代码块的时候,线程B也要抢占偏向锁,证明出存在多线程竞争,线程B就需要将这个偏向锁进行膨胀,这也就意味这个线程B需要操作线程A的线程栈,所以需要等待一个时间点,让线程B来操作,这个时间点就是stop the world的时候,没有字节码执行的时候。

以上就是偏向锁的加锁,撤销的大体逻辑。

现在再讲两个问题:

  1. 膨胀的具体实现流程
  2. 图中没有提到的重偏向
偏向锁膨胀具体流程

同轻量锁一样,当线程A在通过CAS方式后去偏向锁对象的时候,也会在线程A的栈帧中建立一个锁记录(Lock Record),但是这个锁记录比较特别,这个锁记录的空间不需要初始化,但是后面会用到他。
如果线程B在进行抢占,所以需要撤销偏向锁模式,在全局安全点的时候,如果发现线程A持有偏向锁,但是还没有执行完同步代码块,线程B会遍历线程A里面的栈帧,查找到所有与当前偏向锁对象相关联的锁记录(Lock Record),修改这些锁记录(Lock Record)里的内容为轻量级锁的内容,然后把“最老的”(oldest)一个锁记录(Lock Record)的指针写到锁对象的Mark Word里,就好像是原来从没有使用过偏向锁,使用的一直是轻量级锁一样。

至于为什么是最老的,因为最老的肯定是第一次加锁的锁记录,这样在执行解锁的时候会按照从最老到最新的进行解锁,所以需要把“最老的”锁记录的指针写到对象的MarkWord里

偏向锁的重偏向

一个对象先偏向于某个线程, 执行完同步代码后,在某些条件达成后,另一个线程可以直接重新获得偏向锁,也就是批量再偏向(Bulk Rebias)机制。
在这里插入图片描述
其实这个批量再偏向为解决的就是,到底是撤销偏向的消耗小,还是重新回到匿名偏向状态的消耗小。

那么到底采用哪种方式就取决于程序的执行情况了,达到某种情况,撤销偏向消耗小,达到某种情况,回到匿名偏向状态的消耗小。

那么这个某种情况到底是什么呢?
这里涉及一个知识点,在每个类中元数据中会包含一个counter和时间戳,先简单理解一下,在这个类的对象,每次偏向锁的在执行一次撤销的时候,都会将counter增加,时间戳用来记录上次执行批量再偏向(Bulk Rebias)的时间。

counter有两个阈值,一个是bulk rebias(批量重偏向)阈值,一个是bulk revocation(批量撤销)阈值。一旦bulk rebias的阈值达到,就会执行bulk rebias,回到匿名偏向的状态。time阈值用来重置撤销的计数counter,如果自从上次执行bulk bias已经超过了这个阈值时间,就会发生counter的重置。
在这里插入图片描述
说明从上次执行bulk rebias到现在并没有执行多次的撤销操作,也就是说执行偏向仍然是个不错的选择。
但是如果在执行了bulk rebias之后,在时间阈值之内,仍然一直有撤销数量增长,一旦达到了bulk revocation的阈值,就会执行bulk revocation,此时这个类的对象不会再被允许使用偏向锁。

Hotspot中的阈值如下
Bulk rebias threshold 20 Bulk revoke threshold 40 Decay time 25s

每25s的撤销操作小于20,执行批量重偏向,每25s的撤销操作小于40,执行批量撤销。

撤销偏向本身是一个消耗很大的事情,因为它必须挂起所有的线程,遍历栈找到并修改锁记录(Lock Record)

最明显的查找某个数据结构的所有对象实例的方式就是遍历堆,这种方式在堆比较小的时候还可以,但是堆变大就显得性能不好。为类解决这个为题,引申出了 epoch。

epoch是一个时间戳,用来表明偏向的合法性,只要这个锁对象是可偏向的,那么就会在这个对象的Mark Word上有一个对应的epoch bit位

一个对象被认为已经偏向了线程T必须满足两个条件:
1.Mark Word中Thread Id是线程T的Id
2.Mark Word中的epoch必须是和类的epoch相等

通过这种方式,bulk rebiasing(批量重偏向)操作会少去很多的花销。具体操作如下

扫描所有的线程栈来定位当前类的对象中已经锁住的,更新他们的epoch为类的新的epoch,这样就不用扫描堆了,对于那些没有被改变epoch的实例(和类的epoch不同),会被自动当做可偏向但是还没有偏向的状态(也就是初始的匿名偏向状态)。

以上是外网的一篇文章的说明,加上了一下我的理解所总结的。

其实说白了,就是每个锁对象的类维护了一个阈值,如果这段时间内发生了多次的偏向撤销,说明偏向模式不适合这种业务场景,但是没有发生多次撤销则说明撤销模式还是使用的。

偏向模式不适合直接就膨胀为轻量锁的逻辑了。如果偏向模式适合,那么就需要重偏向,但是如果遍历堆的话,会非常慢且耗费性能,然后就引出了个epoch概念,更新类的epoch,和那些拿到偏向锁对象的线程的epoch,其余的线程再次要拿取偏向锁的时候发现类的epoch和偏向锁的epoch不同,说明是执行了批量重偏向的操作,这个时候就可以重偏向,将偏向锁的Thread Id置为当前线程的Id。
在这里插入图片描述
至此,关于synchronized中各种锁的内容都讲完了。

锁降级

最后的最后补充一点小知识,关于锁降级的问题,大多数文章都说锁只能升级不能降级,但是在查阅了有关资料后发现,重量级锁是可以降级的,重量级锁降级发生于STW阶段,如果这个时候没有任何竞争,就可以退回到不可偏向的无锁状态,然后在进行轻量锁的加锁和解锁过程。对应下图的8.1部分。在这里插入图片描述
虽然说重量锁可以降级为轻量锁的流程,但是如果频繁的升降级的话会对jvm造成性能影响(因为发生在stop the world阶段)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值