上海的垃圾分类席卷全国,各地纷纷响应号召,对垃圾分类知识进行普及和倡导。作为中华名族伟大复兴的一员,自然积极主动参与,担负起主人翁的责任与义务。
在大学餐厅有这样一个梗,看见吃完饭就把盘子端走清理的,那是C++程序员;看见吃完直接走人的,那是Java程序员;看见吃了一点就放下走人的,那不算程序员;看见点了两人份的,前方高能,请自觉离开。
自从邂逅了GC,就一发不可收拾了,被它那强大的功能所折服,解决了内存泄漏和溢出等问题,垃圾托管后,作为程序员可以尽情的享受这干净如新的世界,专注于理想,专注于自个。
我是一直享受这GC待遇的人之一,事实上,GC这东西很麻烦,但却是必须的。它是一位少林寺的扫地僧,平日里默默清理这寺院,当然,也是不希望显眼。就是因为它,寺院才一尘不染,才有人前来上香拜佛。也就因为幕后,才使得无数英雄为之倾倒。
说到这,我想先发表一下自个的看法。GC是一种虚拟内存技术,在较小的物理内存开辟一个较大的虚拟空间,用于存储,或许成为称作“垃圾回收”更加亲切。对于C、C++程序员的垃圾回收属于显性回收,对于每一个对象都是从生命开始到终结的维护责任。Java则是把垃圾托管给虚拟机,看似一切都那么美好,一旦出现内存泄漏和溢出异常,不了解虚拟机的内存分配机制,解决异常反而是件麻烦的事。GC性能越好,该系统的处理效率越高,GC的重要性现在不言而喻了。
本文分为算法篇和实践篇,实践篇将在后续更新。《程序员十二时辰》娱乐一下。
什么是垃圾
在堆里,确认那些对象还“存活”着,那些已经“死去”(不可能在被任何途径使用的对象)。“死去”的自然称之为垃圾。
什么是垃圾回收
垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄漏。有效的使用内存空间,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。
算法篇
既然我们要做垃圾回收,首先得找到那些内存得回收。
引用计数算法
引用计数算法(Reachability Counting)是通过在对象头中分配一个空间来保存对该对象被引用的次数。如果该对象被其他对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用为0时,那么该对象就会被回收。
优点:
(1) 可即刻回收垃圾
(2) 最大暂停时间短
(3) 没有必要沿指针查找
缺点:
(1) 计数器值的增减处理繁重
(2) 计数器需要占用很多位
(3) 实现繁琐复杂
(4) 循环引用无法收回
可达性分析算法
可达性分析算法(Reachability Analysis)的基本思路是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连接时(用图论的话来说,即从GC Roots节点到该节点不可达),则证明该对象是不可用的。
通过可达性算法,成功解决了引用计数所无法解决的的问题—循环依赖,只要你无法与GC Roots建立直接或间接的连接,系统就会判定你为可回收对象。
标记-清除算法
标记-清除算法是世界而上首个被值得纪念的算法,自问世至今有半世纪,一直被各种处理程序所用的伟大算法。算法与它的名称一样,分为两部分,一先把内存区域中的这些对象进行标记;二那些标记回收的清除掉,成为未使用的内存空间,等待再次使用。
优点:
(1) 实现简单
(2) 与保守式GC算法兼容
缺点:
(1) 碎片化
(2) 分配速度
(3) 与写时复制技术不兼容
标记-复制算法
标记-复制算法是在标记-清除算法上演化而来,解决标记-清除算法的内存问题。它将可用内存按容量划分为大小相等的两块,每次只是用其中一块。当其中一块的内存用完了,就将还存活的的对象复制到另一块上面,然后再把已使用的内存空间一次清掉。保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况,逻辑清晰,运行高效。
优点:
(1) 优秀的吞吐量
(2) 可实现高速分配
(3) 不会发生碎片化
(4) 与缓存兼容
缺点:
(1) 堆使用效率低下
(2) 不兼容保守式GC算法
(3) 递归调用函数
标记-整理算法
标记-整理算法标记过程仍然与标记-清除算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,在清理掉边界以外的内存区域。标记算法一方面在标记-清除算法上做了升级,解决了内存碎片化问题,也规避了复制算法只能利用一半存储区域的弊端。看起来很美好,但它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。
保守式GC算法
简单来说,保守式GC指的是“不能识别指针和非指针的GC”。不明确的根,寄存器、调用栈、全局变量空间等属于GC Roots。这些GC均不能识别出是指针还是非指针。
优点
(1) 容易编写语言处理程序
(2) 实现比准确式GC简单
缺点
(1) 识别指针和非指针需要付出代价
(2) 错误识别指针会压迫堆
(3) 能够使用的GC算法有限
准确式GC算法
正好与保守式GC算法相反,它是能识别指针和非指针的GC。创建正确的根需要语言处理程序的实现,创建方法居多,如打标签、不把寄存器和栈等当作根。
优点
(1) 解决保守式GC算法的“错误识别指针”问题
(2) 可以实现GC复制等移动对象的算法
缺点
(1) 创建正确的根仍需语言处理程序支援
(2) 创建正确的根是需要付出代价,打标签和取消标签在重新设置,这就得看语言处理程序的执行效率。
分代垃圾回收算法
人们从众多程序案例中总结出了一个经验:“大部分的对象在生成后马上就变成了垃圾,很少有对象能活的很久。”分代垃圾回收利用该经验,在对象中导入了“年龄”的概念,经历了一次GC活下来的对象年龄为1岁。
分代垃圾回收算法严格来说并不是一种思想或理论,而是融合上述算法基础的算法思想,而产生针对不同情况所采用不同算法的一套豪华组合大餐。对象存活周期的不同将内存划分为几块,一般为年轻代和老年代,这样就可以根据各个年代的特点采用最适当的手收集算法。在新生代中,每次垃圾回收时都发现有大批对象死去,只有少量存活,那就选用标记-复制算法,只需要付出少量存活对象的复制成本就可以完成回收。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清除或标记-整理算法来进行回收,这也就是目前最先进的垃圾回收机制。接下来Java虚拟机来进行解说内存模型与回收策略
内存模型与回收策略
Java 堆(Java Heap)是JVM所管理的内存中最大的一块,堆又是垃圾收集器管理的主要区域,这里我们主要分析一下 Java 堆的结构。
Java 堆主要分为2个区域-年轻代与老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 2个区。可能这时候大家会有疑问,为什么需要 Survivor 区,为什么Survivor 还要分2个区。不着急,我们从头到尾,看看对象到底是怎么来的,而它又是怎么没的。
Eden 区
IBM 公司的专业研究表明,有将近98%的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。
通过Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old 区)。
Survivor 区
Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。Survivor 又分为2个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的 To 区(如果 To 区不够,则直接进入 Old 区)。
为啥需要?
不就是新生代到老年代么,直接Eden 到 Old 不好了吗,为啥要这么复杂。想想如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。
所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历16次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。
为啥需要俩?
设置两个Survivor 区最大的好处就是解决内存碎片化。
我们先假设一下,Survivor 如果只有一个区域会怎样。Minor GC 执行后,Eden 区被清空了,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。问题来了,这时候我们怎么清除它们?在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。因为 Survivor 有2个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。
这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivor space 是无碎片的。那么,Survivor 为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案。
Old 区
老年代占据着2/3的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记 --- 整理算法。
除了上述所说,在内存担保机制下,无法安置的对象会直接进到老年代,以下几种情况也会进入老年代。
大对象
大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及2个 Survivor 区之间发生大量的内存复制。当你的系统有非常多“朝生夕死”的大对象时,得注意了。
长期存活对象
虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加1岁。当年龄增加到15岁时,这时候就会被转移到老年代。当然,这里的15,JVM 也支持进行特殊设置。
动态对象年龄
虚拟机并不重视要求对象年龄必须到15岁,才会放入老年区,如果 Survivor 空间中相同年龄所有对象大小的总合大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进去老年区,无需等你“成年”。
这其实有点类似于负载均衡,轮询是负载均衡的一种,保证每台机器都分得同样的请求。看似很均衡,但每台机的硬件不通,健康状况不同,我们还可以基于每台机接受的请求数,或每台机的响应时间等,来调整我们的负载均衡算法。