JVM 垃圾收集器与内存分配策略
概述
在前面介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈这三个区域是线程私有的,也就是随着线程而生,伴随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。
对象是否存活?
垃圾收集器在对堆进行回收前,首先要判断对象是否不被使用了。判活有以下几种办法:
-
引用计数法
给对象添加一个引用计数器,每当有一个地方引用时,计数器值就加一,当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能在被使用的。
引用计数法实现简单,判断效率也高,但是Java虚拟机里面并没有选用引用计数法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
-
可达性分析
这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当有一个对象到GC Roots没有任何引用链相连,即不可达时,则证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说是Native方法)引用的对象
再谈引用
如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用这四种。
-
强引用
在程序代码中普遍存在,类似 Object object = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收被引用的对象。
-
软引用
用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。JDK1.2提供SoftReference类来实现软引用。
-
弱引用
被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
-
虚引用
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。
垃圾收集算法
标记 - 清除算法
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它主要有两点不足:一个是效率问题,标记和清除两个过程的效率都不高;另外一个是空间问题,标记清除后会产生大量的内存碎片。
image复制算法
为了解决效率问题,复制算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了,就将还存活的对象复制到另外一块,然后将已使用的那块内存空间一次清理掉。实现简单,运行高效。但是这种算法的代价就是将内存缩小了原来的一般。
image现在虚拟机都采用复制算法来回收新生代。新生代的对象98%的对象都是朝生夕死的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的Eden区和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性的复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是 8:1 。也就是只有10%的内存会“浪费”。当然,如果Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。
标记 - 整理算法
复制算法在对象存活率比较高的时候是非常低效的,更关键的是,如果不想浪费掉50%的内存空间,就要有额外的空间进行分配担保。所以老年队一般不会选用复制算法。
和标记清除算法的标记过程一致,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
imageHotSpot的算法实现
枚举根结点
可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须确保一致性。一致性的意思是指在整个分析过程中整个系统看起来像被冻结在某个时间点上,不可以出现分析过程中引用关系还在不断变化的情况,这点是导致GC进行时必须停顿掉所有的线程。
安全点
程序执行时并非在所有的地方都能停顿下来开始GC,只有在到达安全点时才能暂停。那么如何让GC发生时所有的线程都跑到安全点上在停顿下来呢?有两种方案可供选择:抢先式中断和主动式中断。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
主动式中断的思想是当GC需要中断线程时,不直接对线程操作,仅仅是设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志时就自己中断挂起。轮询标志的地方和安全点是重合的。
安全区
可以看作是被扩展的安全点。
垃圾收集器
如果说垃圾收集算法是对内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
image如果两个收集器之间存在连线,就说明它们可以搭配使用。
Serial收集器
单线程收集器,它在进行垃圾收集时,必须暂停其他所有的工作线程。
ParNew收集器
其实是Serial收集器的多线程版本。
Parallel Scavenge收集器
是一个使用复制算法的新生代收集器,又是并行的多线程收集器。但是它的关注点和其他收集器不同,CMS等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。例如虚拟机总运行了100分钟,其中垃圾回收花费了一分钟,那么吞吐量就是99%。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Serial Old收集器
是Serial收集器的老年队版本,它同样是一个单线程收集器,使用标记-整理算法。
Parallel Old收集器
是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。
CMS收集器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法实现,并发收集,低停顿。
G1收集器
G1是一款面向服务端应用的垃圾收集器。有以下特点:
- 并行和并发
- 分代收集
- 空间整合
- 可预测的停顿
内存分配和回收策略
对于内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲区,将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代。
对象优先在Eden区分配
大多数情况下,对象在新生代Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
-
新生代GC(Minor GC)
指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
-
老年的GC(Major GC / Full GC)
指发生在老年代的GC,出现论Major GC,经常会伴随至少一次的Minor GC,Major GC的速度一般会比Minor GC慢十倍以上。
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。一般来说,超过3M的对象会直接在老年代进行分配。
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收就必须能识别哪些对象应放在新生代还是老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就会增加一岁。当它的年龄增加到一定程度(默认是15),就将会被晋升到老年代中。