垃圾回收(Garbage Collection,GC)。
java程序员不用去关心内存的动态分配和垃圾回收,这一切都交给了JVM来去做,下面就来深入了解一下java的垃圾回收机制。要学习Java的垃圾回收机制,那么就要知道JVM在什么情况下会将java的对象判断成是垃圾?在判断成功以后,用通过什么方式来将垃圾进行回收处理?在处理的时又会使用什么算法来处理?这些问题都是我们要关注的。
Java判断一个对象是否是垃圾,使用的有两种方法:引用计数法(reference-counting)和可达性分析法(GC Roots Tracing)
引用计数法:每一个对象都有一个引用计数器,当这个对象被引用了一次以后,这个计数器就会 +1,当取消这个对象的一次引用以后,这个计数器就会 -1,当引用计数器的值为0 的时候,就代表这个对象失去了价值,就可以被判断是垃圾,就可以被CG垃圾回收机制处理。
但是这个算法会出现内存泄漏的问题。
这里我们通过代码来说明:
登录后复制
public class Test {
public static void main(String[] args) {
// 一、首先创建两个对象及其引用
GcObj gcObj1 = new GcObj();
GcObj gcObj2 = new GcObj();
// 二、然后将这两个对象进行互相引用
gcObj1.obj = gcObj2;
gcObj2.obj = gcObj1;
// 三、随后将这两个对象置为null
gcObj1 = null;
gcObj2 = null;
}
}
class GcObj {
public Object obj = null;
}
在网上很多文章都会告诉各位,以上的代码采用引用计数器的方式来进行处理的时候,由于gcObj1 和gcObj2 指向的对象已经不可以再被访问了,但是又在彼此引用着对方,这样导致即使对象不可以再被访问了,那么它的引用计数器还是不为0 ,从而不能被GC,但是这样说是很难理解的,下面我来通过JVM内存模型详细的解释这个现象出现的原因。
以上程序的步骤一、二实现了如图所示的情况,
此时两个对象的程序计数器的数值都为2然后接下来程序将虚拟机栈和堆之间的引用给切断了,就出现了下面的这种情况:
此时两个对象的程序计数器的数值为1 ,不符合引用计数法的垃圾回收的条件,但是这个时候两个对象在虚拟机栈中的引用已经不能在访问堆中的具体对象,那这个对象就失去了意义,但是却不能被垃圾回收所处理,便产生了内存泄漏问题。
那么要解决这个问题,便引入了下面这个可达性分析法。
目前主流的虚拟机都是使用的可达性分析算法。这个算法的核心就是以GC Roots对象作为起始点,根据数学中的图论为判断基础,图中的可达起始点的对象便是存活对象,而不可达对象则是需要回收的垃圾内存。这里有两个新的概念,,一个是CG Roots对象 ,另一个是可达性。下面就这两个概念来进行阐述。
什么是CG Roots对象?
虚拟机栈的栈帧的局部变量表所引用的对象 本地方法栈的JNI所引用的对象 方法区的静态变量和常量所引用的对象
什么是可达性?
可达性对象就是可以和CG Roots构成连通图的对象
这里还是用图来进行说明:
如图我们可以看到:
实例对象1 可以到达 方法区 中的CG Roots
实例对象4 可以到达 虚拟机栈 中的CG Roots
实例对象5、6 可以到达 本地方法栈 中的CG Roots
所以以上的对象都是存活对象,不可以被垃圾回收机制所回收,
而实例对象2和3,他们两个之间虽然存在关联,意思就是程序计数器的数值不为0,但是他们两个都不可以到达任意一个CG Roots对象,所以被虚拟机认为是需要进行垃圾回收的对象,这样就解决了引用计数法的内存泄漏问题。
在确定了哪些对象是垃圾,而可以被回收以后,如何进行高效的回收就成了接下来要考虑的问题。
下面就将介绍几个常见的垃圾收集算法。常见的垃圾收集算法有:
Mark-Sweep(标记-清除)算法 Copying(复制)算法 Mark-Compact(标记-整理)算法 Generational Collection(分代收集)算法
这个算法是最简单的算法,最容易实现。具体的步骤分为两个阶段:标记阶段和清除阶段。
标记阶段就是要标记出所有需要被回收的对象
清除阶段就是将标记的对象进行清除
下图中的红色区域就是被标记的对象,在下一次的垃圾回收的时候会将红色的标记对象直接清除。
清除结束:
这个算法的优点是实现简单,但是它的缺点也是显而易见的,就是很容易产生大量的内存碎片,碎片太多会导致此后如果需要为一个对象分配一个较大的内存空间的时候无法找到足够的空间而提前触发一次新的垃圾回收
为了解决Mark-Sweep(标记-清除)算法的缺陷,复制算法应运而生
这个算法将内存区域按照大小分为了大小相等的两块区域,并且一次只是用一个区域,以上图为例:
1、当A区域使用完毕以后,会把A中的存活对象复制到B中
2、清理A中的所有对象
3、当B区域使用完毕以后,会把B中的存活对象复制到A中
4、清理B中的所有对象
5、重复上述的步骤
这种方法是解决了上述的内存碎片问题,但是这个代价又太大,会浪费50%的内存空间,所以这种算法也只是存在于理论中,不具备可用性,一般使用的是优化后的复制算法。
为了解决上面的内存浪费严重和存在大量内存碎片的问题,有出现了标记整理-算法,这种算法的主要思想是:首先是标记出存活的对象,然后将存活的对象按照内存地址移动到内存的一端,最后清除剩下区域中的所有对象。
如下图:
然后进行移动,并清除剩下的区域:
优点:
消除了标记-清除算法当中, 内存区域分散的缺点,而标记-压缩我们需要给新对象分配内存JVM 只需要持有一个内存的起始地址即可。
消除了复制算法当中, 内存减半的高额代价。
缺点:
从效率上来说, 标记-整理算法要低于复制算法。
移动对象的同时, 如果对象被其他对象引用, 则还需要调整引用的地址。
移动过程中, 需要全程暂停用户应用程序。即:STW
为了解决上述的诸多问题,接下了JVM使用了最厉害的处理方法,分代收集算法。
分代收集算法将GC分为两种,一种是Minor GC (新生代GC),另一种是Full GC(老年代GC,也称为Major GC)
何为新生代? 新生代就是java对象刚出生的地方,Java中大部分的对象都是朝生夕死的,所以在新生代的垃圾回收是很频繁的 由于新生代的对象生命周期较短,存活率低,这里使用的复制算法,降低成本何为老年代? 老年代 GC (Major GC / Full GC),是指发生在老年代中的垃圾收集动作 当触发了 Full GC的时候,通常会伴随着 Minor GC,即对整个堆进行垃圾回收,这便是所谓的 Full GC (Major GC 通常是跟 Full GC 等价的) 由于老年代中对象生命周期长,存活率高,没有额外空间进行分配担保,所以采用标记-清除算法或标记-整理算法
新生代:老年代在堆空间中初始的比例为1:2
下面对这两个代进行深入解释:
新生代分为两个区域:Eden 区(伊甸园区)和 Survivor区(幸存者区),而 Survivor区又别分为两个部分 from区 和 to区 ,而to和from两个区域不是固定的,会根据垃圾回收的次数进行来回的替换,接下来用图来解释:
初始的内存比例:Eden 区 :to区:from区 == 8:1:1
接下来我们来看一下在新生代中的垃圾回收的具体过程,在这直接首先要了解一个新的概念:对象年龄计数器。那么何为对象年龄计数器,具体有什么作用:
由虚拟机为每个对象进行创建,用于区分哪些对象应该放在新生代中,哪些对象应该放在老年代中 如果对象在 Eden 区出生,在经过第一次 Minor GC 后仍然存活,并且能被 Survivor 区容纳的话,将会被复制到 Survivor 区中,同时将对象的年龄设置为 1 对象在 Survivor 区中每 “熬过” 一次 Minor GC,年龄就增加一岁,当它的年龄增加到一定程度时 (默认为 15 岁),就会晋升到老年代中 对象年龄的阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置
接下来就进行新生代CG的过程演示:
当对象创建的时候,会首先进入Eden 区,并且初始的年龄都是0,当Eden区被占满的时候,会触发一次垃圾回收
此时触发新生代垃圾回收,将存活的对象复制进from区,对象年龄计数器+1,并且将Eden区清空
此时Eden区里面继续创建新的对象,当Eden区再次被挤满的时候,再次开始新生代垃圾回收,此时from区中也会出现被标记清除的对象,如图所示:
此时将Eden区中存活的对象复制进to区,并且将from区中的存活对象也复制进to区,并且将Eden区中的对象清空
与此同时from区变成to区,to区变成from区
再次进行对象的创建,并且标记对象
以下过程就是重复上述的行为。
…
以上就是新生代中的垃圾回收过程,会周而复始地不断进行
老年代中主要存放生命周期较长的对象
当触发了 Full GC 的时候,通常会伴随着 Minor GC,即对整个堆进行垃圾回收,这便是所谓的 Full GC,严格来说的话,Full GC 表示的是对整个堆进行垃圾回收,而 Major GC 是对老年代进行垃圾回收,不过通常情况下 Full GC 和 Major GC 是等价的
Full GC 的速度比 Minor GC 慢的多,一般会慢 10 倍以上,但执行频率低
哪些对象会进入老年代?
新生成的大对象 大对象:指的是需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组 (例如:byte[] 数组)长期存活的对象 对象每经历一次 Minor GC,年龄就增加一岁,当它的年龄增加到一定程度时 (默认为 15 岁),就会晋升到老年代中 动态对象年龄判断 如果在 Survivor 空间中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象就可以直接 进入老年代,而无须等到 MaxTenuringThreshold 中要求的年龄Survivor 区中存放不下的对象 这里老年代的作用就相当于是年轻代的 “备用仓库” 对象优先在 Eden 区中分配,当 Eden 区中没有足够的空间分配时,会触发一次 Minor GC,每次 Minor GC 结束后 Eden 区就会>被清空,其会将依然存活的对象放到 Survivor 区中,当 Survivor 区中放不下时,则由分配担保机制进入到老年代中
针对老年代的对象,不在使用复制算法来进行垃圾的处理,因为老年代没有了“备用仓库”,这里使用的算法就是之前提到的标记-整理或者标记-清除算法。老年代的存活时间很长,因此使用这种算法虽然会消耗大量的时间,但是由于Full GC的频率比较低,所以不会有太大的影响。
以上就是对Generational Collection(分代收集)算法的解释
JVM 常用的性能调优参数-XX:SurvivorRatio:Eden 区和 Survivor 区的比值,默认为 8:1-XX:NewRatio:老年代和新生代内存大小的比例,默认为 2:1新生代和老年代的大小通过 -Xms (初始堆大小)、-Xmx (最大堆大小) 两个参数来决定的-XX:MaxTenuringThreshold: 对象从新生代晋升到老年代经过 GC 次数的最大阈值
注意,在堆区之外还有一个代就是永久代(Permanet Generation),它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的类。这里我们不做太多的解释。
垃圾收集算法是 内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。下面介绍一下HotSpot虚拟机提供的几种垃圾收集器。
1.Serial/Serial Old
Serial/Serial Old收集器是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial收集器是针对新生代的收集器,采用的是Copying算法,Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。它的优点是实现简单高效,但是缺点是会给用户带来停顿(STW)。
2.ParNew
ParNew收集器是Serial收集器的多线程版本,使用多个线程进行垃圾收集。
3.Parallel Scavenge
Parallel Scavenge收集器是一个新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。
4.Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和Mark-Compact算法。
5.CMS
CMS(Current Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。
6.G1
G1收集器是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。
以上就是我对Java垃圾回收机制的理解和总结,有不对的地方欢迎提出来,我会加以改正。
免责声明:本文系网络转载或改编,未找到原创作者,版权归原作者所有。如涉及版权,请联系删