前言
- 上篇文章介绍了Synchronized和monitor的一些底层基础知识 Synchronized详解(上)| 8月更文挑战;
- 那么有个问题来了,我们知道Synchronized加锁加在对象上的,那么对象是如何记录锁状态的呢?
- 所以这次就介绍一下JVM虚拟机中对象的内存布局
对象内存分析
- HotSpot虚拟机中,对象在内存中分为三块:对象头、实例数据、对其填充;
- 对象头:像hash码、分代年龄、对象锁、锁状态标志、偏向锁ID、偏向时间、数组长度等等。对象头一般占两个机器码(在32位虚拟机中,1个机器码等于4个字节,就是32bit,在64位虚拟机中,一个机器码是8个字节,就是64bit),但如果对象是数组类型,需要三个机器码。因为JVM通过java对象的元数据确定对象的大小,所以需要一块来记录数组长度; - 实例数据:存放类的属性数据信息,包括父类的属性信息; - 对其填充:虚拟机要求对象起始位置必须是8字节的整数倍,该块不是一定存在的,仅仅用于字节对齐;
对象头
- Hotspot虚拟机的对象头包括两部分,第一部分是 “MarkWord”,用于存储对象自身运行时数据,包括hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键;
- 这部分数据的长度在32和64位的虚拟机中分别是32和64bit(暂不考虑指针压缩),官方称为 “MarkWord”。考虑到虚拟机的空间效率,MarkWord被设计为一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间;
- 在32位虚拟机中对象未被锁定状态下,MarkWord的32bit空间有25bit用于存储对象hashcode,4bit存储对象分代年龄,2bit存储锁标志,1bit固定为0。其它状态(轻量锁、重量锁、GC标志、偏向锁)下对象存储结构如下:
- 64位虚拟机结构如下:
指针压缩
- 现在我们虚拟机一般都是64位,而64位的对象头比较浪费空间,所以JVM会默认开启指针压缩,基本也会按照32位的形式记录对象头
XX:+UseCompressedOops
- 哪些信息会被指针压缩?
- 对象的全局静态变量(类属性) - 对象头信息:64位下,原生对象头大小为16字节,压缩后12字节 - 对象的引用类型:引用类型本身8字节,压缩后4字节 - 对象数组类型:数组类型本身24字节,压缩后16字节
- 《java性能权威指南》第八章提到当heap size堆内存大于32GB是用不了指针压缩,对象引用还会额外占用20的内存空间,所以要38GB的内存才相当于开启了指针压缩的32GB堆空间。
Synchronized锁膨胀过程分析
锁的状态一共有四种,无锁、偏向锁、轻量锁和重量级锁。随着锁的竞争,偏向锁会升级到轻量锁,轻量锁会升级到重量级锁,锁的晋升是单向的,不会出现锁的降级。在JDK1.6后默认是开启偏向锁和轻量锁
偏向锁
- 它是一种对加锁操作的优化手段。因为在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程获取锁,因此为了减少同一线程获取锁(涉及到CAS操作,比较耗时)的代价而引入偏向锁。
- 优化机制:如果一个线程获得了锁,那么锁就进入偏向模式,此时MarkWord的结构也变成偏向锁结构,当这个线程再次请求锁时,无需做其他如何操作即可获取锁,省去了大量有关锁申请的操作,也就提高了程序的性能。
- 对于没有锁竞争的场合,偏向锁有很好的优化效果。但是在锁竞争比较激烈的场合,偏向锁就失效了,因为这样的场合每次申请锁的线程都可能是不一样的。当偏向锁失效后并不会立即变成重量级锁,而是先升级为轻量锁。
默认开启偏向锁
- 开启偏向锁: -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
- 关闭偏向锁: -XX:-UseBiasedLocking
轻量级锁
- 当偏向锁失效后,虚拟机会使用轻量锁的优化方式,此时MarkWord的机构也变成轻量锁模式
- 优化机制:对绝大部分锁,在整个同步周期呢都不存在竞争,在其场景内是线程交替执行同步代码块。
- 如果同一时间访问同一锁,那么就会导致轻量锁升级为重量级锁。
自旋锁
- 轻量锁失效后,还会进行一项自旋锁的优化操作
- 优化机制:这是基于在大多数情况下,线程持有锁的时间比较短,如果直接挂起操作系统层面的线程可能会得不偿失(操作系统实现线程切换需要用户态和核心态转换),因此自旋锁假设在短时间内,当前线程可以获得锁,虚拟机会让当前想要获取锁的线程做几次空循环(自旋操作),经过若干次自旋后,如果得到锁,就进入临界区。如果最后还没有得到锁,那么就会将线程在操作系统层面挂起,最后就升级为重量级锁。
锁消除
- 锁消除是虚拟机另一种优化方式
- 优化机制:JVM虚拟机在JIT编译时(及时编译机制),通过对运行上下文扫描解析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省无意义的申请锁时间
- 如StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并不会被其他线程所使用,因此就不会出现锁竞争的情况,JVM会自动将其锁清除。锁消除的依据是逃逸分析。
锁消除前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析
-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+EliminateLocks 表示开启锁消除
逃逸分析
- JVM使用逃逸分析,编译器可以对代码做如下优化:
1. 同步省略。如果一个对象被发现只能从一个线程访问,那么对应这个对象的操作可以不考虑同步 2. 将堆分配转换为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象就可能是栈分配,而不是堆分配 3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或全部可以不存储在内存,而存储在CPU寄存器
- 关于:所有对象和数组都会在堆上分配空间? 所以这个问题是不一定的。
最后
- 虚心学习,共同进步 -_-