笔记 笔记
首页
  • 开发工具
  • Java Web
  • Java 进阶
  • 容器化技术
  • Java 专栏

    • Java 核心技术面试精讲
    • Java 业务开发常见错误 100 例
  • 数据库专栏

    • MySQL 实战 45 讲
    • Redis 核心技术与实战
  • 安全专栏

    • OAuth 2.0 实战课
  • 计算机系统
  • 程序设计语言
  • 数据结构
  • 知识产权
  • 数据库
  • 面向对象
  • UML
  • 设计模式
  • 操作系统
  • 结构化开发
  • 软件工程
  • 计算机网络
  • 上午题错题
在线工具 (opens new window)

EasT-Duan

Java 开发
首页
  • 开发工具
  • Java Web
  • Java 进阶
  • 容器化技术
  • Java 专栏

    • Java 核心技术面试精讲
    • Java 业务开发常见错误 100 例
  • 数据库专栏

    • MySQL 实战 45 讲
    • Redis 核心技术与实战
  • 安全专栏

    • OAuth 2.0 实战课
  • 计算机系统
  • 程序设计语言
  • 数据结构
  • 知识产权
  • 数据库
  • 面向对象
  • UML
  • 设计模式
  • 操作系统
  • 结构化开发
  • 软件工程
  • 计算机网络
  • 上午题错题
在线工具 (opens new window)

购买兑换码请添加

添加时候请写好备注,否则无法通过。

  • 设计模式

  • JVM 详解

    • JVM 与 Java 体系结构
    • 类加载子系统
    • 运行时数据区
    • 程序计数器
    • 虚拟机栈
    • 本地方法接口
    • 本地方法栈
    • 堆
      • 堆的核心概述
        • 堆与进程关系
        • 概念
        • 堆内存细分
      • 设置堆内存大小与 OOM
        • 设置堆内存
        • 示例 1
        • 示例 2
        • OOM
      • 年轻代与老年代
        • 演示 -XX:NewRatio
        • 演示 -XX:SurvivorRatio
      • 图解对象分配过程
        • 概述
        • 执行流程
        • 图解对象分配(一般过程)
        • 图解对象分配(特殊过程)
        • 常用调优工具
      • Minor / Major / Full GC
        • 分类
        • 年轻代 GC(Minor/Young GC)触发机制
        • 老年代 GC(Major GC)触发机制
        • Full GC 触发机制
        • 代码演示
      • 堆空间分代思想
      • 内存分配策略
      • 为对象分配内存:TLAB
        • 什么是 TLAB
        • 为什么有 TLAB
        • TLAB 说明
      • 小结堆空间的参数设置
      • 逃逸分析
        • 堆是分配对象的唯一选择吗?
        • 逃逸分析演示
        • 逃逸分析中的“代码优化”
        • 栈上分配
        • 同步省略(同步消除)
        • 标量替换
        • 什么是标量和聚合量?
        • 标量替换的工作原理
        • 如何启用标量替换(默认开启)
        • -server 参数(重要)
        • 逃逸的不足
      • 小结
    • 方法区
    • 对象的实例化内存布局与访问定位
    • 直接内存
    • 执行引擎
    • StringTable
    • 垃圾回收概述
    • 垃圾回收算法
    • 垃圾回收概念
    • 垃圾回收器
  • Linux

  • Redis

  • 分布式锁

  • Shiro

  • Gradle

  • Java 进阶
  • JVM 详解
EasT-Duan
2024-01-10
目录

堆

欢迎来到我的 ChatGPT 中转站,极具性价比,为付费不方便的朋友提供便利,有需求的可以添加左侧 QQ 二维码,另外,邀请新用户能获取余额哦!最后说一句,那啥:请自觉遵守《生成式人工智能服务管理暂行办法》。

# 堆的核心概述

# 堆与进程关系

  • 堆针对一个 JVM 进程来说是唯一的。也就是一个进程只有一个 JVM 实例,一个 JVM 实例中就有一个运行时数据区,一个运行时数据区只有一个堆和一个方法区。
  • 但是进程包含多个线程,他们是共享同一堆空间的。
点击查看
  1. 堆与 JVM 进程的关系:
    • 在 Java 中,堆是 JVM 的一个重要部分,用于存储 Java 程序运行时创建的对象实例。
    • 每个 JVM 进程都有自己的堆内存。这意味着不同的 Java 应用程序(即不同的 JVM 实例)之间的堆内存是相互隔离的。
    • 在一个 JVM 实例中,除了堆外,还有其他运行时数据区域,例如方法区(Method Area),栈(Stack)等。
  2. 进程、线程与堆的关系:
    • 在一个 JVM 进程中,可以运行多个线程。
    • 这些线程共享同一个 JVM 实例的堆内存。也就是说,所有线程都可以访问和操作存储在堆上的对象。
    • 尽管线程共享堆内存,但每个线程有自己的栈内存,用于存储局部变量和调用方法的记录。

# 概念

  • 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。
  • Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了,堆是 JVM 管理的最大一块内存空间,并且堆内存的大小是可以调节的。
  • 《Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  • 所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
  • 《Java 虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
    • 从实际使用角度看:“几乎” 所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)。
  • 数组和对象可能永远不会存储在栈上(不一定),因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
    • 也就是触发了 GC 的时候,才会进行回收。
    • 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有 stop the word。
  • 堆,是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

随着 JVM 的迭代升级,原来一些绝对的事情,在后续版本中也开始有了特例,变的不再那么绝对。

public class SimpleHeap {
    private int id;//属性、成员变量

    public SimpleHeap(int id) {
        this.id = id;
    }

    public void show() {
        System.out.println("My ID is " + id);
    }
    public static void main(String[] args) {
        SimpleHeap sl = new SimpleHeap(1);
        SimpleHeap s2 = new SimpleHeap(2);

        int[] arr = new int[10];

        Object[] arr1 = new Object[10];
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 堆内存细分

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

  • Java7 及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区。
    • Young Generation Space 新生区 Young/New。
      • 又被划分为 Eden 区和 Survivor 区。
    • Old generation space 养老区 Old/Tenure。
    • Permanent Space 永久区 Perm。
  • Java 8 及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间。
    • Young Generation Space 新生区,又被划分为 Eden 区和 Survivor 区。
    • Old generation space 养老区。
    • Meta Space 元空间 Meta。

约定:新生区 == 新生代 == 年轻代 、 养老区 == 老年区 == 老年代、 永久区 == 永久代。

笔记

顺带提一句,直接在 cmd 里运行 jvisualvm 就能直接打开一个监控工具,较为新的 jdk8 默认带了 Visual GC 插件,如果老一点的 jdk8 到插件那里下载一下。

# 设置堆内存大小与 OOM

# 设置堆内存

  1. Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,大家可以通过选项”-Xms” 和”-Xmx” 来进行设置。

    • -Xms 用于表示堆区的起始内存,等价于 -XX:InitialHeapSize。
    • -Xmx 则用于表示堆区的最大内存,等价于 -XX:MaxHeapSize。
  2. 一旦堆区中的内存大小超过 “-Xmx” 所指定的最大内存时,将会抛出 OutofMemoryError 异常。

  3. 通常会将 - Xms 和 - Xmx 两个参数配置相同的值。

    • 原因:假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在 GC 之后调整堆内存给服务器带来压力。

    • 如果两个设置一样的就少了频繁扩容和缩容的步骤。内存不够了就直接报 OOM。

  4. 默认情况下:

    • 初始内存大小:物理电脑内存大小 / 64。

    • 最大内存大小:物理电脑内存大小 / 4。

# 示例 1

/**
 * 1. 设置堆空间大小的参数
 * -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
 *      -X 是 jvm 的运行参数
 *      ms 是 memory start
 * -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
 *
 * 2. 默认堆空间的大小
 *    初始内存大小:物理电脑内存大小 / 64
 *             最大内存大小:物理电脑内存大小 / 4
 * 3. 手动设置:-Xms600m -Xmx600m
 *     开发中建议将初始堆内存和最大的堆内存设置成相同的值。
 *
 * 4. 查看设置的参数:方式一: jps   /  jstat -gc 进程 id
 *                  方式二:-XX:+PrintGCDetails
 */
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();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

输出结果:

-Xms : 243M
-Xmx : 3586M
系统内存大小为:15.1875G
系统内存大小为:14.0078125G
1
2
3
4

为什么两者数值不一致?这个问题一会儿再解释。先看下面的示例

# 示例 2

设置一下参数:-Xms600m -Xmx600m,得到如下结果。但是为什么少了 25M?

-Xms : 575M
-Xmx : 575M
1
2

通过这几种方式详细看一下

方式一:-XX:+PrintGCDetails

通过计算可得,(179200+409600)1024=575\frac{(179200+409600)}{1024} = 575​1024​​(179200+409600)​​=575

方式二:jps

打开 cmd 命令框,输入 jps,然后就可以看到所有需要 java 环境的进程信息和进程 id(例如:31344),然后通过命令 jstat -gc 31344 来查看。

通过计算(25600+25600+153600+409600)1024=600\frac{(25600+25600+153600+409600)}{1024} = 600​1024​​(25600+25600+153600+409600)​​=600,但是,S0 区和 S1 区两个只有一个能使用,另一个用不了(后面详解)。所以就变成了(25600.0+153600+409600)1024=575\frac{(25600.0+153600+409600)}{1024} = 575​1024​​(25600.0+153600+409600)​​=575。

点击查看

下面是 jstat -gc 命令输出的数据,用 Markdown 表格格式展示:

列名 描述 值(KB)
S0C 第一幸存区的当前大小 25600.0
S1C 第二幸存区的当前大小 25600.0
S0U 第一幸存区的已使用空间 0.0
S1U 第二幸存区的已使用空间 0.0
EC 年轻代 (Eden Space) 的当前大小 153600.0
EU 年轻代的已使用空间 12288.1
OC 老年代 (Old Generation) 的当前大小 409600.0
OU 老年代的已使用空间 0.0
MC 元空间 (Metaspace) 的当前大小 4480.0
MU 元空间的已使用空间 778.0
CCSC 压缩类空间 (Compressed Class Space) 的当前大小 384.0
CCSU 压缩类空间的已使用空间 76.6
YGC 年轻代垃圾回收次数 0
YGCT 年轻代垃圾回收耗时 0.000
FGC 老年代垃圾回收次数 0
FGCT 老年代垃圾回收耗时 0.000
GCT 总垃圾回收耗时 0.000

# OOM

/**
 * -Xms600m -Xmx600m
 */
public class OOMTest {
    public static void main(String[] args) {
        ArrayList<Picture> list = new ArrayList<>();
        while (true) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(new Picture(new Random().nextInt(1024 * 1024)));
        }
    }
}

class Picture {
    private byte[] pixels;

    public Picture(int length) {
        this.pixels = new byte[length];
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

运行后,通过观察 Visual GC 可以看到整个溢出过程。

# 年轻代与老年代

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

    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。
    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致。
  • Java 堆区进一步细分的话,可以划分为年轻代 (YoungGen) 和老年代 (oldGen)。

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

17050504871668

  • 配置新生代与老年代在堆结构的占比(这些参数一般在开发中不会手动去设置)
/**
 * -Xms600m -Xmx600m
 *
 * -XX:NewRatio : 设置新生代与老年代的比例。默认值是 2.
 * -XX:SurvivorRatio :设置新生代中 Eden 区与 Survivor 区的比例。默认值是 8
 * -XX:-UseAdaptiveSizePolicy :关闭自适应的内存分配策略  (暂时用不到)
 * -Xmn:设置新生代的空间的大小。 (一般不设置)
 */
public class EdenSurvivorTest {
    public static void main(String[] args) {
        System.out.println("我只是来打个酱油~");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 演示 -XX:NewRatio

默认 -XX:NewRatio=2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3。可以修改 -XX:NewRatio=4,表示新生代占 1,老年代占 4,新生代占整个堆的 1/5。配置直接加在 -Xmx..... 配置的后面即可。

# 演示 -XX:SurvivorRatio

通过直接查看 Visual GC 可以发现,这个比例并不是 8:1:1,而是 6:1:1。

笔记

这是因为在 JDK7/8 默认都是 Parallel GC,Parallel GC 会根据自己情况自动调整,JDK 工具文档提到是禁用自动适配大小策略参数(-XX:-UseAdaptiveSizePolicy),但是实际上没有任何效果,除非显式声明 -XX:SurvivorRatio=8。

点击查看
  1. JVM 版本和实现差异:
    • 不同的 JVM 实现(如 HotSpot、OpenJ9 等)和不同的版本可能有不同的默认设置和行为。您看到的配置可能反映了特定于您的 JVM 版本的默认行为或配置。
  2. 自适应大小策略(Adaptive Size Policy):
    • UseAdaptiveSizePolicy 选项是用来开启 JVM 的自适应堆大小调整策略的。当启用时,JVM 会根据当前应用程序的运行情况自动调整新生代和老年代的大小以及其中的各个区域的比例。
    • 使用 -XX:-UseAdaptiveSizePolicy 是关闭这个特性,即使用手动或静态的配置。如果您关闭了自适应大小策略但没有看到预期的 8:1:1 比例,可能是因为 JVM 在启动时根据可用内存或其他因素自动设置了不同的默认值。
  3. 其他 JVM 参数的影响:
    • 有些其他 JVM 参数可能会影响新生代和 Survivor 空间的大小和比例,例如 -Xmx (设置最大堆大小)、 -Xms (设置初始堆大小)、 -Xmn (设置新生代大小)等。这些参数可能会间接影响 Eden 和 Survivor 空间的分配。
  4. 实际运行时的动态调整:
    • 在 JVM 运行过程中,根据应用程序的实际内存使用情况,某些内存参数可能会动态调整。特别是在长时间运行的应用程序中,JVM 可能会根据观察到的对象生命周期模式调整内存分配策略。

# 图解对象分配过程

# 概述

为新对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。

# 执行流程

  1. new 的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
  3. 然后将伊甸园中的剩余对象移动到幸存者 0 区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区的,如果没有回收,就会放到幸存者 1 区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。
  6. 啥时候能去养老区呢?可以设置次数。默认是 15 次。可以设置新生区进入养老区的年龄限制,设置 JVM 参数:
    • XX:MaxTenuringThreshold=N 进行设置
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发 GC:Major GC,进行养老区的内存清理
  8. 若养老区执行了 Major GC 之后,发现依然无法进行对象的保存,就会产生 OOM 异常。

# 图解对象分配(一般过程)

  1. 我们创建的对象,一般都是存放在 Eden 区的,当 Eden 区满了后,就会触发 GC 操作,一般被称为 Young GC / Minor GC 操作。
  2. 当我们进行一次垃圾收集后,红色的对象将会被回收,而绿色的独享还被占用着,存放在 S0 (Survivor From) 区。同时我们给每个对象设置了一个年龄计数器,经过一次回收后还存在的对象,将其年龄加 1。
  3. 同时 Eden 区继续存放对象,当 Eden 区再次存满的时候,又会触发一个 MinorGC 操作,此时 GC 将会把 Eden 和 Survivor From 中的对象进行一次垃圾收集,把存活的对象放到 Survivor To(S1)区,同时让存活的对象年龄 + 1。
  4. 我们继续不断的进行对象生成和垃圾回收,当 Survivor 中的对象的年龄达到 15 的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中。

总结

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

# 图解对象分配(特殊过程)

# 常用调优工具

  • JDK 命令行
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • Visual VM(实时监控,推荐)
  • Jprofiler(IDEA 插件和本地都要装,装 11 破解方便)
  • Java Flight Recorder(实时监控)
  • GCViewer
  • GCEasy

# Minor / Major / Full GC

# 分类

JVM(Java 虚拟机)的垃圾回收(GC)是其性能调优的一个重要环节。主要目的是管理和优化内存使用,以避免内存泄漏和过度消耗。垃圾回收过程中常见的问题是 STW(Stop The World),意味着在垃圾回收期间,程序中断执行,这对性能有显著影响。特别是在进行 Major GC(老年代回收)和 Full GC(整堆回收)时,STW 的时间比 Minor GC(新生代回收)长得多。

在 JVM 中,垃圾回收并不总是同时针对所有内存区域,大多数情况下,回收的是新生代内存。对于 Hotspot VM(一种 JVM 实现),其 GC 可以按回收区域分为两大类:

  1. 部分收集(Partial GC):这种方式不会回收整个 Java 堆的垃圾。其中包括:
    • 新生代收集(Minor GC/Young GC):仅针对新生代(Eden 区,Survivor 区 0 和 1)的垃圾回收。
    • 老年代收集(Major GC/Old GC):仅针对老年代的垃圾回收。目前,只有 CMS GC(并发标记清除垃圾回收器)会单独对老年代进行垃圾回收。
  2. 整堆收集(Full GC):涉及整个 Java 堆和方法区的垃圾回收。

值得注意的是,有时人们会混淆使用 Major GC 和 Full GC 的概念,需要明确区分老年代回收和整堆回收。

此外,还有一种称为混合收集(Mixed GC)的方式,它是 G1 GC(Garbage-First 垃圾回收器)的特性,能够同时收集整个新生代和部分老年代的垃圾。

# 年轻代 GC(Minor/Young GC)触发机制

  1. 当年轻代空间不足时,就会触发 Minor GC,这里的年轻代满指的是 Eden 区满。Survivor 满不会主动引发 GC,在 Eden 区满的时候,会顺带触发 s0 区的 GC,也就是被动触发 GC(每次 Minor GC 会清理年轻代的内存)
  2. 因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
  3. Minor GC 会引发 STW(Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

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

  • 老年代 GC 定义:当 GC 发生在老年代并且回收了老年代的对象时,通常被称为 Major GC。有时候,Major GC 也被称为 Full GC,尽管在某些文章中 Full GC 可能指的是同时清理年轻代和老年代,以及方法区的 GC。
  • 与 Minor GC 的关系:
    • 在老年代空间不足时,JVM 会首先尝试通过执行 Minor GC 来释放整个堆内存中的空间,因为 Minor GC 能够通过晋升对象到老年代来为新对象在年轻代腾出空间。简言之:出现了 Major GC,经常会伴随至少一次的 Minor GC。
    • 如果 Minor GC 后老年代的空间仍然不足,那么 JVM 会触发 Major GC。在某些 GC 算法中(如 Parallel Scavenge),可能存在直接触发 Major GC 的策略,不过这种情况不是普遍的。简言之:在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程。
  • 性能:Major GC 的速度一般会比 Minor GC 慢 10 倍以上,STW 的时间更长。
  • 如果 Major GC 后,内存还不足,就报 OOM 了。

# 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 是开发或调优中尽量要避免的。这样 STW 时间会短一些。

# 代码演示

/**
 * 测试 MinorGC 、 MajorGC、FullGC
 * -Xms9m -Xmx9m -XX:+PrintGCDetails
 */
public class GCTest {
    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "atguigu.com";
            while (true) {
                list.add(a);
                a = a + a;
                i++;
            }

        } catch (Throwable t) {
            t.printStackTrace();
            System.out.println("遍历次数为:" + i);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[GC (Allocation Failure) [PSYoungGen: 1994K->488K(2560K)] 1994K->818K(9728K), 0.0021145 secs] [Times: user=0.02 sys=0.02, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2308K->432K(2560K)] 2638K->2090K(9728K), 0.0012259 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1893K->448K(2560K)] 3551K->2810K(9728K), 0.0011161 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1268K->0K(2560K)] [ParOldGen: 6586K->4853K(7168K)] 7854K->4853K(9728K), [Metaspace: 3201K->3201K(1056768K)], 0.0044007 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 4853K->4853K(9728K), 0.0004375 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 4853K->4822K(7168K)] 4853K->4822K(9728K), [Metaspace: 3201K->3201K(1056768K)], 0.0112663 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
遍历次数为:16
Heap
 PSYoungGen      total 2560K, used 97K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 4% used [0x00000000ffd00000,0x00000000ffd184c8,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 4822K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 67% used [0x00000000ff600000,0x00000000ffab5818,0x00000000ffd00000)
 Metaspace       used 3254K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 357K, capacity 388K, committed 512K, reserved 1048576K
java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3332)
	at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
	at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
	at java.lang.StringBuilder.append(StringBuilder.java:136)
	at com.dfd.jvm.chapter08.java1.GCTest.main(GCTest.java:20)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
点击查看
  1. 第一条 GC 日志:

    • [GC (Allocation Failure) [PSYoungGen: 1994K->488K(2560K)] 1994K->818K(9728K), 0.0021145 secs]
    • 这表示发生了一次年轻代(Young Generation)的 GC,由于分配失败(Allocation Failure)触发。
    • [PSYoungGen: 1994K->488K(2560K)] :年轻代从 1994K 减少到 488K,总容量为 2560K。
    • 1994K->818K(9728K) :整个堆(Heap)的使用量从 1994K 减少到 818K,总容量为 9728K。
    • 0.0021145 secs :此次 GC 耗时约 0.002 秒。
  2. 随后的几条 GC 日志:

    • 类似地,这些也是因为分配失败触发的年轻代 GC。每次 GC 后,都会显示年轻代和整个堆的使用情况及耗时。
  3. Full GC 日志:

    • [Full GC (Ergonomics) [PSYoungGen: 1268K->0K(2560K)] [ParOldGen: 6586K->4853K(7168K)] 7854K->4853K(9728K), [Metaspace: 3201K->3201K(1056768K)], 0.0044007 secs]
    • 这是一次整堆回收(Full GC),由 JVM 的自适应调节(Ergonomics)触发。
    • PSYoungGen 和 ParOldGen 分别显示年轻代和老年代的使用情况。
    • Metaspace 部分显示元空间(用于存储类信息等)的使用情况,未发生变化。
    • 此次 Full GC 耗时约 0.004 秒。
  4. 再次 GC 和 Full GC:

    • 类似之前的日志,显示了年轻代 GC 和 Full GC 的细节。

# 堆空间分代思想

Java 堆的分代是基于这样一个观察:“不同对象的生命周期不同”,这是所谓的 “弱代假说”(Weak Generational Hypothesis),它有两个主要方面:

  1. 大多数对象很快无用:许多对象的生命周期都很短,它们被创建后不久就变得不可达,因此可以迅速回收。
  2. 存活对象通常会存活更久:一旦对象经过第一次垃圾回收仍然存活,它们通常会存活更长时间。

基于这些观察,分代垃圾回收提供了以下优势:

  • 高效的内存管理:由于新生代(Young Generation)中的大多数对象都很快无用,所以通过只对新生代进行频繁的小规模回收(Minor GC),可以高效地回收内存,因为只有少数对象需要被复制到存活区或者晋升到老年代。
  • 减少垃圾收集的开销:分代回收减少了需要频繁检查的对象数量,因为老年代(Old Generation)中的对象假定不太可能在任何给定的回收周期中无用。只有在老年代空间不足时,才会对其进行更耗时的回收(Major GC 或 Full GC)。
  • 优化 GC 算法:分代允许 JVM 为不同的对象生命周期采用不同的垃圾回收算法。例如,新生代通常使用复制算法,因为只有少数对象需要保存,而老年代可能使用标记 - 清除或标记 - 整理算法,这些算法在处理大量存活对象时更有效。

如果不分代,JVM 仍然可以进行垃圾回收,但效率可能会大大降低。没有分代机制的话,JVM 每次垃圾收集都需要检查堆中的所有对象,而不是仅仅关注其中的一小部分。这将增加垃圾回收的时间,导致更频繁

的长时间 STW(Stop-The-World)事件,从而对应用程序的性能产生负面影响。此外,不分代的内存管理可能会导致更多的内存碎片,这会进一步影响性能,特别是在进行内存分配时。

分代垃圾回收的策略是现代商业 JVM 提高垃圾回收性能和内存管理效率的关键。然而,也有一些新的垃圾收集器,如 ZGC 和 Shenandoah,它们采用不同的技术来减少 GC 的延迟,并且不完全依赖于传统的分代模型。这些收集器通过并发和增量处理来回收内存,减少 STW 的停顿时间,而不是依靠分代假设。这些垃圾收集器的出现表明,虽然分代回收是一种非常成功的内存管理策略,但随着技术的发展,也可以采用其他方法来实现高效的垃圾回收。

# 内存分配策略

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

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

  1. 优先分配到 Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发 Major GC 的次数比 Minor GC 要更少,因此可能回收起来就会比较慢
  2. 大对象直接分配到老年代:尽量避免程序中出现过多的大对象
  3. 长期存活的对象分配到老年代
  4. 动态对象年龄判断:如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
  5. 空间分配担保: -XX:HandlePromotionFailure 。

# 为对象分配内存:TLAB

# 什么是 TLAB

TLAB(Thread Local Allocation Buffer)是一种为每个线程提供的私有内存缓冲区域,它是从堆区的 Eden 空间中划分出来的。每个线程都有自己的 TLAB,它们彼此隔离,从而实现了线程局部的内存分配。

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

  • 当一个线程的 TLAB 存满时,可以使用公共区域(蓝色)的。

# 为什么有 TLAB

  1. 多线程环境下的线程安全问题:在 JVM 中,堆区是所有线程共享的内存区域,用于存储 Java 对象实例。在多线程环境中,多个线程可能会同时尝试在堆区分配内存空间以创建对象。如果不加控制,这可能会导致线程安全问题,如多个线程同时操作同一内存地址。
  2. 提高内存分配效率:为了避免线程安全问题,可以通过加锁等同步机制来控制对共享内存的访问。然而,这样做会显著降低内存分配的效率。由于对象的创建在 JVM 中非常频繁,因此需要一种更高效的方式来分配内存。

# TLAB 说明

  1. 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
  2. 在程序中,开发人员可以通过选项 “-XX:UseTLAB” 设置是否开启 TLAB 空间。
  3. 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 “-XX:TLABWasteTargetPercent” 设置 TLAB 空间所占用 Eden 空间的百分比大小。
  4. 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

注意

《深入理解 JVM》中说:哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定。

下面补充两点说明

  1. "哪个线程要分配内存,就在哪个线程的本地缓冲区中分配":这意味着当一个 Java 线程需要创建一个新对象时,它会首先尝试在自己的 TLAB 中分配内存。由于每个线程都有自己的 TLAB,因此这个过程是线程局部的,无需担心其他线程的干扰。这样的设计大大减少了对共享资源的竞争,提高了内存分配的速度。

  2. "只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定":TLAB 并不是无限大的,它有一定的大小限制。当一个线程的 TLAB 用完了,它需要从堆区的 Eden 空间中分配一个新的 TLAB。在这个过程中,因为 Eden 空间是被所有线程共享的,所以需要通过同步锁定来确保线程安全。这意味着,只有在分配新的 TLAB 时才需要考虑线程间的同步问题,而在 TLAB 内部分配对象时则不需要。

# 小结堆空间的参数设置

官方地址 (opens new window),不一一介绍了,只说明部分。

/**
 * 测试堆空间常用的 jvm 参数:
 * -XX:+PrintFlagsInitial : 查看所有的参数的默认初始值
 * -XX:+PrintFlagsFinal  :查看所有的参数的最终值(可能会存在修改,不再是初始值)
 *      具体查看某个参数的指令: jps:查看当前运行中的进程
 *                             jinfo -flag SurvivorRatio 进程 id
 *
 * -Xms:初始堆空间内存 (默认为物理内存的 1/64)
 * -Xmx:最大堆空间内存(默认为物理内存的 1/4)
 * -Xmn:设置新生代的大小。(初始值及最大值)
 * -XX:NewRatio:配置新生代与老年代在堆结构的占比
 * -XX:SurvivorRatio:设置新生代中 Eden 和 S0/S1 空间的比例
 * -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
 * -XX:+PrintGCDetails:输出详细的 GC 处理日志
 * 打印 gc 简要信息:① -XX:+PrintGC   ② -verbose:gc
 * -XX:HandlePromotionFailure:是否设置空间分配担保
 *
 */
public class HeapArgsTest {
    public static void main(String[] args) {

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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

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

历史版本

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

# 逃逸分析

# 堆是分配对象的唯一选择吗?

这个问题的答案放在这里。

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

  1. 随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么 “绝对” 了。
  2. 在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
  3. 此外,前面提到的基于 OpenJDK 深度定制的 TaoBao VM,其中创新的 GCIH(GC invisible heap)技术实现 off-heap,将生命周期较长的 Java 对象从 heap 中移至 heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的。

# 逃逸分析演示

  • 没有发生逃逸的对象,则可以分配到栈(无线程安全问题)上,随着方法执行的结束,栈空间就被移除(也就无需 GC)。

    • public void my_method() {
          V v = new V();
          // use v
          // ....
          v = null;
          //方法结束 V 消失
      }
      
      1
      2
      3
      4
      5
      6
      7
  • 下面代码中的 StringBuffer sb 发生了逃逸,不能在栈上分配。这里 StringBuffer 对象 sb 是在方法内部创建的,但由于它被作为返回值返回,因此它逃逸了出方法范围。这意味着 sb 可能被方法外的代码引用,因此不能仅在栈上分配。

    • public static StringBuffer createStringBuffer(String s1, String s2) {
          StringBuffer sb = new StringBuffer();
          sb.append(s1);
          sb.append(s2);
          return sb;
      }
      
      1
      2
      3
      4
      5
      6
  • 如果想要 StringBuffer sb 不发生逃逸。 StringBuffer 对象 sb 在方法内创建并使用,但方法返回的是 sb.toString() 的结果,这是一个新的 String 对象。由于原始的 StringBuffer 对象 sb 没有逃逸出方法(它没有被外部引用),它可能在栈上分配。

    • public static String createStringBuffer(String s1, String s2) {
          StringBuffer sb = new StringBuffer();
          sb.append(s1);
          sb.append(s2);
          return sb.toString();
      }
      
      1
      2
      3
      4
      5
      6
  • /**
     * 逃逸分析
     *
     *  如何快速的判断是否发生了逃逸分析,大家就看 new 的对象实体是否有可能在方法外被调用。
     */
    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 EscapeAnalysis();
        }
        /*
        引用成员变量的值,发生逃逸
         */
        public void useEscapeAnalysis1(){
            EscapeAnalysis e = getInstance();
            //getInstance().xxx()同样会发生逃逸
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37

逃逸分析参数设置

  1. 在 JDK 1.7 版本之后,HotSpot 中默认就已经开启了逃逸分析。
  2. 如果使用的是较早的版本,开发人员则可以通过:
    • 选项 “-XX:+DoEscapeAnalysis” 显式开启逃逸分析。
    • 通过选项 “-XX:+PrintEscapeAnalysis” 查看逃逸分析的筛选结果。

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

# 逃逸分析中的 “代码优化”

  • 栈上分配:逃逸分析用于确定对象的生命周期和作用范围。如果编译器确定一个对象只在创建它的子程序中使用,并且它的引用不会 “逃逸” 到子程序外部,那么这个对象可以在栈上分配而不是在堆上。栈上分配的好处是可以减少垃圾回收的压力,因为栈上的对象会随着子程序的结束而自动销毁。
  • 同步省略:在多线程环境中,通常需要对共享数据进行同步以避免并发问题。但是,如果逃逸分析确定某个对象仅由单个线程访问,那么对这个对象的操作就不需要同步,因为不存在并发访问的问题。这可以减少同步开销,提高性能。
  • 分离对象或标量替换:如果编译器分析发现,一个对象的不同部分被不同方式访问,或者某些部分根本不需要存储为连续的内存结构,它可以选择对对象进行分解。这意味着对象的一部分或全部可能不在内存中作为连续结构存储,而是分散存储在 CPU 的寄存器中。这样可以减少内存访问次数,提高访问速度。

# 栈上分配

  1. JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
  2. 常见的栈上分配的场景:在逃逸分析中,已经说明了,分别是给成员变量赋值、方法返回值、实例引用传递。
/**
 * 栈上分配测试
 * -Xmx128m -Xms128m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
 */
public class StackAllocation {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        // 查看执行时间
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为: " + (end - start) + " ms");
        // 为了方便查看堆内存中对象个数,线程 sleep
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }
    private static void alloc() {
        User user = new User();//未发生逃逸
    }
    static class User {
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[GC (Allocation Failure) [PSYoungGen: 33280K->776K(38400K)] 33280K->784K(125952K), 0.0022002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 34056K->808K(38400K)] 34064K->816K(125952K), 0.0007734 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 34088K->808K(38400K)] 34096K->816K(125952K), 0.0010836 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 34088K->760K(38400K)] 34096K->768K(125952K), 0.0009022 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
花费的时间为: 58 ms
1
2
3
4
5

可以明显发现,程序出现了年轻代的 GC。把逃逸分析开启,发现速度从 58 ms 变成了 7 ms,并且未发生 GC。

// 将逃逸分析开启 -XX:+DoEscapeAnalysis
花费的时间为: 7 ms
1
2

# 同步省略(同步消除)

  • 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
  • 在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
  • 如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除(Lock Elision)。
/**
*	这段代码的问题是,每个线程都会创建 Object 对象,加锁已经没有任何意义了
*/
public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

/**
* 所以在运行时 jit 编译器就会将代码优化为如下
*/
public void f() {
    Object hellis = new Object();
	System.out.println(hellis);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

字节码内容:

 0 new #2 <java/lang/Object>
 3 dup
 4 invokespecial #1 <java/lang/Object.<init>>
 7 astore_1
 8 aload_1
 9 dup
10 astore_2
11 monitorenter
12 getstatic #3 <java/lang/System.out>
15 aload_1
16 invokevirtual #4 <java/io/PrintStream.println>
19 aload_2
20 monitorexit
21 goto 29 (+8)
24 astore_3
25 aload_2
26 monitorexit
27 aload_3
28 athrow
29 return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

但是通过查看字节码,在 11 和 20 还有加锁相关的字节码内容,难道是没生效???

笔记

锁消除是由 JVM 的即时编译器(JIT)在运行时进行的,而不是在将 Java 代码编译为字节码时进行的。这意味着,即使 JIT 在运行时决定消除一个锁,Java 源代码编译成的字节码仍然会包含与这个锁相关的字节码指令。

当 Java 代码被编译成字节码时,编译器通常不会进行复杂的优化,如逃逸分析或锁消除。这些优化是在运行时由 JVM 的 JIT 编译器根据实际的运行情况来执行的。JIT 编译器会监视正在运行的代码,收集各种性能指标,并在适当时基于这些指标来做出优化决策。

因此,在查看 Java 编译后的字节码文件时,你仍然可以看到 synchronized 块对应的字节码指令,例如 monitorenter 和 monitorexit 。这些指令在字节码级别表示进入和退出同步块。然而,在实际运行时,如果 JIT 编译器决定这个锁是不必要的,它将优化掉这些操作,但这种优化不会反映在字节码文件中。

# 标量替换

标量替换是一种性能优化技术,主要用于减少不必要的对象分配。

# 什么是标量和聚合量?
  • 标量(Scalar):这是一个不可再分的量,比如 Java 中的基本类型(int, float, double 等)和指针。
  • 聚合量(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 {
    private int x;
    private int y;
}
1
2
3
4
5
6
7
8
9
10
11
12

经过标量替换后代码就会变成如下所示。

private static void alloc() {
    int x = 1;
    int y = 2;
    System.out.println("point.x = " + x + "; point.y=" + y);
}
1
2
3
4
5
# 如何启用标量替换(默认开启)

在 Oracle 的 HotSpot JVM 中,可以通过 JVM 参数来启用标量替换,例如:

  • -XX:+DoEscapeAnalysis :开启逃逸分析,这是标量替换的前提。
  • -XX:+EliminateAllocations :启用标量替换优化。

未启用

/**
 * 标量替换测试
 *  -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
 */
public class ScalarReplace {
    public static void alloc() {
        User u = new User();//未发生逃逸
        u.id = 5;
        u.name = "www.atguigu.com";
    }

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为: " + (end - start) + " ms");
    }

    public static class User {
        public int id;
        public String name;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 可以看到发生多次 GC
//上述代码在主函数中调用了 1 亿次 alloc()方法,进行对象创建由于 User 对象实例需要占据约 16 字节的空间,因此累计分配空间达到将近 1.5GB。如果堆空间小于这个值,就必然会发生 GC。
[GC (Allocation Failure)  25600K->816K(98304K), 0.0017269 secs]
[GC (Allocation Failure)  26416K->792K(98304K), 0.0011720 secs]
[GC (Allocation Failure)  26392K->760K(98304K), 0.0008388 secs]
[GC (Allocation Failure)  26360K->840K(98304K), 0.0009185 secs]
[GC (Allocation Failure)  26440K->792K(98304K), 0.0011239 secs]
[GC (Allocation Failure)  26392K->776K(101376K), 0.0008289 secs]
[GC (Allocation Failure)  32520K->688K(101376K), 0.0008266 secs]
[GC (Allocation Failure)  32432K->688K(100352K), 0.0003648 secs]
花费的时间为: 64 ms
1
2
3
4
5
6
7
8
9
10
11

已启用

花费的时间为: 8 ms
1
# -server 参数(重要)

如果发现即使配置了 -XX:+EliminateAllocations 也无法开启标量替换,甚至关于逃逸分析的相关特征都无法正常启用,这里有可能是为配置 -server 参数导致的,可以将这个参数加上,例如: -server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations ,一般本地都无需配置,因为安装的 jdk 默认为 server 版本。

# 逃逸的不足

  • 关于逃逸分析的论文在 1999 年就已经发表了,但直到 JDK1.6 才有实现,而且这项技术到如今也并不是十分成熟的。
  • 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
  • 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
  • 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
  • 注意到有一些观点,认为通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于 JVM 设计者的选择。据我所知,Oracle Hotspot JVM 中并未这么做(刚刚演示的效果,是因为 HotSpot 实现了标量替换),这一点在逃逸分析相关的文档里已经说明,所以可以明确在 HotSpot 虚拟机上,所有的对象实例都是创建在堆上。
  • 目前很多书籍还是基于 JDK7 以前的版本,JDK 已经发生了很大变化,intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是 intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

综上:对象实例都是分配在堆上的。也可以看看 ChatGPT 4 的回答。

点击查看

关于逃逸分析和对象在栈上分配的理论与实践之间确实存在一些差异。在理论上,逃逸分析确实允许 JVM 在确定对象不会逃逸到方法或线程的范围之外时,在栈上分配这些对象。但是,实际的 JVM 实现(特别是 Oracle HotSpot JVM)可能并没有充分利用这一点来在栈上分配对象。

逃逸分析虽然是一个强大的优化手段,但它也是一个复杂且耗时的过程。实际上,JVM 实现者需要在逃逸分析带来的性能优化和其自身的性能开销之间找到平衡。在某些情况下,由于逃逸分析的性能消耗可能超过其带来的优化效果,因此 JVM 可能选择不在栈上分配对象,即使理论上这是可能的。

Oracle HotSpot JVM 的实现,主要利用逃逸分析进行标量替换、锁消除等优化,而并不普遍地在栈上分配对象。这意味着尽管理论上通过逃逸分析可以实现栈上分配,但在实践中,HotSpot JVM 通常还是将对象实例分配在堆上。

至于 JDK 的变化,文中提到的关于字符串 intern 缓存和静态变量的处理也是一个很好的例子,展示了 Java 技术的演进如何影响 JVM 的实现和优化决策。随着 JDK 版本的更新,JVM 的内部实现和优化策略也会发生变化,但这不一定意味着每项理论上可行的优化都会在实践中得到应用。

综上所述,您理解的关于逃逸分析和对象分配的理论是正确的,但实际的 JVM 实现(特别是 HotSpot JVM)可能因为各种实际考虑而不完全遵循这一理论。因此,在 HotSpot JVM 上,对象实例确实主要是分配在堆上的。

# 小结

  1. 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
  2. 老年代放置长生命周期的对象,通常都是从 Survivor 区域筛选拷贝过来的 Java 对象。
  3. 当然,也有特殊情况,我们知道普通的对象可能会被分配在 TLAB 上;
  4. 如果对象较大,无法分配在 TLAB 上,则 JVM 会试图直接分配在 Eden 其他位置上;
  5. 如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。
  6. 当 GC 只发生在年轻代中,回收年轻代对象的行为被称为 Minor GC。
  7. 当 GC 发生在老年代时则被称为 Major GC 或者 Full GC。
  8. 一般的,Minor GC 的发生频率要比 Major GC 高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。
#JVM
上次更新: 2025/04/12, 05:37:39
本地方法栈
方法区

← 本地方法栈 方法区→

最近更新
01
Reactor 核心
02-24
02
前置条件
10-30
03
计算机网络
09-13
更多文章>
Theme by Vdoing | Copyright © 2019-2025 powered by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式