前言:
- 首先JVM的内存结构包括五大区域: 程序计数器、虚拟机栈、本地方法栈、方法区、堆区。
- 其中程序计数器、虚拟机栈(Java栈)和本地方法栈三个区域是线程私有的,随线程启动与销毁, 因此这几个区域的内存分配和回收都具有确定性,不需要过多考虑回收的问题。
- 而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,是垃圾回收需要关注的部分。
一、判断对象是否存活
Java堆中存放着几乎所有的对象实例,垃圾回收器在堆进行垃圾回收前,首先要判断这些对象那些还存活,那些已经“死去”。判断对象是否存活有如下几种算法:
1.引用计数法
- 引用计数法描述的算法为:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已“死”。
- 任何引用计数器为0的对象实例都可以进行垃圾回收。当一个对象实例被垃圾回收时,它引用的所有对象实例引用计数器减1。
优点: 引用计数器可以很快的执行,对程序不需要长时间的打断。
缺点: 无法检测出循环引用**。如对象A有对象B的引用,对象B又有对象A的引用,这样他们的引用计数永远都不为0。
2.可达性分析算法
将所有的引用关系看作一张图,从一个节点GC Root开始,寻找对应的引用节点,找到后继续寻找这个节点的引用节点,当所有引用节点寻找完毕后,剩余的节点就被认为是没有被引用的节点,即无用节点,无用节点被判定为可回收对象。
在Java语言中,可作为GC Roots的对象包含以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中(Native方法)引用的对象
- 即使在可达性分析算法中不可达的对象,也并非"非死不可"的,这时候他们暂时处在"缓刑"阶段。要宣告一个对象的真正死亡,至少要经历两次标记过程:如果对象在进行可达性分析之后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被JVM调用过,虚拟机会将这两种情况都视为"没有必要执行",此时的对象才是真正"死"的对象。
- 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它(这里所说的执行指的是虚拟机会触发finalize()方法)。finalize()方法是对象逃脱死亡的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象在finalize()中成功拯救自己(只需要重新与引用链上的任何一个对象建立起关联关系即可),那在第二次标记时它将会被移除出"即将回收"的集合;如果对象这时候还是没有逃脱,那基本上它就是真的被回收了。
二、回收方法区
- 方法区的垃圾回收主要收集两部分内容:废弃常量和无用类。
- 回收废弃常量和回收Java堆中的对象十分类似。以常量池中字面量的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个String对象引用常量池中的"abc"常量,也没有其他地方引用这个字面量,如果此时发生GC并且有必要的话,这个"abc"常量会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
- 判定一个类是否是"无用类"则相对复杂很多。类需要同时满足下面三个条件才会被算是"无用的类"。
(1)该类的所有实例都已经被回收(即在Java堆中不存在任何该类的实例)
(2)加载该类的ClassLoader已被回收
(3)该类对应的Class对象没有任何其他地方被引用,无法在任何地方通过反射访问该类的方法
三、垃圾回收算法
1.标记 - 清除算法
标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行垃圾回收。
缺点:
- 效率问题:标记和清除这两个过程的效率都不高。
- 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。
2.标记 - 复制算法
“标记 - 复制算法” 是为了解决 “标记-清除算法” 的效率问题。
它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。
优点:
每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等的复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。缺点:
这种算法虽然实现简单,运行高效且不易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能使用的空间缩减为原来的一半。很显然,复制算法的效率跟存活对象的数量有很大关联,若存活对象很多,那么效率将大大降低。
3.标记 - 整理算法
该算法是为了解决复制算法的缺陷,充分利用内存空间而提出的。
标记过程仍与“标记-清除”过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。
4.分代收集算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。其核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。
Java一般将堆区分为年轻代和老年代,将方法区划为永久代。
下面对不同的年龄代进行简单说明
- 年轻代: 新创建的对象都存放在这里。因为年轻代会频繁的进行GC清理,JVM在年轻代采用的是标记-复制算法,先标记出存活的实例,然后清除掉无用实例,将存活的实例根据年龄(每个实例被经历一次GC后年龄会加1)拷贝到不同的年龄代。
- 老年代: 老年代中是经历了N此垃圾祸首后仍然存活的对象,其中的N由JVM的参数决定。这块内存区域一般大于年轻代。GC发生的次数也比年轻代要少。
- 永久代: 用于存放静态文件,如Java类、方法等。为方法区。
面试题: 请问了解Minor GC和Full GC么,这两种GC有什么不一样吗?
- Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
- Full GC 又称为老年代GC或者Major GC :
指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在ParallelScavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
四、GC何时触发
GC在优先级最低的线程中运行,一般在应用程序空闲时被调用。当内存不足时才会主动调用。
因为对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有如下两种:
- Scavenge GC
一般情况下,当新对象生成,并且在年轻代申请空间失败时,会触发Scavenge GC, 对年轻代进行垃圾回收。
这种方式的GC不会影响到老年代。因为大部分对象都是年轻代开始的,同时年轻代内存不会分配的很大,所有年轻代的GC会频繁的进行。所以在这里要使用速度快、效率高的算法,使其空间尽快空出来。
若GC一次后仍不能满足内存分配,JVM会进行二次GC,若仍无法满足,则报“out of memory"的错误,Java应用将停止。 - Full GC
对整个内存进行整理,包括年轻代、老年代和永久代,所以Full GC比Scavenge GC要慢, 因此应该尽量减少Full GC的次数。
以下可能引发Full GC的原因:
(1)老年代被写满
(2)永久代被写满
(3) System.gc()被显示调用
(4)上一次GC后堆的各域分配策略动态变化。
如何在程序中减少GC的开销:
- 不要显式调用System.gc()。此函数建议JVM进行GC,虽然只是建议,但是大多数情况下会触发GC,增加了间歇性停顿的次数,大大影响系统的性能。
- 尽量减少临时对象的使用。也就是减少Scavenge GC执行的机会。
- 对象不用时最好显式置为null。将不用的对象置为null,有利于GC收集器判定,从而提高GC的效率。
- 尽量减少静态对象变量。静态变量属于全局变量,不会被GC祸首。
- 能有基本类型的就不要用包装类。基本类型变量栈用的内存资源比对应的包装类要少的多 。
- 使用StringBuffer, 而不是String类累加字符串。因为堆String类型进行加的时候,会创建新的String对象,而StringBuffer是可变长的,在原有基础上进行扩增,不会产生中间对象。
- 分散对象创建或删除的时间。集中在短时间内大量创建新对象,特别是大对象,会突然需要大量内存,JVM在面临这种情况时只能进行GC,以回收内存或整合内存碎片,从而增加GC的频率。集中删除对象,道理也是一样的。它使得突然出现了大量的垃圾对象,空闲空间必然减少,从而大大增加了下一次创建新对象时强制主GC的机会。