Java SE 平台的一个优势是,它使开发人员免去了内存分配和垃圾收集的复杂性。
然而,当垃圾收集成为主要的瓶颈时,了解实现的一些方面是很有用的。垃圾收集器对应用程序使用对象的方式做出假设,这些假设反映在可调参数中,这些参数可以在不牺牲抽象能力的情况下进行调整以提高性能。
分代垃圾收集(Generational Garbage Collection)
当一个对象无法从运行中程序的任何其他存活对象引用到时,它被视为垃圾,其内存可以被虚拟机重新利用。
理论上,最直接的垃圾回收算法是每次运行时迭代遍历每个可达对象。剩余的对象被认为是垃圾。这种方法所需的时间与存活对象的数量成正比,对于维护大量活动数据的大型应用程序来说是不可接受的。
Java HotSpot虚拟机整合了多种不同的垃圾回收算法,使用一种称为代际收集(Generational Collection)的技术。而传统的垃圾回收会在每次检查堆中的所有存活对象,代际收集利用了大多数应用程序的经验观察属性来最小化重新获取未使用(垃圾)对象所需的工作量。其中最重要的观察属性之一是弱代际假设(Weak Generational Hypothesis),该假设述说大多数对象仅生存很短一段时间。
在图3-1中,蓝色区域代表对象生命周期的典型分布。X轴显示以分配的字节数来衡量的对象生命周期,Y轴上的字节数表示具有相应生命周期的对象的总字节数。左侧的尖峰代表可以在分配后不久被回收(也就是“死亡”)的对象。例如,迭代器对象通常只在单个循环的持续时间内存活。
一些对象的寿命更长,因此分布会延伸到右侧。例如,通常在初始化时分配的一些对象会存活直到虚拟机退出。在这两个极端之间的是一些在某些中间计算过程中存活的对象,可以看到它们位于初始峰值右侧的 lump 区域。一些应用程序具有非常不同的分布形状,但令人惊讶的是有大量的应用程序具有这种一般形状。通过专注于绝大多数对象“英年早逝”的事实,可以实现高效的收集。
分代(Generations)
为了优化垃圾回收,内存被管理在不同的代(持有不同年龄对象的内存池)中。当某一代填满时,就对该代进行垃圾回收。
根据描述,大部分对象都是分配在专门用于年轻对象的内存池(即年轻代)中,而大多数对象也在这里被回收。当年轻代填满时,会触发一次小型垃圾回收(Minor GC),在该回收中只有年轻代被清理;其他代中的垃圾不会被回收。这种回收的成本,一般来说,与被收集的存活对象数量成正比;因此,当年轻代充满已经死亡对象时,其清理速度非常快。通常,在每次小型回收期间,部分幸存的对象会从年轻代移动到老年代。最终,老年代填满时必须进行全面回收,即进行一次主要回收(Major GC),在这个过程中整个堆都会被清理。由于涉及到的对象数量显著更多,主要回收通常比小型回收持续时间更长。Figure 3-2展示了串行垃圾回收器中各代的默认排列方式。
对于串行垃圾回收器,请参照相关文档或图表以获取更多详细信息和特定排列。同时也可以考虑使用不同类型的垃圾回收器以优化性能和资源利用。
在Java HotSpot虚拟机启动时,会在地址空间中预留整个Java堆,但并不会为其分配物理内存,除非需要。覆盖Java堆的整个地址空间在逻辑上被划分为年轻代和老年代。用于对象存储的完整地址空间可以被划分为年轻代和老年代。
这种内存管理模式可以灵活地根据具体的内存需求来动态分配物理内存,以实现更高效的内存利用和管理。这种预留和按需分配的方式有助于避免不必要的内存浪费,并能够更好地满足应用程序对内存资源的需求。
根据描述,年轻代由Eden区和两个幸存者空间组成。大部分对象最初都是分配在Eden区。在任何时候,有一个幸存者空间是空的,用作在垃圾回收期间将Eden区和另一个幸存者空间中的存活对象拷贝到该空间中;回收后,Eden区和源幸存者空间都是空的。在下一次垃圾回收时,两个幸存者空间的作用会交换。最近被填满的那个空间成为需要拷贝到另一个幸存者空间中的存活对象的源空间。以这种方式在幸存者空间之间拷贝对象,直到它们被拷贝了一定次数或剩余空间不足。这些对象会被拷贝到老年代。这个过程也称为“aging”(老化)。
性能考虑(Performance Considerations)
在分代垃圾收集中,需要考虑的主要性能因素是吞吐量和暂停时间。
- 吞吐量(Throughput):吞吐量是运行用户代码的时间占总运行时间的比例。总运行时间包括程序的运行时间和内存回收的时间。高吞吐量的应用程序可以容忍较高的暂停时间,因为它们有更长的时间基准,快速响应不是首要考虑的因素。
- 暂停时间(Pause Time):暂停时间是执行垃圾收集时,程序的工作线程被暂停的时间。对于需要快速响应的应用程序,如Web服务器或实时系统,减少暂停时间是非常重要的。
不同用户对于垃圾回收有不同的需求。例如,在Web服务器中,一些人认为吞吐量是一个合适的度量标准,因为垃圾回收期间的暂停可能是可以被容忍或者简单地被网络延迟所掩盖。然而,在一个交互式图形程序中,即使是短暂的暂停也可能会对用户体验产生负面影响。
一些用户可能对其他因素更为敏感。内存占用是进程的工作集,以页面和缓存行为单位来衡量。在物理内存有限或进程较多的系统中,内存占用可能决定了可伸缩性。及时性是指对象变为死态到内存可用之间的时间间隔,在包括远程方法调用(RMI)在内的分布式系统中是一个重要考虑因素。
通常情况下,选择特定代的大小是在这些考虑因素之间进行权衡。举例来说,一个非常大的年轻代可能会最大化吞吐量,但却是以牺牲内存占用、及时性和暂停时间为代价的。小年轻代可以最小化年轻代的暂停时间,但会牺牲吞吐量。一个代的大小不会影响另一个代的回收频率和暂停时间。
选择一个代的大小并没有一个标准的方法。最佳选择取决于应用程序如何使用内存以及用户的需求。因此,虚拟机对垃圾回收器的选择并不总是最优的,可以使用命令行选项来覆盖。请参阅影响垃圾回收性能的因素。
吞吐量和内存占用测量(Throughput and Footprint Measurement)
吞吐量和内存占用最好使用特定于应用程序的指标来衡量。
例如,可以使用客户端负载生成器测试Web服务器的吞吐量。然而,由于垃圾回收而引起的暂停可以通过检查虚拟机本身的诊断输出来轻松估算。命令行选项-verbose:gc会在每次垃圾回收时打印有关堆和垃圾回收的信息。以下是一个示例:
[15,651s][info ][gc] GC(36) Pause Young (G1 Evacuation Pause) 239M->57M(307M) (15,646s, 15,651s) 5,048ms
[16,162s][info ][gc] GC(37) Pause Young (G1 Evacuation Pause) 238M->57M(307M) (16,146s, 16,162s) 16,565ms
[16,367s][info ][gc] GC(38) Pause Full (System.gc()) 69M->31M(104M) (16,202s, 16,367s) 164,581ms
关于这个例子的输出,显示了两次年轻代的垃圾回收,然后是应用程序调用System.gc()触发的一次完全垃圾回收。每行开头都有一个时间戳,表示从应用程序启动开始的时间。接下来是关于此行的日志级别(info)和标签(gc)的信息。然后是一个垃圾回收标识号,这里有三个GC,分别是36、37和38。然后是垃圾回收的类型和触发GC的原因。接着记录了一些关于内存消耗的信息。该日志使用格式"在GC之前使用" -> "在GC之后使用" ("堆大小")。
在示例中第一行是239M->57M(307M),意味着在进行垃圾回收之前使用了239MB内存,并且垃圾回收清理了大部分内存,但仍有57MB幸存下来。堆大小为307MB。需要注意的是,在这个示例中,完全垃圾回收将堆大小从307MB缩小到104MB。内存使用信息之后,还记录了GC的开始和结束时间以及持续时间(结束时间 - 开始时间)。
`-verbose:gc`命令是 `-Xlog:gc`的别名。`-Xlog`是HotSpot JVM中用于日志记录的通用配置选项。它是一个基于标签的系统,其中`gc`是其中一个标签。为了获取有关GC正在执行的更多信息,您可以配置日志记录以打印具有`gc`标签和任何其他标签的任何消息。这个命令行选项是 `-Xlog:gc*`。
以下是G1的一个启动参数使用了 -Xlog:gc*
: 的例子
[10.178s][info][gc,start ] GC(36) Pause Young (G1 Evacuation Pause)
[10.178s][info][gc,task ] GC(36) Using 28 workers of 28 for evacuation
[10.191s][info][gc,phases ] GC(36) Pre Evacuate Collection Set: 0.0ms
[10.191s][info][gc,phases ] GC(36) Evacuate Collection Set: 6.9ms
[10.191s][info][gc,phases ] GC(36) Post Evacuate Collection Set: 5.9ms
[10.191s][info][gc,phases ] GC(36) Other: 0.2ms
[10.191s][info][gc,heap ] GC(36) Eden regions: 286->0(276)
[10.191s][info][gc,heap ] GC(36) Survivor regions: 15->26(38)
[10.191s][info][gc,heap ] GC(36) Old regions: 88->88
[10.191s][info][gc,heap ] GC(36) Humongous regions: 3->1
[10.191s][info][gc,metaspace ] GC(36) Metaspace: 8152K->8152K(1056768K)
[10.191s][info][gc ] GC(36) Pause Young (G1 Evacuation Pause) 391M->114M(508M) 13.075ms
[10.191s][info][gc,cpu ] GC(36) User=0.20s Sys=0.00s Real=0.01s
注意:-Xlog: gc * 产生的输出格式可能会在未来的版本中发生变化。
垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
Serial
Serial收集器是最基本、发展历史最悠久的收集器,曾经(在JDK1.3.1之前)是虚拟机新生代收集的唯一选择。
它是一种单线程收集器,不仅仅意味着它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是其在进行垃圾收集的时候需要暂停其他线程。
优点:简单高效,拥有很高的单线程收集效率
缺点:收集过程需要暂停所有线程
算法:复制算法
适用范围:新生代
应用:Client模式下的默认新生代收集器
Serial Old
Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,不同的是采用"标记-整理算法",运行过程和Serial收集器一样。
ParNew
可以把这个收集器理解为Serial收集器的多线程版本。
优点:在多CPU时,比Serial效率高。
缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差。
算法:复制算法
适用范围:新生代
应用:运行在Server模式下的虚拟机中首选的新生代收集器
Parallel Scavenge
Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,看上去和ParNew一样,但是Parallel Scanvenge更关注系统的吞吐量。
吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)
比如虚拟机总共运行了100分钟,垃圾收集时间用了1分钟,吞吐量=(100-1)/100=99%。
若吞吐量越大,意味着垃圾收集的时间越短,则用户代码可以充分利用CPU资源,尽快完成程序的运算任务。
-XX:MaxGCPauseMillis控制最大的垃圾收集停顿时间,
-XX:GCRatio直接设置吞吐量的大小。
Parallel Old
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法进行垃圾回收,也是更加关注系统的吞吐量。
CMS
>`官网`: https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html#concurrent_mark_sweep_cms_collector
CMS(Concurrent Mark Sweep)收集器是一种以获取 `最短回收停顿时间`为目标的收集器。
采用的是"标记-清除算法",整个过程分为4步
(1)初始标记 CMS initial mark 标记GC Roots直接关联对象,不用Tracing,速度很快
(2)并发标记 CMS concurrent mark 进行GC Roots Tracing
(3)重新标记 CMS remark 修改并发标记因用户程序变动的内容
(4)并发清除 CMS concurrent sweep 清除不可达对象回收空间,同时有新垃圾产生,留着下次清理称为浮动垃圾
>由于整个过程中,并发标记和并发清除,收集器线程可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行的。
G1(Garbage-First)
`官网`: https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection
使用G1收集器时,Java堆的内存布局与就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
每个Region大小都是一样的,可以是1M到32M之间的数值,但是必须保证是2的n次幂
如果对象太大,一个Region放不下[超过Region大小的50%],那么就会直接放到H中
设置Region大小:-XX:G1HeapRegionSize=<N>M
所谓Garbage-Frist,其实就是优先回收垃圾最多的Region区域
(1)分代收集(仍然保留了分代的概念)
(2)空间整合(整体上属于“标记-整理”算法,不会导致空间碎片)
(3)可预测的停顿(比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒)
工作过程可以分为如下几步
初始标记(Initial Marking) 标记以下GC Roots能够关联的对象,并且修改TAMS的值,需要暂停用户线程
并发标记(Concurrent Marking) 从GC Roots进行可达性分析,找出存活的对象,与用户线程并发执行
最终标记(Final Marking) 修正在并发标记阶段因为用户程序的并发执行导致变动的数据,需暂停用户线程
筛选回收(Live Data Counting and Evacuation) 对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划
ZGC
官网: The Z Garbage Collector
JDK11新引入的ZGC收集器,不管是物理上还是逻辑上,ZGC中已经不存在新老年代的概念了
会分为一个个page,当进行GC操作时会对page进行压缩,因此没有碎片问题
只能在64位的linux上使用,目前用得还比较少
(1)可以达到10ms以内的停顿时间要求
(2)支持TB级别的内存
(3)堆内存变大后停顿时间还是在10ms以内
垃圾收集器分类
串行收集器->Serial和Serial Old
只能有一个垃圾回收线程执行,用户线程暂停。
`适用于内存比较小的嵌入式设备`。
并行收集器[吞吐量优先]->Parallel Scanvenge、Parallel Old
多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
`适用于科学计算、后台处理等若交互场景`。
并发收集器[停顿时间优先]->CMS、G1
用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的时候不会停顿用户线程的运行。
`适用于相对时间有要求的场景,比如Web`。