jvm之垃圾收集与内存分配回收策略
垃圾收集与内存分配回收策略
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外边的人想进去,墙里边的人却想出来。来自《深入理解Java虚拟机》
1. 垃圾收集
垃圾收集主要是针对堆和方法区进行。程序计数器,虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束后就会消失,因此不需要对这三个区域进行垃圾回收。
判断一个对象是否可以被回收
对象已经死吗?
-
引用计数算法
为对象添加一个引用计数器,当对象增加一个引用时,计数器+1,引用失效时计数器-1。计数器的值为0时对象可以被回收。(实现简单,判定效率高)java虚拟机不通过引用计数算法来判断对象是否存活,因为当两个对象循环引用的情况下,引用计数器永远不会为0,导致无法对它们回收。
public class Test { public Object instance = null; public static void main(String[] args) { Test a = new Test(); Test b = new Test(); a.instance = b; b.instance = a; } }
-
可达性分析算法
以 “GC Roots” 的对象作为起始点,可达的对象都是存活的,不可达的对象都是可回收对象。Java虚拟机中使用该算法来判定对象是否可以被回收,可以作为 GC Roots 的对象包括下面几种:
- 虚拟机栈中局部变量表中引用的对象
- 本地方法中栈中 JNI 中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
-
引用类型
JDK1.2之后Java对引用的概念进行了扩充分为:强引用(不会被回收),软引用(内存不够时回收),弱引用(一定被回收,只能存活到下一次垃圾回收发生一千),虚引用(不会对其生存时间造成影响,也无法通过虚引用得到一个对象)。 -
方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
1)该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
2)加载该类的 ClassLoader 已经被回收。
3)该类对应的 Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
2. 垃圾收集算法
-
标记 -清除:
标记要回收的对象,然后清楚。
不足:- 标记和清除的效率都不高。
- 会产生大量的不连续的内存碎片,导致无法给大对象分配内存。
-
标记 -整理:
标记过程和标记 -清除算法一样
让所有存活的对象都向一端移动,然后直接清理掉边界意外的内存。 -
复制(新生代使用):
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
主要不足时只是用了一半的内存。现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小小灯的两块,而是一块较大的Eden 空间和两块较小的Survivor 空间,每次使用Eden和其中的一块Survivor。在回收时,将Eden和Survivor中海存活着的对象全部复制到另一块Survivor上,最后清理使用的Eden和Survivor。
HotSpot虚拟机的Eden和Survivor的大小比例默认为8:1,保证了内存的利用率达到90%。如果每次回收有多于10%的对象存活,那么一块Survivor就不够用了,此时需要以来于老年代进行空间分配担保(相当于向银行借贷需要担保人),也就是借用老年代的空间储存放不下的对象。
-
分代收集算法:
当前商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同快采用适当的收集算法。
一般将堆分为新生代(Minor GC)和老年代(Major GC)- 新生代使用:复制算法(对象存活率低,付出少量的内存和复制成本就可以完成收集)
- 老年代使用:标记 -清除 或者 标记 -整理 算法
3. 垃圾收集器
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
- 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程。
- 串行与并行:串行是指垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了CMS和G1之外,其他垃圾收集器都是以穿行的方式执行。
-
Serial 收集器
Serial翻译为串行,也就是它是以串行的方式执行的。
他是单线程收集器,只会使用一个线程进行垃圾收集工作。
他的优点是简单高效,在单个CPU环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。
-
ParNew 收集器
ParNew 其实就是 Serial 收集器的多线程版本。
他是 Server 场景下的默认的新生代收集器,除了性能原因外,主要是因为除了Serial收集器只有它能与 CMS收集器配合使用。 -
Parallel Scavenge 收集器
与ParNew一样是多线程收集器
其他收集器的目标都是尽可能地缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,因此她被称为“吞吐量优先”收集器。这里的吞吐量指CPU用于运行用户程序的时间占总时间的比值。 -
Serial Old 收集器
是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Service 场景下,它有两大用途:- 在 JDK1.5 以及之前的版本(Parallel Old诞生之前)与 Parallel Scavenge 收集器搭配使用。
- 作为CMS收集器的后备预案,并在收集发生 Concurrent Mode Failure 时使用。
-
Parallel Old 收集器
是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。 -
CMS (Concurrent Mark Sweep) 收集器
Mark Sweep指的是标记 -清除算法。- 分为以下四个流程:
- 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清楚:不需要停顿。
- 具有以下缺点:
- 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
- 分为以下四个流程:
-
G1 收集器
G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。
堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
- 如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
- 具备如下特点:
- 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
4. 内存分配与回收策略
Minor GC 和 Full GC
- Minor GC:回收新生代上,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
- Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
内存分配策略
-
对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 区分配, 当 Eden 区空间不够时,发起 Minor GC。 -
大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集器以获取足够的连续空间分配给大对象。
XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survicor 区之间的大量内存复制。
-
长期存活的对象进入老年代
为对象定义年龄计数器,对象在Eden出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加一岁,增加到一定年龄则移动到老年代中。XX:MaxTenuringThreshold 用来定义年龄的阈值。
-
动态对象年龄判定
虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果 Survivor 中相同年龄所有对象大小总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。 -
空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。如果不成立的话,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC ; 如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。
-
Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当Eden满了,就触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:-
调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正的去执行,不建议使用这种方式,而是让虚拟机管理内存 -
老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代,长期存活对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmm 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
-
空间分配担保失败
使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。 -
JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC
-
Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
-