Java 与 C++等语言最大的技术区别:自动化的垃圾回收机制(GC)
栈:栈中的生命周期是跟随线程,所以一般不需要关注
堆:堆中的对象是垃圾回收的重点
方法区/元空间:这一块也会发生垃圾回收,不过这块的效率比较低,一般不是我们关注的重点
分代回收原理

1、 绝大部分的对象都是在垃圾回收时就被回收掉。
2、 熬过多次垃圾回收的对象就越难回收。
根据以上两个理论,容易被回收的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代。
新生代由eden,from,to三个区域构成,老年代由tenured区构成。
GC分类
1、 新生代回收(Minor GC/Young GC):指只是进行新生代的回收。
2、 老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为,在JAVA14 正式移除CMS。
3、 整堆回收(Full GC):收集整个 Java 堆和方法区
垃圾回收算法
复制算法

把可用内存按容量分为大小相等的两块,每次只用其中的一块,当这一块达到预警内存的时候,进行以下操作:将还还存活的对象复制到另一块上面,然后再将原先那块清除(格式化),这样不需要考虑内存碎片等复杂情况,按顺序分配内存就可以了,实现简单,运行高效,但是代价是内存利用率只有一半。
内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。
复制回收算法适用新生代,因为这部分对象容易被回收,复制过去的对象比较少,效率就比较高
改良的复制算法

把可用内存分配一块较大的eden区,和两块较小相等的survivor区(通常叫from区和to区),当对象生成时把它放在eden区,在GC时,把eden区和from区的存活对象复制到to区,然后清空eden区和from区。在下次gc的时候把eden区和to区的存活对象复制到from区,然后清空eden区和to区。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被浪费,这样优化后的内存利用率提高了。
我们没办法保证每次回收都只有10%以下的对象存活,当survivor区的内存不够用时, 会启动了内存分配的担保机制,把 把新生代的存活的对象 直接转移到了老年代,然后新生代腾出来的空间用于为分配给最新的对象。
在不同的GC机制下,也就是不同垃圾回收器组合下,担保机制也略有不同。在Serial+Serial Old的情况下,发现放不下就直接启动担保机制;在Parallel Scavenge+Serial Old的情况下,却是先要去判断一下要分配的内存是不是>=Eden区大小的一半,如果是那么直接把该对象放入老生代,否则才会启动担保机制
标记-整理算法
首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低
我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。
标记整理算法适用于老年代。
标记-清除算法
算法分为“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。
标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次GC
回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。

目前JDK1.8默认使用Parallel Scavenge和Parallel Old算法
Serial /Serial Old (了解即可)
JVM 刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单 CPU。
这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收(可以控制停顿时间在 100ms 左右),但是对于超过这个大小的内存回收速度很慢,所 以对于现在来说这个垃圾回收器已经是一个鸡肋。
单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为“Stop The World(STW)”,但是这种 STW 带来了恶劣的用户体验,例如:应 用每运行一个小时就需要暂停响应 5 分。这个也是早期 JVM 和 java 被 C/C++语言诟病性能差的一个重要原因。所以 JVM 开发团队一直努力消除或降低 STW 的时间。
-XX:+UseSerialGC 新生代和老年代都用串行收集器。
ParNew(了解即可)
它是Serial收集器的多线程版本 ,与 CMS 进行配合,对于 CMS(CMS 只回收老年代),新生代垃圾回收器只有 Serial 与 ParNew 可以选。和 Serial 基本没区别,唯一的区 别:多线程,多 CPU 的,停顿时间比 Serial 少。 随着CPU数量的增加ParNew相较于Serial的优势会越来越明显,但并不是成倍增长的,原因是多线程切换的开销 。
在 JDK9 以后,把 ParNew 合并到了 CMS 了。后续版本已经接近淘汰。
-XX:+UseParNewGC 使用ParNew(年轻代)+Serial Old(老年代)组合进行GC
-XX:+UseConcMarkSweepGC 指定使用CMS后,会默认使用ParNew作为新生代收集器
Parallel Scavenge(ParallerGC)/Parallel Old
为了提高回收效率,从 JDK1.3 开始,JVM 使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成 程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总 共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。
该垃圾回收器适合回收堆空间上百兆~几个 G。
Concurrent Mark Sweep (CMS)
CMS是一种尽可能短的停止用户线程为目标的收集器, 减少了回收的停顿时间,但是降低了堆空间的利用率
从名字(包含“Mark Sweep”)上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些, 整个过程分为 4 个步骤
初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
并发标记和用户的应用程序同时进行,进行 GC Roots 追踪的过程,标记从 GCRoots 开始关联的所有对象开始遍历整个可达分析路径的对象。这个时 间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作)
重新标记为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标 记阶段稍长一些,但远比并发标记的时间短。
并发清除 由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用 户线程一起并发执行的。
CMS默认的回收线程数是(CPU个数+3)/4 , 这个公式的意思是当CPU大于4个时,保证回收线程占用至少25%的CPU资源,这样用户线程占用75%的CPU,这是可以接受的。 当处理核心数不足 4 个时,CMS 对用户的影响较大。
并发清理阶段用户线程还在运行,这段时间就可能产生新的垃圾,新的垃圾在此次GC无法清除,这些垃圾称为 浮动垃圾 。由于垃圾回收阶段用户线程仍在执行,必需预留出内存空间给用户线程使用。因此不能像其他回收器那样,等到老年代满了再进行GC 。只能等到下次清理。默认CMS是在tenured generation占据92%的时候开始进行CMS收集,如果你的年老代增长不是那么快,可以适当调整这个参数: -XX:CMSInitiatingOccupancyFraction=
使用标记-清除算法可能造成大量的空间碎片。空间碎片过多,就会给大对象分配带来麻烦。往往老年代还有很大剩余空间,但无法找到足够大的连续空间来分配当前对象,不得不触发一次Full GC。CMS的解决方案是使用 -XX:+ UseCMSCompactAtFullCollection参数(默认开启),在顶不住 虚拟机将临时启用 Serial Old 来替代 CMS 。 虚拟机还提供了另外一个参数 -XX:+CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认为0,每次进入Full GC时都进行碎片整理)
CMS 问题比较多,所以现在没有一个版本默认是 CMS,只能手工指定。但是它毕竟是第一个并发垃圾回收器,对于了解并发垃圾回收具有一定意义 ,在JAVA14 正式移除CMS。
-XX:+UseConcMarkSweepGC ,表示新生代使用 ParNew,老年代的用 CMS
Garbage First(G1)
分区 G1采用了 分区 的思路, 将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域(Region), 这样在一个时间段内,大部分的垃圾收集操作就只是在一个分区内执行,而不是整个堆。每次分配对象空间将逐段地使用内存 , 因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的 。
卡片 在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。
每一个 区域都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。回收器能够对扮演不同角色的 Region 采用不同的策略去处理,很可能在完成一个年轻代收集之后,一个年轻代的分区在未来的某个时刻被用于老年代分区。同样地,在一个老年代分区完成收集之后,它就成为了可用分区,在未来某个时候作为一个年轻代分区来使用,这样无论是 新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。 由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案。
G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换
G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿
一个大小达到甚至超过分区大小一半的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。 巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,在每个分区记录了一个已记忆集合(RSet),内部类似一个反向指针,记录引用分区内对象的卡片索引。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。
应 用线程可以独占一个本地缓冲区(TLAB)来创建对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)
年轻代收集集合 应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。 同时,年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。
混合收集集合 年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。
转移失败 是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-
运行过程:
eden区和survivor区使用复制算法
old区和humongous区使用以下算法
初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
并发标记和用户的应用程序同时进行,进行 GC Roots 追踪的过程,标记从 GCRoots 开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理。在回收开始时,每一个region分配一块很小的对象,存储回收过程中并发新生对象的指针,这个过程称为TAMS(Top at Mark Start)。标记过程会有漏标问题,使用SATB算法解决,实际过程是生成一个快照
最终标记 在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,处理漏标问题
筛选回收 整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合; 可以自由选择任意多个 Region 构 成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动, 是必须暂停用户线程,由多条收集器线程并行完成的。
本文详细解析了Java与C++等语言的区别在于自动化的垃圾回收机制,重点讲解了栈、堆、方法区的内存管理,新生代与老年代的分代回收原理,以及CMS、ParallelGC等常见垃圾回收算法的特点,如复制、标记-整理和混合收集。特别介绍了G1分区收集器的分区策略和优势。

785

被折叠的 条评论
为什么被折叠?



