Java垃圾回收

2019-03-17  本文已影响0人  tryingpfq

对于垃圾回收,对于Java程序员来说应该是不陌生的。想要长远的发展,必须对这块机制有所了解,
这样才能写出更高效的代码。如果遇到性能的瓶颈,那么肯定要从这方面去分析,做一些调优。

前言

垃圾回收,在Java虚拟机中,垃圾指的是死亡对象所占用的空间,顾明思议就是对内存中已死的对象进行回收,那么如何找出已死的对象,如何进行回收,已近新的对象内存如何分配,
就是垃圾回收要考虑的地方。

如何判断对象已死

所谓的对象已死,就是在内存中游离的对象,没有再被用到,但有占用空间,目前主要有两种方法去判断。

哪些对象是需要回收的呢

猿们都知道JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。
其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,
就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,
这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,
哪些暂时还不能回收,这就要用到判断对象是否存活的算法!

引用计数法

这个做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数,每当有一个地方引用它时,计数器+1,当失效后会减1。
一旦该对象的引用计数器为0,则说明该对象已死亡,便可以被回收。

这种方法会带来一个问题:除了需要格外的空间来存储引用计数器,以及繁琐的更新操作,还有一个重大漏洞,那就是无法处理循环引用
问题。下面这两个对象是不会被标记会死亡的:

可达性分析

目前Java虚拟机的主流垃圾回收器采取的可达性分析算法。这个算法的实质在于将一系列GC Roots作为初始的存活对象集合(live Set),然后从该集合出发,
探索所有能够被该集合引用的对象,并加入到该集合中,这个过程就是标记过程。最终,未被探索到的对象便是死亡,是可回收的。

那么什么是GC Roots,其实就是由堆外指向堆内的,一般GC Roots包括一下几种:

虽然可达性分析,可以解决循环引用问题,但自身也存在一些问题,比如说,在多线程下,其他线程可能会更新已经访问过的对象中的引用,
从而造成误报(将引用设置为null)或者漏报(将引用设置为未被访问过的对象)。


垃圾收集算法

其实垃圾回收目前就三种方式,清除、压缩与复制,下面我们分别来看下这几种算法的过程。

标记-清除

根据名字,就知道,这个算法是分为两步的,先标记需要回收的对象,

很明显,这个算法经过多次收集后,回出现很多内存碎片,之后可能对于大对象无法存放。

复制算法

复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,
就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。


标记-整理算法

标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,
并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

分代收集算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。
一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,
而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

几种算法的比较

算法 优点 缺点
标记-清除 简单 1. 标记和清除两个过程的效率都不高;2. 标记清除之后会产生大量不连续的内存碎片
复制 实现简单,运行高效 1. 减少了内存使用空间;2. 在对象存活率较高时需要进行较多的复制操作(不适合老年代)
标记-整理 根据老年代的特点提出的一种算法,适合老年代 只适合于某些特定情况
分代收集 使用多种收集算法,根据各自的特点选用不同的收集算法 具体实现过程比较复制

安全点 (Safe Point)

HotSpot 并没有为每条指令都生成 OopMap,而只是在 “特定的位置” 记录了这些信息,这些位置称为安全点(Safepoint),
即程序执行时并非在所有地方都能停顿下来开始 GC,只有在达到安全点时才能暂停。

Safepoint 的选定既不能太少以至于让 GC 等待时间太长,也不能多余频繁以至于过分增大运行时的负载。所以,
安全点的选定基本上是以 “是否具有让程序长时间执行的特征” 为标准进行选定的——因为每条指令执行的时间非常短暂,
程序不太可能因为指令流长度太长这个原因而过长时间运行,”长时间执行” 的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,
所以具有这些功能的指令才会产生 Safepoint。

Stop-The-World

在可达性分析的时候,如果有其他工作线程在执行或者结束,那么就会产生新的垃圾,这就处于一个边收集便产生的情况。所以可达性分析必须
在一个能确保一致性的快照中执行。传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是Stop-The-World,停止其他非垃圾回收线程的工作,
直到完成垃圾回收,这也就造成了所谓的暂停时间(GC Pause)。

Java虚拟机中的Stop-The-World是通过安全点(Safepoint)机制来实现的,但java虚拟机收到Stop-The-World请求,它便会等待所有线程都到达
安全点,才允许请求Stop-The-World的线程进行独占的工作。TW是存在所有垃圾收集器中的,即使是CMS和G1这种几乎不停顿的垃圾收集器中,
枚举根节点(GC Root)时也是必须要停顿的。

垃圾收集器

收集器可以从两个方面进行分类:

下面看下收集器图,其中上面区域表示收集的是年轻代,下面区域收集的是老年代,当然G1收集器是都可以的。
线条连接的两个收集器,说明可以组合一起使用。

Serial 收集器(复制算法)

新生代单线程收集器,标记和清理都是单线程,优点是简单高效。它只会是使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束
是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。


Serial Old 收集器(标记-整理算法)

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用 “标记-整理” 算法。Serial/Serial old 收集器的运行过程如图


ParNew收集器(停止-复制算法)

新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。但是,当 CPU 的数量增加时,
它对于 GC 时系统资源的有效利用还是很有好处的,
它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(使用超线程时)的环境下,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。
ParNew/Serial old 收集器的运行过程如下图所示:

Parallel Scavenge收集器(停止-复制算法)

并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。是server级别默认采用的GC方式,
可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。

Parallel Old收集器(停止-复制算法)

Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先。

CMS(Concurrent Mark Sweep)收集器(标记-清理算法)

整个过程分为4个步骤:1. 初始标记(CMS initial mark) 2. 并发标记(CMS concurrent mark) 3. 重新标记(CMS remark) 4. 并发清除(CMS concurrent sweep)

有以下几个特点:

G1收集器

G1算法将堆划分为若干个区域(Region),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。
老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。

几种收集器比较

收集器 特征 使用场景
Serial 收集器 复制算法;单线程;新生代;简单而高效;需要进行 stop the world。 它是虚拟机运行在 Client 模式下的默认新生代收集器
ParNew 收集器 ParNew 收集器 它是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。
Parallel Scavenge 收集器 复制算法;并行多线程;新生代;吞吐量优先原则;有自适应调节策略 适合后台运算而不需要太多交互的任务
Serial Old 收集器 标记-整理算法;老年代;单线程; 这个收集器的主要意义在于给 Client 模式下的虚拟机使用
Parallel Old 收集器 标记-整理;老年代;多线程;与 parallel scavenge 收集器结合实现吞吐量优先 与 Parallel Scavenge 结合使用,适用那些注重吞吐量以及对 CPU 资源敏感的场合
CMS 收集器 标记-清除;老年代;并发收集、低停顿;有三个缺点 标记-清除;老年代;并发收集、低停顿;有三个缺点
G1 收集器 分代收集;空间整合;可预测的停顿 面向服务器应用垃圾收集器

分配策略

Java大部分对象指存活一小段时间,而小部分java对象存活时间长,
Java虚拟机堆划分,将堆划分为新生代和老年代。其中,新生代又被分为Eden区,以及两个大小相等的Survivor区


默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象
的速率,以及 Survivor 区的使用情况动态调整 Eden区和 Survivor 区的比例。
当然,你也可以通过参数 -XX:SurvivorRatio来固定这个比例。但是需要注意的是,其中一个 Survivor
区会一直为空,因此比例越低浪费的堆空间将越高。

对象优先在Eden分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

大对象直接进入老年代

所谓的大对象是指:需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。

大对象对虚拟机的内存分配来说是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

-XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配(避免了在 Eden 以及两个 Survivor 区之间发送大量的内存复制)。

PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效, Parallel Scavenge 收集器不认识这个参数。

长期存活的对象将进入老年代

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。 对象在 Survivor 区中每熬过一次 Minor GC, 年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过参数 -XX: MaxTenuringThreshold 设置。

空间分配担保

有没有想过,如果即时当进行小的对象分配的时候,年轻代没有连续空间存放,但老年代有还有足够空间,那怎么办呢?
这个时候就出现空间分配担保。

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,
如果这个条件成立,那么 Minor GC 可以确保是安全的。当大量对象在 Minor GC 后仍绕存活,
就需要老年代进行空间分配担保,把 Survivor 无法容纳的对象直接进入老年代。
如果老年代的判断到剩余空间不足(根据以往每一次回收晋升到老年代对象容量的平均值作为经验值),则进行一次 Full GC。

参考:
博客圆 极客时间 星哥
个人博客
《深入理解Java虚拟机》周志明

上一篇下一篇

猜你喜欢

热点阅读