一文读懂Synchronized的实现原理

在多线程并发编程中 Synchronized 一直是元老级角色,很多人都会称呼它为重量级锁,但是随着 JVM 对 Synchronized 进行了各种优化之后,有些情况下它并不那么重了,本文详细介绍了 JVM中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

锁的种类

  • 公平锁:ReentrantLock
  • 非公平锁:synchronized 、ReentrantLock、CAS;
  • 独占锁:synchronized 、ReentrantLock
  • 共享锁:semaphore

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁存在哪里呢?锁里面会存储什么信息呢?我们先看一段简单的代码:

public class SynchronizedTest{
	public synchronized void test1(){

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

利用javap工具查看生成的class文件信息来分析Synchronize的实现:
在这里插入图片描述
从上面可以看出,同步代码块是使用monitorenter和monitorexit指令实现的,同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。

JVM 规范规定 JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用 monitorenter 和 monitorexit 指令实现,而方法同步是使用另外一种方式实现的。其细节在 JVM 规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。

  • 同步代码块:monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置和异常处。JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁

  • 同步方法:synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在JVM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法.而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。(摘自:http://www.cnblogs.com/javaminer/p/3889023.html)

【1】Monitor

顾名思义,对象的监视器,只要发生同步操作,线程就为当前对象创建一个Monitor对象与之关联,Monitor只能被一个线程持有,此时当前对象就处于锁定状态,其它线程只能阻塞等待。

与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:
在这里插入图片描述

  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;

  • EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。

  • RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。

  • Nest:用来实现重入锁的计数。

  • HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。

  • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

Java虚拟机(HotSpot)中,Monitor是通过ObjectMonitor实现的(c++),里面有三个重要的属性

ObjectMonitor(){
_header=NULL;
_count=0;//记录个数
_waiters=0;
_recursions=0;
_object=NULL;
_owner=NULL;
_WaitSet=NULL;//处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock=0;
_Responsible=NULL;
_succ=NULL;
_cxq=NULL;
FreeNext=NULL;
_EntryList=NULL;//申请锁的对象
_SpinFreq=0;
_SpinClock=0;
OwnerIsThread=0;
}

Monitor 有两个队列 WaitSet 和 _EntryList,存储ObjectWaiter列表(所有等待的线程都会被包装成ObjectWaiter):

  • ① 线程申请owner Monitor对象,首先会被加入到 _EntryList ;
  • ② 线程申请owner Monitor对象,进入到 Owner区域,此时count +1;
  • ③线程调用wait方法,释放锁,进入到 WaitSet ,此时count -1
  • ④ 线程再次申请owner
  • ⑤ 线程处理完毕后释放资源并退出。
    在这里插入图片描述

Synchronized 锁的管理就是依托于Monitor,当线程owner Monitor的时候则拥有进行同步操作的权利,线程进入同步块调用 monitorenter指令,退出同步块则调用 monitorexit,释放对Monitor的持有。

如何获取锁也就是如何owner Monitor呢?


【2】实例对象构成

在这里插入图片描述
① 对象头

synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Class Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

MarkWord里存储了对象的hashcode 以及锁信息等。除了MarkWord 对象头里还存放类的元信息–Class对象的指针(内存地址)。如果是数组的话,还会存储数组的长度。

在这里插入图片描述
32 位 JVM 的 Mark Word 的默认存储结构如下:
在这里插入图片描述
在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 可能变化为存储以下 4 种数据:
在这里插入图片描述
在 64 位虚拟机下,Mark Word 是 64bit 大小的,其存储结构如下:
在这里插入图片描述

② 实例数据

实例数据部分是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。包括从父类继承来的,都会记录下来。

③ 对齐填充

HotSpot要求对象的起止地址(姑且认为是对象大小)必须是8的整数倍,对象头部分正好是8的倍数,因此当实例数据部分不是8的倍数的话就需要填充了。

这部分详情可以参考博文:
一个Java对象到底占用多大内存
细探究,Java对象创建的奥秘


【3】偏向锁、轻量锁、重量锁

JavaSE1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁、轻量锁。而锁的信息则是在对象头的MarkWord里。Mark Word的存储内容会随着锁的变化而变化,下面的表格就是不同的锁状态对应的存储内容。
在这里插入图片描述
锁的变化归根结底还是线程改写Mark Word的操作。

① 偏向锁

在这里插入图片描述

HotSpot 作者经过研究发现,大多数情况下,锁不仅不存在竞争而且总由同一个线程获得,为了让线程获取锁的代价更低,引入了偏向锁。

  • 偏向锁的获取

case1 如果当前是无锁状态,则将自己的线程id写入到Mark Word,同时向是否是偏向锁写入1,当前线程持有该对象的偏向锁。
case2 当前对象已经是偏向锁,判断Mark Word里存的线程id是不是自己的,如果是则继续持有偏向锁 。
case3 当前对象已经是偏向锁,并且Mark Word里存的线程id也不是自己的,当前线程用过CAS尝试将Mark Word里的线程id改写成自己,如果改写成功则当前线程持有偏向锁。

  • 偏向锁的撤销

上面case3,如果当前线程尝试改写Mark Word的线程ID为自己,改写失败,等待进入全局安全点的时候,它(个人觉得是JVM)首先暂停原持有偏向锁的线程,然后检查原持有偏向锁的线程是否处于不活动或者退出同步。如果是则当前线程将自己线程id写入Mark Word,持有偏向锁。否则当前线程将对象标记为轻量级锁。原持有偏向锁的线程恢复执行,执行完毕退出同步块并唤醒暂停的线程。

  • 关闭偏向锁配置

java6和7里默认是开启偏向锁的,如果你确定应用程序里所有的锁通常是处于竞争状态,可以通过jvm参数配置关闭偏向锁。

XX:-UseBiasedLocking=false

② 轻量锁
在这里插入图片描述

  • 轻量级锁加锁

线程执行同步块之前,JVM会先在当前线程的栈桢中开辟存储锁记录的空间,并将对象头的Mark Word 复制到锁记录,官方称 Displaced Mark Word。然后线程尝试将对象头Mark Word替换为指向锁记录的指针也就是Displaced Mark Word的内存地址,同时改写锁标识为00。如果替换成功则持有轻量级锁。如果失败则尝试自旋获取,自旋有次数限制,超过后则膨胀为重量级锁。

  • 轻量级锁解锁

轻量级解锁,会通过CAS将 DisplacedMarkWord替换回到对象头,如果成功则没有竞争,退出同步块。失败则说明存在竞争,膨胀为重量级锁。


③ 重量锁

这个则是最原始的,锁竞争则阻塞,锁释放则唤醒

④ 几个锁对应的Mark Word的变化

在这里插入图片描述


⑤ 锁的膨胀

对象的锁会随着竞争情况逐渐升级,但是不能降级。
在这里插入图片描述
前面简单通过文字描述了下 偏向锁、轻量锁、重量锁的各自操作,不过还是一个完整的流程图看着更为清晰。

在这里插入图片描述


⑥ 锁的对比

使用场景 优点 缺点
偏向锁 适用于无竞争的同步访问 避免了锁竞争的消耗,和同步执行几乎无差别 如果出现锁竞争,则需要进行偏向锁的撤销,带来额外开销
轻量级锁 追求响应时间,执行速度快 减少竞争阻塞,提高程序响应速度 如果长时间竞争不到锁,则会耗费CPU资源进行自旋
重量级锁 追求吞吐量,执行速度长 线程竞争无自旋,不会额外消耗CPU 线程阻塞,响应时间长

参考博文:
Java虚拟机的锁优化策略
Synchronized的实现原理
聊聊并发(二)——Java SE 中的 Synchronized
JVM 规范(Java SE 7)
Java 语言规范(JAVA SE7)
线程间通信之Object.wait/notify实现

展开阅读全文

没有更多推荐了,返回首页