谈谈Java对象的生命周期

Posted 纵横千里,捭阖四方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了谈谈Java对象的生命周期相关的知识,希望对你有一定的参考价值。

经过前面的分析 ,我们现在来看一下创建的对象到底是什么东西,并且完整的总结一下一个对象从创建到回收到底经过了哪些阶段。

1 对象的创建

对象创建的主要流程:

 1.类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

new指令对应到语言层面上就是:new关键词、对象克隆、对象序列化等。

2.分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

这个步骤有两个问题:

1.如何划分内存。

2.在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

划分内存的方法:

  • “指针碰撞”(Bump the Pointer)(默认用指针碰撞)

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

  • “空闲列表”(Free List)

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

解决并发问题:主要使用两种方法:

  • CAS(compare and swap)方法。虚拟机的** CAS配上失败重试**的方式能够保证更新操作的原子性来对分配内存空间的动作进行同步处理。该问题我们将在多线程部分深入讲解。

  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过-XX:+/-**UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB**),-XX:TLABSize 指定TLAB大小。

3.初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4.设置对象头

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

5.执行方法

执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。例如下面的代码:

public class ClinitTest 
    private int a = 11;
    private int c = 32;
​
    public static void main(String[] args) 
        int b = 2;
    

这里的a和c变量在加载等过程中,值都是0,只有到了执行方法阶段,才会分别赋值为11或和32。

2 对象的结构与指针压缩

2.1 对象的结构

我们知道对象一定是内存的某种结构,那对象到底是什么呢?

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。 HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

32位对象头

64位对象头

 对象头在hotspot的C++源码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)

对象大小可以用jol-core包查看对象的加载情况,先引入依赖

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

我们先看一下基本类Object对象的情况:

import org.openjdk.jol.info.ClassLayout;
​
/**
 * 计算对象大小
 */
public class JOLSample 
    public static void main(String[] args) 
        ClassLayout layout = ClassLayout.parseInstance(new Object());
        System.out.println(layout.toPrintable());
  
  

运行结果:

 可以看到对象头的大小是12字节,最后有4位的填充,并且最后也输出了损失的空间是4个字节。

这里我们会有个疑问,为什么要填充4个字节呢?这是为了让所有的对象在保存的时候都是8的整数倍,管理会更为高效,也方便管理。

为什么整数倍的更好管理呢?这与计算机是寻址方式有关,我们可以结合现实理解一下 ,集装箱相信大家都知道,进行长途运输,以及火车或者海运等都会使用,现实中的货物不可能正好是一个集装箱的大小,此时只能将空闲部分做一些填充。再比如我们搬家的时候,一般会打包成几个打包,方便搬家公司给运输。但是如果一个包不满,我们就会填充一些小物品。

如果是普通的整形对象又如何呢?我们添加代码:

System.out.println();
ClassLayout layout1 = ClassLayout.parseInstance(new int[]);
System.out.println(layout1.toPrintable());

再执行:

 可以看到此时占用的空间大了,前12个是对象头,最后四个字节是一个整型变量,大小正好是16个字节。

如果再定义一个自己的对象呢?

System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());

再定义一个自己的类:

System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());

再看一下执行效果:

 此时对象大小是32字节,最后四个字节也是填充。

我们注意到此时提示有3字节的内容缝隙,一共7个自己的填充,这是因为我们在类A里定义了一个byte b,在创建对象A的时候 ,仍然要求是对齐的,因此也会给做一定的填充。

2.2 指针压缩

什么是java对象的指针压缩?指针就是Klass Point的意思,就是对象在内存的结构。为了节省空间,JVM会采取一些策略对对象进行一定程度的压缩,从而减少存储占用的空间。

1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩。

2.jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针

3.启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops

为什么要进行指针压缩?

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

2.为了减少64位平台下内存的消耗,启用指针压缩功能

3.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)

4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间

5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好

关于对齐填充:对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。

我们继续验证一下。

我们执行下面的代码时:

ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());

给虚拟机选项增加一个参数-XX:-UseCompressedOops然后再执行,结果如下:

 这时候我们发现虽然大小还是16字节,但是已经没有填充了,16个字节都是对象了,这里就是因为在上面的例子中,我们对象将对象进行了压缩。

我们再看一下类A的情况:

System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());

输出结果为:

 可以看到,此时不仅对象头是16个字节,内容的参数例如name等占用的空间也变成8个字节了。

3 对象内存分配

我们现在完整看一下创建对象时如何为其分配空间的。

对象内存分配流程图

 

  • 第一步,确定能否进行栈上分配,在堆的代码优化部分我们说过,在最新的JDK中会首先检测是否可以在栈空间分配对象,如果可以则不必再到堆里分配。

  • 第二步,确定是否是大对象,如果是大对象, 则直接到old区找空间,这样可以减少大对象在年轻代反复移动带来的消耗。具体见后面“大对象直接进入老年代”一节。

  • 第三步,如果不是大对象,则在Eden区分配对象。

  • 第四步,如果Eden区满了会启动minor GC来回收空间,此时影响的区域主要是Eden、S0和S1三个区,如果一个对象存活时间超过阈值(默认15次),则将其移动到Old区。

  • 第五步,如果Old区空间也被占满超过限制(默认为92%),则启动Full GC。

在上面过程中,有几个重要内容,我们再看一下。

1.对象在Eden区分配

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。我们来进行实际测试一下。

在测试之前我们先来看看 Minor GC和Full GC 有什么不同呢?

  • Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。

  • Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。

Eden与Survivor区默认8:1:1

大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可,

JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy

示例:

//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest 
   public static void main(String[] args) throws InterruptedException 
      byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;
      allocation1 = new byte[60000*1024];
​
      //allocation2 = new byte[8000*1024];
​
      /*allocation3 = new byte[1000*1024];
     allocation4 = new byte[1000*1024];
     allocation5 = new byte[1000*1024];
     allocation6 = new byte[1000*1024];*/
   

运行结果:

Heap
 PSYoungGen      total 76288K, used 65536K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)
  eden space 65536K, 100% used [0x000000076b400000,0x000000076f400000,0x000000076f400000)
  from space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)
  to   space 10752K, 0% used [0x000000076f400000,0x000000076f400000,0x000000076fe80000)
 ParOldGen       total 175104K, used 0K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1c00000,0x00000006cc700000)
 Metaspace       used 3342K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K

我们可以看出eden区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用至少几M内存)。假如我们再为allocation2分配内存会出现什么情况呢?

//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest 
   public static void main(String[] args) throws InterruptedException 
      byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;
      allocation1 = new byte[60000*1024];
​
      allocation2 = new byte[8000*1024];
​
      /*allocation3 = new byte[1000*1024];
      allocation4 = new byte[1000*1024];
      allocation5 = new byte[1000*1024];
      allocation6 = new byte[1000*1024];*/
   

运行结果:

[GC (Allocation Failure) [PSYoungGen: 65253K->936K(76288K)] 65253K->60944K(251392K), 0.0279083 secs] [Times: user=0.13 sys=0.02, real=0.03 secs] 
Heap
 PSYoungGen      total 76288K, used 9591K [0x000000076b400000, 0x0000000774900000, 0x00000007c0000000)
  eden space 65536K, 13% used [0x000000076b400000,0x000000076bc73ef8,0x000000076f400000)
  from space 10752K, 8% used [0x000000076f400000,0x000000076f4ea020,0x000000076fe80000)
  to   space 10752K, 0% used [0x0000000773e80000,0x0000000773e80000,0x0000000774900000)
 ParOldGen       total 175104K, used 60008K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 34% used [0x00000006c1c00000,0x00000006c569a010,0x00000006cc700000)
 Metaspace       used 3342K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K     

简单解释一下为什么会出现这种情况: 因为给allocation2分配内存的时候eden区内存几乎已经被分配完了,我们刚刚讲了当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间虚拟机又发现allocation1无法存入Survior空间,所以只好把新生代的对象提前转移到老年代中去,老年代上的空间足够存放allocation1,所以不会出现Full GC。执行Minor GC后,后面分配的对象如果能够存在eden区的话,还是会在eden区分配内存。可以执行如下代码验证:

public class GCTest 
   public static void main(String[] args) throws InterruptedException 
      byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;
      allocation1 = new byte[60000*1024];
​
      allocation2 = new byte[8000*1024];
​
      allocation3 = new byte[1000*1024];
      allocation4 = new byte[1000*1024];
     allocation5 = new byte[1000*1024];
     allocation6 = new byte[1000*1024];
   

运行结果:

[GC (Allocation Failure) [PSYoungGen: 65253K->952K(76288K)] 65253K->60960K(251392K), 0.0311467 secs] [Times: user=0.08 sys=0.02, real=0.03 secs] 
Heap
 PSYoungGen      total 76288K, used 13878K [0x000000076b400000, 0x0000000774900000, 0x00000007c0000000)
  eden space 65536K, 19% used [0x000000076b400000,0x000000076c09fb68,0x000000076f400000)
  from space 10752K, 8% used [0x000000076f400000,0x000000076f4ee030,0x000000076fe80000)
  to   space 10752K, 0% used [0x0000000773e80000,0x0000000773e80000,0x0000000774900000)
 ParOldGen       total 175104K, used 60008K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 34% used [0x00000006c1c00000,0x00000006c569a010,0x00000006cc700000)
 Metaspace       used 3343K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K

4 大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。

比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代。

为什么要这样呢?为了避免为大对象分配内存时的复制操作而降低效率。

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制

Full GC 的时间非常长,会影响用户响应,所以我们要尽量减少其执行的次数,因此根据前面的规则要执行Full GC了,但是JVM仍然加了一些限制来尽可能使用Minor GC,这就是老年代空间分配担保机制要干的事情。

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了。

如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM"

当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”

5 对象内存回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

堆空间的垃圾回收主要分为垃圾标记和回收两个步骤。具体我们下一章《垃圾回收》再讲。我们我们只看一个问题:如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类呢?

类需要同时满足下面3个条件才能算是 “无用的类”

  • 该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。

  • 加载该类的 ClassLoader 已经被回收。

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

如果满足任何一项要求,该对象就可以被回收了,垃圾回收器会定期扫描空间,及时清理这样的对象。

以上是关于谈谈Java对象的生命周期的主要内容,如果未能解决你的问题,请参考以下文章

Java-类的生命周期浅析

第37讲 谈谈Spring Bean的生命周期和作用域

(更新中)谈谈个人对java并发编程中(管程模型,死锁,线程生命周期等问题) 见解

谈谈强引用,软引用,弱引用,幻象引用有什么区别

浅谈Cookie的生命周期问题

源码分析:Java堆的创建