第 3 章 垃圾收集器与内存分配策略
3.1 概述
- Lisp 语言是最早使用内存分配并 GC 的语言
- 因为垃圾收集成为了高并发的瓶颈,并且需要排查内存溢出等复杂问题
- 程序计数器、虚拟机栈、本地方法三个区域是随着线程生而生,线程灭而灭,所以内存的 GC 是固定的;我们只关心运行时的内存 GC,因为只有在运行时我们才知道需要创建什么对象
3.2 对象已死么
- java 堆当中存放着几乎所有的 java 对象实例,在 GC 的时候,需要去判断这些对象实例是否已经死亡
3.2.1 引用计数算法
- 对象引用的时候,计数器 + 1,引用失效的时候,计数器 - 1;但这种算法无法处理对象互相引用的问题
- 如果两个对象互相引用 ,计数器都不为 0,此时 GC 就不会回收他们
- 所以在 java 没有彩计数器的算法来 GC
3.2.2 可达性分析算法
- 通过一个对象的 GC 来做为 Root,向下搜索看看是否存在引用的链路,若存在,说明是对象实例是活着的,无法 GC,若不存在,则 GC
- 几个对象互相引用,但如果没有实例化,则会被 GC 掉
- java 当中可以做为 GC ROOT 的对象有以下几种 :虚拟机栈当中的对象;方法区表类静态属性引用的对象;方法区当中常量引用的对象;本地方法栈中 JNI 引用的对象
- 弊端:当应用工程比较大的时候,在几百兆的内存当中去查询引用链是非常花费时间的;并且不能保证在查找的过程当中,引用链路会发生变化
3.2.3 再谈引用
- 有一些缓存数据,是没有引用关系的,那么这些如果被 GC 掉就会非常的可惜,所以 java 当中将引用分为几个等级;
- 强引用(Strong Reference) :new 出来的对象,这类对象只要引用存在,则不会被回收掉
- 软引用(Soft Reference):只有当内存发生溢出的时候才会把这些对象回收掉
- 弱引用(Weak Reference):只要 GC 工作的时候,就一定会被回收,存活于两次 GC 的间隔
- 虚引用(Phanton Reference):存在的目的就是在对象实例被回收的时候发出一个通知,无法实例化对象,属于最弱的引用
3.2.4 生存还是死亡
- 一个对象若没有通过 GC ROOT 来找到对象的引用链,那么此时会被标记并筛选,若对象覆写 finalize () 方法或 finalize () 方法已经被虚拟机调用过(只能调用一次,第二次仍然会被 GC 掉),那么此对象不会被回收
- 若没有需要执行 finalize () 方法,那么虚拟机将其放入 F-Queue 队列当中进行即将回收处理
- 在即将回收处理的时候,对象可以通过 finalize () 方法当中通过重新建立引用来拯救自己
3.2.5 回收方法区
- 方法区当中的内存被称为永久代,永久代当中的内存回收效率很低,因为大家都比较 “永久”,所以无法全部回收
- 废弃的常量的回收:常量存在于常量池当中,当没有一个对象引用此常量的时候,将在 GC 的时候清理出常量池
- 无用类的回收:当一个类当中的所有实例都被 GC;无法通过反射生成类对象的实例;该类的 ClassLoader 已经被回收的时候,GC 会将这些无用的类回收掉
- 在操作大量反射的时候,需要注意内存的使用情况,以保证永久代不会溢出
3.3 垃圾收集算法
3.3.1 标记 - 清除算法
- 它是最基础的垃圾收集算法
- 它的不足在于被标记清除的内存会产生大量的碎片,而当内存需要分配大对象的时候,会导致内存不连续而无法分配,需要再次触发垃圾回收机制,所以效率不高
TODO 3.3.2 复制算法
- 它的不足:对象过多的时候复制的效率就会下降
3.3.3 标记 - 整理算法
- 先进行标记清除算法,然后将存活的对象都向内存的一端移动,以保存内存空间可以连续,不产生碎片
3.3.4 分代收集算法
- 按照对象的存活周期进行分配,将 java 堆内存分为新生代和老年代两块;
- 新生代里面对象经过 GC 以后存活的很少,采用复制算法进行回收
- 老年代里面对象经过 GC 以后存活的很多,彩标记 - 整理算法进行回收
3.4 HotSpot 算法实现
3.4.1 枚举根节点
- 可达性分析的弊端:工程比较大的时候,查找引用链比较费时;不能保证在查找过程当中引用链不发生变化
- 目前主流的虚拟机都采用准确式 GC,当系统停顿下来之后,可以快速计算出对象之间的引用
- HotSpot 通过 OopMap 的数据结构来记录,当类加载器加载完成后,将对象内什么偏移量是什么数据类型记录下来,同时在特定位置记录在栈和寄存器当中哪些位置是引用的;在 GC 的时候可以直接获取这些信息
3.4.2 安全点
- 但是每次引用链发生变化,都需要生成 OopMap 数据结构,导致需要大量的额外空间,怎么解决这样的问题呢?
- 通过设定 Safepoint,当程序到达安全点的时候,程序会停顿下来进行 GC 处理,才会生成 OopMap 数据结构,防止 OopMap 数据结构频繁地变化
- Safepoint 的值设置多大合适呢?长时间执行的程序才会产生 Safepoint,像循环跳转,方法调用,异常跳转等;
- 另外一个问题,如何保证程序在停顿的时候,所有的线程都停止?一种方式是抢先式中断,将所有的线程都中断,若发现某线程没有到到 Safepoint,再进行恢复,但是基本没有虚拟机采用此种方式;另一种方法,设置标志在安全点的位置,线程在执行过程当中轮询此标志,若为 true, 则自动中断挂起
3.4.3 安全区域
- 当线程处于 Sleep 或者 Blocked 状态的时候,无法进入安全点,那么就不会被 GC,此时就需要使用安全区域来解决这样的问题
- 安全区域是指一段代码在一段时间里面,引用关系没有发生变化,那么在这个区域里面的任何地方开始 GC 都是安全的;
- 虚拟机开始 GC 的时候,不会处理被标记为 Safe Region 的线程,当线程要离开 Safe Region 时,如果已经完成了 GC,那么线程继续捃地,否则一直等待到可以安全离开为止
3.5 垃圾收集器
- 如果收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现;虚拟机里面不止一种收集器
- Hotspot 里面有七种垃圾收集器,新生代的有:Serial、ParNew、ParallelScavenge;老年代的有:CMS、Parallel Old、Serial Old (MSC);还有 G1 是跨新生代和老年代
- 没有完美的收集器
3.5.1 Serial 收集器
- 发展历史最悠久的收集器,采用标记整理的算法
- 单线程的收集器;在 GC 的时候把其他所有的线程都暂停,直接 GC 结束,即 Stop The World;
- 这样的实现方式会给应用带来不好的体验;每运行 1 个小时,应用暂停 5 分钟?但若不暂停所有的线程怎么可以完全的进行 GC 呢?这是一个合理的矛盾,所以收集器的发展就是不断缩小暂停线程的时间
- 在 Client 模式下,分配给桌面应用的内存不会太大,并且如果是单 CPU 的运行环境,Serial 收集器的效率非常的高,优于其他的收集器;
3.5.2 ParNew 收集器
- ParNew 就是 Serial 收集器的多线程版本,它可以对单个的线程进行 GC;默认开启的收集线程数与 CPU 数量相等,可以通过 —XX ParallelGCThreads 来调整 GC 的线程数
- 因为是多线程的操作,所以在 Server 模式下 GC 是首先的收集器;
- 在单 CPU 下面,因为需要开启线程而带来一定的消耗,所以在单 CPU 环境下,没有 Serial 优越
- Parallel 意为并行,多条收集器与线程并行运行,但是是线程仍处理等待状态
- Concurrent 意为并发,多条收集器与线程并发运行,线程与收集器运行于不同的 CPU,保证线程不等待,可以正常进行
3.5.3 Parallel Scavenge 收集器
- Parallel Scavenge 的目的达到一个可控制的吞吐量,而不是缩小线程暂停的时间; 吞吐量 = 运行用户代码的时间 / (运行用户代码的时间 + GC 时间)
- 控制最大垃圾收集停顿时间: -XX:MaxGCPauseMillis;停顿时间越小,则新生代就越小,则 GC 对小新生代的回收就越快
- 设置吞吐量大小: -XX: GCTimeRadio; 参数值区间 [0-100); 默认值是 99,即 1/(1+99)=1%,说明默认设置的允许最大的垃圾收集时间为 1%;
- 自动根据系统适配收集器相关参数的值: -XX: +UseAdaptiveSizePolicy; 虚拟机会根据 GC 自动调节策略去分配新生代大小 (-Xmn) 等参数信息,用户就无需再关注这些参数的设置
- 它无法配和 CMS 工作
3.5.4 Serial Old 收集器
- 是 Serial 收集器的老年代版本,彩复制的算法
- 主要存在的意义在于 client 模式下 GC 使用
- 配合 Parallel Scavenge 和 CMS 使用
3.5.5 Parallel Old 收集器
- 它是 Parallel Scavenge 的老年代版本,采用多线程和标记 - 整理算法
- 在 Parallel Old 收集器出现之前,只能使用 Serial Old,而 Serial Old 无法使用多 CPu 环境的资源,导致性能比较差;
- Parallel Scavenge 与 Parallel Old 配合使用,保证吞吐量优先的垃圾收集器
3.5.6 CMS 收集器
- CMS (Concurrent Mark Sweep) 是一种以缩短系统停顿时间为目标的收集器,基于标记 - 清除算法实现,又称为并发低停顿收集器(Concurrent Low Pause Collector)
- 分四个步骤进行
-
- 初始标记 (CMS initial Mark)
-
- 并发标记 (CMS Concurrent Mark)
-
- 重新标记 (CMS remark)
-
- 并发清除 (CMS Concurrent sweep)
- 步骤 1 和步骤 2 还需要 Stop the World
- 重新标记是标记那些修改了对象引用而产生的变化引用链
- 标记时间停顿长度: 并发标记 < 重新标记 < 初始标记
- CMS 的缺点
-
- 对 CPU 资源敏感,当 CPU 数量少的时候,占用资源会比较多,应用就会变得非常的慢
-
- 无法处理浮动垃圾,即在 GC 过程当中,线程又创建出来的对象,此时无法被清除掉,所以只能等到下一次的 GC
-
- 算法为标记 - 清除,会造成大量的碎片
3.5.7 G1 收集器
- G1 (Garbage-First) 是目前收集器的最前沿的技术,始于 JDK 6u14,直到 JDK 7u14 才被商用
- G1 收集器的特点
- 并行与并发:充分利用多核来缩短停顿的时间,通过并发的方式让 Java 其他线程可以继续运行
- 分代收集
- 空间整合:G1 当中是 标记 - 整理和复制两种方式,所以不会产生碎片
- 可预测停顿:建立预测停顿时间的模型
- 分四个步骤进行:
-
- 初始标记
-
- 并发标记
-
- 最终标记
-
- 筛选回收
3.5.8 理解 GC 日志
- 收集器日志形式都是由收集器本身的实现所决定的,每个收集器的日志格式都可以不一样
- 启动工程的时候添加 jvm 参数,打开 GC 的文件 - XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/tmp/gc.log
- 查看 log 信息以获取 gc 的日志
CommandLine flags: -XX:InitialHeapSize=1073741824 -XX:MaxHeapSize=3221225472 -XX:MaxPermSize=262144000 -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:ReservedCodeCacheSize=67108864 -XX:+UseCodeCacheFlushing -XX:+UseCompressedOops -XX:+UseParallelGC
63.754: [GC [PSYoungGen: 188934K->4025K(888832K)] 300900K->159627K(1588224K), 0.0921650 secs] [Times: user=0.16 sys=0.03, real=0.09 secs]
63.847: [Full GC [PSYoungGen: 4025K->0K(888832K)] [ParOldGen: 155602K->150933K(699392K)] 159627K->150933K(1588224K) [PSPermGen: 74746K->74697K(149504K)], 1.3261620 secs] [Times: user=2.64 sys=0.13, real=1.32 secs]
- InititalHeapSize: 初始堆内存
- MaxHeapSize: 最大堆内存
- MaxPermSize: 最大永久代内存
- UseParallelGC: 使用 Parallel Scavenge 收集器
- 63.754: GC 发生的时间,jvm 从开始运行到 gc 的时间
- GC/Full GC: 收集器的停顿类型,用于区别老年代还是新生代,如果有 Full,说明这次 GC 发生了 Stop-The-World
- PSYoungGen: 新生代(不同的收集器显示的名称不一样,这里为 Parallel Scavenge 收集器显示)
- ParOldGen: 老年代
- PSPermGen: 永久代
- [PSYoungGen: 188934k-> 4025k (888832k)]: GC 前该内存区域已经使用内存大小 -> GC 后该内存区域已使用内存大小(该内存区域总容量)
- 300900k->159627k (15588224k): 方括号外表示 GC 前 Java Heap 已经使用容量 -> GC 后 Java Heap 已经使用容量(Java Heap 总容量)
- 0.0921650 secs: 表示内存区域 GC 占用时间,单位是秒
- [Times: user=0.16 sys=0.03 real=0.09 secs]: 详细的时间,user (消耗 cpu 的时间) sys (内核消耗 cpu 时间) real (操作开始到结束所经过的墙钟时间)
Serial ParNew Parallel Scavenge
DefNew ParNew PSYoungGen
3.5.9 垃圾收集器参数总结
3.6 内存分配与回收策略
- 对象的内存分配一般都分配在堆上
3.6.1 对象优先在 Eden 分配
- Eden 是什么?Eden 是新生代区域的一块,还有两块是 From 和 To;Eden 是存放新生的对象;From 和 To 称为 Survivor,GC 后存活下来的对象
- Eden 满了,那么 jvm 将发起一次 Minor GC,GC 后存活下来的对象 copy 到 Survivor 当中;而当 Survivor 满了,就将 GC 存活下来的对象 copy 到老年代当中
- 每次 GC 后,Eden 都会被清空掉
- Minor GC: 新生代的 GC,回收频繁,速度快
- Major GC: 老年代的 GC(Full GC)比 Minor GC 慢 10 倍以上
3.6.2 大对象直接进入老年代
- 大对象指需要大量连续内存空间的 java 对象,大的字符串、数组等;短命的大对象不好处理
- -XX:pretenureSizeThreshold 参数 可以设置大对象直接进入老年代
- 这样做可以避免在新生代回收的时候,使用复制的回收算法而耗费大量的时间
3.6.3 长期存活的对象将进入老年代
- jvm 为每个对象定义了一个对象年龄计数器;在 Eden 新生成的对象,Age 为 0;经过 GC 后,复制到 Survivor 当中的对象,Age 值为 1;
- 而在 Survivor 当中经过一次 GC,Age 就加 1;当 Age 的值大于 MaxTenuringThreshold 设置的值(默认值为 15)时,就会 copy 到老年代当中
3.6.4 动态对象年龄判定
- jvm 并不是永远按照 MaxTenuringThreshold 的值来将对象晋升为老年代的
- 若 Suvrivor 当中相同年龄的对象的内存总和大于 Survivor 空间的一半,此时大于或等于此年龄的对象都会进入老年代
3.6.5 空间分配担保
- Suvrivor 经过 GC 后的对象进入老年代,老年代里面是否可以分配是无法确定的,有可能没有足够的容量,那么就要进行一次 Full GC
- HandlePromotionFailure 参数来控制老年代分配内存是否允许失败,若允许失败,则进行一次 Full GC;
3.7 本章小结
- 本章介绍了垃圾收集的算法
- jdk1.7 垃圾收集器特点和运作原理
- jvm 内存分配及回收规则
- GC 日志查看
- 一些 jvm options 的意义