synchronized的实现原理与应用&Java对象的内存布局

今天笔者在阅读《Java并发编程的艺术》时,阅读到了Java并发机制的底层实现原理一章中的synchronized部分,觉得作者对Java对象头的描述有些模糊,然后在笔者脑海中,就想起来了前一阵阅读《深入理解Java虚拟机》时,正好阅读过Java对象的内存布局,不过由于笔者智商较低,对这一部分的记忆也有些许模糊了…

正所谓:好记性不如烂笔头。于是!笔者将这两个“模糊”的部分结合到一个文章中,以加深自己的记忆。

这篇文章完全是站在巨人的肩膀上,如果想深入了解Java并发编程与JVM可以去阅读下面的参考书籍

参考书籍:《Java并发编程的艺术》与《深入理解Java虚拟机·JVM高级特性与最佳实践》

吐槽:Typora为什么没有合并单元格的功能,HTML手敲表格真的好累!!!!!

synchronized的实现原理

这里直接引用书中的内容

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

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

synchronized的应用

Java中的每一个对象都可以作为锁,也就是我们常说的“Java中每个对象都持有一把锁”,这是synchronized实现同步的基础。

synchronized实现同步具体表现为以下三种形式:

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的Class对象
  3. 对于同步方法块,锁是synchronized括号里配置的对象

学习过多线程的读者都知道,当一个线程试图访问同步代码块时,它必须首先得到对象的锁,退出或者抛出异常时必须释放锁。

那么锁到底在哪里呢?锁里面会存储什么信息呢?

Java对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对其填充(Padding),下面分别讲述这三部分。

Java对象头

HotSpot虚拟机对象的对象头包括两类信息。但如果对象是一个Java数组,那么对象头中还必须有一块用于记录数组长度的数据,这部分就不作重点说明了,我们主要看前两类信息。

第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据的长度在32位和64位的虚拟机中分别为32个比特和64个比特,官方称它为“Mark Word”。

以32位HotSpot虚拟机为例,当对象未被同步锁锁定时,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0。如下表所示:

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

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4中数据,如表所示:

锁状态 25bit 4bit 1bit 2bit
23bit 2bit 是否是偏向锁 锁标志位
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10
GC标记 11
偏向锁 线程ID Epoch 对象分代年龄 1 01

在64位虚拟机下,Mark Word的存储结构如表所示:

锁状态 25bit 31bit 1bit 4bit 1bit 2bit
cms_free 分代年龄 偏向锁 锁标志位
无锁 unused HashCode 0 01
偏向锁 ThreadID(54bit) Epoch(2bit) 1 01

对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定是哪个类的实例。

实例数据部分

实例数据部分是对象真正存储的有效信息,即我们在程序代码里所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPS),从以上默认的分配策略中可以看到,相同宽度的字段总是被被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

对齐填充

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起者占位符的作用。由于Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数,因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

锁的升级与优化

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但却不可以降级,目的是为了提高获得锁和释放锁的效率。

偏向锁

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

下面就分析一下线程获得偏向锁所经历的过程:

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进人和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。

如果测试成功,表示线程已经获得了锁。

如果测试失败则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):

  • 如果没设置,则使用CAS竞争锁;
  • 如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

对于CAS这里要简单说明一下:Java的Atomic包使用CAS算法来更新数据,而不需要加锁。也就是一种更新数据的方法。

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着

  • 如果线程不处于活动状态,则将对象头设置成无锁状态
  • 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁

最后唤醒暂停的线程

轻量级锁

轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

最后希望大家保持自律,努力学习!
在这里插入图片描述

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码

)">
< <上一篇
下一篇>>