并发笔记之Synchronized实现原理

初识java解决线程并发问题,首先想到的可能就是synchronized关键字,操作简单;但相对于Locksynchronized是一个重量级锁 并不是那么高效
但JDK1.6对其进行了各种优化后,性能有了很大提升。那么synchronized的实现机制是什么?JDK是如果对它进行优化以及锁优化机制、锁的存储结构和升级过程是什么样的呢?

实现原理

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入临界区,同时它可以保证共享变量的内存可见性。

Java中每个对象都可以作为锁

  1. 普通同步方法 锁是当前实例对象
  2. 静态同步方法 锁是当前类的class对象
  3. 同步方法块 锁是括号内的对象

举个栗子

1
2
3
4
5
6
7
8
9
public class Hello {
public synchronized void hello1(){

}
public void hello2(){
synchronized(this){
}
}
}

javap -verbose反编译下代码看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public synchronized void hello1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 4: 0

public void hello2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
LineNumberTable:
line 6: 0
line 7: 4
line 8: 14
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 9
locals = [ class Hello, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4


反编译后可以看出

  1. 同步方法 JVM采用了ACC_SYNCHRONIZED 将该方法的access_flags字段中的synchronized标志置为1,表示该方法是同步方法并使用调用该方法的对象或该方法所属
    Class在JVM内部对象Klass作为锁对象
  2. 同步代码块 在同步代码块的入口和出口位置分别插入字节码指令monitorentermoniterexit

Java对象模型

Java对象保存在堆内存中,一个对象包含三部分:对象头、实例数据和对齐填充。我们对对象进行操作时其实操作的是对象的引用,那么对象在堆内存中的存储结构是什么样的呢?
HotSpot虚拟机为例

OPP-Klass Model

HotSpot JVM是基于C++实现,C++本身也是一种面向对象的语言,故Java中的对象表示最简单的就是为每个Java对象生成一个C++对象与之对应。但HotSpot没这么做,而是设计了一个
OPP-Klass Model:OPP就是普通对象指针(Ordinary Object Pointer),Klass是描述对象实例的具体类型

Java对象头和monitor是实现synchronized的基础

对象头

synchronized使用的锁在对象头里,HotSpot虚拟机的对象头主要包括两部分数据:

  1. Mark Word(标记字段):存储对象自身的运行时数据 它是现实轻量级锁和偏向锁的关键
  2. Klass Pointer(类型指针):对象指向其类元素据的指针 虚拟机通过这个指针来判断对象是哪个类的实例

当我们在Java代码中,使用new创建一个对象实例的时候,(hotspot虚拟机)JVM层面实际上会创建一个 instanceOopDesc对象。
Hotspot虚拟机采用OOP-Klass模型来描述Java对象实例,OOP(Ordinary Object Point)指的是普通对象指针,Klass用来描述对象实例的具体类型。Hotspot采用instanceOopDesc和arrayOopDesc来描述对象头,arrayOopDesc对象用来描述数组类型
instanceOopDesc的定义在Hotspot源码中的 instanceOop.hpp文件中,另外,arrayOopDesc的定义对应 arrayOop.hpp
从instanceOopDesc代码中可以看到 instanceOopDesc继承自oopDesc,oopDesc的定义载Hotspot源码中的 oop.hpp文件中
在普通实例对象中,oopDesc的定义包含两个成员,分别是 _mark和 _metadata
_mark表示对象标记、属于markOop类型,也就是接下来要讲解的Mark World,它记录了对象和锁有关的信息
_metadata表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中Klass表示普通指针、 _compressed_klass表示压缩类指针

Mark Word

普通对象的对象头由两部分组成,分别是markOop以及类元信息,markOop官方称为Mark Word 在Hotspot中,markOop的定义在 markOop.hpp文件中,代码如下
Mark word记录了对象和锁有关的信息,当某个对象被synchronized关键字当成同步锁时,那么围绕这个锁的一系列操作都和Mark word有关系。Mark Word在32位虚拟机的长度是32bit、在64位虚拟机的长度是64bit。

Mark Word存储的对象运行时数据,如哈希码(HashCode)、垃圾回收分代年龄、线程持有的锁、锁状态标志、偏向线程ID、偏向时间戳等。Java对象头一般占有两个机器码(32位虚拟机中,1个机器码等于4个字节,
也就是32bit),但如果是数组则需要三个机器码,因为JVM可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确定数组大小,故用一块单独的内存记录数组长度。
Java对象头存储结构如下(32位):

25bit 4bit 1bit 2bit
对象的HashCode 对象的分代年龄 是否是偏向锁 锁标志位

对象头信息是与对象自身定义的数据无关的额外存储成本,但考虑到虚拟机的空间效率,Mark Word被设计成一个非固定地的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,
也就是,Mark Word会根据程序运行的变化,变化状态如下(32位虚拟机):

Monitor

通过字节码我们可以发现,修饰在方法层面的同步关键字,会多一个 ACC_SYNCHRONIZED的flag;修饰在代码块层面的同步块会多一个 monitorenter和 monitorexit关键字。无论采用哪一种方式,本质上都是对一个对象的监视器(monitor)进行获取,而这个获取的过程是排他的,也就是同一个时刻只能有一个线程获得同步块对象的监视器。
synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。当我们的JVM把字节码加载到内存的时候,会对这两个指令进行解析。这两个字节码都需要一个Object类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那么这个对象就是加锁和解锁的对象;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,获取对应的对象实例或Class对象来作为锁对象

其实前面只提到了锁标志位的存储,但是为什么任意一个Java对象都能成为锁对象呢?
首先,Java中的每个对象都派生自Object类,而每个Java Object在JVM内部都有一个native的C++对象 oop/oopDesc进行对应。
其次,线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor可以认为是一个同步对象,所有的Java对象是天生携带monitor.
在hotspot源码的 markOop.hpp文件中
多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识,上面的代码中ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系(后续会详细分析)

锁升级

在JDK1.6之前,synchronized是一个重量级锁,性能比较差。从JDK1.6开始,为了减少获得锁和释放锁带来的性能消耗,synchronized进行了优化,引入了 偏向锁和 轻量级锁的概念。所以从JDK1.6开始,锁一共会有四种状态,锁的状态根据竞争激烈程度从低到高分别是:无锁状态->偏向锁状态->轻量级锁状态->重量级锁状态。这几个状态会随着锁竞争的情况逐步升级。为了提高获得锁和释放锁的效率,锁可以升级但是不能降级。

偏向锁

在大多数的情况下,锁不仅不存在多线程的竞争,而且总是由同一个线程获得。因此为了让线程获得锁的代价更低引入了偏向锁的概念。偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。偏向锁可以通过 -XX:+UseBiasedLocking开启或者关闭

偏向锁获取

偏向锁的获取过程非常简单,当一个线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,表示哪个线程获得了偏向锁,结合前面分析的Mark Word来分析一下偏向锁的获取逻辑

  • 首先获取目标对象的Mark Word,根据锁的标识为和epoch去判断当前是否处于可偏向的状态
  • 如果为可偏向状态,则通过CAS操作将自己的线程ID写入到MarkWord,如果CAS操作成功,则表示当前线程成功获取到偏向锁,继续执行同步代码块
  • 如果是已偏向状态,先检测MarkWord中存储的threadID和当前访问的线程的threadID是否相等,如果相等,表示当前线程已经获得了偏向锁,则不需要再获得锁直接执行同步代码;如果不相等,则证明当前锁偏向于其他线程,需要撤销偏向锁。

CAS:表示自旋锁,由于线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说性能开销很大。同时,很多对象锁的锁定状态指会持续很短的时间,因此引入了自旋锁,所谓自旋就是一个无意义的死循环,在循环体内不断的重行竞争锁。当然,自旋的次数会有限制,超出指定的限制会升级到阻塞锁。

偏向锁撤销

当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,撤销偏向锁的过程需要等待一个全局安全点(所有工作线程都停止字节码的执行)。

  • 首先,暂停拥有偏向锁的线程,然后检查偏向锁的线程是否为存活状态
  • 如果线程已经死了,直接把对象头设置为无锁状态
  • 如果还活着,当达到全局安全点时获得偏向锁的线程会被挂起,接着偏向锁升级为轻量级锁,然后唤醒被阻塞在全局安全点的线程继续往下执行同步代码

轻量级锁

当存在超过一个线程在竞争同一个同步代码块时,会发生偏向锁的撤销。偏向锁撤销以后对象会可能会处于两种状态

  • 一种是不可偏向的无锁状态,简单来说就是已经获得偏向锁的线程已经退出了同步代码块,那么这个时候会撤销偏向锁,并升级为轻量级锁
  • 一种是不可偏向的已锁状态,简单来说就是已经获得偏向锁的线程正在执行同步代码块,那么这个时候会升级到轻量级锁并且被原持有锁的线程获得锁
轻量级锁加锁
  • JVM会先在当前线程的栈帧中创建用于存储锁记录的空间(LockRecord)
  • 将对象头中的Mark Word复制到锁记录中,称为Displaced Mark Word.
  • 线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针
  • 如果替换成功,表示当前线程获得轻量级锁,如果失败,表示存在其他线程竞争锁,那么当前线程会尝试使用CAS来获取锁,当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁
轻量级锁解锁
  • 尝试CAS操作将所记录中的Mark Word替换回到对象头中
  • 如果成功,表示没有竞争发生
  • 如果失败,表示当前锁存在竞争,锁会膨胀成重量级锁

重量级锁

重量级锁依赖对象内部的monitor锁来实现,而monitor又依赖操作系统的MutexLock(互斥锁)
假设Mutex变量的值为1,表示互斥锁空闲,这个时候某个线程调用lock可以获得锁,而Mutex的值为0表示互斥锁已经被其他线程获得,其他线程调用lock只能挂起等待

为什么重量级锁的开销比较大呢?
原因是当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的

锁优化

JVM中monitorentermoniterexit字节码指令依赖于底层的操作系统mutex lock实现,但mutex lock需要将当前线程挂起并从用户态切换到内核态来执行,切换代价比较高昂。
实际使用过程中,大部分情况下同步方法是运行在单线程环境若每次都调用mutex lock将严重影响性能。好在JDK1.6对锁的实现引入了大量的优化,来减少锁操作的开销:

  1. 锁粗化(lock coarsening)
    将不必要的连续的lock、unlock操作扩展成一个更大范围的锁
  2. 锁消除(lock elimination)
    通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护
  3. 轻量级锁(lightweight locking)
  4. 偏向锁(biased locking)
  5. 适应性自旋锁(adaptive spinning)

synchronized的源码分析

坚持原创技术分享,您的支持将鼓励我继续创作!