说到 Java 很多人脑海会直接蹦出内存自动回收,会经常听到 GC 这些词,GC 指的是 Garbage Collection 也就是垃圾回收。说到垃圾回收就不可避免的去看下 Java 的内存管理机制。

内存管理

提到内存管理可能很多人都会联想起 C/C++ 的手动内存管理,以及 Java/Python 的自动管理,但实际上这都是指的堆内存管理。常规的内存管理可以分成两个部分,栈内存管理和堆内存管理。

栈的发明解决了部分内存的自动回收,但是栈的局限在于只能自动管理固定长度的内存,而对于堆内存,不同语言有不同的管理方式:

  • 纯手动管理 C/C++
  • 自动管理 Java/Python/PHP/C# 等
  • 半自动 C++ 智能指针 Swift/Rust 等

什么是 GC 以及为何要有 GC

对于通常含义上的 GC 可以参考维基词条, John McCarthy 在发明 Lisp 时一并发明了内存自动管理机制。

什么是内存垃圾,也就是程序在执行过程中再无法访问的对象,这些对象所占用的内存空间可以收回来重新使用。

优点:

  • 编码容易
  • 减少因内存管理而导致的 bug,野指针,内存泄露等等

GC 的缺点:

  • 需要消耗额外的 CPU / Memory 资源
  • 代码执行时间无法估计

GC 实现方式

判断对象是否存活一般有两种方式:

  • 引用计数
  • 可达性分析 (Reachability Analysis):从 [[GC Roots]] 开始向下搜索,搜索走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链时,此对象不可用,不可达。

Reference counting

引用计数,最简单也最容易实现的一种,原理是在每个对象中保存该对象的引用计数,当引用发生增减时对计数进行更改。

优点:

  • 当对象不再被引用立即就会被释放,算法运行快
  • 空间释放是针对个别执行,和其他算法相比,GC 中断时间比较短

问题:

  • 额外的内存占用,每个对象需要一个 counter
  • 引用发生增减时需要对计数做出正确的增减,如果发生计数错误可能会导致难以发现的内存错误
  • 循环引用,两个对象互相引用,能解决但需要大量计算
  • 引用计数不适合并行处理,多线程同时对引用计数进行增减时,引用计数可能会产生不一致的问题,而如果采用加锁方式,带来的开销也非常大

引用计数的例子:

  • Python
  • PHP
  • Swift/ OC

Mark and Sweep

标记清除,是最早开发出来的算法(1960 年),原理,从根开始可能被引用的对象用递归的方式进行标记,然后将没有标记的对象作为垃圾回收。

缺点:

  • 效率问题,在分配了大量对象,而只有一小部分存活的情况下,算法消耗的时间多
  • 大量不连续的内存碎片
  • 执行时间不可控

Copy and Collection

复制收集,为了克服标记清除的问题,将内存分为大小相同的两块区域,每次只使用一块,在算法中将根开始被引用的对象复制到另外的空间中,然后将复制的对象所能够引用的对象用递归方式复制下去。

复制收集方式的过程相当于标记清除算法中的标记阶段,但由于清除阶段依然要对所有对象进行扫描,如果存在大量对象,而且大量对象已经死亡的情况,开销会加大。

优点:

  • 没有内存碎片
  • 复制收集过程中,按照对象被引用的顺序将对象复制到新空间,关系较近的对象被放到较近空间的可能性提高,局部性能提升,内存缓存可能更容易命中

缺点:

  • 和标记清除相比,复制对象的开销加大,当存活对象较多的情况下,性能损耗
  • 对于长时间存活的对象将反复拷贝,效率低

Mark-Compact

标记-整理算法,复制算法在对象存活率较高的时候需要反复执行多次拷贝,并且需要浪费 50% 空间。所以老年代一般不用。

根据老年代的特征,提出了标记-整理算法,标记过程和标记清除算法一致,后续不是对可回收的对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

分代收集算法

Generational Collection 算法,分为新生代和老年代,根据不同年代的特征选择不同的收集算法。

新生代中有大量对象死去,只有少量存活,就使用复制算法。

而老年代对象存活率较高,使用标记清除或标记整理算法。

JVM 如何实现 GC

现代版的 GC 使用分代收集,按照对象存活时间长短来使用不同的垃圾回收算法。

Heap 堆内存分为:

  • Young Generation:新创建的对象,Young Generation 又被分成 Eden space (所有新对象开始的地方),两个 Survivor spaces(在存活一个 gc 之后移动到这里),当对象在 Young Generation 被回收,这是 minor garbage collection event(简称 minor gc)。
  • Old Generation: 当对象存活足够长时间,会从 Young Generation 移动到 Old Generation。当对象在 Old Generation 被回收,这是一次 major garbage collection event.
  • Permanent Generation: 类,方法等 Metadata 会保存在 Permanent Generation。不再被使用的 Classes (ClassLoader 回收后)会被回收。

所以一个对象在不同分区的流程可能是:

  • 新对象在 Eden 中创建
  • Eden 满时进入 Survivor spaces
  • 两个 Survivor 空间的对象相互交换
  • 在 Survivor 存活一定时间后进入 Old

一种 JVM 的实现,由 1999 年引入,HotSpot 为代表,HotSpot JVM 有四种 Garbage Collector:

  • Serial GC : 所有的 garbage collection events 通过一个线程连续管理, Compaction 在每一次 garbage collection 之后执行
  • Parallel GC : 并行进行 minor garbage collection,一个线程用来 major garbage collection 和 Old Generation compaction
  • Concurrent-Mark-and-Sweep GC : 简称 CMS,多个线程用来 minor garbage collection ,使用和 Parallel 相同的算法。CMS 在应用程序之外运行,GC 和 应用程序并行,减少中断。不会执行 compaction
  • G1 GC(1.7+) : Garbage First,新的 garbage collector,用来替换 CMS,同样是并行并发的,但是原理和工作方式完全不一样

Java 的 GC 是不确定的,没有方法来预测何时会发生 gc。在代码中可以使用 System.gc() 或者 Runtime.gc() 方法来暗示 gc,但是 Java 不能保证 gc 一定会执行。

GC 的调优可以从 JVM 的参数调节:

  • -Xms 初始堆大小,可以设置和 Xmx 相同,避免每次垃圾回收后 JVM 重新分配内存
  • -Xmx 最大堆大小
  • -XX:NewRatio=n Young 和 old 区的大小比 1:n
  • -XX:MaxPermSize Permanent 大小
  • -XX:+UseG1GC 使用 G1
  • -XX:MaxGCPauseMillis=n 最大希望暂停时间
  • -XX:InitiatingHeapOccupancyPercent=n 堆使用到多少百分比时开始 CMS 过程
  • -XX:+PrintGC 输出 GC 日志
  • -XX:+PrintGCDetails 输出 GC 的详细日志
  • -XX:+PrintGCTimeStamps 输出 GC 时间戳
  • -XX:+PrintGCDateStamps 输出 GC 时间戳(日期形式,2019-01-01T01:01:02.212+0800)
  • -XX:+PrintHeapAtGC 进行 GC 的前后打印堆信息
  • -verbose:gc
  • -XX:+PrintReferenceGC 年轻代各个引用的数量以及时长
  • -Xloggc:../logs/gc.log 日志文件输出路径

JVM 内存区域划分

Heap 区

  • Eden
  • Survivor
  • Old gen

非 Heap 区:

  • Code Cache, 代码缓存区,它主要用于存放 JIT 所编译的代码
  • Perm Gen,Permanent Generation space,是指内存的永久保存区域
  • Jvm Stack
  • Local Method Stack

为什么需要 Survivor 区

前提知识,新生代内存中除了 Eden 区外还有两个 Survivor 区,Eden 占 80%,两块 Survivor 区占比 20%。

如果没有 Survivor ,Eden 区每进行一次 Minor GC,存活的对象都会被送到老年区,老年代很快被填满。

每进行一次 Minor GC,存活下来的对象会被计数 +1,当对象在 Minor GC 下存活多次,达到一个阈值后会被移动到老年代。

Survivor 存在的意义,减少被送到老年代的对象,减少 Full GC 发生,只有经历 16 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。

JVM 参数设置和调优

生产环境 Xms 和 Xmx 设置相同的值

In a production environment, if you monitor the GC data, you will notice that is a relatively short period of time (usually less than an hour), the JVM will eventually increase the heap size to the -Xmx setting. Each time the JVM increases the heap size it must ask the OS for additional memory, which takes time (and thus adds to the response time of any requests that were is process when the GC hit). And usually the JVM will never let go of that memeory. Therefore, since the JVM will eventually grab the -Xmx memory, you might as well set it to that at the beginning.

Another point is that with a smaller heap size (starting with -Xms), GCs will happen more often. So by starting with a larger heap initially the GCs will happen not as often.

Finally, in a production environment, you usually run only one app server per OS (or per VM). So since the app server is not competing for memory with other apps you might as well give it the memory up front.

Note that the above is for production. It applies also to the syatem test environment since the system test environment should mimic production as close as possible.

For development, make -Xms and -Xmx different. Usually, you are not doing much work with the app server in development, so it will often stay with the -Xms heap setting. Also, since in development the app server will share the machine with lots of other apps (mail client, word processors, IDEs, databases, browsers, etc), setting the -Xms to a smaller size lets the app server play more nicely with the other software that is also competing for the same resources.

gc log

enable gc log

通过如下任意一个开启 gc log:

-XX:+PrintGC
-XX:+PrintGCDetails
-Xloggc:gc.log

开启 -XX:+PrintGC 后,打印日志:

[GC (Allocation Failure)  61905K->9848K(256000K), 0.0040139 secs]

说明:

  • GC 表示是一次 YGC(Young GC)
  • Allocation Failure 表示是失败
  • 61905KK->9848K 表示年轻代从 61905KK 降为 9848K
  • 256000K 表示整个堆的大小
  • 0.0040139 secs 表示这次 GC 总计所用的时间

开启 -XX:+PrintGCDetails 后,日志:

2020-03-28T08:55:24.916+0800: 229805.169: [GC (Allocation Failure) 2020-03-28T08:55:24.916+0800: 229805.170: [ParNew: 584336K->24291K(629120K), 0.0145141 secs] 1849190K->1289986K(2027264K), 0.0155393 secs] [Times: user=0.08 sys=0.02, real=0.02 secs]

说明:

  • 第一个时间戳 2020-03-28T08:55:24.916+0800 是 Mirror GC 发生的时间
  • 229805.169 GC 开始的时间,这里是相对 JVM 启动时间,单位秒
  • GC 用来区分 GC 类型,Minor GC 或者 Full GC
  • Allocation Failure 分配内存失败
  • ParNew 收集器名称
  • 584336K->24291K 前后年轻代使用
  • 629120K 整个年轻代的容量
  • 跟随的时间是 gc 发生的时间
  • 1849190K->1289986K 堆前后使用情况
  • 后接的时间是 ParNew 收集器标记和复制年轻代存活的对象的时间

    最后出现的 [Times] 中三个时间是:

  • user: GC 线程在垃圾收集中使用 CPU 时间
  • sys: 系统调用时间
  • real: 应用被暂停的时钟时间,GC 是多线程的所以, real < (user + sys)

Example

public class GCLogDemo {

	public static void main(String[] args) {
		int _1m = 1024 * 1024;
		byte[] data = new byte[_1m];
		// 将 data 置为 null 即让它成为垃圾
		data = null;
		// 通知垃圾回收器回收垃圾
		System.gc();
	}
}

相关命令

jstat

jstat 可以用来查看 JVM 数据信息:

jstat [options] vmid [interval] [count]
  • options 使用 -gc 或者 -gcutil
  • 这里的 vmid 是 vm 的进程号,当前运行的 java 进程号

比如查看 gc 情况

jstat -gc [PID]

每 5 秒打印一次特定 PID 的 GC 情况

jstat -gc [PID] 5000
jstat -gc [PID] 5s

结果说明:

前提知识,新生代内存中除了 Eden 区外还有两个 Survivor 区,Eden 占 80%,两块 Survivor 区占比 20%。

S0C:当前年轻代中第一个 survivor(幸存区)的容量 (字节) ,简记成 Survivor 0 Current
S1C:当前年轻代中第二个 survivor(幸存区)的容量 (字节) 
S0U:年轻代中第一个 survivor(幸存区)目前已使用空间 (字节) 
S1U:年轻代中第二个 survivor(幸存区)目前已使用空间 (字节) 
EC:当前年轻代中 Eden(伊甸园)的容量 (字节) 
EU:年轻代中 Eden(伊甸园)目前已使用空间 (字节) 
OC:当前 Old 代的容量 (字节) 
OU:Old 代目前已使用空间 (字节) 
PC:当前 Perm(持久代)的容量 (字节) 
PU:Perm(持久代)目前已使用空间 (字节) 
YGC:从应用程序启动到采样时年轻代中 gc 次数 
YGCT:从应用程序启动到采样时年轻代中 gc 所用时间 (s) 
FGC:从应用程序启动到采样时 old 代(全 gc)gc 次数 
FGCT:从应用程序启动到采样时 old 代(全 gc)gc 所用时间 (s) 
GCT:从应用程序启动到采样时 gc 用的总时间 (s) 
NGCMN:年轻代 (young) 中初始化(最小)的大小 (字节) 
NGCMX:年轻代 (young) 的最大容量 (字节) 
NGC:年轻代 (young) 中当前的容量 (字节) 
OGCMN:old 代中初始化(最小)的大小 (字节) 
OGCMX:old 代的最大容量 (字节) 
OGC:old 代当前新生成的容量 (字节) 
PGCMN:perm 代中初始化(最小)的大小 (字节) 
PGCMX:perm 代的最大容量 (字节)   
PGC:perm 代当前新生成的容量 (字节) 
S0:年轻代中第一个 survivor(幸存区)已使用的占当前容量百分比 
S1:年轻代中第二个 survivor(幸存区)已使用的占当前容量百分比 
E:年轻代中 Eden(伊甸园)已使用的占当前容量百分比 
O:old 代已使用的占当前容量百分比 
P:perm 代已使用的占当前容量百分比 
S0CMX:年轻代中第一个 survivor(幸存区)的最大容量 (字节) 
S1CMX :年轻代中第二个 survivor(幸存区)的最大容量 (字节) 
ECMX:年轻代中 Eden(伊甸园)的最大容量 (字节) 
DSS:当前需要 survivor(幸存区)的容量 (字节)(Eden 区已满) 
TT: 持有次数限制 
MTT : 最大持有次数限制 

如果使用 -gcutil 则是打印 GC 的使用率:

jstat -gcutil -h 10 [PID] 1000

jmap

使用 jmap 打印堆相关信息,更多细节可以参考这篇文章

jhat

更多关于 jhat 的用法可以参考这篇

reference