一、核心概述

  1. 一个JVM实例只存在一个堆,堆是Java内存管理最大的一块核心区域。
  2. Java堆区在JVM启动的候即被创建,其空间大小也就确定了,但可以在创建前调整大小。
  3. 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间,但逻辑上连续
  4. 所有的线程共享Java堆,堆还可以被划分为线程私有缓冲区(Thread Local Allocation Buffer, TLAB)。
  5. 《Java虚拟机规范》中对Java堆的描述是
    • 从设计初衷来讲所有的对象实例以及数组都应当在运行时分配到堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
    • 从实际角度看,这里讲的应当是几乎(almost),对象可能出现在栈上。
    • 从设计初衷来讲,数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或数组在堆中的位置
  6. 方法结束后,堆中的对象不会立即被移除,仅在垃圾回收的时候才被移除。
  7. 是垃圾收集器(Garbage Collector)执行垃圾回收的重点区域

二、年轻代与老年代

JVM堆结构.svg

堆结构划分

  • 存储在JVM中的Java对象可以被划分为两类:
    • 生命周期较短的对象,创建和消亡非常迅速。
    • 生命周期很长的对象,极端情况下可以与JVM生命周期保持一致。
  • Java堆区进一步细分的话,可以划分为年轻代(YoungGen)老年代(OldGen)
  • 年轻代进一步细分为伊甸园区(Eden)幸存者0(Suvivor 0)幸存者1(Suvivor 1)

堆结构占比说明

  • 新生代比例,默认-XX:NewRatio=2,代表【新生代 : 老年代】 = 1 : 2
  • 幸存者比例,默认-XX:SurvivorRatio=8,代表【幸存者0 : 幸存者1 : 伊甸园】 = 1 : 1 : 8

补充

  • 几乎所有Java对象都是在伊甸园(Eden)被new出来的。
  • 绝大部分Java对象的销毁在新生代(Young Gen)进行。
    • IBM公司研究表明,新生代中80%的对象都是“朝生夕死”的。
  • -Xmn 设置新生代最大内存大小。一般默认即可。

三、对象分配过程

1. 重要性

  1. 内存如何分配,在哪里分配。
  2. 内存分配垃圾回收密切相关,回收后是否产生及如何处理产生内存碎片

2. 过程概述

  1. new创建的对象放到伊甸园区,有大小限制。
  2. 伊甸园区满,程序还需对象,则JVM对伊甸园区执行垃圾回收(Minor GC),将不需要的对象销毁,再创建新的对象放伊甸园区。
  3. 伊甸园区中的剩余对象移动到幸存者0区(Survivor 0)
  4. 再次触发垃圾回收,此时上次幸存下来的幸存者0区(Survivor 0)如果未被回收,放到幸存者1区(Survivor 1)
  5. 再次触发垃圾回收,重复3和4的过程。
  6. 老年代,默认15次垃圾回收触发之后的存活对象。
    • -XXMaxTenuringThreshold=,设置存活移动到老年代的垃圾回收次数。

堆_对象分配过程.svg

3. 小结

  • 针对幸存者S0区和S1区:
    • 复制之后有交换,谁空谁是to;
  • 关于垃圾回收,
    • 频繁新生区收集,
    • 很少老年区收集,
    • 几乎不永久区/元空间收集。

4. 特殊情况补充

08_堆 - 图3

四、GC类型的简单梳理

1. 涉及范围

JVM在进行GC时,并非每次都对上面的三个内存(新生代老年代方法区)一起回收,大部分时候回收的都是新生代

2. GC类型

Hotspot VM的实现,按回收区域分又分为两种类型:

1) 部分收集

非完整的Java堆收集。

  1. 新生代收集(Minor GC / Young GC):收集Eden区,S0区,S1区
  2. 老年代收集(Major GC / Old GC):收集老年代
    • 目前仅有CMS GC会有单独收集老年代的行为。
    • 很多时候Major GC和Full GC混合使用需具体分辨老年代回收还是整堆回收。
  3. 混合收集(Mixed GC):整个新生代 + 部分老年代
    • 目前,仅有G1 GC会有这种行为。

2) 整堆收集

收集整个Java堆 + 方法区

五、分代式GC触发条件

1. 年轻代GC(Minor GC)触发条件 => Eden区满

  • Survivor满不会触发GC,因为每次MinorGC会清理年轻代的内存。
  • Java对象大多是朝生夕灭的,所以Minor GC频繁,且回收速度较快
  • Minor GC会引发STW(Stop the World),暂停用户线程,回收结束再恢复

2. 老年代GC(MajorGC / Full GC)触发机制 => 老年代空间不足

  • 经常会伴随至少一次MinorGC(非绝对,在Parallel Scavenge收集器的收集策略中可直接执行Major GC策略)。
    • 老年代空间不足,先尝试触发Minor GC;
    • 若还是不足,再触发Major GC。
  • Major GC一般比Minor GC慢10倍以上,STW的时间更长。
  • Major GC后内存不足,报OOM异常

3. Full GC触发机制简述

1) 情况分类

  1. 调用System.gc(),系统建议执行Full GC,但不必然。
  2. 老年代空间不足。
  3. 方法区空间不足。
  4. Minor GC后,进入老年代的平均大小大于老年代可用内存。
  5. Eden区 + S0(From Space)区向S1(To Space)区复制时,对象大于To Space可用内存,则把该对象转存到老年代,且老年代可用内存小于该对象大小。

2) 说明

Full GC是开发或调优种尽量要避免的,尽量减小用户线程挂起时间

六、堆空间分代思想

为什么要把Java堆分代?不分代就不能正常工作了吗?

  • 经研究,不同对象的生命周期不同。70%~99%的对象是临时对象。
  • 不分代完全可以,分代的唯一理由是优化GC性能。如果没有分代,那么所有的对象都放一块。

七、内存分配策略

1. 整体分配策略

  1. 如果对象再Eden出生,并经过第一次Minor GC后任然存活,并且能被Survivor容纳,则对象将被移动到Survivor空间,并且年龄设置为1。
  2. 对象在Survivor区每熬过一次MinorGC,年龄增加1。
  3. 年龄增长到某个阈值(Hotspot默认15,有JVM和GC程序差异性),就会被晋升到老年代(Old Gen)中。
    • 对象晋升老年代的年龄阈值,可以通过选项 -XX:MaxTenuringThreshold=选项来设置

2. 针对不同年龄段的对象分配原则

  1. 优先分配到Eden区。
  2. 大对象直接分配到老年代。
    • 尽量避免程序中出现过多的大对象。
  3. 长期存活的对象分配到老年代。
  4. 动态对象年龄判断。
    • 如果Survivor区中相同年龄的所有对象大小的总和,大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold要求的年龄。
  5. 空间分配担保。
    • -XX:HandlePromotionFailure

八、为对象分配内存:TLAB

1. 为什么有TLAB(Thread Local Allocation Buffer)?

  • 线程共享区域,任何线程都可以访问到堆中的共享数据。
  • 对象实例创建在JVM中非常频繁,因此,在并发环境下从堆中划分内存空间是不安全的。
  • 避免多线程同时操作同一地址,使用加锁等机制影响分配速度

2. TLAB是什么?

  • 内存模型角度,对Eden区域继续进行划分,为每个线程分配一个私有缓存区域,它包含在Eden空间内。
  • 多个线程同时分配内存时候,使用TLAB可以避免一系列的非线程安全问题,同时提升内存分配的吞吐量,这种策略被称为快速分配策略
  • 目前所知的OpenJDK衍生出来的JVM都提供了TLAB设计。

3. TLAB再说明

  • 尽管不是所有的对象实例都能在TLAB中成功分配,但JVM确实将TLAB设计为内存分配的首选
  • 开关设置:-XX:UseTLAB
  • 内存大小占比设置:-XX:TLABWasteTargetPercent默认仅1%
  • 一旦对象在TLAB内存分配失败时,JVM就会尝试着通过加锁机制确保数据操作的原子性,在Eden空间分配内存。 08_堆 - 图4

    九、堆空间常用JVM参数

1. -XX:+PrintFlagsInitial

查看所有参数的默认初始值

2. -XX:+PrintFlagsFinal

查看所有参数的最终值

3. -Xms

初始堆空间大小,默认物理内存的 1 / 64。

4. -Xmx

最大堆空间大小,默认为物理内存的 1 / 4。

5. -XX:NewRatio

配置新生代与老年代比例中,新生代为1份时老年代的份数

6. -XX:SurvivorRatio

配置新生代中,Eden与S0 / S1中,S0 / S1为1份时Eden的份数

7. -XX:MaxTenuringThreshold

设置晋升老年代的年龄限制。

8. -XX:+PrintGCDetails

输出GC处理详细日志

  • 输出GC简要信息
    • -XX:+PrintGC
    • -verbose:gc

9. -XX:HandlePromotionFailure

是否设置空间分配担保。在Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象所占总空间

  • 如果大于,此次Minor GC是安全的。
  • 小于或等于,虚拟机会查看 -XX:HandlePromotionFailure是否开启
    • 未开启,执行Full GC。
    • 已开启。继续检查老年代最大可用连续空间,是否大于历次晋升到老年代对象的平均大小。
      • 大于,尝试一次Minor GC(仍然有风险)。
      • 小于,改为进行一次Full GC。

JDK6 update24(JDK 7+)之后,该参数不会再影响到虚拟机空间分配的担保策略,观察OpenJDK中的源码变化,虽然仍然定义但却未再使用。

之后的规则变为:

  • 只要老年代连续空间大于新生代对象总大小或历次晋升平均大小,就会进行Minor GC否则进行Full GC

十、逃逸分析

1. 概述

  • 如何栈上分配对象,需要逃逸分析技术。
  • 有效减少Java程序中同步负载和内存分配压力的跨函数全局数据流分析
  • 通过逃逸分析,编译器能分析一个新对象的引用的使用范围,从而决定分配位置(堆或栈)
  • 逃逸分析的基本行为就是分析对象动态作用域
    • 对象在方法中被定义,且仅在方法内部使用,则认为没有发生逃逸。
    • 对象在方法中被定义,它被外部方法引用,则人为它发生了逃逸。
      • 例:作为调用参数传递
  • JDK 6u23版本后,Hotspot默认开启了逃逸分析(必须在Server模式下才可启用,64位电脑默认已开启,否则加上-server参数)。
    • 显式开启逃逸分析
      • -XX:+DoEscapeAnalysis
    • 查看逃逸分析的筛选结果
      • -XX:+PrintEscapeAnalysis

2. 举例

  1. 未发生逃逸
  • 分析:方法内,new V();这个对象在只有v指针指向它,v指针置空后,该对象就没有引用了,所以仅在方法内部消耗,未发生逃逸。
    1. public class Main {
    2. public void method() {
    3. V v = new V();
    4. // use v
    5. // ...
    6. v = null;
    7. }
    8. }
  1. 发生逃逸的情况及优化
  • 分析:new StringBuffer()对象被sb引用,sb作为返回值,所以发生了逃逸。

    1. public class Main {
    2. public static StringBuffer createStringBuffer(String s1, String s2) {
    3. StringBuffer sb = new StringBuffer();
    4. sb.append(s1);
    5. sb.append(s2);
    6. return sb;
    7. }
    8. }
  • 改进

    1. public class Main {
    2. public static String createString(String s1, String s2) {
    3. StringBuffer sb = new StringBuffer();
    4. sb.append(s1);
    5. sb.append(s2);
    6. return sb.toString();
    7. }
    8. }

3. 代码优化

1) 栈上分配

  1. 概述
    • JIT编译器根据逃逸分析结果,对象作用域未逃逸出方法的话,就可能被优化为栈上分配。
    • 随方法栈帧出栈,栈帧的局部变量也被回收,无需GC判断。
  2. 常见逃逸场景
    • 给成员变量赋值;
    • 方法返回值;
    • 实例引用传递。
  3. 目前栈上分配仅是在技术尚未成熟,未应用到Hotspot虚拟机中
    • 原因:无法定量分析逃逸分析过程本身的性能消耗对代码优化上的提升

2) 同步省略

  1. 问题
    • 线程同步的代价非常高,同步的后果是降低并发和性能
  2. 逃逸分析解决
    • 编译同步块,JIT编译器借助逃逸分析,判断同步块使用的锁对象是否只能被一个线程访问而没有发布到其他线程。
      • 若只能被一个线程访问,则JIT编译该同步块就会取消该部分代码的同步,大大提高并发性和性能。
      • 同步省略即是取消同步的过程,也叫锁消除
      • 优化过程发生在字节码文件加载到内存后的JIT即时编译,所以字节码class文件中仍然可以看见synchronized。

优化之前

  1. public class Main {
  2. public void f() {
  3. Object hollis = new Object();
  4. synchronized(hollis) {
  5. System.out.print(hollis);
  6. }
  7. }
  8. }

优化后

  1. public class Main {
  2. public void f() {
  3. Object hollis = new Object();
  4. System.out.print(hollis);
  5. }
  6. }

3) 分离对象或标量替换

  1. 相关定义
    • 标量:无法再分解成更小数据的数据。如Java中的基本类型。
    • 聚合量:还可以被分解的数据。如Java中的对象。
  2. 标量替换
    根据逃逸分析结果,如果一个对象不会被外界访问,JIT优化就会把该对象拆解为若干个其中包含的若干个成员变量来代替。

    1. public class Main {
    2. private static void alloc() {
    3. Point point = new Point(1, 2);
    4. System.out.println("point.x = " + point.x + "; point.y = " + point.y);
    5. }
    6. class Point {
    7. private int x;
    8. private int y;
    9. }
    10. }
    • 针对alloc()方法的标量替换

      • 发现Point聚合量并未逃逸,就被替换为两个标量了。
      • 好处是减少堆内存的使用(绝大部分对象分配到堆)。
        1. public class Main {
        2. private static void alloc() {
        3. int x = 1;
        4. int y = 2;
        5. System.out.println("point.x = " + point.x + "; point.y = " + point.y);
        6. }
        7. }
    • 参数开关(默认打开)

      • -XX+EliminateAllocations

十一、小结

  • 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
  • 老年代放置长生命周期的对象,通常都是从survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,在新生代无法找到足够长的连续空闲空间,JVM就会直接分配到老年代
  • 当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGC。当GC发生在老年代时则被称为MajorGC或者FullGC。一般的,MinorcC 的发生频率要比MajorGc高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。