跟着锋哥学Java

深入浅出JVM(六)之对象的内存布局

1.对象的内存布局

  1.在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

2.对象内部结构分为:对象头、实例数据、对齐填充(保证8个字节的倍数)。

 3.数组对象与普通对象的内存结构区别在于数组的对象头里面多了一个数组的长度

 

 

 

 

 

 

 

 

 

 

 

1.1对象头

   1.对象头分为对象标记(markOop)和类元信息(klassOop),类元信息存储的是指向该对象类元数据(klass)的首地址。

    2.在64位系统中,Mark Word占了8个字节,类型指针占了8个字节,一共是16个字节

 

  1.1.1对象头的之MarkWord

       1.第对象标记(MarkWord)用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等

       2.默认存储对象的HashCode、分代年龄和锁标志位等信息;这些信息都是与对象自身定义无关的数据,所以MarkWord被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。

       3.它会根据对象的状态复用自己的存储空间,也就是说在运行期间MarkWord里存储的数据会随着锁标志位的变化而变

  

          1)无锁状态,就是普通对象的状态。一个对象被new出来以后,没有任何的加锁标记,这时候他的对象头分配是

   2)25位:用来存储对象的hashcode

   3)4位:用来存储分代年龄。之前说过一个新生对象的年龄超过15还没有被回收就会被放入到老年代。为什么年龄设置为15呢?因为分代年龄用4个字节存储,最大就是15了。

   4)1位:存储是否是偏向锁,2位:存储锁标志位

     1.1.2对象头的之Klass Pointer类型指针

       1.另外一部分是类型指针(类元信息),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,

       2.在64位机器下,类型指针占8个字节,但是当开启压缩以后,占4个字节

       3.一个对象new出来以后是被放在堆里的,类的元数据信息是放在方法区里的,在new对象的头部有一个指针指向方法区中该类的元数据信息。这个头部的指针就是Klass Pointer

        4.Klass Pointer类型指针的含义:Klass不是class,class pointer是类的指针;而Klass Pointer指的是底层c++对应的类的指针

        5.例如:当代码执行到math.compute()方法调用的时候,是怎么找到compute()方法的呢?

       ps:知道了math指向的对象的地址,再根据对象的类型指针找到方法区中的源代码数据,再从源代码数据中找到compute()方法(实际上就是通过类型指针去找到的)

public static void main(String[] args) {
      Math math = new Math();
      math.compute();
}

  5 .对于Math类来说,他还有一个类对象, 如下代码所示:

Class<? extends Math> mathClass = math.getClass();

ps:这个类对象是存储在哪里的呢?这个类对象是方法区中的元数据对象么?不是的。这个类对象实际上是jvm虚拟机在堆中创建的一块和方法区中源代码相似的信息

    

 6.那么在堆中的类对象和在方法区中的类元对象有什么区别呢?

     a.类的元数据信息是放在方法区的,堆中的类信息,可以理解为是类装载后jvm给java开发人员提供的方便的访问类的信息。

   b.通过类的反射我们知道,我们可以通过Math的class拿到这个类的名称,方法,属性,继承关系,接口等等。

     c.我们知道jvm的大部分实现是通过c++实现的,jvm在拿到Math类的时候,他不会通过堆中的类信息(上图堆右上角math类信息)拿到,而是直接通过类型指针找到方法区中元数据实现的,这块类型指针也是c++实现的。在方法区中的类元数据信息都是c++获取实现的。

     d.而我们java开发人员要想获得类元数据信息是通过堆中的类信息获得的。堆中的class类是不会存储元数据信息的。我们可以吧堆中的类信息理解为是方法区中类元数据信息的一个镜像。

   1.1.2代码证明

   

    说明:

       1.OFFSET 偏移量,也就是到这个字段位置所占用的byte数

  2.SIZE 后面类型的字节大小
       3.TYPE 是Class中定义的类型
       4.DESCRIPTION DESCRIPTION是类型的描述
       5.VALUE VALUE是TYPE在内存中的值

      6.前两行是对象头(Mark Word), 占用8个字节;

      7.第三行是Klass Pointer类型指针,占用4个字节,如果不压缩的话会占用8个字节;

      8.第四行是Object Alignment对象对齐,对象对齐是为了保证整个对象占用的位数是8的倍数。

      9.如果有数据还要加上数据的长度

   1.2实例数据

       1.存放类的属性(Field)数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

   1.3对齐填充

        1.虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐这部分内存按8字节补充对齐。

        2.在一个对象实例化的过程中,对齐填充本身并没有特殊的实际意义,目的仅仅是使得对象实例占用的空间是8字节的倍数。 如果一个对象实例后不是8字节的倍数,就会使用对齐填充来实现

        2.对齐填充的意义是 提高CPU访问数据的效率 ,主要针对会存在该实例对象数据跨内存地址区域存储的情况。

  1.3.1.为什么是8字节

      1.CPU读取内存数据的时候,并不是按照一个字节一个字节来读取,而是以字长为单位进行数据的访问。

      2.字长(Word Size):是指cpu一次能够并行处理的二进制位数,通常是8字节的整数倍。

      3.因此对齐填充在每一次填充对象内存占用空间时都为8字节的倍数,例如:在没有对齐填充的情况下,内存地址存放情况如下:

image.png

       因为处理器只能0x00-0x07,0x08-0x0F这样读取数据,所以当我们想获取这个long型的数据时,处理 器必须要读两次内存,第一次(0x00-0x07),第二次(0x08-0x0F),然后将两次的结果才能获得真正的数值。

那么在有对齐填充的情况下,内存地址存放情况是这样的:

          

 

 

 

现在处理器只需要直接一次读取(0x08-0x0F)的内存地址就可以获得我们想要的数据了。

    1.3.2 空间换时间

         1. 在一个64位的操作系统中,CPU访问内存读取数据的单位就是8字节。这样每次填充同样是8字节的倍数,在CPU访问内存数据时就可以减少访问次数,有效的提高CPU使用率

        2.其实像这样的填充虽然是无效的填充,但是这种空间换时间的思路却减少了cpu访问的次数,提高了cpu使用效率。

 1.4对象的指针压缩

         1.在开启指针压缩的情况下,占4字节(32bit),未开启情况下,占8字节(64bit,jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩

        2.指针压缩就是将Klass Pointer类型指针进行压缩

   3.vm配置参数: UseCompressedOops,compressed­­压缩、oop(ordinary object pointer)­­对象指针
  4..启用指针压缩:­XX:+UseCompressedOops(默认开启),禁止指针压缩:­XX:­UseCompressedOops,开启指针压缩占用4字节, 不开启占用8字节。

 

默认情况下是开启指针压缩的。上面分析过这个类结构,第三行是Klass Pointer类型指针,占用4个字节,如果不压缩的话会占用8个字节

   1.4.1  指针压缩的目的

         1.在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力

        2.为了减少64位平台下内存的消耗,启用指针压缩功能
        3.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
        4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
        5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好 

   2.对象的创建过程 

      2.1对象的创建主流程

        1. Java中对象的创建方式一般有两种 通过new关键字创建实例对象和通过反射创建对象。不管哪一种创建方式,jvm底层的执行过程是一样的。

        2.创建对象大致分为5步:1.检查类是否加载,没有加载先加载类 2.分配内存 3.初始化 4.设置对象头 5.执行初始化方法 例如构造方法等     

          

 3.类加载检查:当需要创建一个类的实例对象时,需要先判断该类是否被成功加载过了,若没有加载,要先进行类的加载,如果加载过了,会在堆区有一个类的class对象,方法区会有类的相关元数据信息。类的加载都是懒加载,只有当使用类的时候才会加载,所以先要有这个判断。

     4.分配内存:类加载成功后,jvm就能够确定对象的大小了,然后jvm会在堆内存划分一块对象大小的内存空间出来,分配给新生对象。

     5.初始化:  就是对分配的这一块内存初始化为零值,也就是给实例对象的成员变量赋值为零值,引用类型为null,int类型赋值为0等等操作。这样的话,对象就可以在没有赋值情况下使用了,只不过访问对象的成员变量都是零值。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

      6.设置对象头:初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。具体前面已经讲。

   2.2.jvm如何在堆中分配内存的呢?

          给对象分配内存有两种方式:一种是指针碰撞,另一种是空闲列表

      2.2.1 指针碰撞

            指针碰撞(Bump the Pointer),默认采用的是指针碰撞的方式。如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

           

 

 

       

 

 

 

 

       2.2.2空闲列表

        如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记 录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录

 2.3对象的逃逸分析

    2.3.1逃逸

    判断一个对象是否是逃逸对象,就看这个对象能否被外部对象访问到

  public User test1() {
        User user = new User();
        user.setId(1);
        user.setName("test");
        return user;
    }
    public void test2() {
        User user = new User();
        user.setId(2);
        user.setName("test");
    }

   1.Test里有两个方法,test1()方法构建了user对象,并且返回了user,返回回去的对象肯定是要被外部使用的。这种情况就是user对象逃逸出了test1()方法。

   2.test2()方法也是构建了user对象,但是这个对象仅仅是在test2()方法的内部有效,不会在方法外部使用,这种就是user对象没有逃逸

   3.Test2()方法的user对象只会在当前方法内有效,如果放在堆里,在方法结束后,其实这个对象就已经是垃圾的,但却在堆里占用堆内存空间。如果将这个对象放入栈中,随着方法入栈,逻辑处理结束,对象就变成垃圾了,再随着栈帧出栈。这样可以节约堆空间。尤其是这种非逃逸对象很多的时候。可以节省大量的堆空间,降低GC的次数

 2.3.2对象的逃逸分析

   1.就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

   2.简单的说对象的逃逸分析就是判断user对象是否会逃逸到方法外,如果不会逃逸到方法外,那么就建议在堆中分配一块内存空间,用来存储临时的变量。

 public User test1() {
        User user = new User();
        user.setId(1);
        user.setName("test");
        return user;
    }
    public void test2() {
        User user = new User();
        user.setId(2);
        user.setName("test");
    }
很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内存一起被回收掉。
   3.JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力
   4.JVM对于开启逃逸参数:-XX:+DoEscapeAnalysis,关闭参数:-XX:-DoEscapeAnalysis,JDK7之后默认开启逃逸分析
   5.这说明了JVM在逃逸分析之后,栈上的对象在方法执行完之后,栈桢弹出,对象就会自动回收。这样的话就不需要等内存满时再触发内存回收。这样的好处是程序内存回收效率高,并且GC频率也会减少,程序的性能就提高了。

2.4标量替换

     1.如果有一个对象,通过逃逸分析确定在栈上分配了,以User为例,为了能够在有限的空间里能够放下User中所有的东西,我们不会在栈上new一个完整的对象了,而是只是将对象中的成员变量放到栈帧里面去

    2.栈帧空间中没有一块完整的空间放User对象,为了能够放下,我们采用标量替换的方式,不是将整个User对象放到栈帧中,而是将User中的成员变量拿出来分别放在每一块空闲空间中。这种不是放一个完整的对象,而是将对象打散成一个个的成员变量放到栈帧上,当然会有一个地方标识这个属性是属于那个对象的,这就是标量替换。

   3.通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配了。

   4.开启标量替换参数是:开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认

   2.5标量与聚合量

  标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等)标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量

温馨提示:关注微信公众号,有更多优质文章等着你

posted on 2022-05-20 16:08  跟着锋哥学Java  阅读(530)  评论(0编辑  收藏  举报

导航