likes
comments
collection
share

深度解析:大对象分配引发的GC问题案例研究

作者站长头像
站长
· 阅读数 3

本文旨在以第一视角,分享有限条件下综合使用多种工具、结合源码、官方文档等资料,深度排查问题原因、理解问题发生的背后原理,并最终还原问题发生过程的有趣经历。你可能需要先了解一些 G1 的基础知识,再阅读本文,Oracle 官方的 Tutorial 会是一个不错的学习素材 www.oracle.com/technetwork…

一、案发

深度解析:大对象分配引发的GC问题案例研究

图1: JVM 老年代告警

事情要从 图1 的告警说起。可以看到 172...151(Pod-151)这台实例的老年代内存,在时间刚过 04:00(24h) 时,突然快速增长,直到利用率超过 75%(告警阈值) 触发了告警。而 Pod-151 只是冰山一角,集群中 60+ 个实例,全都出现了相同特征的老年代使用率过高的问题,只是发生的时间各有不同,但都分布在 00:00 - 05:00 之间。

该服务的核心 JVM 参数配置如下(已经隐去了和本次问题无关的配置项):

深度解析:大对象分配引发的GC问题案例研究

1.1 嫌疑人画像

基于问题的特点,我首先尝试做一些初步的分析:

  1. 考虑到使用的内存管理策略是 G1,老年代持续增长的典型原因可能有 2 种:① Young GC 过程中大量对象升代(promoted) ② 频繁的 Humongous Object 分配 [1]^{[1]}[1]
  2. 60+ 实例全都出现了相同的问题,说明触发该问题的机制一定是某种 “例行任务(Routine Task)” 或显而易见的代码 Bug,而非触碰了某种精妙的 “机关”;
  3. 问题发生的时间集中在凌晨,正是业务低峰期,在这 5 个多小时里,大多数实例都只零星的处理了一两个业务请求,甚至有一些实例上一个业务请求都没有。因此,问题原因大概率和维持服务运转的运维行为有关[2]^{[2]}[2],而与业务逻辑无关。

二、搜证和推理

有了前面的画像,就有了侦破问题的方向。接下来,就是寻找线索,证实前面的猜想,或者,推翻前面的猜想。而立刻能想到的获取线索的途径至少有两个:① 分析 JVM 监控数据或 GC 日志 ② 通过 jmap [3]^{[3]}[3] 获取 JVM 堆内存快照(heap dump),分析快照。

在当初排查问题时,还没有此刻的上帝视角,heap dump 往往需要恰当的时机才有可能捕获到问题,即便正确的时机获得了 “堆快照”,我们暂时也不清楚要寻找的目标是什么。因此,我决定先从 JVM 监控数据查起,同时分多日在 00:00 - 02:00 之间,抽样部分实例进行 “堆快照” 采集,供后续分析。

2.1 Humongous Allocation GC

监控数据给我们提供了一条重要线索,图2 可以看到,从整个集群的视角,在问题发生的时段内,Humongous Allocation 引发的 GC (GC Count::G1 Humongous Allocation 指标 [4]^{[4]}[4])与老年代内存的使用情况(JVM Mem Heap Used::G1 Old Gen 指标 [5]^{[5]}[5])呈现明显的正相关性。为了避免统计学上的幸存者偏差,我又逐个实例进行了确认,基本都呈现类似 图3 的特征,这进一步说明老年代的增长可能是由大对象分配导致的。至此,我们在前文中对老年代持续增长原因的推论就有了答案。

深度解析:大对象分配引发的GC问题案例研究

图2:JVM 监控(Cluster View)

深度解析:大对象分配引发的GC问题案例研究

图3:JVM 监控(Single Pod View)

这条线索指明了接下来的搜证方向:我们要去 “堆快照” 里找一个 Humongous Object。不过在行动之前,让我们先分析一下这个 Humongous Object 可能长什么样子?这就要求我们必须弄明白两个重要的概念,而这两个概念对应了统计一个对象使用了多少内存的两种方式:Shallow Size 和 Retained Size。

  • Shallow Size:对象的 Shallow Size 是指为存储对象本身而分配的内存量,不包括该对象引用的对象;
  • Retained Size:对象的 Retained Size 是该对象自身的 Shallow Size,再加上那些只能从该对象直接或间接访问的对的 Shallow Size 之和;

其中 Shallow Size 比较好理解,例如当我们用下方的 Dog 类实例化一只 图4 的德牧时,可以很容易计算出这个实例的 Shallow Size 是多少:

Shallow Size of the dog = sizeof(breed) + sizeof(age) + size(name)

= sizeof(reference to string) + sizeof(int) + sizeof(reference to string) 

= 4 bytes + 4 bytes + 4 bytes 

= 12 bytes

深度解析:大对象分配引发的GC问题案例研究

图 4:一只德牧犬

public class Dog {
    // String is a reference type:4bytes
    private String breed;
    // int: 4bytes
    private int age;
    // reference type: 4bytes  
    private String name;
}

实际上,不论我们创建的是一只德牧还是一只柯基犬,在相同的 JVM 架构下,对象的 Shallow Size 都是 12 字节 [6]^{[6]}[6],即两个引用类型 [7]^{[7]}[7] 和一个 int 类型的内存大小之和,不会因为引用指向的字符串长短不同而有所不同。

而 Retained Size 解释起来要复杂一些。在前面定义 Retained Size 时,我专门突出了 “只能从该对象直接或间接访问的对象” 这段描述,理解 Retained Size 的关键就在这里。让我们来对比一下 图5 和 图6,分别计算这两张图中 obj1 的 Retained Size 是多少?

深度解析:大对象分配引发的GC问题案例研究

图 5

深度解析:大对象分配引发的GC问题案例研究

图 6

在 图5 和 图6 中,可以通过 obj1 直接或间接访问的对象都是 obj2、obj3、obj4 和 obj5。但在两张图中 obj5 除了被 obj1 引用,还都被 GC Roots 引用,而在 图5 中,obj3 也被 GC Roots 引用,换句话说,不仅仅能通过 obj1 访问到这两个对象(通过 GC Roots 也能访问到),它们不能被算进 obj1 的 Retained Size 中。因此:

  • 图5 中 obj1 的 Retained Size 为:obj1、obj2 和 obj4 的 Shallow Size 之和;
  • 图6 中 obj1 的 Retained Size 为:obj1、obj2、obj3 和 obj4 的 Shallow Size 之和;

结合大家熟悉的 “可达性分析” 概念,从内存管理的角度来给 Retained Size 下个定义的话,任意一个对象 obj 的 Retained Size 就是指:对象 obj 的 Shallow Size,以及仅通过 obj 可达的所有对象的 Shallow Size 之和。 我们已经知道,在 G1 中 Humongous Object 指的是 “占用内存空间 > ½ RegionSize” 的对象,那么这里的 “占用内存” 是指 Shallow Size 还是 Retained Size 呢?答案毋庸置疑,指的就是 Shallow Size。且上文已经介绍过,该服务的 Region Size 被设置为 16MB (-XX:G1HeapRegionSize=16),也就是说,我要找一个 Shallow Size 超过 8MB 的对象,而绝大多数对象很难有如此巨大的内存开销。因此,我推测很可能是一个存储了 "海量元素的大数组(Big Array)"。做出这个推测的理由主要有两个:

  • JVM 需要给数组分配连续的空间来存储元素,而数组的 Shallow Size 包含存储在数组内的所有元素的 Shallow Size 之和;
  • 很多内置数据结构、工具类的底层实现都依赖数组,例如 ArrayList、String、BufferedInputStream 等等,在代码中有着十分广泛的使用;

从成为 Humongous Object 的潜力来说,数组绝对是天生丽质、又集万千宠爱于一身的存在。那么接下来,就让我们试试看,能否找到这个 Big Array

2.2 Gotcha!Big Array!

我使用了一个叫做 YourKit Java Profiler [8]^{[8]}[8] 的工具来分析此前获得的 “堆快照”,并有了重要发现。从 “堆快照” 中很快锁定了一个巨大的字符数组,如 图7。包含 5636094 个 char 元素,占用了 11272208B(≈10.8MB) 的 Shallow Size,远超 Region Size 的一半(8MB),显然是一个 Humongous Object。

深度解析:大对象分配引发的GC问题案例研究

图7:Humongous Array

从缩略信息可以窥见,数组的内容是一段有意义的文本。于是借助 YourKit 的导出功能,成功将数组内容导出成了一个 Text 文件。初步分析后发现,内容的格式完全符合 Prometheus Exposition Formats [9]^{[9]}[9],如 图8。因此判断,我们寻找的这个 Humongous Object 正是由 Prometheus 监控指标打点引起的。这也契合了上文(1.1 节)关于 “例行任务”、“运维行为” 的相关推测。

深度解析:大对象分配引发的GC问题案例研究

图8:Prometheus Exposition Formats

起初我认为,一定是输出的指标太多了,才创建了如此大的数组,有两个依据:

  • 一个 metric 指标中的任意一个 label_name 对应的 label_value产生一个新值(即便其他 label_name 的值完全没变),都会输出一条新的文本行。以 图8 中 jvm_buffer_memory_used_bytes 这个 metric 指标为例,假设 id 有N个可选值、hll_metric_type 有M个可选值、hll_data_type 有S个可选值、hll_appid 有P个可选值、hll_envK个可选值、hll_ip 有T个可选值,那么该指标最多可能输出N × M × S × P × K × T 行文本数据;
  • 为了在发生线上问题时能够更快速的定位,我们在代码中埋点了一些自定义的异常指标。这些异常指标区分 Error Code 和 Error Message(Label names),而这两者的可选值非常多,再和默认的 hll_appidhll_envhll_ip 等 Label 做乘积,极有可能导致大量的行文本数据输出;

但很快就排除了这个猜想 [10]^{[10]}[10]。快速搜索关键词后,我便发现导出的文本中并没有匹配自定义异常指标名称的行。难道是其他指标的问题?于是我决定按 Metric name 对文本行进行归类,统计每个指标分别输出了多少字符。由于是文本文件,我编写了一段如下的 Python 代码来进行分析,并得到了如 图9 的结果。

import re
import argparse
from typing import Final


MATCHER: Final = re.compile(r'(\w+){.+}.*')
CSV_TITLE: Final = [
    "MetricName",
    "Characters(HO)",
    "MemUsed(KB)",
]


def collect(text_file_path, csv_file_path):
    collector = {}
    with open(text_file_path, "r"as input_file:
        for line in input_file:
            key = None
            if line.startswith("#"):
                key = "comment"
            else:
                matcher = MATCHER.match(line)
                if matcher is not None:
                    key = matcher.group(1)
            if key is not None:
                collector[key] = collector[key] + len(line) if key in collector else len(line)
    with open(csv_file_path, "w"as output_file:
        print(f"{';'.join(CSV_TITLE)}", file=output_file)
        total_amount_of_characters = 0
        total_memory_usage = 0
        for (k, v) in collector.items():
            total_amount_of_characters += v
            total_memory_usage += v * 2
            print(f"{k};{v};{round(v * 2 / 1024.02)}", file=output_file)
        print(f"Total;{total_amount_of_characters};{round(total_memory_usage / 1024.02)}",
                file=output_file)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--text_file"type=str, required=Truehelp="Path of the input text file")
    parser.add_argument("--csv_file"type=str, required=Truehelp="Path of the output csv file")
    args = parser.parse_args()
    collect(args.text_file, args.csv_file)

深度解析:大对象分配引发的GC问题案例研究

图9:Metric 分析结果

此时我惊讶的发现,不仅没有任何一个单独的指标算得上超大文本(输出字符最多的指标为 hllci_soa_call_seconds_bucket,共计 1436280 个字符),即便是将所有的指标和注释相加得到的总字符数(共计 2863295 个字符),也远小于数组的长度。

一下仿佛走入了死胡同,到底是哪里错了呢?就在此时,脑海中突然灵光一闪,难道是 YourKit 导出文本的时候截断了?但文本的开头已经反复看过好几遍了,注释、指标都是完整的,如果真有截断一定是在文本末尾。于是再次用 Vim 打开导出的 Text 文件,直接跳转到最后一行,果然发现了问题 [11]^{[11]}[11]

深度解析:大对象分配引发的GC问题案例研究

图10:Array Padding

如 图10,第 8406 行,存储了一大段不可显字符,而第 8405 行的指标输出是完整的,这说明并非 YourKit 导出 Text 文件时发生了截断。于是我再次用 Python 统计了整个文件的总字符数(包括不可显字符),结果为 5636094,这与 “堆快照” 提示的数组大小一致。进一步分析第 8406 行,最终发现该行包含了 2772799 个 '\x00'(UTF-8, '\u0000' in Unicode),熟悉 ASCII 的人应该都知道,这是一个控制字符,语义同NULL。于是我马上想到,这可能是数组初始化时填充的默认值 [12]^{[12]}[12]。但为什么初始化一个 5636094 元素的数组,却只存了 2863295 字符?我认为唯一合理的可能性是:数组在写入字符的过程中遵循 某种规则 进行了动态扩容。而且扩容可能是成倍率的,至少是 2 倍[13]^{[13]}[13],示例中的数组刚刚完成扩容,因此有大量未使用的空间保留了初始值。

是时候到代码里寻找一些蛛丝马迹了。上文提到,文本的内容是 Prometheus 监控指标,而在 SpringBoot 项目中,这个监控指标是通过 Spring Actuator API [14]^{[14]}[14] 输出的,而 Actuator API 则是通过 JMX,或是通过定义如下的 HTTP 端点(Endpoint)实现的。

@WebEndpoint(id = "prometheus")
public class PrometheusScrapeEndpoint {

        private final CollectorRegistry collectorRegistry;

        public PrometheusScrapeEndpoint(CollectorRegistry collectorRegistry) {
                this.collectorRegistry = collectorRegistry;
        }

        @ReadOperation(produces = TextFormat.CONTENT_TYPE_004)
        public String scrape() {
                try {
                        Writer writer = new StringWriter();
                        TextFormat.write004(writer, this.collectorRegistry.metricFamilySamples());
                        return writer.toString();
                }
                catch (IOException ex) {
                        // This actually never happens since StringWriter::write() doesn't throw any
                        // IOException
                        throw new RuntimeException("Writing metrics failed", ex);
                }
        }

}

其中核心代码为 13-15 行:

  • Line 13:初始化一个 StringWriter 类型的 writer 对象;
  • Line 14TextFormat.write004(writer, ...)方法按 Prometheus Exposition Formats 规范,向传入的 writer 对象中不断写入指标数据;
  • Line 15:调用 writer对象的toString()方法返回完整的格式化文本;

write004()方法的实现也没有什么奇淫巧技,就是逐一遍历指标项,调用 writer 对象的 write()方法(e.g. writer.write("# HELP ");),写入字符串。

关键问题就出在 StringWriter身上。下方我给出了 StringWriter 的核心代码,可以清晰的看到,StringWriter内部维护了一个 StringBuffer 对象,而调用writer.write(str)实际上就是将该字符串 append 到StringBuffer中。我意识到已经十分接近真相了,因为StringBuffer正是通过一个 char[] value; 数组对字符串进行存储的,而随着不断地 append(newStr), 数组也会进行动态扩容。

public class StringWriter extends Writer {

    private StringBuffer buf;

    /**
     * Create a new string writer using the default initial string-buffer
     * size.
     */
    public StringWriter() {
        buf = new StringBuffer();
        lock = buf;
    }
    
    /**
     * Write a string.
     */
    public void write(String str) {
        buf.append(str);
    }
    
    /**
     * Return the buffer's current value as a string.
     */
    public String toString() {
        return buf.toString();
    }
}

下方贴出了 StringBuffer 扩容的核心代码,重点看 4-6 行 和 第 11 行。

  • Line 4-6:当字符数量超过当前数组容量时(minimumCapacity - value.length > 0),计算新的数组容量(newCapacity(minimumCapacity)),并通过 Array Copy,对数组 value 进行扩容;
  • Line 11:新数组的容量计算方式为 (value.length << 1) + 2[15]^{[15]}[15],即 value.length × 2 + 2,即扩容后数组的容量是扩容前数组容量的 2 倍 + 2 个字符;
private void ensureCapacityInternal(int minimumCapacity) {
    // minimumCapacity = amount of characters already in the `value` array 
    //                 + characters required by the new `append(newStr)` operation
    if (minimumCapacity - value.length > 0) {
        value = Arrays.copyOf(value,
                newCapacity(minimumCapacity));
    }
}

private int newCapacity(int minCapacity) {
    int newCapacity = (value.length << 1) + 2;
    if (newCapacity - minCapacity < 0) {
        newCapacity = minCapacity;
    }
    return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
        ? hugeCapacity(minCapacity)
        : newCapacity;
}

代码证实了动态扩容是有可能发生的,但还是需要回到“堆快照”中,寻找更有力的证据证明扩容 “真实的” 发生了。和前文找 Big Array 一样,要从凌乱的“堆快照”中有所收获,首先得知道找什么、怎么找?因此,让我们先来做一个思想实验:假设定义一个 char 数组来存储字符串,随着不断地向数组中添加新的字符,数组发生了多次扩容,数组的容量是如何变化的?

答案显而易见,扩容的过程符合以下规律:

  • Initial Capacity;
  • ...(several expansion)...;
  • Capacity A;
  • Capacity B = 2 × A + 2
  • Capacity C = 2 × B + 2
  • ...(more expansion)...;

换句话说,只要数组扩容 ≥ 2 次,我们一定能找到一组容量值 A、B、C 满足 B = 2 × A + 2 且 C = 2 × B + 2。我们要找的就是 A、B、C 这样一组关系。再次仔细分析 “堆快照”,顺利找到了这组关系,如 图11(A)、图12(B)、图13(C)

深度解析:大对象分配引发的GC问题案例研究

图 11

深度解析:大对象分配引发的GC问题案例研究

图 12

深度解析:大对象分配引发的GC问题案例研究

图 13

到此为止,已经理清了 “案情” 有关的所有关键证据,让我来对 “案发过程” 做一个总结性推理:

Prometheus 调用 Spring Actuator API 获取监控指标; PrometheusScrapeEndpoint(Actuator API 的实现)将指标转换成文本信息,并写入 StringWriter 中; StringWriter是对 StringBuffer的包装,StringBuffer内部使用一个 char[] value数组存储字符串; 随着写入的文本信息越来越多,value数组多次扩容,且每次扩容后的容量都 ≈ 2 × 扩容前容量的; 直到某次扩容后,value数组占用内存达到或超过了一半的 Region Size(8MB),成为 Humongous Object; 以上步骤重复发生,频繁创建 Humongous Object,而 Humongous Object 直接在老年代分配,最终导致老年代快速增长,并触发告警。

三、疑点清查

然而我们还不能就此结案,因为上文的推理虽然完美,但证据链还不够扎实,要办成铁案,还必须回答几个疑点。

  • 疑点1:数组的 Shallow Size 是如何算出来的?

不知道大家在读前面的内容时是否好奇过,YourKit 解析 “堆快照” 后,提示数组char[5636094]一共占用了 11272208 字节,而 Java 中一个 char 类型占用 2 个字节,5636094 个字符应该占用 11272188 字节。多出来的 20 个字节是从哪儿来的?、 如果不能把这个问题解释清楚,那上文讨论的所有内容,就好比在还没弄明白导体的电阻是如何产生的情况下,就侃侃而谈室温超导一般,丰墙峭址。

  • 疑点2:Humongous Allocation、Humongous Allocation GC 和 Old Gen 内存使用率之间是如何相互影响的?

起初我是通过 G1 Humongous Allocation 和 JVM Mem Heap Used::G1 Old Gen 两个指标特征的正相关性,推测老年代持续增长是由大对象分配导致。但这个推测要成立,需要满足 3 个条件,如 图14

  • Humongous Allocation 会导致 Old Gen 内存增长;
  • Humongous Allocation 会触发 Humongous Allocation GC;
  • Old Gen 增长会影响 Humongous Allocation GC;

我们已经知道,Humongous Allocation 导致 Old Gen 内存增长的原因是 Humongous Object 直接老年代分配。而 Humongous Allocation 是如何触发 GC 的、以及老年代增长是如何影响 GC 的,到目前都还不清楚。能否弄明白这三者之间的关系,决定了 “老年代持续增加与大对象分配有关” 的推测,是不是足够站得住脚。

深度解析:大对象分配引发的GC问题案例研究

  图 14

  • 疑点3:为什么告警只发生在凌晨?白天业务流量大的时候反而安然无恙? 这也是本文的终极问题!

这些疑点,我将在 3.1 节、3.2 节 中逐个剖析。

3.1 计算一维数组的 Shallow Size

要计算一维数组的 Shallow Size,首先要弄明白数组在 JVM 中的内存结构。在 64-bit HotSpot 中,数组的内存布局 如 图15。数组在 JVM 视角中也是一个实例对象,和其他对象一样都有一个 “对象头”,但数组的 “对象头” 中,除了有 Mark Word 和 Class Pointer 外,比其他对象多出了 4 个字节 用来记录数组长度(Array Size)。“对象头” 后是存储在数组里的元素(Elements)占用的空间。最后,64-bit HotSpot 要求对象地址按照 8 字节 对齐,因此需要对实例进行内存填充(Padding),使得整个数组占用内存的字节数是 8 的整数倍。

深度解析:大对象分配引发的GC问题案例研究

图15:64位 HotSpot 一维数组内存模型

那么就让我们按 图15 中的内存模型,计算一下 char[5636094]占用的内存大小:

  • Mark Word(H):在 64-bit HotSpot 中,固定为 8 bytes,即 H = 8
  • Class Pointer(P):在 64-bit HotSpot 中,开启指针压缩的情况下为 4 bytes,未开启指针压缩的情况下为 8 bytes。JDK8 默认启用了指针压缩,我们当前讨论的应用没有修改这项默认配置,因此 P = 4
  • Array Size(L):在 64-bit HotSpot 中,固定位 4 bytes,即 L = 4; Elements:要分两部分看
    • 元素个数(N):N = 5636094
    • 单个元素的内存大小(E):在 Java 中 char 类型占用 2 个字节,因此 E = 2
  • Padding(𝜟):这是一个不确定大小的值,需要根据内存对齐的要求,通过如下公式计算出来, (⎣(H + P + L + N × E) ÷ 8⎦+ 1 ) × 8 - (H + P + L + N × E)[16]^{[16]}[16],即先找到一个 8 的整数倍值,使得其刚好大于当前已使用的内存大小,再用该值减去当前内存,就是要 Padding 的大小。将 H、P、L、N、E代入计算可以得出,𝜟 = 4;

因此,最终的计算结果为 :

Shallow Size of the array = H + P + L + N × E + 𝜟 

                                       = 8 + 4 + 4 + 5636094 × 2 + 4 

                                       = 11272208

这和我们在 “堆快照” 中看到的结果是完全一致的。

3.1.1 使用 JOL 分析 Java 对象内存布局

如果觉得上面的计算太过复杂,也可以借助 JOL(Java Object Layout)[17]^{[17]}[17] 工具进行对象的内存结构分析。JOL 的使用非常简单,继续拿 char[5636094]数组为例,可以得到下方的示例代码。

import org.openjdk.jol.info.ClassLayout;

public class AnalyseJavaArrayLayout {
    public static void main(String[] args) {
        char[] array = new char[5636094];
        System.out.println(ClassLayout.parseInstance(array).toPrintable());
    }
}

深度解析:大对象分配引发的GC问题案例研究

图16:JOL 分析结果

运行后得到的内存结构分析结果如 图16。将 Size 这一列相加,得到的结果和我前面计算的结果也是一致的。基于多个验算得出的一致结果,疑点1 算是解答了。

3.2 G1 在分配和回收 Humongous Object 时的奇技淫巧

想要解释清楚 疑点2 和 疑点3,关键就在于理解 G1 是如何分配和回收 Humongous 对象的。因此,这一节让我们结合 HotSpot 源码,来一窥究竟。

3.2.1 如何获得正确的 HotSpot 源码

在开始对代码进行分析之前,先来介绍一下怎么获得正确的 HotSpot 源代码。如果纯粹是为了学习 HotSpot,只需确定 “主要版本”,例如 1.8.0,获取该主要版本的最新源代码即可。然而,与一般的学习目的不同,要正确的排查问题,则必须获取与生产运行版本完全一致的源代码(“次要版本”、“构建版本” 也必须一致)[18]^{[18]}[18]

深度解析:大对象分配引发的GC问题案例研究

图17:HotSpot Version

如 图17,首先找到一台生产实例通过 java -version查看详细的 Java 版本。可以看到,“主要版本” 为 1.8.0(jdk8),“次要版本” 为 202,“构建版本” 为 b08,根据 Java 的版本规范拼接起来,就得到了我们要获得的目标源代码版本号为 jdk8u202-b08

接下来,就是打开 Mercurial 上 OpenJDK 的项目主页 [19]^{[19]}[19],下载版本号为 jdk8u202-b08 的源代码。具体步骤为:

  1. 在首页点击 jdk8u,跳转到新页面;
  2. 在新页面点击 jdk8u/hotspot 或 jdk8u-dev/hotspot,jdk8u 和 jdk8u-dev 仅仅是 master 分支和 dev 分支的区别,选择 hotspot 的原因是我们需要用到的是 JVM 的源代码;
  3. 在打开的新页面中,继续点击左侧目录栏中的 tags;
  4. 搜索 jdk8u202-b08 这个 tag,并点击打开 tag 页;
  5. 点击 tag 页左侧目录栏中的 zip/gz/bz2,下载对应压缩格式的源代码文件;

HotSpot 是通过 C++ 实现的,VSCode 是一个不错的阅读工具。接下来,就让我们聚焦源码,继续探索、解疑。

3.2.2 Humongous Object 的分配

我们可以从 src/share/vm/gc_implementation/g1/g1CollectedHeap.cpp 文件中,找到 Humongous Object 分配的代码实现,入口方法为attempt_allocation_humongous。如 图18,为了更好的解释重点,我有意隐藏了一些细节,仅保留了最核心的代码部分。

深度解析:大对象分配引发的GC问题案例研究

图18:Humongous Allocation Implementation

让我们结合下方 图19 的流程图,对 图18 中的代码做一个简单分析:

  • Line 4-7:决策并按需启动 Concurrent Marking Cycle Phases,很快我将会展开讨论这个部分;
  • Line 10-45:“Allocation → GC → Allocation” 循环,注意 Line 10 的 for 循环是没有退出条件的,而是在循环中,判断分配成功/失败,主动return 退出循环
    • GC 调度成功,且 GC 后申请到足够的内存:循环终止,直接返回申请到的内存空间,分配成功;
    • GC 调度成功,但 GC 后未能申请到足够的内存:循环终止,返回NULL,分配失败;
    • GC 调度失败:重新开始循环,再次尝试 humongous_obj_allocate
  • Line 15-18:尝试进行对象分配,如果分配成功则直接返回;
  • Line 23-35:分配失败,且没有活跃的 GC 任务,则主动调度 GC 线程进行内存回收。此时会产生 3 种结果
  • Line 35-41:分配失败,且有活跃的 GC 任务,则先判断 GC 的重试次数是否超过了上限,超过上限则不再等待 GC 完成,直接返回 NULL,分配失败。如果没有超过重试上限,则等待 GC 完成,再次尝试 humongous_obj_allocate

深度解析:大对象分配引发的GC问题案例研究

图19:Humongous Allocation Flow Chart

重点一提的是,Line 6(collect) 和 Line 25-26(do_collection_pause) 都在尝试进行垃圾回收,在 HotSpot 中,垃圾回收任务被统一抽象为 VM_G1IncCollectionPause 类型的对象。二者的主要区别是对回收任务的设定不同,如 图20图21

深度解析:大对象分配引发的GC问题案例研究

图 20

深度解析:大对象分配引发的GC问题案例研究

图 21

在 图20 中,word_size 字段传的值为 0,而 图21 中传入的则是分配 Humongous Object 实际需要申请的内存大小,是一个大于 0 的值。当 word_size > 0 时,GC 线程会在 Collection Pause 成功后 [20]^{[20]}[20],尝试分配该大小的内存空间,代码如下。

if (_pause_succeeded && _word_size > 0) {
  // An allocation had been requested.
  _result = g1h->attempt_allocation_at_safepoint(_word_size, allocation_context(),
                                    true /* expect_null_cur_alloc_region */);
}

其中 _pause_succeeded 表示 Collection Pause 是否成功,_word_size 为创建VM_G1IncCollectionPause对象时传入的word_size 参数。这种 GC 停顿后,立即尝试分配的策略,被称作 post-GC allocation。 另一个不同之处在于 图20 中 should_initiate_conc_mark的传值为``true,**图21** 中则传了false`。要理解这里传不同值的意义,必须先弄明白 G1 的 “垃圾回收周期(Garbage Collection Cycle)”。

G1 将垃圾回收分为了几个不同的 “阶段(Phase)”:

  1. Normal Young GC phase:这个阶段由一些 “常规年轻代回收(Normal young collection)”(以下简称 Young GC) 构成,这些回收动作只会回收年轻代 region,在这个过程中,部分年轻代对象晋升到老年代;

2.Marking Cycle:包含多个 Phase

a. Initial Mark phase:在此阶段,G1 会标记 GC Roots,以及从 GC Roots 直接可达的对象。这个操作是 Normal Young GC “捎带” 执行的,目的是复用 Root Scan。所以从 图20图21 的源码中可以看到,VM_G1IncCollectionPause 的参数是直接指定是否要启动并发标记(should_initiate_conc_mark),而不需要指定是否要启动 Initial Mark 的原因。由此也可以得出结论,Concurrent Marking 总是伴随着一次 Young GC 发生的;

b. Concurrent Marking phase:该阶段 G1 会对整个堆中的 “活对象(live objects)” 进行标记;

c.Remark phase:完成最终标记,跟踪不可访问对象,处理引用关系;

d. Cleanup phase:该阶段 G1 会识别 “空闲 Region(free region)” [22]^{[22]}[22]、以及候选为待进行 Mixed GC 的 region。空闲 region 将被直接清空并添加到 free list;

  1. Space-reclamation phase:进行多次 Mixed GC,同时回收年轻代和老年代 region;

有必要提一下的是,在官方文档中,Normal Young GC phase 和 Marking Cycle(viz. Initial Mark、Concurrent Marking、Remark、Cleanup)被统称为 Young-only phase。这样划分可能是因为 Marking Cycle 本质上是 Normal Young GC phase 的一种延续,是 “伴随 Young GC 发生” 的。尽管对 Humongous Object 有一些特殊逻辑(后文会详细说明),但总的来说,是基于 Young GC 的结果扩大标记范围,为老年代回收 “做准备”,并没有实际针对老年代内存的 Reclaim 操作。但这里为了叙述更清晰,我将 Marking Cycle 单独剥离了出来。

实际上,启动垃圾回收任务后(VMThread::execute(&op)),经过多层调用(execute(&op) → VM_Operation::evaluate(); → VM_G1IncCollectionPause::doit();),最终执行的是do_collection_pause_at_safepoint 方法。该方法也定义在 src/share/vm/gc_implementation/g1/g1CollectedHeap.cpp 文件中,内存回收的核心操作就是在这个方法中进行编排的,如 图22。方法的实现比较复杂,为了更好的关注重点,图中仅保留了最重要的代码“骨架”。

  • Line 5:这里调用 decide_on_conc_mark_initiation方法,是为了判断在执行Young GC之后,是否需要启动并发标记。G1CollectorPolicy对象(g1_policy() 方法的返回值)维护了若干个和并发标记有关的状态位,decide_on_conc_mark_initiation 通过状态位 _during_initial_mark_pause,和 Line 9 的 during_initial_mark_pause方法实现联动。要完全理解这个过程,涉及到多处代码走读,在这里就不展开了。我们只需要知道,当 VM_G1IncCollectionPause的 should_initiate_conc_mark 参数传 true时,decide_on_conc_mark_initiation会将状态位 _during_initial_mark_pause 设置为 true,从而使得 Line 9 的方法调用g1_policy()->during_initial_mark_pause也返回true,即 should_start_conc_mark = true ;
  • Line 9 和 Line 46:Line 9先记录是否要启动并发标记的状态,用于在 Line 46 的 if判断;
  • Line 11-42:执行 Young GC 的核心代码,包含 Initial Mark
  • Line 47:待完成 Line 11-42 的 Young GC之后,如果should_start_conc_mark = true,则启动并发标记;

深度解析:大对象分配引发的GC问题案例研究

图22:Collection Pause 核心逻辑

并发标记是在一个额外的、独立线程中进行的,doConcurrentMark的核心逻辑如图23,在 Line 4 启动并发标记线程。因此在并发标记的过程中也可能进行多次的 Young GC。

深度解析:大对象分配引发的GC问题案例研究

图23:启动并发标记线程

之所以 Line 9 就确定了 should_start_conc_mark,却要到 Line 46-47 才执行并发标记,源码给出了一段解释:

// It should now be safe to tell the concurrent mark thread to start // without its logging output interfering with the logging output // that came from the pause.

关于这一点,我就没有去做更进一步的考证了。但重要的是,通过对上面代码的分析,能让我们比较清晰的认识到 Young GC 和 Marking Cycle 的转换过程,理解为什么说 “Concurrent Mark 是伴随 Young GC 发生的”。 现在我们知道 G1 的垃圾回收过程是 “Young GC(young-only regions) → Marking Cycle → Mixed GC(young & old regions)→ Young GC(yong-only regions)” 的循环,Marking Cycle 是进行老年代回收的必经之路。而 图19 中,collect 方法之所以将 should_initiate_conc_mark设置为 true,就是为了指定 GC 线程启动并发标记,从而触发 Mixed GC 对老年代空间进行回收。

但执行 collect 有个前提条件:“非年轻代内存的使用率超过了 IHOP [21]^{[21]}[21] 阈值”。判断该阈值的代码实现在 need_to_start_conc_mark方法中,如图24

  • Line 7_g1->capacity()返回整个堆的大小 InitiatingHeapOccupancyPercent(IHOP) 是一个正整数,默认值为 45,所以要除以 100。这里等价于:threshold = total_heap_size × 45%;
  • Line 8:non_young_capacity_bytes() = Used Old Region Size + Used Humongous Region Size;
  • Line 9:当前新申请的内存空间,在 Line 7 会和已使用的 “非 young 区” 内存相加;
  • Line 11:总的 “非 young 区” 内存使用率 > threshold;

深度解析:大对象分配引发的GC问题案例研究

图24:决策是否启动并发标记

通过上面的代码分析,已经能够把 疑点2 中 “Humongous Allocation 如何触发 GC” 的问题解释清楚了。为了方便更直观的理解 Humongous Allocation、Humongous Allocation (caused) GC 和 Old Gen 内存使用率之间的作用关系,我绘制了一张状态机循环图,如 图25

深度解析:大对象分配引发的GC问题案例研究

图25:Humongous Allocation、Humongous Allocation GC 和 Old Gen内存使用率之间的作用关系

3.2.3 Humongous Object 的提前回收(eager reclaim)

最后,让我们来讨论下 疑点3。由于已经知道 Humongous Object 是因为调用 Spring Actuator API 获取 Prometheus 指标时产生的,我首先想到,可能是因为这个动作只在凌晨进行、或者白天比较低频凌晨比较高频。但在业务繁荣的白天减少监控采样、反而在业务低谷的凌晨增加监控采样,显然太合理。因此,我尝试寻找证据,排除这种可能性。

上文提到,Spring Actuator API 的实现是 HTTP Endpoint,获取 Prometheus 指标的过程实际上就是发起一个类似curl 'http://localhost:8080/actuator/prometheus' -i -X GET [23]^{[23]}[23]的 HTTP 请求。通过关键词/actuator/prometheus 搜索应用的 access log,很快就找到了相关日志,如 图26

深度解析:大对象分配引发的GC问题案例研究

图26:获取 Prometheus 指标的请求日志

从日志中可以看到,即便是白天,也在频繁且规律的获取着 Prometheus 指标(以 24s/8s 的请求间隔轮转),第一种可能性被排除了。既然创建 Humongous Object 的地方没有差别,那只可能是对 Humongous Object 的回收有差异了。

接着,通过对比白天和凌晨的 GC 监控数据,发现了一些端倪。如 图27,白天 GC 始终以一个比较稳定、密集的频率在执行,两次 Evacuation Pause 的时间间隔维持在 1.5 分钟左右。而凌晨 GC 的执行频率明显下降,如 图29,两次 Evacuation Pause 的时间间隔从 10 分钟到 20 分钟,逐渐扩大。之所以有这样的变化,是因为白天业务流量大,频繁的进行年轻代对象分配,这会导致 Eden Gen 快速增长,进而更频繁的触发 Young GC。而到了凌晨,业务流量大幅下降,Eden Gen 的增长变慢,Young GC 的触发间隔也就越来越长。


如何判断 G1 Evacuation Pause 是 Young GC? 最高效且准确的办法,当然是查看 GC 日志,在 GC 日志中会通过如下的方式来区分 G1 Evacation Pause 是(young)还是(mixed):

  • [GC pause (G1 Evacuation Pause) (young)...]
  • [GC pause (G1 Evacuation Pause) (mixed)...]

但我们的应用并没有配置将 GC 日志输出到 Pod (;¬_¬) ... 所以...这里判断的依据是:

Mixed GC 需要老年代内存先达到 IHOP,触发 Concurrent Mark。应用的 IHOP 设置为 45%,堆大小为 6GB,也就是老年代至少要使用到 2.7GB 左右,才可能触发 Mixed GC。但从 图28图30 可以看到,标注的 G1 Evacuation Pause 发生时,Old Gen 远远没有达到 2.7GB。


深度解析:大对象分配引发的GC问题案例研究

图27:白天 GC 频率

深度解析:大对象分配引发的GC问题案例研究

图28:白天 Heap Used

深度解析:大对象分配引发的GC问题案例研究

图29:凌晨 GC 频率

深度解析:大对象分配引发的GC问题案例研究

图30:凌晨 Heap Used

这种 GC 频率的差异带来的影响也是显而易见的。如 图28,白天的时候 G1 Old Gen 仅有小幅度的波动,总体维持在 550MB 左右。而凌晨时,G1 Old Gen 在每两个 GC 间隔内的 “峰值” 则是在递增的,仔细观察 图30 会发现,G1 Old Gen 增长的速率(黄线斜率)在每个 GC 间隔内是差不多的,但由于两次 GC 的间隔时间增加,容许老年代增长的时间更多了。随着两次 GC 间隔越来越久,最终老年代将获得足够多的时间增长到 IHOP 附近,并在下一次大对象分配时,触发 Humongous Allocation GC。

将 GC 频率和 Old Gen 的变化联系起来,似乎能够很好的解释 疑点3。但也却引出了一个非常反直觉的结论:“Young GC 竟然回收了老年代内存”。这就不得不说 Humongous Object 的特殊性了。

官方文档 [24]^{[24]}[24] 是这样写的:

Generally, humongous objects can be reclaimed only at the end of marking during the Cleanup pause, or during Full GC if they became unreachable. There is, however, a special provision for humongous objects for arrays of primitive types for example, bool, all kinds of integers, and floating point values. G1 opportunistically tries to reclaim humongous objects if they are not referenced by many objects at any kind of garbage collection pause. This behavior is enabled by default but you can disable it with the option -XX:G1EagerReclaimHumongousObjects.

大致意思是,对于原生类型(e.g. bool, int, float, char...)构成的大对象数组,只要这些大数组不再被其他对象引用(Unreachable),G1 在任何 GC 停顿(包括 Young GC)都会尝试进行回收。

还记得 图22 的代码片段吗?上文我们提到,标号❸ 的代码块为 Young GC 的核心代码,而 Line 31 的 eagerly_reclaim_humongous_regions()方法调用,就是实现了 Eager Reclaim Humongous Objects 特性。这一特性是从 jdk8u60 开始引入的 [25]^{[25]}[25],默认开启,可以通过指定 JVM 参数 -XX:G1EagerReclaimHumongousObjects来手动关闭。我们应用的版本为 jdk8u202,且没有手动配置-XX:G1EagerReclaimHumongousObjects参数,显然开启了该特性。

梳理下前面的信息,不难归纳出下方的表格来解答 疑点3

深度解析:大对象分配引发的GC问题案例研究

这就好比乘坐上海地铁,高峰期虽然站台上很快就会站满了人,但由于加开了列车班次,每两个班次之间间隔时间短,我们往往不需要等很久就能上车离开站台。而到了低峰期,虽然站台上只有零星的一些人到来,但由于列车班次之间间隔时间变长了很多,我们可能要在站台等很久很久才能上车离开。这个例子中,地铁列车就是 GC 操作,站台就是堆内存,等车的路人是年轻代对象,而等车的我们就是老年代对象。

四、案情还原

其实我们可以将 疑点3 的结论推广一下:只要在两次 Young GC 之间,以足够快的速度创建 Humongous Object,就会触发老年代堆积告警和 Humongous Allocation GC。因此,复现起来并不困难,一段 Bash Script 就能解决。

#!/bin/bash

for _ in {1..20}
do
    curl 'http://localhost:8080/actuator/prometheus' -si -X GET | awk \
        -F ':' '/[C,c]ontent-[L,l]ength/ {printf("%.2f KB\n", $2 / 1024.0)}'
    sleep 1s  # disabled in script #1
done

选择一台应用所在的 Pod 执行上面的脚本,同时观察 JVM 监控,得到如 图31 的结果。

深度解析:大对象分配引发的GC问题案例研究

图31:问题复现

脚本是分两次执行的,第一次执行脚本(图中通过 script #1 标注)时 sleep 1s 是注释掉的,第二次执行脚本时(图中通过 script #2 标注)则会在两次调用之间间隔 1s。目的是通过曲线斜率的变化,证明确实由复现脚本导致了 Old Gen 增长。

  • 执行复现脚本前:对比两段粉色标注的曲线,可以看到 Old Gen 从上一次 Young GC 结束后的状态开始缓慢增长,增长速率和前一个周期基本一致;
  • 第一次执行复现脚本(no sleep) :可以看到从 script #1 箭头标注的位置开始,整个黄色标注的曲线说明 Old Gen 开始快速堆积,这是因为复现脚本正以一个非常快的速率获取 Prometheus 指标, 快速创建了大量的 Humongous Object;
  • 第二次执行复现脚本(sleep 1s) :从 script #2 箭头标注的位置开始,蓝色标注的曲线说明 Old Gen 的堆积速度开始放缓,但仍在堆积;
  • GC Start:非年轻代内存 > IHOP,触发 Concurrent Mark(GC Cause 为 Humongous Allocation),GC Count 曲线记录了一次 G1 Humongous Allocation;
  • GC Finish:Humongous Object 在 Concurrent Mark 之前的 Young GC 阶段,以及 Marking Cycle 的 Cleanup 阶段都会被回收,因此 Old Gen 曲线下降;

五、总结

写到这里,有一个很值得在篇末好好总结一下的话题:

“如何在代码设计中有效避免由数组引发的内存管理问题?”

以下是我根据个人经验给出的一些建议,以帮助大家规避可能的陷阱:

  • 关注 “动态扩容” 的潜在风险;
  • 在定义大容量数组时需倍加谨慎;
  • 当考虑将数组容量参数化时,务必审慎;
  • 执行可能具有 “放大效果” 的操作时,要格外小心;

5.1 关注 “动态扩容” 的潜在风险

本文讨论的案例就归属于这一类。实际上,类似 StringBuffer, 底层通过数组实现存储且支持动态扩容的 Java 类型还有很多,下面列出了一些典型的例子:

  • StringBuffer 的孪生兄弟: StringBuilder
  • 各种容器类ArrayList、ArrayDequePriorityQueue ...;
  • I/O 流ByteArrayOutputStreamCharArrayWriter ...;

动态扩容不仅可能导致频繁的大对象分配,影响 GC 效率,还可能导致大量的内存浪费。 考虑一个这样的例子(设定 -XX:G1HeapRegionSize=2):

定义一个 StringBuffer 类型的实例 buffer,不断向 buffer 中添加只有单个字符的字符串。假设总共要添加的字符数为Nbuffer 的底层 char[]可能会经历如下的扩容过程。

深度解析:大对象分配引发的GC问题案例研究

根据总字符数 N 的不同,我们以第 15 次扩容作为分界线,讨论两种典型的 Bad Case:

  1. N 远大于 589822(e.g. N > 301989886):此时,底层 char[]将执行全部批次的扩容操作,从第 15 次扩容开始,Array copy 创建的新数组都是 Humongous Object,而从第 16 次扩容开始,Array copy 之后废弃的原数组也是 Humongous Object,这些大数组都将占用老年代空间,影响 GC;
  2. N 刚刚超过 294910(e.g. N = 294920):即刚刚超过第 14 次扩容后的最大容量。此时,底层char[]将执行第 15 次扩容,但第 15 次扩容后,将有 294902 个数组空间被浪费。同样的情况也可能发生在第16、17、18...次扩容的时候。元素个数的基数越大,遇到这种情况时,内存的浪费问题越严重。回想一下 2.2 节,我对可显字符和数组元素个数不匹配的困惑,正是遇到了这种情况。 “堆快照” 捕获到字符数组容量为 5636094, 仅存储了 2863295 字符的有效文本,浪费了超过 49% 的数组空间;

此外,Humongous Object 的分配方式,也会造成内存的浪费,同时导致更多的内存碎片。

官方文档 [24]^{[24]}[24] 对此也有专门的提示:

Every humongous object gets allocated as a sequence of contiguous regions in the old generation. The start of the object itself is always located at the start of the first region in that sequence. Any leftover space in the last region of the sequence will be lost for allocation until the entire object is reclaimed.

这段话大致表达了三层含义:

  1. 每一个 Humongous Object 都会在老年代中作为一个连续区域序列进行分配(使用一个到多个连续的 region);
  2. 对象本身的起始总是位于该序列的第一个 region 的起始处;
  3. 在最后一个 region 中的任何剩余空间,直到整个对象被回收之前,都将无法用于分配新的对象;

其中最重要的是标粗的第 3 点,如 图32

深度解析:大对象分配引发的GC问题案例研究

图32:Humongous Object 分配导致的内存浪费

5.2 在定义大容量数组时需倍加谨慎

当准备写 char[] array = new char[5636094]; 这样的代码时,一定要明白,不论这个数组最终装填 1 个、10 个、100 个、... N个(N ≤ 5636094)元素,根据 jls 规范 [12]^{[12]}[12],这个数组都会被分配一段 5636094 个元素的连续内存,并且初始化为空值。

5.3 当考虑将数组容量参数化时,务必审慎

理由同 5.2,可以看成是 5.2 问题的一种特例。例如定义一个如下的方法,任何调用方都可能传入一个巨大的 size参数(e.g. doSomething(/* 1st arg */, /* 2nd arg */, 5636094);):

public void doSomething(/* 1st arg *//* 2nd arg */int size) {
    char[] array = new char[size];
    // todo
}

5.4 执行可能具有 “放大效果” 的操作时,要格外小心

一个典型示例是在代码中进行“解压缩”。主流的压缩算法在压缩效率方面表现都很出色,特别是对于纯文本数据(e.g. Gzip 的压缩率大约在60% ~ 70%,即压缩后的数据可以达到原始数据的 30% ~ 40%)。因此,执行解压缩操作时,可能会遇到输入一个容量较小的 byte[],但解压后得到一个容量非常大的byte[],也就是说,解压前并不是一个大对象,但解压后却得到了一个大对象。  下面是一个使用 Zstd 进行解压缩的代码示例:

  • Line 6:计算解压后的字节数;
  • Line 7:创建byte[]用来存储解压后的数据,当 decompressedSize 足够大时,byte[]就成了大对象。此外,这里也存在 “数组容量参数化” 的问题;
 public static void main(String[] args) {

        byte[] compressed = /* load compressed data */

        try {
            long decompressedSize = Zstd.decompressedSize(compressed);
            byte[] decompressed = new byte[decompressedSize];  // probably a Humongous Object
            Zstd.decompress(decompressed, compressed);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

六、结语

在我看来,排查技术问题与进行刑侦工作之间存在许多显著的相似性,它们都涉及一系列的持续搜证、推理和信息整合过程。要探寻事实真相,不仅需要有独特的视角和思考,还需要有细致入微勘察细节的耐心,常常还要借助先进的工具。如同我们常说的“八仙过海,各显神通”,是对参与者的知识体系,以及综合运用各类技能的一次严峻挑战。本文重在分享心得,难免有疏漏之处,欢迎大家评论区多多交流。

Take care of your apps, don't let them crash!

脚注

[1] 在 G1 中 Humongous Object 是指那些 “占用内存空间 > ½ RegionSize” 的对象,Humongous Object 直接在老年代分配,占用老年代空间;

[2] 例如,历史上曾出现过,因挂载流量回放工具 repeater,导致 Metaspace 使用率增加的问题;

[3] 命令:jmap -dump:format=b,file=/tmp/dump.hprof ,这里没有指定 -dump:format=b,live,过往经验告诉我们,live-only dump 经常发现不了问题对象;

[4] GC Count::G1 Humongous Allocation 指标:表示 “因 Humongous Allocation(大对象分配)导致的 GC 次数”,集群视角下,该指标是集群中所有实例的聚合。注意,该指标不能代表实际发生大对象分配的次数,因为大对象分配并不总能触发 GC;

[5] JVM Mem Heap Used::G1 Old Gen 指标:表示 “老年代占用的内存大小(GB)”,集群视角下,该指标是集群中所有实例的聚合;

[6] 为了更简单的说明问题,这里没有将 Java Class Layout 占用的内存以及内存对齐考虑进来,后文我会在计算真实例子的时候更加严谨的介绍这两者的影响;

[7] 不开启指针压缩的前提下,在 32 位平台和堆边界小于 32GB(-Xmx32G)的 64 位平台上,引用的大小为 4 个字节,此边界高于 32GB 时为 8 个字节;

[8] Yourkit Java Profiler 是一个收费的 “堆快照” 分析工具,但运行速度、使用体验明显好于 MAT 等较老的分析工具:www.yourkit.com/java/profil…

[9] 有关 Prometheus Exposition Formats 可参考:github.com/prometheus/…

[10] 实际上并非是这个猜想完全不对,只是这个 Big Array 产生的过程有更多“玄机”,后文会展开讨论;

[11] 之前一直没有注意到问题,是因为这个文本文件很大,一直没有翻阅到末尾查看过;

[12] 根据 jls-4.12.5(docs.oracle.com/javase/spec… class variable, instance variable, or array component is initialized with a default value when it is created. For type char, the default value is the null character, that is, '\u0000'

[13] 假设扩容的猜测正确,而单次扩容后有一半的空间还未使用,极有可能是该数组刚刚完成扩容。因此,我们可以简单计算得到:扩容的倍数 ≥ Array Capacity/Used = 5636094/2863295 ≈ 2 倍 。当然,做出这样的假设有了一些运气的成分,如果我们从 “堆快照” 中找到的大数组是 Capacity=5636094, Used=5630070,可能根本就想不到这一点;

[14] 根据官方文档 Production-ready Features(docs.spring.io/spring-boot… Boot includes a number of additional features to help you monitor and manage your application when you push it to production. You can choose to manage and monitor your application by using HTTP endpoints or with JMX. Auditing, health, and metrics gathering can also be automatically applied to your application;

[15] 这便是上文我推测 “数组在写入字符串的过程中进行动态扩容” 时,提到的 “某种规则”,并且也基本符合扩容倍数为 2;

[16] 符号 ⎣ 和 ⎦ 表示对中间的计算结果向下取整;

[17] JOL's Github:github.com/openjdk/jol…

[18] 这是因为有一些 Feature 可能在某个“次要版本(更新版本)”中引入、修改 或 废弃;

[19] OpenJDK's Mercurial Repository: hg.openjdk.org/;

[20] 实际上,当 word_size > 0 时,不仅是在 Collection Pause 成功后会尝试分配,在 Pause 之前也会先尝试分配,如果分配成功则直接认为本次 Pause 完成,类似于一种 Fast-Success 设计;

[21] 通过 -XX:InitiatingHeapOccupancyPercent 设置,默认值是 45%;

[22] 即 “没有存活对象” 的 region;

[23] 参考:docs.spring.io/spring-boot…

[24] 参考:docs.oracle.com/en/java/jav…

[25] 参考:bugs.openjdk.org/browse/JDK-…