垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。
在堆里面存放这java世界中几乎所有的实例对象,垃圾回收器在对堆进行回收前,第一件事情就是要确定这些对象中哪些是垃圾(即不可能再被任何途径使用的对象)。
引用计数算法(Reachability Counting)是通过在对象头中分配一个空间来保存该对象被引用的次数(Reference Count)。
如果该对象被其它对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用计数为0时,那么该对象就会被回收。
|
优点:引用计数算法是将垃圾回收分摊到整个应用程序的运行当中了,而不是在进行垃圾收集时,要挂起整个应用的运行,直到对堆中所有对象的处理都结束。
因此,采用引用计数的垃圾收集不属于严格意义上的"Stop-The-World"的垃圾收集机制。
缺点:主流的java虚拟机都没有采用该算法,主要原因是因为它很难解决对象之间相互循环引用的问题。
|
我们可以看到,最后这2个对象已经不可能再被访问了,但由于他们相互引用着对方,导致它们的引用计数永远都不会为0,通过引用计数算法,也就永远无法通知GC收集器回收它们。
可达性分析算法(Reachability Analysis)的基本思路是,通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个
对象到 GC Roots 没有任何引用链相连时(即从 GC Roots 节点到该节点不可达),则证明该对象是不可用的。
在 Java 语言中,可作为 GC Root 的对象包括以下4种:
简单介绍几种算法的思想。
标记-清除(Mark-Sweep)算法是最基础的垃圾回收算法(后续的垃圾回收算法都是基于对其不足进行改进而得到的),如同它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要被回收的对象,
在标记完成后统一回收所有被标记的对象。
主要的两个不足:
复制(coping)算法的主要思想是:它将可有内存按照容量划分为大小相等的两块,每次只使用其中一块,当其中一块的内存用完了,就将还存活的对象复制到另外一块内存上,
然后把已经使用过的内存空间一次性清理掉。
优点:每次都对整个半区进行内存回收,内存分配也就不用考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效
缺点:代价太大,每次只能使用的内存缩小为原来的一半。
实际应用:现在商业虚拟机都采用这种算法来回收新生代,IBM 公司的专业研究表明,新生代的对象有将近98%都是“朝生夕死",所以不需要按照1:1的比例来分配内存空间,
而是将又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 2个区。
通过 Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old 区)。
★关于Survivor 区:Survivor 分为2个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的 To 区(如果 To 区不够,则直接进入 Old 区)。
每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。
这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivor space 是无碎片的。那么,Survivor 为什么不分更多块呢?比方说分成三个、四个、五个?显然,
如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案。
标记整理算法(Mark-Compact)标记过程仍然与标记 --- 清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。
优点:解决了标记-清除算法的两个不足:内存只能使用一半和内存碎片问题。
缺点:从上图可以看到,它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。
这种算法并没有什么新的思想,只是根据对象存活周期不同将内存划分为几块。一般是把java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最合适的算法。
新生代:每次Minor GC都发现大量对象死去,只有少量存活,那就采用复制算法。
老年代:对象存活率高,采用“标记-清理” 或者“标记-整理”算法。
如果说回收算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现,下面介绍几种主要的垃圾回收器
serial收集器是最基本、发展历史最基础的收集器,这是一个单线程的收集器,运行示意图如下:
serial收集器的多线程版本,示意图如下:
CMS(Concurrent Mark Sweep)收集器,以获取最短回收停顿时间【也就是指Stop The World的停顿时间】为目标,多数应用于互联网站或者B/S系统的服务器端上。其中“Concurrent”并发是指垃圾收集的线程和用户执行的线程是可以同时执行的。
CMS是基于“标记-清除”算法实现的,整个过程分为4个步骤:
1、初始标记(CMS initial mark)。
2、并发标记(CMS concurrent mark)。
3、重新标记(CMS remark)。
4、并发清除(CMS concurrent sweep)。
注意:“标记”是指将存活的对象和要回收的对象都给标记出来,而“清除”是指清除掉将要回收的对象。
缺点:
1.CMS收集器对CPU资源十分敏感:
并发意味着多线程抢占CPU资源,即GC线程与用户线程抢占CPU。这可能会造成用户线程执行效率下降。
CMS默认的回收线程数是(CPU个数+3)/4。这个公式的意思是当CPU大于4个时,保证回收线程占用至少25%的CPU资源,这样用户线程占用75%的CPU,这是可以接受的。
但是,如果CPU资源很少,比如只有两个的时候怎么办?按照上面的公式,CMS会启动1个GC线程。相当于GC线程占用了50%的CPU资源,这就可能导致用户程序的执行速度忽然降低了50%,50%已经是很明显的降低了。
2.CMS收集器无法处理浮动垃圾:
浮动垃圾(Floating Garbage,就是指在之前判断该对象不是垃圾,由于用户线程同时也是在运行过程中的,所以会导致判断不准确的, 可能在判断完成之后在清除之前这个对像已经变成了垃圾对象,所以有可能本该此垃圾被回收但是没有被回收,
只能等待下一次GC再将该对象回收,所以这种对像就是浮动垃圾。主要产生在并发清理阶段。
3.CMS收集器是基于“标记清除-算法”,收集完成后回产生大量空间碎片
G1(Garbage First)垃圾收集器是当今垃圾回收技术最前沿的成果之一。早在JDK7就已加入JVM的收集器大家庭中,成为HotSpot重点发展的垃圾回收技术。同优秀的CMS垃圾回收器一样,
G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,官方也推荐使用G1来代替选择CMS。
特点:
1、并行与并发:
G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
2、分代收集:
分代概念在G1中依然得以保留。虽然G1可以不需要其它收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。也就是说G1可以自己管理新生代和老年代了。
3、空间整合:
由于G1使用了独立区域(Region)概念,G1从整体来看是基于“标记-整理”算法实现收集,从局部(两个Region)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片。
4、可预测的停顿:
这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用这明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
在G1收集器之前收集器进行的收集范围都是整个新生代或老年代,而G1不再是这样:
G1打破了以往将收集范围固定在新生代或老年代的模式,GI 将 Java 堆空间分割成了若干相同大小的 区域,即 region,包括 Eden、Survivor、 Old、 Humongous 四种类型。
其中, Humongous 是特殊的 Old 类型,专门放置大型对象(大于G1中region大小50%的对象)。这样的划分方式意昧着不需要一个连续的内存空间管理对象。 GI 将 空间分为多个区域,优先回收垃圾最多的 区域。
Region的数值是在1M到32M字节之间的一个2的幂值数,JVM会尽量划分2048个左右、同等大小的Region。
在JVM运行的时候,从运行管理角度不需要预先设置分区是新生代分区还是老年代分区,而是在内存分配时决定:当新生代需要空间时,则分区被加入到新生代中;当老年代需要内存空间时,则分区被加入老年代分区。
事实上,G1通常的运行状态是:映射G1的虚拟内存随着时间的推移在不同的代之间切换。例如:一个G1分区被指定为新生代,经过一次新生代的回收之后,整个新生代分区都会被划入待使用的分区,那它既可以作为新生代分区使用,也可以作为老年代使用。
G1工作示意图:
筛选回收:
G1会跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region.4
跨代引用问题:
Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被本Region 中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,必须扫描整个Java堆才能保证准确性!这个问题其
实并非在G1中才有,只是在G1中更加突出而已。在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象时也面临相同的问题,如果回收新生代时也不得不同时扫描老年代的话,
那么Minor GC的效率可能下降不少。在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。
Remembered Set:
RSet是一个抽象概念,记录对象在不同代际之间的引用关系,目的是加速垃圾回收的速度(减少回收过程中STW时间)。G1中每个Region都有一个与对应的Remembered Set。通常有两种方法记录引用关系,分别为point out和point in。比如a=b(a引用b),
若采用point out结构,则在a的RSet中记录b的地址;若采用point in结构,则在b的RSet中记录a的地址。G1的RSet采用的是point in结构,即谁引用了我。
当进行内存回收时,在GC根节点的枚举范围中加入RSet即可保证不对全堆扫描也不会有遗漏。
G1的gc停顿分析:
标记阶段停顿分析
清理阶段停顿分析
复制阶段停顿分析
初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。
我们知道每一款新的垃圾收集器都是针对上一款的收集器的不足进行优化而产生的,ZGC也不例外,ZGC也是为了解决G1的不足,首先我们看下G1有哪些不足:
ZGC如何达成这一目标的呢?简单的说就是ZGC把一切能并发处理的工作都并发执行。
1.ZGC是在G1的基础上发展起来的,我们知道G1中实现了并发标记,所以标记已经不影响停顿时间了。从上传的G1的GC停顿分析也可以看出,G1中的停顿时间主页来自于垃圾回收(YGC和混合回收)阶段中的复制算法。在G1中这一阶段都是STW的,
而ZGC就是把对象的复制转移也并发的执行,从而满足停顿时间10ms以下。
2.在G1中可能发生FGC,每次FGC的停顿时间不可控,而在目前的ZGC中,每次垃圾回收都是FGC,而每次停顿时间都在10ms以下,从而FGC停顿时间不可控这一存在G1中的问题也被解决了。
3.ZGC除了并发转移,还对整个垃圾回收进入STW的过程进行了改进,把原来的串行改成了并发执行。下图是不同垃圾回收器在并发粒度上的区别:
对于非串行回收器,不支持并发执行分为串行执行和并行执行两种情况。
理解ZGC触发时机
相比于CMS和G1的GC触发机制,ZGC的GC触发机制有很大不同。ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。
ZGC有多种GC触发机制,总结如下:
着色指针:
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。
ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。
与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。
ZGC堆内存:
GI 将 Java 堆空间分割成了若干相同大小的区域;ZGC则是分为一个个的页面:
Region
只会放一个大对象,所以实际容量可能会小于中型Region
,最小到4MB。大型Region
在ZGC
实现中不会被重分配,因为复制一个大对象代价太高。在进行垃圾回收时,ZGC对于不同页面的回收策略也不同,简单的说,小页面优先回收;中页面和大页面尽量不回收。
ZGC并发处理算法
ZGC强大之一是他数据的处理转移是并发执行的,下面简单说下ZGC并发处理算法:
ZCC初始化之后,整个内存空间的地址视图被设置为Remaped,当进人标记阶段时的视图转变为Markedo(也称为M0)或者Makedl(也称为M1),从标记阶段结束进人转移阶段时的视图再次设置为Remapped.ZGC通过视图的切换加上SATB算法实现并发
处理。具体算法如下:
1.初始化阶段
在ZGC初始化之后,此时地址视图为Remapped,程序正常运行,在内存中分配对象,满足一定条件后(ZGC垃圾回收的触发时机)垃圾回收启动,此时进入标记阶段。
2.标记阶段
第一次进入标记阶段时视图为M0,在标记阶段,应用程序和标记线程并发执行,那么对象的访问可能来自标记线程和用程序线程。
2.应用程序线程,在正常运行用户代码时访问对象,所做的工作有:
所以,在标记阶段结束之后,对象的地址视图要么是MO,要么是Remapped.如果对象的地址视图是M0,说明对象在标记阶段被标记或者是新创建的,是活跃的;如果对象的地址视图是Remapped,说明对象在标记阶段既不能通过根集合访问到,
也没有应用程序线程访问它,所以是不活跃的,即对象所使用的内存可以被回收。
3.并发转移阶段
标记结束后就进入转移阶段,此时地址视图子啊此被设置为Remaped,转移阶段会把活跃对象转移到新的内存中,并回收对象转移前的内存空间。转移阶段会把
应用程序和标记线程并发执行,那么对象的访问可能来自转移线程和应用程序线程。
1. 转移线程:移线程仅仅根据标记阶段标记的活跃对象进行转移,所以只需要针对对象活跃信息表中记录的对象进行转移。当转移线程访问对象时:
2.应用程序线程,在正常运行用户代码时访问对象,所做的工作有:
至此,ZGC的一个垃圾回收周期中,并发标记和并发转移就结束了。我们提到在标阶段存在两个地址视图M0和M1,上面的算法过程显示只用到了一个地址视图,为什设计成两个?
简单地说是为了区别前一次标记和当前标记。
这3个地址视图代表的含义是:
图示: