JVM-垃圾回收
本文最后更新于:2022年1月16日 晚上
JVM-垃圾回收
1. 如何判断对象可回收
1.1 引用计数法
一个对象被另一个对象引用,则本对象的计数值加1,另一个对象取消对本对象的引用,则引用计数值减1,当引用计数值为0时,判断该对象可回收。
存在循环引用的问题:
JVM没有采取这种方法
1.2 可达性分析法
在Java中,是通过可达性分析(Reachability Analysis)来判定对象是否存活的。该算法的基本思路就是通过一些被称为引用根(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。
如上图所示,object1~object4对GC Root都是可达的,说明不可被回收,object5和object6对GC Root节点不可达,说明其可以被回收。
主体思路:扫描堆中的对象,看是否能够沿着以GC Root对象为起点的引用找到该对象,如果找不到,就表示可以回收。
哪些对象可以作为GC Root?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
- 系统核心线程运行所需要的对象
- 被加锁的对象
- 当前活跃线程中的对象
1.3 五种引用
强引用
强引用就是指在程序代码中普遍存在的,类似
Object obj = new Object()
这类似的引用,只要强引用在,垃圾搜集器永远不会搜集被引用的对象。也就是说,宁愿出现内存溢出,也不会回收这些对象。对某个对象的所有强引用消失时,才会将该对象回收
软引用
软引用是用来描述一些有用但并不是必需的对象,在Java中用
java.lang.ref.SoftReference
类来表示。对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。仅有弱引用引用该对象时,在垃圾回收之后,如果内存仍不足,则会再次发起垃圾回收,回收软引用对象。
当要申请第5个字符数组时,内存不足,GC将前四个字符数组回收。
如何清理软引用对象后,把对应的软引用(SoftReference)也回收?
1
2
3
4
5
6
7
8
9
10//声明一个引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
//创建软引用时,设置了第二个参数,让当前的软引用关联到该引用队列,当软引用所引用的对象(byte[])被回收时,软引用自己会加入到引用队列中
SoftReference<byte[]> ref = new SoftReference<byte[]>(new byte[4MB],queue);
//取出引用队列中无用的软引用对象,在list中移除
Reference< ? extends byte[]> poll = queue.poll();
while(poll!=null){
list.remove(poll);
poll = queue.poll();
}弱引用
弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。
1
WeakReference<byte[]> ref = new WeakReference<byte[]>(new byte[4MB]);
虚引用
虚引用对象,比如Cleaner,例如当ByteBuffer被回收时,他所引用的直接内存并没有被回收,此时需要将虚引用对象Cleaner对象放入一个引用队列,由另一个jvm线程检查队列中的是否存在虚引用,如果存在,则调用虚引用的clean方法,释放直接内存。
虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在java中用
java.lang.ref.PhantomReference
类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。
要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。终结器引用
finallize(),终结方法。
当一个对象在进行可达性分析之后发现与GC roots之间不存在引用链之后,他将会被进行第一次标记,随后进行依次筛选,筛选的依据就是该对象是否需要执行
finalize()
方法。如果该对象没有重写
finalize
方法或者重写了finalize
方法,但是该方法已经被虚拟机执行过一次了,则判定为不需要执行finalize
方法了,此时本次垃圾收集将会将该对象当做垃圾回收。如果本次垃圾收集判断需要执行
finalize()
方法,则将会把该对象放置在一个F-queue
队列中,稍后由虚拟机自动建立一条低优先级的Finalizer
线程去执行他们的finalize()
方法(这里的执行是指虚拟机会触发这个方法的开始,但并不承诺等待他的结束,防止在执行该方法时花费太多时间或死循环,造成之后的队列中的对象持续等待)。
在执行完
finalize()
方法后,将会进行第二次标记,如果经过了finalize()
方法,该对象与其他GC roots建立了联系,则相当于他通过finalize()
方法完成了一次自救,本次垃圾回收不会将他回收,将其移出“即将回收”的集合中,倘若经过了finalize()
方法之后,该对象依旧不可达,则本次垃圾回收他将会被回收。但是如果在之后的某次垃圾回收时,该对象又变成了不可达,则他这次无能为力了,因为
finalize()
方法已经被执行过一遍了,即任何一个对象的finalize()
方法都只会被系统调用一次。如果之前执行过了,那么这次该对象已经没有机会了,只能被当做垃圾被回收。终结器引用同样必须和引用队列关联使用,软引用和弱引用可关联,可不关联。
2.垃圾回收算法
下面三种只是具体算法,并不是JVM实际所用的实现方法。
标记清除
记录需要回收的对象空间的起始地址,加入空闲块列表,速度快,但容易产生内存碎片
标记整理
在完成标记后,进行整理——使所有存活的对象都向内存空间的一段移动,但是在老年代这种每次回收都有大量对象存活的区域,移动存活的对象并更新所有引用这些对象的地方将会是一项繁重的操作,并且这种对象移动需要全程暂停用户进程才能进行,又称
Stop The World
复制
将内存按照容量分为大小相等的两块,每次只使用其中的一块,当这一块的空间不足时,就将其中还存活的对象复制到另一块上面,然后把已经使用过的内存块一次清理掉。每次都是针对半区的回收,分配空间时不用考虑内存碎片的情况,只需要移动对顶指针,按顺序分配即可,但需要占据双倍空间。常用于新生代,因为新生代中存活对象较少,复制对象时复制量较少。
从垃圾收集的停顿时间
看,不移动对象的停顿时间会更短,甚至不停顿;但从整个程序的吞吐量
上看,移动对象会跟划算,这里的吞吐量实际上是用户程序
和垃圾收集器
的效率总和,即时不移动对象会使垃圾收集器的效率提高,但是因为内存分配和访问相比垃圾收集的频率要高得多,不移动对象将会加剧内存分配和访问的负担,因此内存分配和访问的耗时增加,总体的吞吐量依然下降。
HotPot
中关注吞吐量的Paralle-Scavenge
收集器是基于标记-整理算法
的,而关注延迟的CMS
收集器则是基于标记-清除算法
的。
3. 分代垃圾回收
4. 垃圾回收器
目前已经商用的几款垃圾收集器如下:
4.1 Serial收集器
Serial收集器是最基础,历史最悠久的收集器,这个收集器是一个单线程工作的收集器,但他的”单线程”并不仅仅是说明他只会使用一个处理器或收集器线程去完成垃圾收集工作,更重要的是强调在他进行垃圾收集时,必须暂停其他的工作线程,直到垃圾收集结束。Stop The World
的工作是由虚拟机自动发起和执行的,在用户看来,是不可知,不可控的。
尽管Serial收集器会在垃圾收集过程中停止一切用户工作线程,看起来老而无用,但它依然是HotSpot虚拟机运行在客户机模式下默认的新生代收集器,它相比于其他垃圾收集器,更加的简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有垃圾收集器里额外内存消耗最小的(保证垃圾收集能顺利高效的进行而存储的额外信息);对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾回收自然可以获得最高的单线程收集效率。在客户端模式下,分给虚拟机管理的内存并不会很大,垃圾收集的停顿时间很小,对于用户来说完全可以接受,因此,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
4.2 ParNew收集器
ParNew实际上就是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾回收之外,其他的与Serial收集器差别不大。可以用于运行在服务端模式下的HotSpot虚拟机,用于新生代的垃圾收集,目前只能与CMS收集器配合工作,ParNew用于新生代垃圾收集,CMS用于老年代垃圾收集。
ParNew默认开启的线程数与处理器核心数量相同,在处理器核心非常多的情况下,可以使用+XX:ParallelGCTreads
参数来限制垃圾收集的线程数。
在垃圾收集时的”并行”与”并发”的区别:
- 并行:描述的是
多条垃圾收集器线程之间
的关系,说明同一时间内有多条这样的线程在协同工作,同城默认此时用户线程处于等待状态(STW) - 并发:描述的是
垃圾收集器与用户线程之间
的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能够响应服务请求,只不过由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定的影响。
4.3 Parallel Scavenge收集器
Parallel Scavenge收集器也是一款用于新生代的收集器,同样基于标记-复制算法实现,可并行收集的多线程收集器。
Parallel Scavenge收集器的特点是他的关注点与其他收集器不同,CMS等收集器的关注点时尽可能的缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量
。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值
,即:
$$
吞吐量 = 运行用户代码的时间/(运行用户代码的时间+运行垃圾收集的时间)
$$
停顿时间越短越适合需要与用户交互或保证服务相应质量的程序,良好的相应速度能提升用户的体验;
高吞吐量可以以最高效率的利用处理器的资源,尽快完成运算任务,主要适合在后台运算而不需要太多交互的分析任务。
自适应调节策略也是Parallel Scavenge与ParNew一个重要区别
4.4 Serial Old收集器
Serial Old收集器是Serial的老年代版本,同样是单线程的,使用标记-整理算法。
这个收集器的主要意义也是客户端模式下用于HotSpot虚拟机的老年代回收,或者在服务端模式下,在JDK5以及之前的版本中与新生代的Parallel Scavenge配合使用,或者作为CMS收集器发生失败时的后背预案,在并发收集发生Concurrent Mode Failure
时使用。
4.5 Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Paralle Old收集器的组合。
4.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
基于标记-清除算法实现。
运行过程
- 初始标记
- 并发标记
- 重新标记
- 并发清除
初始标记阶段:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;
并发标记阶段:就是从GC Roots的直接关联对象开始便利整个对象图的过程,这个过程耗时较长但不需要停顿用户线程,可与垃圾收集线程一起并发运行;
重新标记阶段:是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。(停顿时间比初始标记阶段稍长一些,远比并发标记阶段的时间短);
并发清除阶段:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,这个阶段可以与用户线程同时并发。
CMS的优点:
(1)并发收集(2)低延迟
CMS的弊端:
(1)会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
(2) CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
(3) CMS收集器无法处理浮动垃圾。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
(4)如果CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次”并发失败(Concurrent Mode Failure)”,这时虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就更长了。
4.7 Garbage First收集器
Garbage First(简称G1),开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
G1是一款主要面向服务端应用的垃圾收集器,G1可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间、或者老年代空间。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。
G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,既每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region。
关键细节问题
将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?
使用记忆集避免全堆作为GC Roots扫描。
在并发标记阶段如何保证收集线程与用户线程不干扰运行?
通过原始快照(SATB)算法来实现。
怎样建立起可靠的停顿预测模型?
以衰减均值为理论基础来实现的,
运作过程
初始标记 :仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际上并没有额外的停顿。
并发标记 : 从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
最终标记 : 堆用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录(原始快照)
筛选回收 : 负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成的。
优点
- 可以指定最大停顿时间
- 分Region的内存布局
- 按收益动态确定回收集
- G1从整体来看时基于“标记-整理”算法来实现的收集器,从局部上看又是基于“标记-复制”算法实现的,这两种算法都意味着G1运行期间不会产生内存空间碎片,垃圾收集完成后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。
本文作者: ziyikee
本文链接: https://ziyikee.fun/2022/01/08/JVM-%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!