1 堆的核心概述

  • 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。
  • Java 堆区在 JVM 启动时即被创建,其空间大小也就确定了。是 JVM 管理的最大一块空间。
    • 堆内存的大小是可以调节的。
  • 《Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  • 所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
  • 《Java 虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在对上。
    • 从实际使用角度看,“几乎”所有的对象实例都在堆中分配内存。
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
  • 堆,是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

1.1 内存细分

现代垃圾收集器大部分都基于分代收集理论设计的,堆空间细分为:

Java 7及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区

  • Young Generation Space,即新生区,Young/New
    • 又被划分为 Eden 区和 Survivor 区
  • Tenure Generation Space,即养老区,Old/Tenure
  • Permanent Space,即永久区,Perm

Java 8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间

  • Young Generation Space,即新生区,Young/New
    • 又被划分为 Eden 区和 Survivor 区
  • Tenure Generation Space,即养老区,Old/Tenure
  • Meta Space,即元空间,Meta

以下名词表示同一个区:

  • 新生区、新生代、年轻代
  • 养老区、老年区、老年代
  • 永久区、永久代

1.2 堆空间内存结构(JDK7)

2 设置堆内存大小与 OOM

可以通过选项“-Xmx”和“-Xms”来设置堆(新生代和老年代)的大小:

  • -Xms:用于表示堆区的起始内存,等价于 -XX:InitialHeapSize
    • -X:jvm 的运行参数
    • ms:memory start
  • -Xmx:用于表示堆区的最大内存,等价于 -XX:MaxHeapSize

一旦堆区中的内存大小超过 -Xmx 所指定的最大内存时,将会抛出 OutOfMemoryError 异常。

通常会将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

默认情况下:

  • 初始内存大小:物理内存大小 / 64
  • 最大内存大小:物理内存大小 / 4

2.1 手动设置堆内存大小

public class HeapSpaceInitial {
    public static void main(String[] args) {
        // 返回 Java 虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        // 返回 Java 虚拟机试图使用的最大内存总量
        long maxMemory  = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms: " + initialMemory + "M");
        System.out.println("-Xmx: " + maxMemory + "M");

        System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
        System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");

        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

idea 中设置堆内存大小:

2.2 查看设置的参数

方式一:通过命令行

jps:查看当前程序运行的进程。

jstat:查看进程的内存使用情况。

方式二:设置参数 -XX:+PrintGCDetails

运行结果:

3 年轻代与老年代

存储在 JVM 中的 Java 对象可以被分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。
  • 另一类对象的生命周期却非常长,在某些极端的情况下还能与 JVM 的生命周期保持一致。

年轻代可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区)。

下面这参数开发中一般不会调:

配置新生代与老年代在堆结构的占比:

  • 默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
  • 可以修改 -XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

cmd中查看该参数的值:jinfo -flag NewRatio

在 HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是8:1:1。

可以通过 -XX:SurvivorRatio 调整这个空间比例,如:-XX:SurvivorRatio=8。

几乎所有的 Java 对象都是在 Eden 区被 new 出来的。

绝大部分的 Java 对象的销毁都在新生代进行了。

可以使用选项 -Xmn 设置新生代最大内存大小,但一般使用默认值就可以了。如果使用了 -Xmn,那么 -XX:NewRatio 就会无效。

注意:在实际使用中,Eden 空间与两个 Survivor 空间的比例可能不是8:1:1,这是因为 JVM 有自适应机制。使用选项 -XX:-UseAdaptiveSizePolicy 可以关闭自适应的内存分配策略。但是应用了该选项后,该比例可能依旧不是8:1:1。因此,要想让该比例是8:1:1,就要显式的使用 -XX:SurvivorRatio=8。

4 图解对象分配过程

对象分配过程概述:

  1. new 的对象先放在 Eden 区,此区有大小限制。
  2. 当 Eden 区的空间填满时,程序有需要创建新对象,JVM 的垃圾回收器将对 Eden 区进行垃圾回收(Minor GC),将 Eden 区中不再被其它对象所引用的对象销毁,再加载新的对象放到 Eden 区。
  3. 然后将 Eden 区中剩余的对象移动到 Survivor0 区。
  4. 如果再次触发垃圾回收,则将 Eden 区和 Survivor0 区中幸存下来的对象放到 Survivor1 区。Survivor0 区和 Survivor1 区依次交换。
  5. 默认超过15次,就会移动到养老区。
    • 可以使用 -XX:MaxTenuringThreshold=<N> 参数进行设置。
8.4.2.png 8.4.3.png

总结:

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

常用的调优工具

  • JDK 命令行
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • VisualVM
  • Jprofiler
  • Java Flight Recorder
  • GCViewer
  • GC Easy

5 Minor GC、Major GC 和 Full GC

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

针对 HotSpot VM 实现,它里面的 GC 按照回收区域又分为两大种类型:部分收集(Partial GC)和整堆收集(Full GC)。

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集。
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
      • 目前,只有 CMS GC 会有单独收集老年代的行为。
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有 G1 GC 会有这种行为。
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

5.1 最简单的分代式 GC 策略的触发条件

年轻代 GC(Minor GC)触发机制

  • 当年轻代空间不足时,就会触发 Minor GC,这里的年轻代满指的是 Eden 区满,Survivor 满不会引发 GC。(每次 Minor GC 会清理 Eden 区和 Survivor 区的内存)
  • 因为 Java 对象大多具有朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • Minor GC 会引发 STW(stop the work),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
![](https://img2020.cnblogs.com/blog/2128165/202008/2128165-20200819235611613-366223189.png)

老年代 GC(Major GC )触发机制

出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。

Major GC 的速度一般会比 Minor GC 慢10倍以上,STW 的时间更长。

如果 Major GC 后,内存还不足,就会报 OOM。

Full GC 触发机制

触发 Full GC 执行的情况有如下五种:

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

Full GC 是开发或调优中尽量要避免的。这样暂定时间会短一些。

6 内存分配策略(或对象提升(Promotion)规则)

如果对象在 Eden 出生并经过第一次 MinorG 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中, 并将对象年龄设为1。对象在区中每熬过一次 MinorGC,年龄就增加1 岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。

  • 对象晋升老年代的年龄阈值,可以通过选项:-XX:MaxTenuringThreshold 来设置。

针对不同年龄段的对象分配原则如下所示:

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

7 为对象分配内存:TLAB

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

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

什么是 TLAB ?

  • 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的线程问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

TLAB 的再说明

尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但JVM 确实是将 TLAB 作为内存分配的首选

在程序中,开发人员可以通过选项 -XX:UseTLAB 设置是否开启 TLAB 空间(默认情况下是开启的)。

默认情况下,TLAB 空间的内存非常小,仅占用整个 Eden 空间的1%。可以通过选项 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。

一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

对象分配过程:TLAB

8 小结堆空间的参数设置

官网说明:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值。
  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能存在修改,不再是初始值)
  • -Xms:设置初始堆空间内存(默认为物理内存的1/64)
  • -Xmx:设置最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小(初始值及最大值)
  • -XX:NewRatio:设置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中 Eden 区和 S0/S1 区空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX:+PrintGCDetails:输出详细的 GC 处理日志
    • 打印 gc 简要信息
      1. -XX:PrintGC
      2. -verbose:gc
  • -XX:HandlePromotionFailure:设置是否开启空间分配担保

空间分配担保:HandlePromotionFailure

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

  • 如果大于,则此次 Minor GC 是安全的。
  • 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败
    • 如果 HandlePromotionFailure=true,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代的对象的平均大小。
      • 如果大于,则尝试进行一次 Minor GC ,但这次 Minor GC 依然是有风险的;
      • 如果小于,则改为进行一次 Full GC 。
    • 如果HandlePromotionFailure=false,则改为进行一次 Full GC。

在 JDK6 Update24 之后(即 JDK7),HandlePromotionFailure 参数不再会影响到虚拟机的空间分配担保策略。观察 OpenJDK 中的源码变化,虽然源码中还定义了 HandlePromotinFailure 参数,但是在代码中已经不会再使用它。JDK6 Update24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC

相当于 JDK7 之后的策略为 HandlePromotionFailure=true。

9 堆是分配对象存储的唯一选择码?

在《深入理解 Java 虚拟机》中关于 Java 堆内存有这样一段描述:

随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在 Java 虚拟机中,对象是在 Java 堆中分配内存的,但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行垃圾回收了。这也是最常见的堆外存储技术。

此外,基于 OpenJDK 深度定制的 TaoBaoVM,其中创新的 GCIH(GC invisiable heap)技术实现的 off-heap,将生命周期较长的 Java 对象从 heap 中移至 heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提高 GC 的回收效率的目的。

10 逃逸分析

10.1 概述

逃逸分析是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

通过逃逸分析,Java HotSpot 编译器能够分析出一个新对象的引用的使用范围从而决定是否要将这个对象分配到栈上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

例子:

public class EscapeAnalysis {
    
    public EscapeAnalysis obj;
    
    /*
    方法返沪EscapeAnalysis对象,发生逃逸
    */
    public EscapeAnalysis getInstance() {
        return obj == null ? new EscapeAnalysis() : obj;
    }
    
    /*
    为成员变量赋值,发生逃逸
    */
    public void setObj() {
        this.obj = new EscapeAnalysis();
    }
    // 思考:如果当前的 obj 引用声明为static?仍然会发生逃逸
    
    /*
    对象的作用域仅在当前方法中,没有发生逃逸
    */
    public void useEscapeAnalysis() {
        EscapeAnalysis e = new EscapAnalysis();
    }
    
    /*
    引用成员变量的值,发生逃逸
    */
    public void useEscapeAnalysis1() {
        EscapeAnalysis e = getInstance();
        // getInstance().xxx() 同样会发生逃逸
    }
}

参数设置

在 JDK 6u23 版本之后(JDK7),HotSpot 中默认开启逃逸分析。

如果使用较早的版本,则可以通过:

  • 选项 -XX:+DoEscapeAnalysis 显式开启逃逸分析。
  • 通过选线 -XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果。

结论:开发中能使用局部变量的,就不要在方法外定义。

10.2 代码优化

使用逃逸分析,编译器可以对代码做如下优化:

一、栈上分配。将堆分配转换成栈分配。如果一个对象在子程序中被分配,要是指向该对象的指针永远不会发生逃逸,对象可能是栈分配的候选,而不是堆分配。

二、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。

栈上分配

JIT 编译器在编译期间根据逃逸分析结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收,这样就不用进行垃圾回收了。

同步省略(消除)

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

如以下代码:

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

代码中对 hollis 这个对象进行加锁,但是 hollis 对象的生命周期只在 f() 方法中,并不会被其他线程所访问到,所以在 JIT 编译阶段就会被优化掉,优化成:

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}

标量替换

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。

相对的,那些还可以被分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为它可以分解成其它聚合量和标量。

在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换

例如:

public static void main(String[] args) {
    alloc();
}
private static void alloc() {
    Point point = new Point(1, 2);
    System,out.println("point.x=" + point.x + "; point.y=" + point.y);
}
class Point {
    public int x;
    public int y;
}

以上代码,经过标量替换后,就会变成:

private static void alloc() {
    int x = 1;
    int y = 2;
    System.out.println("point.x=" + x + "; point.y=" + y);
}

可以看到,Point 这个聚合量经过逃逸分析后,发现它并没有逃逸,就被替换成两个聚合量了。

标量替换的好处是:可以大大减少内存的占用,因为一旦不需要创建对象了,那么就不再需要分配堆内存了。

标量替换为栈上分配提供了很好的基础。

标量替换参数的设置:

参数 -XX:+EliminateAllocations:开启标量替换(默认开启),允许将对象打散分配在栈上。

10.3 逃逸分析小结

HotSpot 中并没有实现通过逃逸分析进行栈上分配,但实现了标量替换。