Java对象头Object Header、偏向锁、轻量锁、重量锁研究

1 JAVA对象头的组成

1.1 理论研究

我们都知道,Java对象存储在堆(Heap)内存。那么一个Java对象到底包含什么呢?概括起来分为对象头、对象体和对齐字节。如下图所示:

img

对象的几个部分的作用:

1.对象头中的Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode;

2.Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例;

3.数组长度也是占用64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;

4.对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;

5.对齐字节,是为了因为对象的大小必须是Word字长的倍数(64bit的JVM其word长度为64bit既8字节)所以需要一定的字节进行拼凑。

从官方文档:http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html
在这里插入图片描述

里面有关于Object header的表述。翻译大概如下:

object header: 每个被gc管理的堆对象在内存中都具有一个开始的公共结构,既对象头。每个oop(指向对象的指针)会指向一个对象头实现对object的引用。对象头包括关于堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息。由两个Word组成。若对象为数组,紧随其后的是数字长度字段。注意,Java对象和vm内部对象都有通用的对象头格式。

上面提到ojbect header的包括以下内容:

  • mark word是对象的第一个word(长度64bit),是一些bit域来存放对象的sync同步锁状态、hashcode,已经同步锁相关的新,GC信息状态等。
  • klass pointer是对象的第二个word(长度也是64bit),存储指针指向对象原型定义(Java类的内部表示,在Metaspace元数据区)。 虽然通常是64bit,但如果JVM启用了JVM指针压缩机制,则实际可能被压缩为32bit。
  • 若对象为数组,紧随其后的是数字长度字段。

注意:关于word字长。这里的word是一个抽象的概念,不同的地方有不同的含义,不同的长度。

比如cpu字长是指cpu同时参与运算的二进制位数,现在主流的pc的机器字长都是64bit的。机器字长直接决定着机器可寻址的虚拟空间地址大小。

对于jvm的字长,jvm specification e7并没有找到jvm定义字长,但是在《深入Java虚拟机》这本老经典里头,看到作者写到

The basic unit of size for data values in the Java virtual machine is the word--a fixed size chosen by the designer of each Java virtual machine implementation(字长选择取决于JVM的实现者). The word size must be large enough to hold a value of type byte, short, int, char, float, returnAddress, or reference. Two words must be large enough to hold a value of type long or double. An implementation designer must therefore choose a word size that is at least 32 bits(字长至少为32bit), but otherwise can pick whatever word size will yield the most efficient implementation. The word size is often chosen to be the size of a native pointer on the host platform.  
  
The specification of many of the Java virtual machine's runtime data areas are based upon this abstract concept of a word. For example, two sections of a Java stack frame--the local variables and operand stack-- are defined in terms of words. These areas can contain values of any of the virtual machine's data types. When placed into the local variables or operand stack, a value occupies either one or two words.  
  
As they run, Java programs cannot determine the word size of their host virtual machine implementation. The word size does not affect the behavior of a program. It is only an internal attribute of a virtual machine implementation.  

一般字长选择都根据底层主机平台的指针长度来选择,而指针长度是由cpu运行模式的寻址位数决定的,所以64位的物理机器上,运行64位OS,安装64位JVM的话,其Word的大小通常也是64bit的(当然这根实际使用内存大小jvm参数设置相关)。 这里可以记住一个经验值:对于64bit的JVM,每个Word大大小通常就是64bit。

1.2 对象头结构

通过源代码src/share/vm/oops/markOop.hpp文件分析(http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/markOop.hpp),按照big endian大端顺序的object header如下:

object header Mark word(64bit) klass pointer(64bit)
normal object 普通对象,无锁。 unused:25|identity_hashcode:31|unused:1|age:4|biased_lock:1|lock:2 oop to metadata object 指向对象的元定义,可能会被指针指针压缩
biased object 带有偏向锁的对象 thread:54 |epoch:2 |unused:1|age:4|biased_lock:1|lock:2 oop to metadata object 指向对象的元定义,可能会被指针指针压缩
带有轻量锁的对象 prt_to_lock_record:62 |lock:2 oop to metadata object 指向对象的元定义,可能会被指针指针压缩
带有重量锁的对象 prt_to_heavyweigth_monitor:62 |lock:2 oop to metadata object 指向对象的元定义,可能会被指针指针压缩
GC |lock:2 oop to metadata object 指向对象的元定义,可能会被指针指针压缩

java的对象头在对象的不同状态下会有不同的表现形式,主要有三种状态,无锁状态、加锁状态(又细分为偏向锁、轻量锁、重量锁)、gc标记状态。

1.2.1 markword结构

概括起来markword中各个的含义为:

  • unused:表示未使用。

  • identity_hashcode:31位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算后,才会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。

  • 4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。

  • biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock共同表示对象处于什么锁状态。

  • lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个Mark Word表示的含义不同。biased_lock和lock一起,锁状态含义不同情况表示的含义如下表

    img

  • thread:持有偏向锁的线程ID。

  • epoch:偏向锁的时间戳。

  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。

  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。

1.2.2 Klass point(类指针)

​ 这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:

  • 每个Class的属性指针(即静态变量)
  • 每个对象的属性指针(即对象变量)
  • 普通对象数组的每个元素指针

当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向Metaspace的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。

1.2.3 数组长度

如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。

1.3 锁类型

我们通常说的通过synchronized实现的同步锁,真实名称叫做重量锁(在Java 1.6之前)。重量锁会直接使用操作系统的底层的锁,会造成线程排队(串行执行),且会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。为了提高效率,从Java 1.6开始,JVM进行了优化,synchronized不一定直接使用重量锁,引入了偏向锁。具体为:

​ 建议先看一文《图解 偏向锁,轻量锁,重量锁》:https://xvshu.blog.csdn.net/article/details/88039489

1.3.1 偏向锁(Biased Locking)

偏向锁是jdk1.6引入的一项锁优化,其中的“偏”是偏心的偏。它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。

也就是说:

在此线程之后的执行过程中,如果再次进入或者退出同一段同步块代码,并不再需要去进行加锁或者解锁操作,而是会做以下的步骤:

  1. 判断一下当前线程id是否与对象的Markword当中的线程id是否一致.
  2. 如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码.
  3. 如果不一致,则要检查一下对象是否还是可偏向,即“是否偏向锁”标志位的值。
  4. 如果还未偏向,则利用CAS操作来竞争锁,也即是第一次获取锁时的操作。
  5. 如果此对象已经偏向了,并且不是偏向自己,则说明存在了竞争。此时可能就要根据另外线程的情况,可能是重新偏向,也有可能是做偏向撤销,但大部分情况下就是升级成轻量级锁了。

可以看出,偏向锁是针对于单个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。

为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。

JVM启动时,偏向锁的开关是默认开启的,可以使用-XX:+UseBiasedLocking 开启和关闭。

JVM启动时,偏向锁立即使用而且启动几秒后延迟才能使用。可以使用-XX:BiasedLockingStartupDelay设置秒数,默认为4秒。

一旦对象计算了hashcode,就不能使用偏向锁。因为markword的数据被占用了。

1.3.2 轻量锁

偏向锁假设只有单线程访问,如果真的只有单线程下则避免了资源同步,提高了性能。 但是万一不止一个线程范围,还有其他线程访问,那么偏向锁无法解决,则需要升级为轻量锁。

​ 顾名思义,轻量是相对于重量锁,使用轻量锁时,不需要申请操作系统的互斥量(mutex)。而是将mark word中的信息复制到当前线程的栈中,然后通过cas尝试修改mark word并替换成轻量锁,如果替换成功则执行同步代码。如果此时有线程2来竞争,并且他也尝试cas修改mark word但是失败了,那么线程2会进入自旋状态(既自旋锁)。

  • 自旋锁

    所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。 经验表明,大部分同步代码块执行的时间都是很短很短的,也正是基于这个原因,才有了轻量级锁这么个东西。

    自旋锁的一些问题
    1. 如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时侯,其他线程在原地等待空消耗cpu,这会让人很难受。
    2. 本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁。

    基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁

    自旋锁是在JDK1.4.2的时候引入的

    默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin来进行更改。

    自适应自旋锁

    所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。

    其大概原理是这样的:

    假如一个线程A刚刚成功获得一个锁,当它把锁释放了之后,线程B获得该锁,并且线程B在运行的过程中,此时线程A又想来获得该锁了,但线程B还没有释放该锁,所以线程A只能自旋等待,但是虚拟机认为,由于线程A刚刚获得过该锁,那么虚拟机觉得线程A这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程A自旋的次数

    另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源。

    轻量级锁也被称为非阻塞同步乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待,串行执行。

1.3.3 重量锁

轻量级锁膨胀之后,就升级为重量级锁了。重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。。

为什么说重量级锁开销大呢

主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。这就是说为什么重量级线程开销很大的。

互斥锁(重量级锁)也称为阻塞同步悲观锁

1.3.4 锁类型总结

**为什么JVM要优化synchronized,是因为发现在实际情况情况资源争用,不一定都要使用重量锁才能解决,所有才在Java1.4引入了轻量锁,1.6引入了偏向锁。**所以,才有了锁逐步升级,JVM的想法是先尽量尝试性能高效的,如果不行再逐步升级。也是从偏向锁,轻量级锁,再到重量级锁的过程。

  1. 先假设只有单线程访问(偏向锁),这样连同步都省略了。
  2. 万一不是单线程访问,则升级为轻量锁。通过自旋反复尝试(自旋是非阻塞的,需要消耗CPU),这时再JVM内部,无需借助操作系统的MutexLock。
  3. 如果多次自旋还还不行,就升级为重量锁。借用操作系统的MutexLock,避免消耗CPU,使用MutexLock的阻塞或者唤醒机制。

这个过程也告诉我们,假如我们一开始就知道某个同步代码块的竞争很激烈、很慢的话,那么我们一开始就应该使用重量级锁了,从而省掉一些锁转换的开销。

1.4 锁升级

JVM的想法是先尽量尝试性能高效的,如果不行再逐步升级。也是从偏向锁,轻量级锁,再到重量级锁的过程,如下步骤:

  1. 初期对象刚创建时,还没有任何线程来竞争,对象的Mark Word是下图的第一种情形,这偏向锁标识位是0,锁状态01,说明该对象处于无锁状态(无线程竞争它)。 其中hashCode需要调用hashCode()方法后才会赋值。

  2. 当有一个线程来拿本对象的锁,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。这时Mark Word会记录自己偏爱的线程的ID,把该线程当做自己的熟人。如下图第二种情形。

  3. 当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。如下图第三种情形。

  4. 如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程。如下图第四种情形。

    img

2 JOL分析工具和环境

​ JOL全称为Java Object Layout,是分析JVM中对象布局的工具,该工具大量使用了Unsafe、JVMTI来解码布局情况,所以分析结果是比较精准的。使用该工具,只需要将jol的jar报文引入工程即可。具体如下:

已经安装JDK 64bit后,在eclipse新建一个Maven工程。然后在pom.xml加入以下引用,以使用JOL工具。

		<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
		<dependency>
		    <groupId>org.openjdk.jol</groupId>
		    <artifactId>jol-core</artifactId>
		    <version>0.10</version>
		</dependency>

新建一个被分析的类CA.java,只定义简单的两个属性, 代码如下:

package com.zyp.StudyObjHeader;

public class CA {
   
    private boolean flag = false;
    private int number = 16;
}

再新建一个测试类JOLExample1.java,代码如下:


                
  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Synchronized 是 Java 中用于实现线程同步的关键字,它可以保证在同一时刻只有一个线程可以访问被 Synchronized 修饰的代码块或方法。在实现线程同步时,Synchronized 使用了不同的,包括偏向重量偏向是一种针对单线程场景的优化,它会在第一次获取时,将当前线程 ID 记录在对象中,之后该线程再次获取时,无需竞争资源,直接获取即可。举个例子: ```java public class BiasLockExample { private static Object lock = new Object(); public static void main(String[] args) { long start = System.currentTimeMillis(); for (int i = 0; i < 100000000; i++) { synchronized (lock) { // do something } } System.out.println("Time cost: " + (System.currentTimeMillis() - start) + "ms"); } } ``` 在以上代码中,由于所有的都是在同一个线程中获取的,因此会使用偏向进行优化,从而提高了程序的执行效率。 是一种适用于竞争不激烈的场景的,它会在第一次获取时,将对象中的信息复制到线程栈中,然后通过 CAS 操作来更新对象中的信息,如果更新成功,则表示该线程获取了,如果失败,则需要升级为重量。举个例子: ```java public class LightweightLockExample { private static Object lock = new Object(); public static void main(String[] args) { new Thread(() -> { synchronized (lock) { System.out.println(Thread.currentThread().getName() + " got the lock"); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); new Thread(() -> { synchronized (lock) { System.out.println(Thread.currentThread().getName() + " got the lock"); } }).start(); } } ``` 在以上代码中,由于第一个线程获取后会休眠 5 秒钟,因此第二个线程需要等待第一个线程释放之后才能获取,而这时就会使用进行优化。 重量是一种适用于竞争激烈的场景的,它会导致线程阻塞,从而消耗大的系统资源。举个例子: ```java public class HeavyweightLockExample { private static Object lock = new Object(); public static void main(String[] args) { new Thread(() -> { synchronized (lock) { System.out.println(Thread.currentThread().getName() + " got the lock"); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); new Thread(() -> { synchronized (lock) { System.out.println(Thread.currentThread().getName() + " got the lock"); } }).start(); } } ``` 在以上代码中,由于两个线程同时竞争同一个,因此会使用重量进行优化,从而导致第二个线程需要等待第一个线程释放之后才能获取,这样会导致线程阻塞,从而影响程序的执行效率。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值