Java 锁相关知识汇总及锁升级

  • 锁的基础知识
  • 什么是Synchronized
  • 锁的四种状态
  • 类相关信息
    • 32位虚拟机对象头
    • 64位虚拟机对象头
  • CAS
  • 锁消除
  • 锁粗化
  • Lock Record
  • 偏向锁
  • 轻量级锁
  • 重量级锁
    • Monitor
  • 其他

锁的基础知识

锁从宏观上分类,分为悲观锁与乐观锁。

乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。

原文链接:

什么是Synchronized

synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
  2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
  3. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
  4. 修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。

多线程情况下,不同线程需要对 共享数据的修改、临界资源访问 会对资源冲突


锁的四种状态

  • 无锁状态

  • 偏向锁

    • 只被一个线程持有。对于轻量级锁每次加锁解锁通常都有一个CAS操作;对于重量级锁而言,加锁也会有一个或多个CAS操作,所以为提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的比较和设置,而不是开销相对较大的CAS命令。
  • 轻量级锁 CAS

    • 不同线程交替持有锁(即 有不是特别多个线程,但同步代码块执行时间短,或者一次持有锁的时间短)
  • 重量级锁。

户态和内核态

内核态:cpu可以访问内存所有数据,包括外围,硬盘、网卡,CPU也可以将自己从一个程序切换到另一个程序。

用户态:只能受限的访问内存,且不允许访问外围设备。

用户程序都是运行在用户态,但有时候程序需要做一些内核的事情,如从硬盘中读数据,或者从硬盘中获取数据(Synchronized中依赖的monitor,因此需要用户态到内核态的切换。) 这时候:用户态切换到内核态

就是说,使用Synchronized同步锁的时候需要进行用户态到内核态的切换。
而底层操作系统Mutex Lock实现需要将当前线程挂起,并从用户态->内核态,这种切换非常昂贵。

因此1.6开始对Synchronized进行优化,来减少线程切换的次数,优化了synchronized的机制,就是所谓的锁升级。通过对象头及ObjectMonitor对象将锁划分了几个类型,其升级顺序为:无锁->偏向锁->轻量级锁->重量级锁

1.6之前,都是Synchronized重量级锁。

1.6及之后,引入了偏向锁,轻量级锁,重量级锁,来减少竞争带来的上下文切换。


类相关信息

原文

在HotSpot虚拟机中,Java对象保存在内存中,由3个部分组成:

  • 对象头(Header)
  • 实例数据(Instance Data,类中所有的实例字段数据)
  • 对齐填充字节(Padding)

JAVA对象 = 对象头 + 实例数据 + 对象填充。

对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针,及对象指向它的类元数据的指针。

JVM中的对象头有两种形式(数组对象与非数组对象),它由三部分组成:

  • Mark Word
  • Klass Pointer(指向本类的.class源码指针)
  • 数组长度(只有数组对象才有)

几个部分的作用:

  1. 对象头中的Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode;
  2. Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例;
  3. 数组长度也是占用64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;
  4. 对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;
  5. 对齐字是为了减少堆内存的碎片空间(不一定准确),用来保证java对象在虚拟机中占内存大小为8N bytes。

为什么需要对象填充?
字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。其实对其填充的最终目的是为了计算机高效寻址。

图解 :

简单地说,一个Java类在JVM中被拆分为了两个部分:数据和描述信息,分别对应OOP和Klass。OOP表示java对象应该承载的数据(运行时实例数据),而Klass表示描述对象有多大,函数地址,对象大小,静态区域大小。

Ordinary Object Pointer (普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。Klass是在class文件在加载过程中创建的,OOP则是在Java程序运行过程中new对象时创建的。

一个OOP对象包含以下几个部分:

一般jvm在加载class文件时,会在方法区(元空间)创建InstanceKlass,表示其元数据,包括常量池、字段、方法等。

着重介绍InstanceKlass的两个字段:

_prototype_header:原型头,用于用于标识Mark Word原型,在对象被创建出来以后,会从_prototype_header拷贝数据到对象头的Mark Word中。

revocation_count:撤销计数器,每次该class的对象发生偏向锁撤销操作时,计数器会自增1,当达到批量重偏向阈值(默认20)时,会执行批量重偏向;当达到批量撤销的阈值(默认40)时,会执行批量撤销。

原文

对象头 = Mark Word + 类型指针(未开启指针压缩的情况下)
在32位系统中,Mark Word = 4 bytes = 32 bits,对象头 = 8 bytes = 64 bits;
在64位系统中,Mark Word = 8 bytes = 64 bits ,对象头 = 16 bytes = 128bits;

bytes 是字节,bits 是位。所以说,在32位JVM虚拟机系统中,Mark Word部分,占了4个字节,Klass Word部分也占了4个字节,所以,对象头大小为8个字节。在64位JVM虚拟机系统中,Mark Word部分,占了8个字节,Klass Word部分也占了8个字节,所以,对象头大小为16个字节。

32位虚拟机对象头

32位虚拟机对象头普通对象

|--------------------------------------------------------------|
|                     Object Header (64 bits)                  |
|------------------------------------|-------------------------|
|        Mark Word (32 bits)         |    Klass Word (32 bits) |
|------------------------------------|-------------------------|

32位虚拟机数组对象的对象头

|---------------------------------------------------------------------------------|
|                                  Object Header (96 bits)                        |
|--------------------------------|-----------------------|------------------------|
|       Mark Word(32bits)        | Klass Word(32bits)    | array length(32bits)   |
|--------------------------------|-----------------------|------------------------|

32位虚拟机普通对象头详情如下

|-----------------------------------------------------------------------------------------------------------------|
|                                             Object Header(64bits)                                               |
|-----------------------------------------------------------------------------------------------------------------|
|                       Mark Word(32bits)                           |  Klass Word(32bits)    |      State         |
|-----------------------------------------------------------------------------------------------------------------|
|     hashcode:25                      | age:4 | biased_lock:0 | 01 | OOP to metadata object |      Nomal         |
|-----------------------------------------------------------------------------------------------------------------|
|     thread:23              | epoch:2 | age:4 | biased_lock:1 | 01 | OOP to metadata object |      Biased        |
|-----------------------------------------------------------------------------------------------------------------|
|     ptr_to_lock_record:30                                    | 00 | OOP to metadata object | Lightweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
|     ptr_to_heavyweight_monitor:30                            | 10 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
|                                                              | 11 | OOP to metadata object |    Marked for GC   |
|-----------------------------------------------------------------------------------------------------------------|

32位MarkWord解析:

64位虚拟机对象头

|-----------------------------------------------------------------------------------------------------------------|
|                                             Object Header(128bits)                                              |
|-----------------------------------------------------------------------------------------------------------------|
|                                   Mark Word(64bits)                    |  Klass Word(64bits)    |      State         |
|-----------------------------------------------------------------------------------------------------------------|
|    unused:25|identity_hashcode:31|unused:1|age:4|biase_lock:0| lock:01 | OOP to metadata object |      Nomal         |
|-----------------------------------------------------------------------------------------------------------------|
|    thread:54|      epoch:2       |unused:1|age:4|biase_lock:1| lock:01 | OOP to metadata object |      Biased        |
|-----------------------------------------------------------------------------------------------------------------|
|                        ptr_to_lock_record:62                 | lock:00 | OOP to metadata object | Lightweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
|                       ptr_to_heavyweight_monitor:62          | lock:10 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
|                                                              | lock:11 | OOP to metadata object |    Marked for GC   |
|-----------------------------------------------------------------------------------------------------------------|

64位MarkWord解析:

无锁状态 和 偏向锁状态 不能共存,即如果开启了”偏向锁模式“,那么对象在初始化的时候,其对象头的 MarkWord 部分就是偏向锁状态,而不是无锁状态,其中 thread id = 0。所以说就没法保存 hashcode,所以:

  • 当一个对象已经计算过 identity hash code,它就无法进入偏向锁状态;
  • 当一个对象当前正处于偏向锁状态,并且需要计算其identity hash
    code的话,则它的偏向锁会被撤销,并且锁会膨胀为轻量级锁或者重量锁,如果对象调用hashcode方法,会自动禁用偏向锁,是因为偏向锁的对象头中没办法存储hashcode,而轻量级锁和重量级锁的hashcode都存放在获得锁的线程中的栈帧中,当释放锁时会将hashcode、age等数据恢复给锁对象,对象头状态恢复为普通状态。

关于调用HashCode:

一个对象在调用原生hashCode方法后(来自Object的,未被重写过的),该对象将无法进入偏向锁状态,起步就会是轻量级锁。若hashCode方法的调用是在对象已经处于偏向锁状态时调用,它的偏向状态会被立即撤销,并且锁会升级为重量级锁。

调用HashCode的详细解析及案例

对象头参数解析 :

lock: 2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。

biased_lock lock 状态 存储内容
0 01 无锁 对象哈希码、对象分代年龄
1 01 偏向锁 偏向线程ID、偏向时间戳、对象分代年龄
0 00 轻量级锁 指向锁记录的指针 (Lock Record)
0 10 重量级锁 执行重量级锁定的指针 (Monitor)
0 11 GC标记 空(不需要记录信息)

biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有启偏向锁。
age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
identity_hashcode:25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。
thread:持有偏向锁的线程ID。
epoch:偏向时间戳(下文批量重偏向详讲)。
ptr_to_lock_record:指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:指向管程Monitor的指针。

原文

对比 :

CAS

在计算机科学中, 比较和交换 (Conmpare And Swap)是用于实现多线程同步的原子指令,是一种无锁原子算法。

多个线程对一个变量读写的覆盖。就比如一个共享变量x。线程读到自己的“本地缓存”,然后进行操作。B线程也读到自己的“本地缓存”中进行操作。他俩写回内存就有覆盖问题。

使用CAS以后,读取数据的时候用一个标志位记录原来的数据,操作完写回去时需要需要对比标志位数是都和内存最新值是否相同相同则写回,不相同则失效,需要重新读取。

锁消除

在一段程序里你用了锁,但是jvm检测到这段程序里不存在共享数据竞争问题,也就是变量没有逃逸出方法外,这个时候jvm就会把这个锁消除掉

我们程序员写代码的时候自然是知道哪里需要上锁,哪里不需要,但是有时候我们虽然没有显示使用锁,但是我们不小心使了一些线程安全的API时,如StringBuffer、Vector、HashTable等,这个时候会隐形的加锁。比如下段代码

public void sbTest(){StringBuffer sb= new StringBuffer();for(int i = 0 ; i < 10 ; i++){sb.append(i);}System.out.println(sb.toString());
}

上面这段代码,JVM可以明显检测到变量sb没有逃逸出方法sbTest()之外,所以JVM可以大胆地将sbTest内部的加锁操作消除。

锁粗化

在使用锁的时候,要让锁的作用范围尽量的小,这样是为了在锁内执行代码尽可能少,缩短持有锁的时间,其他等待锁的线程能尽快拿到锁。 在大多数的情况下这样做是正确的。但是连续加锁解锁操作,可能会导致不必要的性能损耗,比如下面这个for循环:

//锁粗化前:
for (...) {synchronized (obj) {// 一些操作}
}//锁粗化后:
synchronized (this) {for (...) {// 一些操作}
}

Lock Record

LockRecord是线程私有的,一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。线程获取到锁时,在当前线程栈帧中插入一个Lock Record 记录,表示一次加锁,重入多少次就加多少个Lock Record 记录;Lock Record包含两个信息:锁对象的Mark Word信息、owner(指向锁对象的引用地址)

在偏向锁和轻量级中使用LockRecord进行加解锁

  • 偏向锁时Lock Record 中的Mark Word为空,owner指向锁对象的引用地址
  • 轻量级锁时LockRecord 保存锁对象的Mark Word信息,owner指向锁对象的引用地址,并将锁对象的Mark Word信息替换为获得锁线程的LockRecord地址.

例(轻量级锁):

偏向锁

原文链接

应用在实际运行时,很可能只有一个线程会调用相关同步方法。所以在JDK1.6中为了提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的CAS命令。

引入偏向锁的目的

在没有多线程竞争的情况下,尽量减少不必要的轻量级锁的执行。轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只依赖一次CAS原子指令。但在多线程竞争时,需要进行偏向锁撤销步骤,因此其撤销的开销必须小于节省下来的CAS开销,否则偏向锁并不能带来收益。

JDK 1.6中默认开启偏向锁,可以通过-XX:-UseBiasedLocking来禁用偏向锁。当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式(默认所有class的偏向模式都是是开启的),那新创建对象的mark word将是可偏向状态,此时mark word中的thread id(参见上文偏向锁状态下的mark word格式)为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

需要注意的是,即使模式默认开启,出于性能(启动时间)的原因,在JVM启动后的的头4秒钟这个feature是被禁止的。这也意味着在此期间,_prototype_header会将它的locked_bias位设置为0,以禁止实例化的对象被偏向。4秒钟之后,所有的_prototype_header的locked_bias位会被重设为1,如此新的对象就可以被偏向锁定了。

  1. 当对象头的locked_bias为0时,此对象处于未锁定不可偏向的状态。

    在此状态下,如果有线程尝试获取此锁,会升级为轻量级锁。如果有多个线程尝试获取此锁,会升级为重量级锁。

  2. 当对象头的locked_bias为1时,此对象处于以下三种状态:

    • 匿名偏向(Anonymously biased)

      在此状态下ThreadId为NULL(0),意味着还没有线程偏向于这个锁对象。第一个试图获取该锁的线程将会面临这个情况,使用原子CAS指令可将该锁对象绑定于当前线程。这是允许偏向锁的类对象的初始状态。

    • 可重偏向(Rebiasable)

      在此状态下,偏向锁的epoch字段是无效的(与锁对象对应InstanceKlass的_prototype_header的epoch值不匹配,下文详解)。下一个试图获取锁对象的线程将会面临这个情况,使用原子CAS指令可将该锁对象绑定于当前线程。在批量重偏向的操作中,未被持有的锁对象都被至于这个状态,以便允许被快速重偏向。

    • 已偏向(Biased)

      在此状态下,Thread Id非空,为已经持有此锁的线程ID 且epoch为有效值——意味着其他线程正在使用这个锁对象。

加锁过程

偏向锁获取可以分为几个步骤:

  • 验证对象Mark Word的locked_bias位。

    如果是0,则该对象不可偏向,走轻量级锁逻辑;如果是1,继续下一步操作。

  • 验证对象所属InstanceKlass的_prototype_header的locked_bias位。

    确认_prototype_header的locked_bias位是否为0,如果是0,则该类所有对象全部不允许被偏向锁定,并且该类所有对象的locked_bias位都需要被重置,使用轻量级锁替换;如果是1,继续下一步操作。

  • 如果为可偏向状态,第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个入锁线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS来加锁和解锁了,在通过检查后,会往当前线程的栈中添加一条 Mark Word 为空的Lock Record,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。

  • 如果如果为可偏向状态,且线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行同步代码块;如果竞争失败,执行下一步。

  • CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时(在这个时间点上没有字节码正在执行),去查看偏向的线程是否还存活,如果获得偏向锁的线程存活则被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)

    • 如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里.
    • 如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),也就是锁撤销,再检查对象是否可以重偏向:
      • 如果可重偏向就重新偏向至当前线程。
      • 如果不可重偏向就升级为轻量级锁(即发生了竞争,不符合偏向锁的情况了,以后都是轻量级锁)。

偏向锁的CAS详解

如果线程ID不为当前线程ID时那么将会使用CAS操作锁对象的Mark Word的线程id,用新的线程ID替换原来的0,如果成功那么获取到偏向锁继续执行同步代码块,如果失败了就说明至少有一个线程已经获取到偏向锁了需要升级为轻量级锁,但是因为锁升级是不可逆的,所以此时虚拟机会做挽回策略避免不必要的升级:

  1. 首先会先等持有偏向锁的线程进入安全点(与gc时的安全点是一样的)暂停线程
  2. 虚拟机会检查持有线程锁的线程状态是否存活,如果存活遍历栈帧中的Lock Record,判断该线程是否在执行同步代码块中的代码,如果是那就没办法只能升级
  3. 如果线程状态不是存活的或者不在执行同步代码块中的代码将会判断是否开启重偏向。如果成功执行了重偏向那么将会重新设置为偏向锁状态,将线程id更新为新的线程id;不可重偏向就升级为轻量级锁

当线程1访问同步代码块并获取锁对象时。会在Java对象头和栈帧中记录偏向锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致,则还是线程1获取锁对象,则无需调用CAS来加锁、解锁;如果不一致(其他线程如线程2要竞争锁对象,而偏向锁不会主动释放,因此存储的还是线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其他线程可以竞争设置其为偏向锁;如果存活,那么立刻查找该线程的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停线程1,撤销偏向锁,升级为轻量级锁;如果线程1不再使用该锁对象,那么将锁对象状态设置为无锁状态,重新偏向新的线程。

偏向锁->轻量级锁:
当另外有一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头中的Mark World中线程ID不是自己的线程ID,就会进行CAS操作获取锁,如果获取成功,则直接替换Mark Word中的线程ID为自己的线程ID,该锁会保持偏向锁状态;如果获取失败,代表锁有竞争,偏向锁升级成轻量级锁。

举例:

假设A线程 持有锁 X(此时X是偏向锁) 这是有个B线程也同样用到了锁X,而B线程在检查锁对象的Mark World时发现偏向锁的线程ID已经指向了线程A。这时候就需要升级锁X为轻量级锁。轻量级锁意味着标示该资源现在处于竞争状态。

当有其他线程想访问加了轻量级锁的资源时,会使用自旋锁优化,来进行资源访问。

偏向锁到偏向锁:

由于偏向锁线程1获取锁后,不会主动修改对象头,所以哪怕此线程1实际已消亡,之前加锁对象的对象头还是保持偏向锁状态。这个时候线程2想要进入同步方法,他会去查看线程1是否还存活:

  1. 如果已经消亡,则把锁定对象的对象头恢复成无锁(锁撤销),进行一次偏向锁->锁撤销的过程,然后重复无锁->偏向锁的过程。
  2. 同时如果线程1未消亡,但是其栈帧信息中不在需要此持 有这个锁对象,也会进行一次偏向锁->锁撤销->偏向锁的过程

偏向锁的重偏向

如果有很多对象,这些对象同属于一个类(假设是类A)被线程1访问并加偏向锁,线程1结束之后线程2来访问这些对象(不考虑竞争情况),通过CAS操作把这些锁升级为轻量锁,会是一个很耗时的操作。

JVM对此作了优化:

当对象数量超过某个阈值时(默认20, jvm启动时加参数-XX:+PrintFlagsFinal可以打印这个阈值),Java会对超过的对象作批量重偏向线程2,此时前20个对象是轻量锁, 后面的对象都是偏向锁,且偏向线程2。

如果对象虽然被多个线程访问,但没有发生竞争,这时偏向了T1的对象仍然有机会重新偏向T2,重偏向会重置对象的ThreadID

*偏向锁的撤销

批量撤销就是对重偏向的一个补充

如果一个类的大量对象被一个线程T1执行了同步操作,也就是大量对象先偏向了T1,T1同步结束后,另一个线程也将这些对象作为锁对象进行操作(锁撤销),会导偏向锁重偏向的操作超过阈值20的会重新设置为偏向锁(重偏向)。

偏向锁的撤销的场景:

  1. 被偏向的对象进行hashcode计算时,不管该对象有没有被锁定,都会触发偏向锁撤销,通过CAS将计算好的hashcode存入Mark Word中。 (计算hashcode)
  2. 当前的对象是已偏向未锁定状态,即所有者线程已经退出同步代码块,此时有其它的线程尝试获取偏向锁;在允许重偏向的情况下,原所有者线程会触发解锁,将对象恢复成匿名可偏向的状态;如果不允许重偏向,则会触发偏向锁撤销,将对象设置为未锁定且不可偏向的状态,竞争者线程按轻量级锁的逻辑去获取锁。(锁定状态到禁用状态)
  3. 当前的对象是已偏向已锁定的状态,即所有者线程正在执行同步代码块,此时有其它的线程尝试获取偏向锁,由于所有者线程仍需要持有这把锁,此时产生了锁竞争,偏向锁不适合处理这种有竞争的场景,即会触发偏向锁撤销,原偏向锁持有者线程会升级为轻量级锁定状态,竞争者线程按轻量级锁的逻辑去获取锁。(锁升级)

当一个偏向锁如果撤销次数到达阈值(默认40)的时候就JVM就认为该class的使用场景存在多线程竞争;那么JVM会把这个对象所对应的类所有的对象都撤销偏向锁;并且新实例化的对象也是不可偏向的,直接走轻量级锁的逻辑。。

锁撤销和解锁是两个不同的概念。撤销是指在获取偏向锁的过程因为不满足条件导致要将锁对象改为非偏向锁状态,即低三位变为010;解锁是指退出同步块的过程,即移除最近的锁记录。

重偏向与撤销原理 :

引入一个概念epoch,除了对象中的epoch,对象所属的类class信息中,也会保存一个epoch值。以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该 class 的对象发生偏向撤销操作(即膨胀,锁升级)时,该计数器 +1 ,当这个值达到重偏向阈值(默认20)时,JVM 就认为该 class 的偏向锁有问题,因此会进行批量重偏向。

每个class对象会有一个对应的 epoch 字段,每个处于偏向锁状态对象的 mark word 中 也有该字段,其初始值为创建该对象时class 中的epoch的值。每次发生批量重偏向时(上述),就将该值 + 1,同时遍历 JVM 中所有线程的栈,找到该 class 所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id 改成当前线程Id。

  • 对象的epoch 值和 其class 的 epoch 不相等说明:该对象比 class的其他对象少回到偏向锁状态,所以即使应该升级为轻量级锁,照样保持偏向锁。

可以理解为是第几代偏向锁。 偏向锁在有竞争的时候是要执行撤销操作的,其实就是要升级成轻量级锁。 而当一类对象撤销的次数过多,比如有个Tets 类的对象作为偏向锁,经常被撤销,次数到了一定阈值 (XX:BiasedLockingBulkRebiasThreshold设置,默认为 20 )就会把当代的偏向锁废弃,把class的 epoch 加1。所以当class类对象和锁对象的 epoch 值不等的时候,当前线程可以将该锁重偏向至自己,因为前一代偏向锁已经废弃了。
不过为保证正在执行的持有锁的线程不能因为这个而丢失了锁,偏向锁撤销需要所有线程处于安全点,然后遍历所有线程的Java栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch值加 1。
当撤销次数超过另一个阈值(XX:BiasedLockingBulkRevokeThreshold,默认值为40),则废弃此类的偏向功能,也就是说这个类都无法偏向了。

对象的 epoch 自增针对的是 被当前存活的 thread 持有的偏向锁锁对象。

epoch改变值图文详情

epoch解析及锁相关知识博客

偏向锁解锁过程

当偏向锁被一个线程获取到时,会往所有者线程的栈中添加一条Displaced Mark Word为空的Lock Record,每重入一次就加一个Lock Record。

当有其他线程尝试获得锁时,根据遍历偏向线程的Lock Record来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将栈中的最近一条Lock Record 的obj字段设置为NULL(删除该条记录) 如下图。需要注意的是,偏向锁的解锁步骤中并不会修改锁对象头中的thread id。

调用锁对象的 hashcode()

  • 当一个对象已经计算过identity hash code,它就无法进入偏向锁状态;

  • 当一个对象当前正处于偏向锁状态,并且需要计算其 identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为轻量级锁或者重量锁;

轻量锁时 可以通过 Mark Word找到 Lock word,然后用其header 属性保存 hashcode
重量锁时 可以通过 Mark Word 找到 Object Monitor ,然后用 header 属性保存hashcode

-XX:BiasedLockingBulkRebiasThreshold = 20 // 默认偏向锁批量重偏向阈值
-XX:BiasedLockingBulkRevokeThreshold = 40 // 默认偏向锁批量撤销阈值
-XX:+UseBiasedLocking // 使用偏向锁,jdk6之后默认开启
-XX:BiasedLockingStartupDelay = 0 // 延迟偏向时间, 默认不为0,意思为jvm启动多少ms以后开启偏向锁机制(此处设为0,不延迟)
-XX:BiasedLockingDecayTime=25000ms //线程无切换后撤销次数偏向次数时间

整体流程 :

轻量级锁

升级至轻量级锁:

  1. 当禁用偏向锁时,新创建的对象为普通状态,即使该对象被synchronized修饰,也不会变为偏向锁状态(biased_lock被设置为0),直接为轻量级锁,并且对象Mark Word的锁标志位变为“00”。

    • 使用-XX:+UseBiasedLocking 禁用偏向锁
    • 当一个偏向锁如果撤销次数到达阈值40
  2. 在发现2个不同线程在竞争偏向锁时由偏向锁升级为轻量级锁

轻量级锁也就是自旋锁,利用CAS尝试获取锁(替换锁对象Mark Word为自己线程创建的Lock Record地址)。如果你确定某个方法同一时间确实会有一堆线程访问,而且工作时间还挺长,那么建议直接用重量级锁,不要使用synchronized,因为在CAS过程中,CPU是不会进行线程切换的,这就导致CAS失败的情况下他会浪费CPU的分片时间,都用来干这个事了。

偏向锁到轻量级锁:

偏向锁时,如果线程2需要进入同步方法,线程1还持有这个对象或者,那么就会进入偏向锁->轻量级锁的过程。此时线程2进行CAS替换失败,会修改对象头,升级为轻量级锁,同时开启自旋,重复尝试替换。升级中的CAS:

  1. 首先会挂起持有偏向锁的线程,因为要进行尝试修改锁记录指针,MarkWord会有变化,所有线程会利用CAS尝试将MarkWord的锁记录指针改为指向自己(线程)的锁记录
  • CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时(在这个时间点上没有字节码正在执行),去查看偏向的线程是否还存活,如果获得偏向锁的线程存活则被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)

    • 如果偏向的线程存活且还在同步块中则将锁升级为轻量级锁:当前线程则走入到锁升级的逻辑里.,将原偏向线程的LockRecord替换锁对象的MarkWord(原偏向锁已创建为MarkWord空的LockRecord),原偏向的线程继续拥有锁
    • 如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),也就是锁撤销,再检查对象是否可以重偏向:
      • 如果可重偏向就重新偏向至当前线程。
      • 如果不可重偏向就升级为轻量级锁,走轻量级获取锁。

偏向锁是不会主动释放的,你无法通过ThreadID知道当前是否还在工作还是工作完成了。其实不主动释放是因为如果没有竞争关系,轻量级锁的cas根本不会浪费时间,直接就会成功,所以没有必要去对偏向锁的flag和threadid进行修改。

轻量级锁加锁 :

线程在执行同步块之前,JVM会先在需要加锁的线程的栈帧中创建一个Lock Record,其包括一个用于存储对象头中的 Mark Word以及一个指向锁对象的指针。然后使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针:

  • 如果这个更新动作成功了,即代表表该线程拥有了这个对象的锁,直接执行同步代码块。(当前锁对象的MarkWord的结构会有所变化,不再是存着hashcode等信息,将会出现一个指向LockRecord的指针,指向锁记录。)

  • 如果失败就说明至少存在一条线程与当前线程竞争获取该对象的锁。

    • 情况一:其它线程已经持有了该对象的锁,表名此时发生了锁的竞争,进行自旋锁的优化(默认为10),轻量级锁会膨胀为重量级锁。
    • 情况二:自己执行了synchronized的锁重入,那么再添加一条Lock Record作为重入的记数,如下图:

轻量级锁解锁:

释放时会检查Mark Word中的Lock Record指针是否指向自己(获得锁的线程Lock Record),使用原子的CAS将Mark Word替换回对象头,如果成功,则表示没有竞争发生,如果替换失败则升级为重量级锁。

  1. 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
  2. 如果Lock Record的Mark Word为 null,代表这是一次重入,将obj设置为 null 后continue。
  3. 如果Lock Record的Mark Word不为 null,则利用 CAS 指令将锁对象头的Mark word恢复成为Lock Record中的Mark Word。如果成功,则continue,否则膨胀为重量级锁(失败是因为锁已经膨胀,mark word 已被替换成其他标志)。

两种自旋:

所谓自旋,是指当有另一个线程想获取被其它线程持有的锁的时候,不会进入阻塞状态,而是使用空循环来进行自旋(CAS)。注意:自旋是会消耗cpu的,所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。

  • 自旋锁

自旋锁的一些问题 :

  1. 如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时侯,其他线程在空循环,消耗cpu。
  2. 本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁,此后再升级为重量级锁相比直接就是重量级锁更加浪费低效。

基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。默认情况下,自旋的次数为10次,用户可以通过 -XX:PreBlockSpin 来进行更改。

  1. 自适应自旋锁
    所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

    • 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
    • 反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

JVM对于自旋周期的选择,JDK1.5这个限度是一定的写死的(10次),在JDK1.6中引入了适应性自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。

如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。

相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。

同时JVM还针对当前CPU的负荷情况做了较多的优化:

  1. 如果平均负载小于CPUs则一直自旋
  2. 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  3. 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  4. 如果CPU处于节电模式则停止自旋
  5. 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  6. 自旋时会适当放弃线程优先级之间的差异

JDK 1.6的轻量级锁使用的是普通自旋锁,且需要使用-XX:+UseSpinning选项手工开启。JDK
1.7后,轻量级锁使用自适应自旋锁,JVM启动时自动开启,且自旋时间由JVM自动控制。

重量级锁

重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。

当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长,这就是说为什么重量级线程开销很大的。

Monitor 的本质是 Object Monitor(翻译叫监视器或者管程),其也有自己的队列,最终阻塞调用的还是 t -> park() 函数,但是 park 依赖于底层操作系统的 mutex Lock、condition 信号量、counter计数器(和 LockSupport 的 park / unpark 相同),由于使用 Mutex Lock 和 cond_wait 都需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。

每一个 JAVA 对象都会与一个监视器 Monitor关联,Monitor和对象一起创建、销毁。重量级锁的状态下,对象的Mark Word为指向一个堆中 Monitor对象的指针。当一个 Monitor 被重量锁对象持有后,该对象将处于锁定状态。

监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。

  • 同步。监视器所保护的临界区代码是互斥地执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还。
  • 协作。监视器提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行。

轻量级锁到重量级锁

  1. 轻量级锁CAS替换失败到达一定次数(默认为10)后,轻量级锁升级为重量级锁。(通过CAS修改所标志位,但不修改持有ID)。当后序的线程尝试获取锁时,就将自己挂起,等待被唤醒。
  2. 需要注意,如果线程2自旋期间,有线程3也需要访问同步方法,则立刻由轻量级锁膨胀为重量级锁
  3. 有线程调用轻量级锁或偏向锁对象的hashcode方法
    因为这个时候mark word是没有办法存储hash值的,所以需要膨胀到重量级锁
  4. 持锁的线程调用锁对象的wait()方法
    因为锁对象处于偏向或者轻量级锁的状态下,是没有管程对象和等待队列的,所以无法保存线程节点

关于升级成重量级锁 :

虚拟机为了避免线程真实地在操作系统层面挂起,会进行自旋锁的优化,在经过若干次循环后,如果得到锁,就顺利执行同步代码。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。最后没办法也就只能升级为重量级锁了。

重量级锁将控制权交给了操作系统,有操作系统来负责线程间的调度和状态变换,会出现频繁的对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。

轻量级锁膨胀之后升级为重量级锁,重量级锁是依赖对象内部的Monitor 锁来实现的,而 Monitor 又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。重量级锁的状态下,对象的Mark Word为指向一个堆中Monitor对象的指针。

|-----------------------------------------------------------------------------------------------------------------|
|                                             Object Header(128bits)                                              |
|-----------------------------------------------------------------------------------------------------------------|
|                                   Mark Word(64bits)                    |  Klass Word(64bits)    |      State         |
|-----------------------------------------------------------------------------------------------------------------||                       ptr_to_heavyweight_monitor:62          | lock:10 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|

Monitor

Monitor译为「监视器」或「管程」,是重量级锁实现原理,此类对象由操作系统提供,每个Java对象都可以关联一个 Monitor 对象。

Java对象与Monitor对象是一一对应的。如果使用 synchronized 给一个Java对象上了锁(重量级锁),该Java对象头中的 MarkWord 对应的指针就会指向一个唯一的 Monitor 对象。

Monitor中有三个重要变量实现加锁与等待锁操作,分别是OwnerEntryListWaitSet

Owner 当某个线程 拥有锁,那么 Owner 就会指向该线程,表示 只允许该线程执行代码块。

EntryList 链表结构,当线程 没能抢到锁,那么该线程就会被加入到EntryList中。待拥有锁的线程执行完毕,就会根据JVM底层的算法机制,唤醒其中的一个线程并使之成为新的 Owner。

WaitSet: 调用wait()方法的线程, 也就是说处于wait状态的线程 会存在这个里面

一个Monitor对象包括这么几个关键字段:

ObjectMonitor() {_header       = NULL; // 对象头的 mark word_count        = 0; // 线程获取锁的次数_waiters      = 0; // wait 状态线程个数_recursions   = 0; // 重入计数器(加锁线程的重入次数)_object       = NULL; // 关联的对象(和 _header 的对象相同,比如synchronized括号里的对象)_owner        = NULL; // 哪个线程占有该 monitor_WaitSet      = NULL; // 处于wait状态的线程节点(调用wait()方法的线程)_WaitSetLock  = 0 ;_Responsible  = NULL ;_succ         = NULL ; // 当前线程释放锁后,下一个执行的线程_cxq          = NULL ; // 获取锁失败的线程节点FreeNext      = NULL ;_EntryList    = NULL ; // 等待被唤醒的线程节点_SpinFreq     = 0 ;_SpinClock    = 0 ;OwnerIsThread = 0 ;}

线程在任何时间最多出现在一个列表上:要么是cxp, 要么在EntryList要么在waitSet

主要结构:

  • ContentionList/Cxq:竞争队列(Contention Queue),所有请求锁的线程首先被放在这个竞争队列中。

    • Cxq并不是一个真正的队列,只是一个虚拟队列,原因在于Cxq是由Node及其next指针逻辑构成的,并不存在一个队列的数据结构。每次新加入Node会在Cxq的队头进行,通过CAS改变第一个节点的指针为新增节点,同时设置新增节点的next指向后续节点;从Cxq取得元素时,会从队尾获取。显然,Cxq结构是一个无锁结构。
      因为只有Owner线程才能从队尾取元素,即线程出列操作无争用,当然也就避免了CAS的ABA问题。
  • EntryList:等待队列,存放等待锁而被block的线程队列,Cxq中那些有资格成为候选资源的线程被移动到EntryList中。这个列表也是存争抢锁失败的锁

    • EntryList与Cxq在逻辑上都属于等待队列。Cxq会被线程并发访问,为了降低对Cxq队尾的争用,而建立EntryList。在Owner线程释放锁时,JVM会从Cxq中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为OnDeck
      Thread(Ready Thread)。EntryList中的线程作为候选竞争线程而存在,对应的状态是BLOCKED状态。
  • WaitSet:线程调用await()方法后,则线程会加入到waitSet中,

  • Owner:表示当前持有锁的线程,若无则为null

WaitSet

如果Owner线程被Object.wait()方法阻塞,就转移到WaitSet队列中,直到某个时刻通过Object.notify()或者Object.notifyAll()唤醒,该线程就会重新进入EntryList中。对应的状态是WAITING、TIMED_WAITING。

只有已经获取锁的线程,才可以调用锁的wait()、notify()方法,否则会抛出异常IllegalMonitorStateException。

notify()方法其实就是移动waitset中的线程要么到cxq要么到entrylist中,当同步方法结束的时候会触发唤醒机制,根据Qmode不同类型进行不同的规则唤醒。

notif() 根据Policy 做不同的操作

Policy == 0 :放入到entrylist队列的排头位置
Policy == 1 :放入到entrylist队列的末尾位置
Policy == 2 :判断entrylist是否为空,为空就放入entrylist中,否则放入cxq队列排头位置(默认策略)
Policy==3 :判断cxq是否为空,如果为空,直接放入头部,否则放入cxq队列末尾位置

为什么会有_cxq 和 _EntryList 两个列表来放线程?

因为会有多个线程会同时竞争锁,所以搞了个 _cxq 这个单向链表基于 CAS 来 存住这些并发,然后另外搞一个 _EntryList 这个双向链表,来在每次唤醒的时候搬迁一些线程节点,降低 _cxq 的尾部竞争。

加锁过程

  • 如果当前线程是之前持有轻量级锁的线程,重入计数重置为 1,设置owner字段为当前线程(膨胀后 owner 是指向Lock Record的指针)

  • 如果当前是无锁状态,即 owner 为 NULL, CAS获取锁,如果能CAS设置成功,则当前线程直接获得锁。获取失败会进行自旋等待

  • 如果owner 不为 NULL,检查是否是锁重入,是锁重入,直接_recursions ++ (重入计数加 1),如果不是锁重入,自旋等待;

  • 如果该锁已经被其他线程占用,即 owner 不为 NULL且不是锁重入。先自旋尝试获得锁(设置 owner 为当前线程),这样做的目的是为了减少执行操作系统同步操作带来的开销

    • 自旋的过程中获得了锁,则直接返回。如果之前的自旋失败,会再进行一次TryLock获取锁,如果失败,还会再进行一次自旋操作,如果自旋任然失败,则重量级锁的获取中的所有的自旋操作到此结束
    • 经过多次自旋操作,依然没有获取到锁,会将线程的进入队列等待被唤醒。如下:
      • 将该线程封装成一个Object Waiter对象插入到 cxq(单向链表)的队列的队首,修改_cxq指向当前节点
      • 加入节点成功之后,还会再进行一次TryLock尝试获取锁,如果获取锁失败,则调用park函数挂起当前线程。在linux系统上,park函数最终起作用的是gclib库的pthread_cond_wait,JDK的ReentrantLock底层也是用该 park方法挂起线程的(后面 LockSupport 部分讲)。
  • 当被唤醒后再尝试获得锁

如果线程获得锁后调用 Object#wait 方法,则会将线程加入到 WaitSet 中,知道调用notify/notifyAll 方法 才能将WaitSet(单向链表)中的头节点或者全部节点移动到cxq或EntryList中去,然后在由线程释放锁之后进行唤醒。
需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁(因为轻量锁和偏向锁没有保存wait 线程的存储结构)

OnDeck Thread与Owner Thread

JVM不会直接把锁传递给Owner Thread,而是把锁竞争的权利交给OnDeck Thread。当线程获取到锁时,会将获取到锁的线程指定为OnDeck Thread。OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大地提升系统的吞吐量,在JVM中,也把这种选择行为称为“竞争切换”。

OnDeck Thread获取到锁资源后会变为Owner Thread。无法获得锁的OnDeck Thread则会依然留在EntryList中,考虑到公平性,OnDeck Thread在EntryList中的位置不发生变化(依然在队头)。在OnDeck Thread成为Owner的过程中,还有一个不公平的事情,就是后来的新抢锁线程可能直接通过CAS自旋成为Owner而抢到锁。

简单总结过程:

(1)膨胀到重量级锁之后,当一个线程想要获取锁的时候,先通过CAS将锁对象的Object Monitor内的owner字段设置为当前线程,尝试几次获取锁。设置成功就是获取锁成功了,没成功的话就是有占用。

(2)有占用时先封装成一个ObjectWaiter对象,存入到EntryList的队首,然后调用park挂起当前线程。底层就是通过Linux的mutex互斥量来实现

(3)当线程释放锁的时候,会从等待队列或者EntryList中选择一个线程进行唤醒,被选中的线程叫假定继承人,因为在它获取锁的过程中,外部线程有机会获取到锁,这也是Synchronized是不公平锁的原因。

(4)当持锁的线程调用await()方法时,会将线程加入到waitSet中,当被Object.notify唤醒后,会从waitSet移动到EntryList,继续等待获取锁。

释放过程

当线程释放锁时,会从 cxq 或 EntryList 中挑选一个线程唤醒,被选中的线程叫做Heir presumptive 即假定继承人(应该是这样翻译),就是上图中的Ready Thread,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁(这也是它叫"假定"继承人的原因)。

  • 如果_owner不是当前线程

    • 当前线程是之前持有轻量级锁的线程。由轻量级锁膨胀后还没调用过 enter 方法,_owner会是指向Lock Record的指针。则改为指向当前线程,然后继续执行后面代码
    • 否则异常情况,即当前不是持有锁的线程,抛出异常。
  • 如果_recursions (重入计数器) -1 后还不为0,则返回继续执行程序代码
  • 如果_recursions (重入计数器) -1 后为0,设置owner为null,即释放锁,这个时刻其他的线程能获取到锁。这里是一个非公平锁的优化;
  • 如果当前没有等待的线程则直接返回就好了,因为不需要唤醒其他线程。或者如果说succ不为null,代表当前已经有个"醒着的"继承人线程,那当前线程不需要唤醒任何线程;
  • 根据QMode的不同,会执行不同的唤醒策略(QMode默认为0)
    • QMode = 2且 cxq 非空:取 cxq 队列队首的 ObjectWaiter 对象,调用 ExitEpilog方法(该方法会唤醒ObjectWaiter对象的线程)然后立即返回;
    • QMode = 3且 cxq 非空:把 cxq 队列插入到 EntryList的 尾部;
    • QMode = 4且 cxq 非空:把 cxq 队列插入到 EntryList 的头部;
    • QMode = 0:暂时什么都不做,继续往下(QMode默认是0);
      只有QMode=2的时候会提前返回,等于0、3、4的时候都会继续往下执行解锁流程

      • 如果 EntryList 的 首元素非空,就取出来调用 ExitEpilog 方法,该方法会唤醒 ObjectWaiter 对象的线程,然后立即返回;
      • 如果 EntryList 的首元素为空,就将 cxq 的所有元素放入到 EntryList 中,然后再从 EntryList 中取出来队首元素执行 ExitEpilog 方法,然后立即返回;

Qmode=0的判断逻辑就是先判断entrylist是否为空,如果不为空,则取出第一个唤醒,如 果为空再从cxq里面获取第一个唤醒。notify唤醒后,会从waitSet移动到EntryList,线程最开始获取锁时们总是在_cxq 中,后再往EntryList 中移动,所以notify唤醒的线程会比普通线程获取锁的线程前执行。可以看出都是默认情况下的jdk,唤醒线程是先唤醒waitset中的线程 再去唤醒cxq中的

重量级锁注意要点:

  1. 重量级锁依赖于MonitorObject对象,每个java对象都有一个MonitorObject,所以每个java对象都可以作为同步锁
  2. 重量级锁才会涉及线程内核态转换,做出阻塞和唤醒线程的动作,wait 和 notify方法其实是MonitorObject的方法
  3. 重量级锁有两个队列,阻塞队列——执行到synchronized方法,但没获得锁,只能在方法外面排队,只有被唤醒并获得锁,才能进入方法体内部:cxq 和 EntryList等待队列——已经执行到synchronized方法内部,主动执行等待操作,暂时放弃锁,一旦被唤醒并重新获得锁后,则可以接着原来的代码执行:WaitSet
  4. 等待队列的线程被唤醒后也会被扔到阻塞队列,和原本就在外面的阻塞线程同台竞争

重量级锁字节码解析
节码解析原文

ApplicationTask lock = new ApplicationTask();
public void lockObject() {synchronized (lock){System.out.println("lock success !");}
}

从上图来看,执行 System.out 之前执行了 monitorenter 执行,这里执行争锁动作,拿到锁即可进入代码块执行方法,执行完之后有个 monitorexit 指令,表示释放锁。图中还标了一个 monitorexit 指令时,因为有异常的情况也需要解锁,不然就死锁了

也很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

monitorenter指令(争锁)

monitorexit指令(解锁):

执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

从生成的字节码我们也可以得知,为什么 synchronized 不需要手动解锁?
因为编译器生成的字节码都帮咱们做好了,异常的情况也考虑到了。

详细流程

重量锁的非公平性:

Demo :

public class SyncDemo {public static void main(String[] args) throws InterruptedException {SyncDemo syncDemo1 = new SyncDemo();syncDemo1.startThreadA();Thread.sleep(100);syncDemo1.startThreadB();Thread.sleep(100);syncDemo1.startThreadC();      }final Object lockobj = new Object();public void startThreadA() {new Thread(() -> {synchronized (lockobj) {System.out.println("A get lock");try {Thread.sleep(500); //等待A和B都加入阻塞队列} catch (InterruptedException interruptedException) {interruptedException.printStackTrace();}System.out.println("A release lock");}}, "thread-A").start();}public void startThreadB() {new Thread(() -> {synchronized (lockobj) { System.out.println("B get lock"); }}, "thread-B").start();}public void startThreadC() {new Thread(() -> {synchronized (lockobj) { System.out.println("C get lock"); }}, "thread-C").start();}
}

结果:默认策略下,在A释放锁后一定是C线程先获得锁。输出:

A get lock
A release lock
C get lock
B get lock

原因:因为在获取锁时,是将当前线程插入到cxq的头部。而释放锁时,默认策略是:如果EntryList为空,则将cxq中的元素按原有顺序插入到到EntryList,并唤醒第一个线程。也就是当EntryList为空时,是后来的线程先获取锁。

这点JDK中的Lock机制是不一样的,Synchronized是非公平锁,ReentrantLock是可以是公平也可以是非公平的。

  • Synchronized在特定的情况下对于已经在等待的线程是后来的线程先获得锁
  • 而ReentrantLock对于已经在等待的线程一定是先来的线程先获得锁;

重量锁原文


其他

锁升级全流程:

几种锁的优缺点

锁状态 优点 缺点 适用场景
偏向锁 加锁解锁无需额外的消耗,和非同步方法时间相差纳秒级别 如果竞争的线程多,那么会带来额外的锁撤销的消耗(撤销时会暂停原所有者线程) 锁不存在竞争关系或者线程总是能有序的获取到锁(线程A执行完同步代码块后线程B才尝试去获取锁)
轻量级锁 竞争的线程不会阻塞,使用自旋,提高程序响应速度 如果一直不能获取锁,长时间的自旋会造成CPU的消耗 适用于少量线程竞争锁对象,且线程持有锁的时间不长,追求响应速度的场景
重量级锁 线程竞争不使用CPU自旋,不会导致CPU空转导致消耗CPU资源 线程阻塞,响应时间长 很多线程竞争锁,且锁的持有时间长,追求吞吐量的场景

用锁的实践

synchronized (new Object())

错误 :每次调用创建的是不同的锁,相当于无锁

private Integer count;
synchronized (count)

String,Boolean在实现了都用了享元模式,即值在一定范围内,对象是同一个。所以看似是用了不同的对象,其实用的是同一个对象。会导致一个锁被多个地方使用

// 普通对象锁
private final Object lock = new Object();
// 静态对象锁
private static final Object lock = new Object();
//方法锁 用在普通方法上,默认的所就是this,当前实例; 用在普通方法上,默认的所就是this,当前实例
public synchronized void method() {.......
}
//类锁
synchronized (Demo.class) {.......
}

相关面试

synchronized怎么保证可见性

  1. 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
  2. 线程加锁后,其它线程无法获取主内存中的共享变量。
  3. 线程解锁前,必须把共享变量的最新值刷新到主内存中。

synchronized怎么保证有序性

  1. synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized保证同一时刻,代码是单线程执行的。
  2. 因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。
  3. 所以synchronized保证的有序是执行结果的有序性,而不是防止指令重排的有序性。

synchronized怎么实现可重入的

synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。

synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到计数器清零,就释放锁了。

缓存行的伪共享

我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。
在多核cup的处理器中,每个cup都有自己独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了提高性能,cpu读写数据是以缓存行为最小单元读写的;32位的cpu缓存行为32字节,64位cup的缓存行为64字节,这就导致了一些问题。

例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁;

为了防止伪共享,不同jdk版本实现方式是不一样的:

  1. 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行;
  2. 在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现;
  3. 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数:
    -XX:-RestrictContended

sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离;

github锁源码详细解析
较清晰带图文锁升级过程博客
IT乾坤博客

Java 锁相关知识汇总及锁升级相关推荐

  1. JAVA面试题|JAVA锁相关面试题总结(一)

    JAVA基础篇面试题 文章目录 JAVA基础篇面试题 1. 什么是JMM 2. 介绍一下violated 3. 写一个单例模式 4. 介绍一下CAS 5. CAS的问题 6. ArrayList线程不 ...

  2. JAVA之JVM知识汇总

    Java虚拟机(JVM)你只要看这一篇就够了!_Java笔记-CSDN博客_jvm JVM GC 机制与性能优化_橙子wj的博客-CSDN博客 为什么新生代内存需要有两个Survivor区_橙子wj的 ...

  3. java重要基础知识汇总

    Table of Contents 关于java语言设计理念 抽象类与接口的区别是什么 java为什么不支持多继承 java里的枚举实现机制是什么 java中的内部类 关于类Collections,A ...

  4. [Java 基础]-- corejava知识汇总

    -------------------CoreJava第一阶段--------------------------- day1------------------------------------- ...

  5. Java基础-基础知识汇总

    Java基础 一.变量与常量 1.变量 2.常量 3.数据类型 3.1 基本数据类型 3.1.1 整型字面值 3.1.2 浮点型字面值 3.1.3 字符型字面值 3.1.4 布尔类型字面值 3.1.5 ...

  6. Java核心基础知识汇总

    3.1.Java简介(了解) Java是一门编程语言,Java发展到今天,已经成为了一个真正意义上的语言标准,如果学习过(C.C++.Java)可以发现语法结构是很类似的,但是Java的标准指的是一种 ...

  7. Java 锁相关 笔记整理

    用户态与内核态 JDK早期,synchronized 叫做重量级锁, 因为申请锁资源必须通过kernel, 系统调用 ;hello.asm ;write(int fd, const void *buf ...

  8. Java SE 基础知识汇总

    目录 一.Java概述 1.1 Java语言背景 1.2 Java语言的三个版本 1.3 Java语言的跨平台原理 1.4 JRE和JDK 1.5 DOS命令 二.Java环境搭建 2.1 环境变量的 ...

  9. Java数据库连接池知识汇总(C3P0+DBCP+Druid)

    为什么要使用数据库连接池技术? 数据库连接池技术的优点 1. 资源重用 由于数据库连接得以重用,避免了频繁创建,释放连接引起的大量性能开销.在减少系统消耗的基础上,另一方面也增加了系统运行环境的平稳性 ...

最新文章

  1. SSL之CA证书颁发机构安装图文详解
  2. 为了边缘计算,亚马逊、谷歌、微软已正面交锋!
  3. 总结Unity 初学者容易犯的编译与运行时错误(第三部分)
  4. python做自动化界面_使用Python进行自动化测试如何切换窗口
  5. 烂泥:学习ssh之ssh隧道应用
  6. MATLAB求导相关知识,matlab如何求导相关阅读-matlab如何求导文章阅读-123文学网
  7. 关于dom4j中jaxen运行报错问题
  8. PDF怎么转换成CAD图纸?两个方法轻松操作。
  9. 房产中介管理系统的开发(附源码)
  10. php远程登录linux,如何远程连接linux桌面
  11. 经济学基础(本)【1】
  12. python中str是什么函数_python里的str是什么函数
  13. 2022年南京大学计算机拔尖班初试考后感想
  14. ndnSIM学习(八)——examples之ndn-simple.cpp每个函数逐行剖析
  15. python装饰器带参数函数二阶导数公式_【计算机程序的构造和解释】使用函数构建抽象——5. 高阶函数...
  16. ORACLE各种常见java.sql.SQLException归纳
  17. 第一个小程序 – 百纯起名
  18. Random Thoughts #12 @2013:P2P狂想曲
  19. npm run dev 报错:bash: npm: command not found
  20. Activiti7-BPMN介绍

热门文章

  1. 华洛机器人_男友约你爬山,在山顶突然推你,这时你会用哪个LOL英雄技能保命?...
  2. Mac--修改Xmind的试用时间,超长试用18527天
  3. 商业流程服务BPass你真的了解吗?
  4. 刘强东卸任京东 CEO,“二号位”徐雷接棒!三大电商巨头“二把手”正式集齐...
  5. latex 中图片或者表格跨两栏居中的方法
  6. 微信公众号开发教程(七)JSSDK-监听分享朋友圈事件
  7. 【Query Embedding on Hyper-relational Knowledge Graphs】 超关系知识图谱上的查询嵌入 论文结果复现
  8. 内网服务器通过CCproxy代理上网(…
  9. Spring定时任务,手动关闭开启
  10. 深圳公司注册银行开户