Java对象的深度剖析

1,050 阅读20分钟

对象的创建过程

image.png

  • 1.检查类是否被加载: 当jvm遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  • 2.分配内存: 给这个对象划分一块空间,这个空间的大小是通过常量池里的类元信息来确定的。
  • 3.初始化: 给对象的成员变量赋默认值,int = 0,boolean = false等,保证了对象的实例字段在Java代码中可以不赋初始值就直接使用。
  • 4.设置对象头: 将KlassPointer、MarkWord、数组长度(只有数组对象有)等信息封装到对象头里面。
  • 5.执行init方法: 这个init方法是C++里面对应的那个用来创建java对象的struct对象的方法,这个方法会给对象的属性赋值,调用java对象的构造器。

内存的分配方式

内存划分

针对堆内存现有的规整情况不同,给对象分配内存的方式也不同,这和具体的垃圾回收器有关系。默认情况下第一次分配内存或者内存结构很规整的情况下使用指针碰撞,如果内存不规整的话使用空闲列表

  • 指针碰撞: 将已使用过的内存和未使用过的内存各放一边,使用一个已使用的内存的偏移量的指针来指向最近分配过的内存的地址,如果有新的对象需要分配内存则指针会向后移动对象大小的空间。
  • 空闲列表: 在虚拟机内部维护了一个列表,记录着哪些内存是可用的,在分配内存的时候从列表中找到一块对象大小的空间划分给对象,并更新列表上的记录。

并发处理

两种分配内存的方式都会遇到并发分配内存的线程安全问题,针对这种问题也有2种解决办法,循环CAS本地线程分配缓冲(TLAB)

  • 循环CAS: 使用过多线程的同学都知道这种方式,就是把内存中的预期值拿出来进行更新,在更新之前再判断一下当内存中现有的值和预期值是否一致,不一致则重新获取预期值,一致的话直接进行更新。但是这种方式会出现因自旋太久带来的cpu开销问题,所以默认使用的是TLAB方式解决。
  • 本地线程分配缓冲(Thread Local Allocation Buffer): 为每个线程都预先分配一块空间去划分内存,每个线程来了都从自己的空间里去分配内存。如果预留的内存不够划分则会回退到CAS的方式;可以通过-XX:TLABSize=xx来设置预留的大小,避免回退到CAS。

补充:这里我们要注意一个细节:对象的半初始化问题。

对象的组成结构

我们java的对象不仅仅只有成员变量,这个层面的理解太浅了,实际上java的对象包含了3个部分:对象头实例数据对齐填充

image.png

  • 对象头:对象头是Java对象中非常重要的一部分,他存储了对象的各种底层信息:比如MarkWord、KlassPointer、数组长度
  • 实例数据:实例数据就是我们对象拥有的属性。
  • 对其填充:因为是64bit操作系统,内存的宽度是64bit也就是8字节,8的整数倍寻址会更高效,对对象大小进行补位的,有中间对齐和尾部对齐。

对象头

下面我们深入对象头看看,到hotspot源码中找到markOop.hpp文件,看下注释怎么描述对象头的:

// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

上面分别是32位和64位的对象头信息,64位系统的MarkWord在对象头中占64位,我们详细来分析一下对象头里面有哪些东西

image.png

从上面的图中可以发现在markword中对象处于不同的状态下,它内部的结构也是不一样的,本篇文章以无锁状态进行分析:无锁偏向锁状态下用4bit来存储对象的分代年龄,默认情况下是0000, 最大值只能是1111,也就是15,之前的章节我们说过对象在躲过15次GC依然存活的话,就会被移到老年代,好像和这里的15刚好吻合,这里我们可以留一个大胆的猜想:GC回收的年龄就是通过对象头里面的MarkWord进行标识的

我们可以通过代码看一下对象的组成结构:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
public static void main(String[] args) {
    ClassLayout layout = ClassLayout.parseInstance(new Object());
    System.out.println(layout.toPrintable());
}

运行结果: image.png 把结果复制出来,和上面的MarkWord图对比着看一下:

 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)

前8个字节是markword,它的值是:00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000。其中01是锁标志位,前面的0表示是否是偏向锁,我们这个对象是没有加锁的,所以这个地方是0。后4个字节是类型指针,理论上在64bit操作系统中它应该是8个字节才对,但是因为jvm默认开启的指针压缩,所以它的大小和32bit大小一样。可以通过:-XX:-UseCompressedOops来关闭,关闭之后我们看一下它的值:

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           00 1c 39 7e (00000000 00011100 00111001 01111110) (2117671936)
     12     4        (object header)                           b7 01 00 00 (10110111 00000001 00000000 00000000) (439)
Instance size: 16 bytes

关闭指针压缩之后,类型指针的大小就变成了16byte。

指针压缩

指针压缩是jdk1.6之后针对64位机器采取的一种内存优化措施,当堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间,当堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存建议不要大于32G。

  • 压缩范围:
  1. 对象的全局静态变量(即类属性)
  2. 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
  3. 对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
  4. 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
  • 为什么要进行指针压缩:
  1. 将对象的指针进行压缩,对象存储在堆中占用的内存就会很少,GC发生的频次就低,相同时间下可以存储更多的对象。
  2. 在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)。

申请内存的过程

潜意识里,我们都认为只要new对象,都会放在堆内存里。如果我换种方式问你:new出来的对象一定是在堆里面吗?不一定吧?

对象栈上分配

如果所有对象都在堆中进行分配,当对象没有被引用的时候,GC对于对象的回收会产生大量的STW,性能下降,hotspot这么强大的研发团队怎么会意识不到这个问题呢,所以在jdk1.7版本及之后的版本中对对象的分配做了优化,尽可能的让对象分配在栈内存中,这样就会减少GC的回收压力;但是对象要分配在栈中要同时满足逃逸分析标量替换。默认是开启的,可以通过以下参数关闭,关闭逃逸分析:-XX:-DoEscapeAnalysis;关闭标量替换:-XX:-EliminateAllocations

  • 逃逸分析: 分析对象动态作用域,当一个对象在方法中被定义后,如果会被外部方法引用,比如Person p = createPerson(); 这个p对象是createPerson方法内部创建的,被外部引用的,这种情况属于对象逃逸出方法外;否则对象就没有逃逸;针对没有逃逸的对象就会进行优化。

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

通过下面的例子演示一下对象是怎么在栈上分配的,先关闭标量替换,看一下优化之前的GC情况:

// -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC
public static void main(String[] args) {
    for (int i = 0; i < 100000000; i++) {
        allocate();
    }
}
public static void allocate() {
    Person person = new Person();
    person.setId(1);
    person.setName("zhangsan");
}

控制台会打印很多次GC日志:

image.png 我们把-XX:-DoEscapeAnalysis这个参数去掉,再看一下结果:

image.png GC只执行了一次,这很正常,在JVM启动的时候内部也会创建一些对象,很明显和上面的结果不同,说明我们的对象没有逃逸,直接在栈上分配了。

对象Eden区分配

eden区是对象分配在堆内存的情况下大多数优先分配的空间。如果没有剩余空间则会进行一次MinorGC,将剩余对象复制到另外一块survivor区,默认情况下eden区和survivor区的空间比例是8:1:1,这是通过-XX:+UseAdaptiveSizePolicy这个参数设置的,默认是开启的。我们可以通过下面的例子看一下对象的分配情况:

// -XX:+PrintGCDetails
public static void main(String[] args) {
    byte[] allocation1, allocation2;
    allocation1 = new byte[1024 * 60000];
}

输出如下结果:

Heap
 PSYoungGen      total 76288K, used 65536K [0x000000076b200000, 0x0000000770700000, 0x00000007c0000000)
  eden space 65536K, 100% used [0x000000076b200000,0x000000076f200000,0x000000076f200000)
  from space 10752K, 0% used [0x000000076fc80000,0x000000076fc80000,0x0000000770700000)
  to   space 10752K, 0% used [0x000000076f200000,0x000000076f200000,0x000000076fc80000)
 ParOldGen       total 175104K, used 0K [0x00000006c1600000, 0x00000006cc100000, 0x000000076b200000)
  object space 175104K, 0% used [0x00000006c1600000,0x00000006c1600000,0x00000006cc100000)
 Metaspace       used 3301K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 359K, capacity 388K, committed 512K, reserved 1048576K

仔细分析一下:eden区被使用空间已经100%,from和to就是两个survivor区,也可以叫做s0和s1,他俩的使用率都是0,再看老年代的使用也是0;改一下上面的代码,看看会出现什么现象:

// -XX:+PrintGCDetails
public static void main(String[] args) {
    byte[] allocation1, allocation2;
    allocation1 = new byte[1024 * 60000];
    allocation2 = new byte[1024 * 30000];
}

输出结果:

[GC (Allocation Failure) [PSYoungGen: 65245K->776K(76288K)] 65245K->60784K(251392K), 0.0247767 secs] [Times: user=0.00 sys=0.02, real=0.03 secs] 
Heap
 PSYoungGen      total 76288K, used 31431K [0x000000076b200000, 0x0000000774700000, 0x00000007c0000000)
  eden space 65536K, 46% used [0x000000076b200000,0x000000076cfefef8,0x000000076f200000)
  from space 10752K, 7% used [0x000000076f200000,0x000000076f2c2020,0x000000076fc80000)
  to   space 10752K, 0% used [0x0000000773c80000,0x0000000773c80000,0x0000000774700000)
 ParOldGen       total 175104K, used 60008K [0x00000006c1600000, 0x00000006cc100000, 0x000000076b200000)
  object space 175104K, 34% used [0x00000006c1600000,0x00000006c509a010,0x00000006cc100000)
 Metaspace       used 3302K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 359K, capacity 388K, committed 512K, reserved 1048576K

eden区46%,from区7%,to区0%,老年代34%,为什么会这样子呢?
看上面的信息发现eden区是65M左右,from和to各10M左右;当执行allocation1 = new byte[1024 * 60000];的时候对象优先在eden区分配60M空间,此时eden区域已经满了(eden区可能也会存在一些jdk内部的一些对象,所以eden区会放满),紧接着又执行allocation2 = new byte[1024 * 30000]; 这个allocation2对象大小是30M,也要往eden区放,因为eden已经满了,所以执行了一次MinorGC,准备将eden区原有的对象放到了survivor区,但是此时survivor区是放不下60M的对象的,所以被移动到了老年代,因为老年代的空间比较大所以存放对象之后,used就变成了34%。再将allocation2的大概30M对象放入eden区。from区的7%是jdk内部的一些其他对象。

大对象直接进老年代

JVM对于大对象的定义是申请一块连续内存且内存大小大于-XX:PretenureSizeThreshold参数的值,如果大于这个大小的对象需要回收的话,会进行大量的内存复制,导致年轻的STW也会很长,所以针对这种情况,hotspot的实现中直接将这样的对象放入老年代,给年轻代更大的空间。注意:这种机制只支持SerialParNew回收器。
下面一段代码演示一下对象直接分配到老年代的效果:

public static void main(String[] args) {
    byte[] bytes = new byte[1024 * 1000 * 1024 * 600000];
}

输出结果:

Heap
 PSYoungGen      total 76288K, used 6556K [0x000000076b200000, 0x0000000770700000, 0x00000007c0000000)
  eden space 65536K, 10% used [0x000000076b200000,0x000000076b867130,0x000000076f200000)
  from space 10752K, 0% used [0x000000076fc80000,0x000000076fc80000,0x0000000770700000)
  to   space 10752K, 0% used [0x000000076f200000,0x000000076f200000,0x000000076fc80000)
 ParOldGen       total 1748480K, used 1572864K [0x00000006c1600000, 0x000000072c180000, 0x000000076b200000)
  object space 1748480K, 89% used [0x00000006c1600000,0x0000000721600010,0x000000072c180000)
 Metaspace       used 3302K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 359K, capacity 388K, committed 512K, reserved 1048576K

可以看到老年代直接占用89%,占用的空间大概是我们执行的这段代码。如果老年代也放不下的话会先执行一次FullGC,对老年的垃圾做一次回收,如果还没有回收出来可用的空间的话就会出现我们经常说的Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

看完以上的知识点我们可以梳理出来一个对象分配的流程图,如下所示:

image.png

老年代空间分配担保机制

上面已经说过了,当对象往eden区分配内存的时候,如果eden区已经满了,会执行一次MinorGC,其实在执行MinorGC之前还有一步很重要的判断:年轻代里所有的对象大小之和是否小于老年代的可用空间大小,为什么要做这个判断呢,在极端情况下,很有可能年轻代里面所有的对象都不是垃圾,会导致所有对象都进入老年代,如果老年代放不下年轻代的全部对象,会接着判断老老年代的可用空间是否大于以前年轻代对象移入老年代的平均大小,这一步是根据-XX:-HandlePromotionFailure参数来的,默认开启,如果放不下则会触发一次Full GC,对年轻代,老年代,方法区都进行一次垃圾回收,回收之后如果还放不下那就OOM。

  • 为什么要有间分配担保机制:
    还是jvm内部的优化机制,尽量减少Full GC的频率, 尽量让对象放入老年代的时候不触发GC,通过各种判断各种策略如果对象还是无法放入老年代的话,那没办法了,只能GC了。
    老年代空间分配担保机制的过程: image.png

内存回收

判定垃圾的方式

我们都知道的一个概念就是如果一个对象变成垃圾的时候就会被进行回收,那么对于垃圾是怎么定义的,什么样的对象才算是垃圾呢,通过引用计数法根可达算法进行判定。

  • 引用计数法:一旦有对象被其他对象引用,那么就给这个对象加的引用值+1,当引用被释放的时候就给引用值-1;当引用值等于0的时候,说明该对象就是垃圾了。但是这样会存在一个问题:循环引用:比如A引用了B,B又引用了A,但是他俩没有别的对象去引用,他俩都是垃圾,这种问题可以通过Recycler算法解决,但是性能不高,没必要。
  • 根可达算法(Hotspot默认):以线程栈的本地变量、静态变量、本地方法栈的变量作为GC Root, 从这个起点开始向下搜索引用的对象并进行标记,没有标记的对象就是垃圾了。

对象的引用类型

虽然说GC进行垃圾回收的时候是判断对象有没有被标记,一般情况下是这样的,但是特殊情况下也会出现对象的引用还在但是依然会被回收的情况,这里就涉及到了对象的4种引用:强引用软引用弱引用虚引用

  • 强引用: 我们通常new的对象都属于强引用,这种情况下只有对象没有被GC ROOT引用的时候才会被回收。比如:Object o = new Object();这个object就是强引用。
  • 软引用:SoftReference包裹的对象就属于软引用,他的特征是当执行完GC之后如果没有可用内存的话,软引用的对象就会被回收,不管你有没有GC ROOT。比如:SoftReference<Object> object = new SoftReference<Object>(new Object());,这种对象一般可以用来做jvm级别的缓存。
  • 弱引用:WeakReference包裹,但是GC每次都会对他进行回收,所以这种对象在我们的业务中基本也找不到使用场景,但是在ThreadLocal类中,可以用来做一些线程级别的缓存,将数据存储在ThreadLocalMap里面,并将Key包装成一个弱引用对象,这样在每次GC的时候就会对该值进行回收。

image.png

  • 虚引用: 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用。

对象的自救

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。对象最终要被回收要经过两个过程:

    1. 当一个对象第一次被标记位垃圾的时候,会判断对象有没有重写finaliza()方法,如果没有重写则直接会被回收,如果重写则进入第2步;
    1. 如果重写了finalize()方法,会在回收前再执行一次finalize()方法,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。但是一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。

finalize()方法自救的代码:

public class OOMTest {

   public static void main(String[] args) {
      List<Object> list = new ArrayList<>();
      int i = 0;
      int j = 0;
      while (true) {
         list.add(new User(i++, UUID.randomUUID().toString()));
         new User(j--, UUID.randomUUID().toString());
      }
   }
}

//User类需要重写finalize方法
@Override
protected void finalize() throws Throwable {
    OOMTest.list.add(this);
    System.out.println("关闭资源,userid=" + id + "即将被回收");
}

这里要特别注意一下:finalize()这个方法最好不要用,有些坑人面试题还说可以在finalize()里面做资源清理的逻辑,你去看看hotspot的源码,finalize()方法是交由底层的一个Finalizar线程去执行的,这个线程是守护线程,你的资源清理的逻辑很有可能会不执行的,再说了官方已经明确声明不推荐这么使用了,你要真想做资源清理try-catch-finally满足不了你吗?

总结

本篇涉及到的JVM参数:
-XX:TLABSize:TLAB默认的预留空间大小。
-XX:-UseCompressedOops:指针压缩,默认开启
-XX:-DoEscapeAnalysis:逃逸分析,默认开启
-XX:-EliminateAllocations:标量替换,默认开启
-XX:+UseAdaptiveSizePolicy:自适应eden区和survivor区,默认开启
-XX:PretenureSizeThreshold:大对象判定,单位是字节
-XX:-HandlePromotionFailure:老年代空间分配担保,默认开启

博观而约取。厚积而薄发。