通过前一篇文章我们已经知道,java运行时数据区的程序计数器、虚拟机栈、本地方法栈这三个区域随着线程而生而灭,虚拟机栈中的栈帧应该分配多少内存在类结构确定下来就已经已知了,因此这几个区域的线程分配和回收都具有确定性。
而java堆和方法区不一样,我们只有在程序运行期间才能知道创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的正是这部分。
垃圾收集器在对堆进行回收时,一定要确定哪些还“活着”,哪些已经“死去”(即不可能再被途径使用的对象)。那么判断一个对象是否死亡一般有这两种算法:引用计数算法、可达性分析算法
引用计数算法:给对象添加一个引用计数器、每当有一个地方引用它时,计数器的值就加 1;引用失效时,计数器的值就减 1,任何时刻计数器的值为0的对象就是不可能再被使用的。
大多数情况下它都是一个优秀的算法,但是主流的java虚拟机里没有选用引用计数算法来管理内存的,主要原因就是他很难解决对象之间互相循环引用的问题。示例代码如下。
public class DemoGcPerson
{
public Object instance;
public static void main(String[] args)
{
DemoGcPerson demoNewPerson1 = new DemoGcPerson();
DemoGcPerson demoNewPerson2 = new DemoGcPerson();
demoNewPerson1.instance = demoNewPerson2;
demoNewPerson2.instance = demoNewPerson1;
}
}
在主流的程序语言如 Java、C# 的主流实现中,都是通过可达性分析算法来判定对象是否存活的。这个算法的根本思想就是通过一系列的称为“GC Roots”(所有的根节点对象)的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称之为引用链(References chain),当一个对象到GC Roots没有任何引用链相连的时候(就是从GC Roots到这个对象不可达),就证明此对象是不可达的。
在java语言中,可作为GC Roots的对象包括以下几种:
以上两种算法判定对象是否存活都与“引用”有关,在JDK1.2以前,java中对引用的定义很狭隘,即被引用和没有被引用两个状态。
而我们希望能描述这样一类对象:
在JDK1.2之后,java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用,这四种引用强度逐渐减弱。
即使是在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于缓刑阶段,要真正宣告一个对象是否死亡,至少要经历两个标记过程:第一次标记并进行一次筛选、第二次标记
如果对象在经过可达性分析后发现其到GC Roots不可达,那么它将会被第一次标记并且进行一次筛选,筛选的条件是
此对象是否有必要执行finalize()方法。有下面两种情况:
第一种: 如果对象经过第一次标记并筛选后是第一种情况,直接进行第二次标记,然后进行垃圾回收。
第二种: 如果对象经过第一次标记并筛选后是第二种情况,那么对象会被插入到一个F-Queue队列中,由一个虚拟机自动创立的、低优先级的Finalizer线程触发其finalize()方法。finalize()方法是对象逃脱死亡的最后机会,GC会对队列中的对象进行第二次小规模的标记,假如对象在finalize()方法中成功拯救了自己——与引使用链上的任何一个对象建立了联络,那么在第二次标记时,对象会被移出“即将回收”集合。如果这时还没有逃脱,那基本上就是被回收了。
值得一提的是低优先级的Finalizer线程并不承诺等待对象的finalize()方法执行结束,这样子做的原因是,如果finalize()方法执行很慢或者死循环,将会导致在F-Queue队列中的其它对象永久等待,严重甚至导致内存回收系统崩溃。
当对象重写finalzie()方法后第一次gc时逃脱死亡,第二次gc时真正死亡,演示代码如下:
package com.example.demo.javasebase.jvmtest;
/**
* Create by likaihai 2019/5/8
*/
public class FinallizeGCTest
{
private static FinallizeGCTest finallizeGCTest = null;
public static void main(String[] args) throws InterruptedException
{
finallizeGCTest = new FinallizeGCTest();
finallizeGCTest = null;
System.gc();
Thread.sleep(5000);
if (finallizeGCTest != null){
finallizeGCTest.isAlive();
} else {
System.out.println("no,I am dead");
}
finallizeGCTest = null;
System.gc();
Thread.sleep(5000);
if (finallizeGCTest != null){
finallizeGCTest.isAlive();
} else {
System.out.println("no,I am dead");
}
}
@Override
public void finalize() throws Throwable
{
super.finalize();
finallizeGCTest = this;
System.out.println("run finalize method");
}
public boolean isAlive()
{
System.out.println("yes, I am still alive");
return true;
}
}
特别注意的是采用这种方式来拯救对象并不鼓励,因为他的运行代价高昂,不确定性大,很多教材中说可以在finalize()方法中做“关闭外部资源”之类的工作,完全是对这种方法用途的一种安慰。finalize()方法能做的,try-finally都可以做得更好更及时。
方法区的垃圾手机主要分为两部分内容:废弃常量和无用的类。
回收废弃常量和回收对象类似,例如一个字符串“abc”已经进入常量池中,但是当前系统的堆中没有任何一个对象是叫做“abc”的,没有任何String对象引用常量池中的“abc”常量,也没有其它地方引用了这个字面量(“abc”字符串)。如果这时发生内存回收,必要的话,这个“abc”常量就会被系统清理出常量池。常量池中类(接口)、方法、字段的符号引用也与此类似。
“无用的类”需要满足下面三个条件:
虚拟机可以对满足上述三个条件的类进行回收,是否对类进行回收,hotSpot虚拟机提供了-Xnoclassgc参数进行控制。
大量使用动态代理、反射、动态生成JSP等场景都需要虚拟机具备类卸载的功能。
“标记-清除”算法也是最基础的收集算法,首先标记出所有对象,在标记完成之后再统一回收所有被标记对象,标记过程在上一节已介绍过。
它主要有两个不足:
为了解决“标记-清除”算法的效率问题,“复制”(Copying)收集算法出现了,它的主要思想是将内存划分为容量大小相等的两块,每次只使用其中一块,当这块用完了,就将还存活的对象复制到另一块上面。然后把之前使用过的这一块的内存空间一次性清理掉。
在这种算法中,首先进行可达性分析算法标记出存活的对象,然后将存活的对象复制到另外的Survivor空间中。(另一种说法是:先从根开始将被引用的对象复制到另外的空间中,然后,再将复制的对象所能够引用的对象用递归的方式不断复制下去。我想这种方式应该是错的)
这种方式不用再考虑内存碎片等复杂情况,内存空间是连续的,所以是使用指针碰撞的方式分配新的内存,实现简单,运行高效。但是带来的代价就是内存使用率仅为50%。复制算法的执行过程如下图所示。
HotSpot虚拟机就是采用这种方式收集算法来回收新生代,由于新生代中大多数对象是“朝生夕死”的,所以实际上不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大Eden空间和两块较小的Survivor空间,每次使用其中Eden和一块Survivor。HotSpot虚拟机默认Eden和Survivor(两块中的一块Survivor)的大小比例为8:1。
新生代每次回收时,就将Eden和Survivor中还存活的对象一次性复制到另外一个Survivor中,最后清理掉Eden和之前Survivor空间中的所有对象。当另外一块Survivor没有足够的内存空间存放上一次新生代收集下来的存活对象时,这些对象将会通过分配担保机制进入老年代。
复制收集算法在对象存活率过高时就需要进行较多的复制操作,效率将会降低,更关键的是,不能浪费那50%的内存。“标记-整理”算法与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是将所有存活对象都向一端移动,然后清理掉端边界以外的内存,所以内存空间是连续的,使用指针碰撞的方式分配新的内存。
“标记-整理”算法的执行过程如下。
“存活”对象比例很少时使用标记算法(多了清除/整理阶段,耗时长)开销较大,这时应该使用复制算法,只需要付出少量存活对象的复制成本就可以完成收集(新生代gc概率大,所以牺牲内存换取性能,不能采用标记-整理这种耗时的算法)。
“存活”对象比例较高的时候使用复制算法所需要开销比较大(对象存活率高,使用复制算法的内存担保机制得不偿失),这时应该使用标记-整理算法。