跳到主要内容

02、调优实战 - 对象及内存分配策略与分代

1 虚拟机对象

1.1 对象的创建

虚拟机接收到new指令时,检查这个指令能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化;如果都没有,先执行类加载过程

在类加载通过后,虚拟机为新对象分配内存(把一块确定大小的内存从Java堆中划分出来),内存大小在类加载完成后即可完全确定。

两种分配方式

指针碰撞(指针加法 bump the pointer):假设Java堆中内存是绝对规整的,即使用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为指示器,通过移动指针实现内存分配;(如果加法后空余内存指针的值扔小于或等于指向末尾的指针,则代表分配成功)

空闲列表:如果Java堆中的内存并不是规整的,即已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录哪些内存块是可用的,通过从列表中寻找空间划分给对象实例来分配内存。

Java堆是否规整由所采用的垃圾收集器是否有压缩整理功能决定。

在虚拟机中创建对象不是线程安全的行为,可能出现在给对象A分配内存,指针还没来得及修改,对象B又使用了原来的指针来分配内存。有两种解决方案:

对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;

把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Loal Allocation Buffer,TLAB)。

内存分配完成后,需要将分配到的内存空间都初始化为零值,保证对象的实例字段在Java代码中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型对应的零值。

设置对象,把对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等存放在对象头中。

1.2 对象的内存布局

  • 对象在内存中存储的布局可以分为3块:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

  • 对象头(包括两部分信息):

  • 标记字:存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,官方称为Mark Word(非固定的数据结构,根据对象的状态复用自己的存储空间)。

 

在32位和64位的虚拟机中分别占32bit和64bit *

  • 类型指针:即指向对象的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;(32G内存以下,会默认开启对象指针压缩,占32bit,4个字节
  • 即:对象头一共占用 12字节

实例数据:对象真正存储的有效信息,即程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是子类自己定义的,都需要记录;

  • boolean/byte:1个字节;

  • short/char:2个字节;

  • int/float:4个字节;

  • long/double:8个字节;

  • String:4个字节;

  • 引用类型(refrece type):4个字节;

  • 对齐填充:不是必然存在,起着占位符的作用,由于HotSpot VM要求对象的大小必须是8字节的整数倍,当对象头和实例数据的大小之和不满足时,通过对齐填充来补全

  • 计算示例:

  • 首先引入 OpenJDK官方提供的分析java内存布局工具 jol(Java Object Layout):

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

代码示例:

public class ObjectSize {
   public static void main(String[] args) {
       Person person = new Person("bgy", 10);
       System.out.println(ClassLayout.parseInstance(person).toPrintable());
   }
}

  • 执行结果:

 

1.3 对象的访问定位

Java通过栈上的reference数据(局部变量表中的对象引用)来操作堆上的具体对象,reference只规定了指向对象的引用,没有定义怎么去定位,访问堆中的对象的位置。对象访问方式由虚拟机实现。

句柄访问:Java堆会划分出一块内存作为句柄池,reference存储的就是对象的句柄地址,句柄包含了对象实例数据和类型数据各自的地址信息。

 

优势:reference中存储的是稳定的句柄地址,对象移动时只改变句柄中的实例数据指针,不改变reference。

直接指针:reference中存储的直接就是对象地址,Java堆中放置访问对象类型数据(存放在方法区)的地址。

 

优势:速度更快,节省了一次指针定位的时间开销,HotSpot是使用直接指针访问

2 内存分代与内存分配

2.1 内存分代

2.1.1 为什么要分代

为什么要分代

堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的区域,程序所有的对象实例都存放在堆内存中;

如果堆内存没有区域划分,所有新创建的对象和生命周期很长的对象都放在一起,随着程序的执行,堆内存需要频繁地进行垃圾回收,而每次回收都要遍历所有对象,遍历这些对象所花费的时间代价是很大的,所以会严重影响GC效率

分代的优点

有了内存分代,新创建的对象会在新生代中分配内存;经历多次回收仍活下来的对象和大对象存放在老年代中;静态属性、类信息等存放在永久代中;

新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC;老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收;

可以根据不同年代的特点来采用合适的垃圾收集算法和垃圾收集器,所以分代收集提升了垃圾回收效率;

2.1.2 内存分代划分

Java虚拟机将堆内存划分为新生代、老年代、永久代(也就是方法区的实现,在JDK1.8中,HotSpot虚拟机采用了元数据空间(Metaspace)来实现方法区)

内存分代示意图:

 

2.1.2.1 新生代(Young)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低;在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,回收效率很高。

HotSpot虚拟机将新生代划分为三块:一块较大的Eden空间和两块较小的大小相同的Survivor空间;默认情况下,JVM采取的一种动态分配的策略,根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。也可以通过参数 -XX:SurvivorRatio 来固定这个比例,比如设置为8,则比例为 8:1:1。

划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配,大对象直接进入老年代,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

Minor GC的过程

  • GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。
  • GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阈值(默认为15,JVM参数“-XX:MaxTenuringThreshold”;GC分代年龄存储在对象的header)的对象会被移到老年代中,没有达到阈值的对象会被复制到To Survivor区(每复制一次,年龄加1);接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。
  • 接着,From Survivor区和To Survivor区会进行交换,把To Survivor区中的对象复制到From Survivor区中,也就是新的From Survivor区就是上次GC的To Survivor区。总之,不管怎样都会保证To Survivor区在一轮GC之后是空的。
  • GC时当To Survivor区没有足够的空间存放新生代存活的对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中

2.1.2.2 老年代(Old)

老年代是存放长期存活的对象,或者大对象;老年代中的对象生命周期较长,存活率较高,在老年代中进行GC的频率相对较低,回收的速度也比较慢。

当老年代需要垃圾回收时,代表堆的空间已经耗尽了,这个时候JVM往往需要做一次全堆扫描,耗时较长。

老年代空间分配担保:

如果在新生代中有大量的对象存活下来,并且Survivor区放不下这些对象的时候,就会寻求老年代进行帮助存放(空间分配担保);

空间分配担保步骤:

在执行任何一次Minor GC之前,JVM都会先检查一下老年代的可用内存空间是否大于新生代所有对象的总大小(因为最极端情况下,在Minor GC之后,所有对象都存活下来了,就会全部进入老年代);如果大于,直接进行Minor GC;

如果老年代的可用内存大小 小于新生代的全部大小,就得看一个JVM参数:“-XX:-HandlePromotionFailure”是否配置;(JDK 1.6 之后已经废弃,默认配置)

如果配置了,就判断老年代的可用内存大小,是否大于之前每一次Minor GC后进入老年代的平均大小

如果大于这个平均大小,则会冒险尝试一次Minor GC;此时进行Minor GC有几种可能:

Minor GC之后,存活对象 小于Survivor区的大小,则存活对象直接进入Survivor区;

Minor GC之后,存活对象 大于Survivor区的大小,但是 小于老年代可用内存大小,则直接进入老年代;

Minor GC之后,存活对象 大于Survivor区的大小,且也大于老年代可用内存大小,即此时老年代也放不下这些对象了;就会发生“Handle Promotion Failure”的情况,且触发一次 Full GC;

如果小于这个平均大小,直接触发一次 Full GC

如果没有配置,直接触发一次 Full GC

对象进入老年代的条件:

  • 分配担保规则
  • 年龄阈值:当经过多次Minor GC之后,年龄阈值(-XX:MaxTenuringThreshold)达到15的对象,就会被移到老年代;
  • 动态年龄判断:如果年龄从小到大的一批对象的总大小大于了Survivor区的50%,此时大于等于这批对象最大年龄的对象,则提前进入老年代;(即年龄1+年龄2+年龄n的多个对象大小总和超过了Survivor区的50%,此时就会把年龄n以上的对象提前放入老年代)
  • 大对象:对应JVM参数为:“-XX:PretenureSizeThreshold”(它默认值为0,意思是当不主动设置值时,不管多大的对象都会先在新生代分配内存);当手动设置了这个值时,如果生成一个大于这个大小的对象(比如一个超大的数组或者其他对象),就会直接在老年代中为这个对象分配内存;(G1收集器中有专门的大对象Region,不存在老年代)

2.1.2.3 永久代(Permanent)(Metaspace)

永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据;在元数据空间满了的时候,也会进行垃圾回收;

2.2 内存分配策略与回收策略

2.2.1 内存分配策略总结:

  • 对象优先在Eden分配;大多数情况下,对象在新生代的Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC;
  • 大对象会进入老年代;大对象是指需要大量连续内存空间的Java对象,最典型的就是那种很长的字符串以及数组;如果设置了虚拟机参数“-XX:PretenureSizeThreshold”,当对象大于这个值时,直接在老年代分配内存;
  • 长期存活的对象将进入老年代;虚拟机给每个对象定义了一个对象年龄计数器,当经过多次Minor GC之后如果年龄值大于阈值(默认15),将会进入老年代;
  • 动态年龄判断:为了能更好的适应不同程序的内存情况,虚拟机不是永远的要求对象的年龄必须达到阈值才能晋升老年代;如果在Survivor区中的相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无须达到阈值;

2.2.2 回收策略(GC 策略):

2.2.2.1 新生代GC(Minor GC/Young GC)

Minor GC是指发生在新生代的垃圾回收动作,因为Java对象大多都具备朝生夕灭的特性,所以Monir GC非常频繁,一般回收速度也比较快;当Eden区空间不足以为对象分配内存时,会触发Minor GC;

理想情况下,Eden区中的对象基本都死亡了,那么需要复制的数据很少,因此新生代采用 标记-复制算法;Minor GC另外一个好处是不用对整个堆进行垃圾回收;

但是有个问题:老年代的对象可能引用新生代的对象(也就是说,标记存活对象的时候,需要扫描老年代中的对象,如果老年代对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots,这里不就又做了一次全堆扫描?(JVM使用卡表解决)

2.2.2.2 老年代GC(Old GC/Full GC)

这里其实Old GC和Full GC并不相等,Old GC专指老年代的GC,而Full GC会包含新生代、老年代、永久代的所有的GC。

Full GC的速度一般比Major GC慢10倍以上;所以如果系统频繁出现Full GC,会导致系统性能被严重影响,出现频繁卡顿现象;当老年代内存不足或者显示调用System.gc()方法时,会触发Full GC;

因为老年代中的对象要不就是存活时间较长,要不就是比较大,就不能采用复制算法,所以老年代采用的是 标记整理算法

出现了Full GC经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器的收集策略里就有直接进行Full GC的策略选择过程),因为需要把老年代里没有引用的对象给回收掉,才能让Minor GC后存活的对象进入老年代。

如果要是Full GC后,老年代还是没有足够的空间存放Minor GC后存活的对象,那就会导致“OOM”内存溢出了。