Java对象结构
一个对象包括三部分:
对象头
实例数据
对其填充
对象头:
Mark Word:用于存储对象自身运行时的数据,如哈希码(Hash Code),GC分代年龄,锁状态标志,偏向线程ID、偏向时间戳等信息,它会根据对象的状态复用自己的存储空间。它是实现轻量级锁和偏向锁的关键。
Klass Pointer:存储指向方法区对象类型指针
Array Length:如果是数组,还包括数组长度
如果对象为非数组类型,用2字宽存储对象头。
如果对象为数组类型,用3字宽存储对象头。
在32位虚拟机中,1字宽等于4字节,即32bit。在64位虚拟机中,1字宽等于8字节,即64bit。如下表所示:
实例数据:
存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐;
对齐填充:
不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐。
对象头结构
Mark Word:
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。
32位JVM 的Mark Word的默认存储结构如下:
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变 化为存储以下4种数据
完整结构:
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
这里我们主要关注这3个部分:锁状态、是否偏向锁、锁标志位。
锁标记位(lock):该标记值表示对象锁的状态。
是否为偏向锁(biased_lock):对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
class pointer:
这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。
开启压缩指针(-XX:+UseCompressedOops) 关闭压缩指针(-XX:-UseCompressedOops)
,其中,oop即ordinary object pointer普通对象指针。
array length:
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。
synchronized锁
介绍完对象头,现在我们来介绍synchronized关键字。synchronized是对象锁,锁状态变化就体现在上面介绍的Mark Word中的偏向锁以及锁标志位。
锁升级介绍:
我们先介绍下锁升级过程。
JD6之后分为无锁,偏向锁,轻量级锁,重量级锁。其中偏向锁->轻量级锁->重量级锁的升级过程不可逆。
偏向锁:当一个线程第一次获取到锁之后,再次申请就可以直接取到锁
核心思想:
一开始无锁状态,JVM会默认开启“匿名”偏向的一个状态,就是一开始线程还未持有锁的时候,就预先设置一个匿名偏向锁,等一个线程持有锁之后,就会利用CAS操作将线程ID设置到对象的mark word 的高54位上【64位虚拟机】。如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。
轻量级锁:没有多线程竞争,但有多个线程交替执行。
轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁将会升级为轻量级锁,Mark Word 的结构也变为轻量级锁的结构。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
执行同步代码块之前,JVM会在线程的栈帧中创建一个锁记录(Lock Record),并将Mark Word拷贝复制到锁记录中。然后尝试通过CAS操作将Mark Word中的锁记录的指针,指向创建的Lock Record。如果成功表示获取锁状态成功。如果失败,则进入自旋获取锁状态。如果自旋获取锁也失败了,则升级为重量级锁,也就是把线程阻塞起来,等待唤醒。
自旋锁与自适应自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
自旋锁:许多情况下,当线程没有获得monitor对象的所有权时,就会进入阻塞,当持有锁的线程释放了锁,当前线程才可以再去竞争锁,但是如果按照这样的规则,就会浪费大量的性能在阻塞和唤醒的切换上,特别是线程占用锁的时间很短的话。
为了避免阻塞和唤醒的切换,在没有获得锁的时候就不进入阻塞,而是不断地循环检测锁是否被释放,这就是自旋。在占用锁的时间短的情况下,自旋锁表现的性能是很高的。
但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,那么自旋的次数就会变多,占用cpu时间变长导致性能变差。当然我们也可以设置自旋锁的自旋次数,当自旋一定的次数(时间)后就挂起。但是如果设置次数少了或者多了都会导致性能受到影响,所以在JDK1.6引入了自适应性自旋锁。
自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
表现是如果此次自旋成功了,很有可能下一次也能成功,于是允许自旋的次数就会更多,反之,如果很少有线程能够自旋成功,很有可能下一次也是失败,则自旋次数就更少。这样能最大化利用资源,随着程序运行和性能监控信息的不断完善,虚拟机对锁的状况预测会越来越准确,也就变得越来越智能。
重量级锁:有多线程竞争,线程获取不到锁进入阻塞状态。
重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。
锁升级验证
锁升级过程是非常复杂的,很多理论知识很难用实践验证,这里我们只验证锁状态的变化过程,也就是Mark Word中锁标志位的变化。
这里我们引用一个 Maven 依赖 jol(Java Object Layout),这个类提供了工具方法可以打印虚拟机状态。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
接下来查看虚拟机信息。
System.out.println(VM.current().details());
可以看到是64位的jvm,并且开启了对象指针压缩和类型指针压缩。
开启偏向锁
我们设计两个线程,线程0和线程1,分别获取对象锁,然后打印对象头看锁状态变化。
先看开启偏向锁的情况:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
无锁竞争,偏向锁->轻量级锁
执行结果:
这里先介绍下如何看对象头状态。前面说过64位jvm对象头的Mark Word占8个字节,所以这里05 00 00 00 00 00 00 00都是Mark Word。由于jvm采用大端模式存储字节,将高位字节存放在低地址,将低位字节存放在高地址,所以这里对照对象头表格来看要倒序,即00000101对应这8位。可以看到这时对象处于偏向锁状态。
分析执行结果:
线程0和线程1无并发冲突,线程0两次都是获取的偏向锁,验证了前面关于偏向锁的定义。线程1获取锁的时候发现当前占有锁的是线程0,于是升级为轻量级锁。
有锁竞争,偏向锁->重量级锁
执行结果:
分析执行结果:
线程0和线程1存在锁竞争,于是从偏向锁升级为重量级锁。
关闭偏向锁
-XX:-UseBiasedLocking:
无锁竞争,无锁->轻量级锁:
执行结果分析:
关闭偏向锁,默认是无锁状态。无锁竞争,从无锁状态升级为轻量级锁。
有锁竞争,无锁->重量级锁:
执行结果分析:
关闭偏向锁,默认是无锁状态。有锁竞争,从无锁状态升级为重量级锁。
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/282654.html