Java虚拟机中对象探秘--对象头创建、对象头、对象锁、synchoronized底层实现


今天看别人的博客,讲到面试相关的问题,其中有一个知识点是:synchronized关键字,Java对象头、Markword概念、synchronized底层实现,monitorenter和monitorexit指令,一脸蒙逼,虽然早期把《深入理解Java虚拟机》这本书看过一遍了,可是发现当时只是走马观花的看,对于这些知识点,还是不知道,今天特意把Java对象这一节再次重新读一遍,加深记忆。对上面这些知识点就立马懂了。

1. 对象的创建

创建对象(如克隆、反序列化)通常仅仅一个new关键字,但在虚拟机中,对象的创建的过程需要如下步骤:

1.1 类加载检查

先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析、初始化过,若没有,则必须先执行相应的类加载过程。

1.2 为新生对象分配内存

对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。根据java堆内存是否绝对规整,划分方法不同:
1)指针碰撞(Bump the Pointer): Java堆中内存绝对规整(所有用过的内存放在一边,空闲的内存放在另一边,中间放一个指针作为分界点的指示器),所分配的内存仅需要把指针向空闲空间那边挪动一段与对象大小相等的距离。
2)空闲列表(Free List),Java堆中内存并不规整(已使用的内存和空闲的内存相互交错),虚拟机通过维护一个记录哪些内存可用的列表,在分配时从列表中找到一块中够大的空间划分给对象实例,并更新列表上的记录。

分配方式由Java堆是否规整决定,而是否规整由所采用的垃圾惧器是否带有压缩整理功能决定。

【注】由于对象创建在虚拟机中是非常频繁的行为,仅修改一个指针所指向的位置,并发情况下也不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存情况。解决方案有两种:
1)对分配内存空间的动作进行同步处理,虚拟机采用CAS配上失败重试方式保证更新操作的原子性;
2)本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),给每个线程在Java堆中预先分配一小块内存,哪个线程要分配内存,就在哪个线程的TLAB上分配,TLAB用完分配新的时才需要同步锁定。可以通过- XX:+/-UseTLAB参数来设定是否使用TLAB。

1.3 将分配的内存空间初始化为零值(不包括对象头)

若使用TLAB,可以提前至TLAB分配时进行。这一步操作保证了对象实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

1.4 对对象头进行必要设置

如这个对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。根据虚拟机当前的运行状态不同,如是否启用偏向锁等,对象头会有不同的设置方式。关于对象头见下面 2.1对象头

1.5 执行方法

把对象按照程序员意愿进行初始化,即执行方法。

2. 对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分成三块:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

2.1 对象头

对象头分成两部分信息:

2.1.1 存储对象自身的运行时数据

如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据长度在32位和63位的虚拟机中分别为32bit和64bit,官方称它为"Makr Word"。对象要存储的运行时数据很多,其实已超出32位、64位Bitmap结构所以记录的限度,但对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息。它会根据对象状态复用自己的存储空间。

下面表示32位HotSpot虚拟机中,如果对象处理未被锁定状态,Mark Word的32位空间分成:25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下:

2.1.1 类型指针

类型指针即代表对象指向它的类元数据的指针,虚拟机通过这个指针来确定该对象是哪个类的实例。但查找对象的元数据信息并不一定要经过对象本身。如果对象是一个Java数组,在对象头中还须有一块用于记录数组长度的数据,因为虚拟机可通过普通Java对象的元数据信息确定Java对象的大小,但从数组的元数据中无法确定数组的大小。

2.2 实例数据

真正存储有效信息,即程序代码中所定义的各种类型的字段内容,包括父类继承下来的和子类定义的。这部分存储顺序受虚拟机分配策略参数(FiledsAllocationStyle)和字段在Java源码中定义的顺序的影响。HotSpot虚拟机默认分配策略为longs/double、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略可以看出,相同宽度的字段总是被分配到一起。在此前提条件下,父类中定义的变量会出在子类之前,若CompactFileds的参数值为true,那么子类中较窄的变量也可能会插到父类变量的空隙中。

2.3 对齐填充

不是必然存在的,仅起着占位符作用。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象大小必须是8字节的整数倍,而对象头正好是8字节的整数倍,而对象实例数据部分没有对齐,需要通过对齐填充来补全。

3. 对象的访问定位

建立对象是为了使用对象,Java程序需要通过栈上的引用数据来操作堆上的具体对象,由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中对象的具体位置,故对象访问方式取决于虚拟机实现。目前主要有两种方式:

3.1 使用句柄

Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息。如下图所示:

3.2 直接指针

Java堆对象的布局中需要放置访问类型数据的相关信息,reference中存储的直接就是对象的地址,如下图所示:

4. synchronized底层实现原理

4.1 synchronized使用场景

synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。synchronized的作用主要有三个:(1)确保线程互斥的访问同步代码(2)保证共享变量的修改能够及时可见(3)有效解决重排序问题。从语法上讲,它总共有三种使用场合:

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁

  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

4.2 synchronized底层实现原理

由2.1介绍Java对象头知道,在Java对象头中有2bit用来标识对象存储锁的,可知锁对象是存储在Java对象头里的。synchronized的对象锁对应的重量级锁,锁标识位为10, 其中指针指向的monitor对象(管程或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联,对象与其monitor间关系存在多种实现方式,monitor可以与对象一起创建或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处理锁定状态。

4.2.1 synchronized作用于实例方法

synchronied修饰实例对象中的实例方法,不包括静态方法,如下:

public synchronized void increase()
{        
    i++;    
}

使用javap反编译后的字节码如下:

//省略没必要的字节码
  //==================increase方法======================
  public synchronized void increase();
    descriptor: ()V
    //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 12: 0
        line 13: 10
}

从字节码可知JVM采用ACC_SYNCHRONIZED标识指明该方法是一个同步方法,从而执行相应的同步调用。

4.2.2 synchronized作用于代码块

public class SyncCodeBlock {

   public int i;

   public void syncTask(){
       //同步代码库
       synchronized (this){
           i++;
       }
   }
}

使用javap反编译后得到字节码如下:

//===========主要看看syncTask方法实现================
  public void syncTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter  //注意此处,进入同步方法
         4: aload_0
         5: dup
         6: getfield      #2             // Field i:I
         9: iconst_1
        10: iadd
        11: putfield      #2            // Field i:I
        14: aload_1
        15: monitorexit   //注意此处,退出同步方法
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit //注意此处,退出同步方法
        22: aload_2
        23: athrow
        24: return
      Exception table:
      //省略其他字节码.......
}

从字节码中可知同步语句块实现是使用monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取objectref(对象锁)所对应的monitor的持有权。直到线程执行完毕,monitorexit指令执行,释放monitor。

【后记】根据前面讲的Java对象头知识,我们知道还有偏向锁、轻量级锁,这些都是相对于synchronized这样的重量级锁的优化,但这不是本文关注的重点,想了解更多,可以查看这篇文章深入理解Java并发之synchronized实现原理


原文链接:https://blog.csdn.net/smileiam/article/details/80364641