synchronized的实现原理——对象头解密
前言
并发编程式Java基础,同时也是Java最难的一部分,因为与底层操作系统和硬件息息相关,并且程序难以调试。本系列就从synchronized原理开始,逐步深入,领会并发编程之美。
正文
基础稍微好点的同学应该都知道,Java中获取锁有两种方式,一种是使用synchronized关键字,另外一种就是使用Lock接口的实现类。前者就是Java原生的方式,但在优化以前(JDK1.6)性能都不如Lock,因为在优化之前一旦使用synchronized就会发生系统调用进入内核态,所以性能很差,也因此大神Doug Lea自己写了一套并发类,也就是JUC,并在JDK1.5版本引入进了Java类库。那么作为Java的亲儿子synchronized自然也不能示弱啊,所以sun公司对其做了大量的优化,引入了偏向锁、轻量级锁、重量锁、锁消除、锁粗化,才使得synchronized性能大大提升。
线程模型
Java的线程本质是什么?
首先我们需要了解线程的模型,实现线程有以下三种方式:
- 使用内核线程,即一对一模型
- 使用用户线程,即一对多模型(一个内核线程对应多个用户线程,如现在比较火的Golang)
- 混合实现,即多对多模型,这种比较复杂,不用太过深入。
而Java现在就是采用的一对一模型(JDK1.2以前是使用的用户线程实现),即当调用start方法时都是真实地创建一个内核线程(KLT),但程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LWP)。轻量级进程和内核线程也是一对一的关系,因此使用它可以保证每个线程都是一个独立的调度单元,即当前线程阻塞了也不会影响整个进程工作,但带来的问题就是在线程创建、销毁、同步、切换等场景都会涉及系统调用,性能比较低;另外每个轻量级进程都要占据一定的系统资源,因此,能够创建的线程数量是有限的。
锁优化
因为大部分情况下不会出现线程竞争,所以为了避免线程每次遇到synchronized都直接进入内核态,sun公司使用大量的优化手段:
- 偏向锁:当一个线程第一次获得锁后再次申请获取就可以直接拿到锁,相当于无锁,这种情况下效率最高。
- 轻量级锁:在没有多线程竞争,但有多个线程交替执行情况下,避免调用系统函数mutex(特指linux系统)产生的性能消耗。
- 重量级锁:发生了多线程竞争,就会调用mutex函数使得未获取到锁的线程进入睡眠状态。
- 锁消除:代码经过逃逸分析后,判断没有数据会逃逸出线程,就不会给这段这段代码加锁。
- 锁粗化:如果虚拟机检测到有一系列零碎的操作都对同一对象加锁,就会将整个同步操作扩大到这些操作的外部,这样就只需要加锁一次即可。
本篇主要讨论锁膨胀的过程对对象的影响,所以总结为一句话就是:当一个线程第一次获取锁后再去拿锁就是偏向锁,如果有别的线程和当前线程交替执行就膨胀为轻量级锁,如果发生竞争就会膨胀为重量级锁。这个就是synchronized锁膨胀的原理,但并不完全正确,其中还有很多细节,下面就一步步来说明。
对象的内存布局
理论
对象在内存中是如何分配的呢?学过JVM的人应该都知道,如下图:

但上图只是说明了一个对象在内存中由哪几部分组成,但具体每一部分多大,整个对象又有多大呢?比如下面这个类的对象在内存中占用多少个字节:
public class A{}
32位和64位虚拟机表现不同,这里以主流的64位进行说明。一个对象在内存中存储必须是8字节的整数倍,其中对象头占了12字节,这里A对象没有实例数据,所以还需要4字节的对其填充,所以占用16字节(如果该对象中有一个boolean对象的成员变量,这个对象又占用多少字节呢)。另外对象头中也分为了两部分,一部分是指向方法区元数据的类型指针(klass point),固定占用4字节32位;另一部分则是则是用于存储对象hashcode、分代年龄、锁标识(偏向、轻量、重量)、线程id等信息的mark word,占用8字节64位。由于类型指针是固定的,下面主要讨论mark word部分的内存布局。
我们可以看到在mark word中存储了很多信息,这么多信息64位肯定是不够存储的,那怎么办呢?虚拟机将mark word设计成为了一个非固定的动态数据结构,意思是它会根据当前的对象状态存储不同的信息,达到空间复用的目的,下图就是一个对象的mark word在不同的状态下存储的信息:

从上图我们可以发现无锁、偏向锁、轻量锁、重量锁分别的状态是:01、01、00、10,偏向锁同时还需要额外的以为表示是否可偏向。因为当一个对象持有偏向锁时,需要在对象头中存储线程id和偏向时间戳,占用56bit,而对象的hashcode需要占用31bit,空间就不够了,所以一旦对象调用了未重写的hashcode方法就无法获取偏向锁。
另外我们可以看到当锁膨胀为轻量锁或重量锁时,对象头中62bit都用来存储锁记录(Lock record)的地址了,那他们的分代年龄、hashcode这些信息去哪了呢?其实就存在于锁记录空间中,而锁记录是存在于当前线程的栈帧中的。虚拟机会使用CAS操作尝试把mark word指向当前的Lock record,如果修改成功,则当前线程获取到该锁,并标记为00轻量锁,如果修改失败,虚拟机会检查对象的mark word是否指向当前线程的栈帧,如果是,则直接获取锁执行即可,否则则说明有其它线程和当前线程在竞争锁资源,直接膨胀为重量级锁,等待的线程则进入阻塞状态。
证明
偏向锁
上面说的都是理论,怎么证明呢?先引入下面这个依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
然后针对之前创建的A类,执行下面的方法:
public class TestJol {
static A l = new A();
public static void main(String[] args) throws InterruptedException {
log.debug("线程还未启动----无锁");
log.debug(ClassLayout.parseInstance(l).toPrintable());
}
}
控制台就会打印如下信息:

我们主要看到二进制部分内容前两行内容(第三行是类型指针),按照之前所说,当前这个对象应该是无锁可偏向状态,那么前25个bit应该是未被使用的,后三个bit应该是101,中间部分也应该都是0,但是图中显示的和我们理论不符啊。别急,这其实是由于我们现在的家用电脑基本上采用的都是小端存储导致的,那什么又是小端存储呢?小端存储就是高地址存高字节,低地址存低字节。

所以小端地址输出的格式是反着的从右到左(反之大端存储输出格式就是符合我们人类阅读习惯的格式),这里只是帮助理解,不深入探究大小端存储问题。
因此之前输出的信息是符合我们上面所说的理论的,接着我们在输出对象头之前获取下hashcode,看看会发生什么,main方法中增加下面这行代码。
System.out.println(Integer.toHexString(l.hashCode()));

可以看到对象头中存储的hashcode和我们输出的hashcode是一致的,同时状态变为了无锁不可偏向(001)。
再来看看加锁之后会有什么变化:
public static void testLock() {
//偏向锁 首选判断是否可偏向 判断是否偏向了 拿到当前的id 通过cas 设置到对象头
synchronized (l) {//t1 locked t2 ctlock
log.debug("name:" + Thread.currentThread().getName());
//有锁 是一把偏向锁
log.debug(ClassLayout.parseInstance(l).toPrintable());
}
}
去掉hashcode方法的调用并调用这个方法,另外还需要关闭偏向延迟-XX:BiasedLockingStartupDelay=0,否则也会直接膨胀为轻量锁。输出结果如下:

可以看到在获取偏向锁后将线程id存入到了对象头中。
轻量锁
接下来我们看看膨胀为轻量锁的过程,导致膨胀轻量锁的原因主要有以下几点:
- 调用了未重写的hashcode方法
- 开启了偏向延迟(因为我们是短时间执行程序,默认延迟时间是4s中)
- 多线程交替执行
前两点读者可自行打印输出看看,这里主要来看最后一点,使用如下程序:
public class TestJol {
static A l = new A();
static Thread t1;
static Thread t2;
public static void main(String[] args) throws InterruptedException {
t1 = new Thread() {
@SneakyThrows
@Override
public void run() {
testLock();
Thread.sleep(1000);
testLock();
}
};
t2 = new Thread() {
@SneakyThrows
@Override
public void run() {
testLock();
Thread.sleep(2000);
testLock();
}
};
t1.setName("t1");
t1.start();
t2.setName("t2");
t2.start();
}
public static void testLock() {
//偏向锁 首选判断是否可偏向 判断是否偏向了 拿到当前的id 通过cas 设置到对象头
synchronized (l) {//t1 locked t2 ctlock
log.debug("name:" + Thread.currentThread().getName());
//有锁 是一把偏向锁
log.debug(ClassLayout.parseInstance(l).toPrintable());
}
}
}
这里创建了两个线程t1、t2,各自先调用一次testLock方法,然后使用sleep睡眠让出cpu后再调用一次,形成交替执行testLock方法,最终打印如下:

注意t1和t2首次都是获取到的偏向锁,并且线程id是相同的,但是按理说线程id应该会变才对,这里笔者猜测为JVM优化,使得线程可以重用,但暂时还无法验证。接着看后两条记录是睡眠之后打印的,这时t1和t2获取到的锁都是轻量级锁了,对象头中存储的Lock record的地址,和我们猜测相符合。
重量锁
最后去掉上面代码中的两个sleep,这样两个线程就会发生竞争膨胀为重量锁:

可以看到和我们的理论也是相符合的。
总结
本篇是并发系列的第一篇,也是synchronized原理的第一篇,主要分析了锁对象在内存中的布局情况以及锁膨胀的过程,并通过代码验证了所学理论,但synchronized的实现原理是非常复杂的,尤其是优化过后。更深入的内容将在后面的文章中逐步展开,另外读者们可以思考一个问题,synchronized有没有使用自旋锁来优化?
synchronized的实现原理——对象头解密的更多相关文章
- 015-线程同步-synchronized几种加锁方式、Java对象头和Monitor、Mutex Lock、JDK1.6对synchronized锁的优化实现
一.synchronized概述基本使用 为确保共享变量不会出现并发问题,通常会对修改共享变量的代码块用synchronized加锁,确保同一时刻只有一个线程在修改共享变量,从而避免并发问题. syn ...
- 并发王者课-青铜5:一探究竟-如何从synchronized理解Java对象头中的锁
在前面的文章<青铜4:synchronized用法初体验>中,我们已经提到锁的概念,并指出synchronized是锁机制的一种实现.可是,这么说未免太过抽象,你可能无法直观地理解锁究竟是 ...
- 盘一盘 synchronized (一)—— 从打印Java对象头说起
Java对象头的组成 Java对象的对象头由 mark word 和 klass pointer 两部分组成, mark word存储了同步状态.标识.hashcode.GC状态等等. klass ...
- 码农会锁,synchronized 对象头结构(mark-word、Klass Pointer)、指针压缩、锁竞争,源码解毒、深度分析!
作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 感觉什么都不会,从哪开始呀! 这是最近我总能被问到的问题,也确实是.一个初入编程职场 ...
- synchronized的实现原理-java并发编程的艺术读书笔记
1.synchronized实现同步的基础 Java中的每个对象都是可以作为锁,具体有3种表现. 1.对于普通同步方法,锁是当前实例对象. 2.对于静态同步方法,锁是当前类的Class对象. 3.对于 ...
- Java的多线程机制系列:(三)synchronized的同步原理
synchronized关键字是JDK5之实现锁(包括互斥性和可见性)的唯一途径(volatile关键字能保证可见性,但不能保证互斥性,详细参见后文关于vloatile的详述章节),其在字节码上编译为 ...
- 深度解析synchronized的实现原理(并发一)
一.synchronized实现原理 1.synchronized实现同步的基础: 1).普通同步方法:锁是当前实例对象 2).静态同步方法:锁是当前类的class对象 3).同步方法块:锁是括号里面 ...
- synchronized底层实现原理&CAS操作&偏向锁、轻量级锁,重量级锁、自旋锁、自适应自旋锁、锁消除、锁粗化
进入时:monitorenter 每个对象有一个监视器锁(monitor).当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:1 ...
- synchronized的实现原理与应用
Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令. sync ...
随机推荐
- 精讲RestTemplate第1篇-在Spring或非Spring环境下如何使用
一.什么是 RestTemplate? RestTemplate是执行HTTP请求的同步阻塞式的客户端,它在HTTP客户端库(例如JDK HttpURLConnection,Apache HttpCo ...
- 当asp.net core偶遇docker一(模型验证和Rabbitmq 一)
比如我们有一些设计,依赖于某些软件,比如rabbitmq 当管理员功能,反复错误三五次之后,就发送一条消息到队列里去,我们又不希望对原先设计带来侵入式的改变业务 这个时候,我们就可以在模型验证里面加入 ...
- Android 菜单的使用
有时间就随笔记录自己遇到的问题和所学的知识哈. 这是对本牛崽知识的提升也可以给其他牛牛们来点鸡汤和开胃菜. 菜单Menu的创建 首先menu是属于布局的嘛,所以嘞,咱们得在res(也就是布局资源)创建 ...
- “随手记”开发记录day14
今天继续昨天没有完成的增加“修改”功能.对于已经添加的记账记录,长按可以进行修改和删除的操作. 但是今天并没有完成……
- 如果连这10个Python缩写都不知道,那你一定是Python新手
简介 对于许多开始学习编程的人来说,Python已经成为他们的首选.Python有非常直观的语法和支持动态类型的灵活性.此外,它是一种解释语言,这使得使用交互式控制台进行学习成为可能.基本上,我们只需 ...
- 浅析Facebook LibraBFT与比原链Bystack BBFT共识
如果说什么是区块链的灵魂,那一定是共识机制. 它是区块链的根基.无论公链或是联盟链,共识机制都从基础上限制了区块链的交易处理能力和扩展性. 2019年6月18日,Facebook 发布了自己 Libr ...
- 谈谈MySql索引
刚刚学习完丁奇老师<MySql 实战 45 讲>专栏中的索引部分,图文并茂的风格解开了我之前的许多疑惑,并且学习到许多新的东西,在此做个笔记,方便后续复习.由于 MySql 中存在多种存储 ...
- C++实现哈夫曼编码/译码器(数据结构)
设计一个哈夫曼编码.译码系统.对一个ASCII编码的文本文件中的字符进行哈夫曼编码,生成编码文件:反过来,可将编码文件译码还原为一个文本文件.(1) 从文件中读入任意一篇英文短文(文件为ASCII编码 ...
- 2020-08-08:有一批气象观测站,现需要获取这些站点的观测数据,并存储到 Hive 中。但是气象局只提供了 api 查询,每次只能查询单个观测点。那么如果能够方便快速地获取到所有的观测点的数据?
福哥答案2020-08-08: 参考答案:A.通过shell 或python 等调用api,结果先暂存本地,最后将本地文件上传到 Hive 中.B.通过 datax 的 httpReader 和 hd ...
- C#算法设计排序篇之09-基数排序(附带动画演示程序)
基数排序(Radix Sort) 该文章的最新版本已迁移至个人博客[比特飞],单击链接 https://www.byteflying.com/archives/691 访问. 基数排序属于" ...