细说Java垃圾回收

目录

前言

什么是垃圾回收?

手动回收垃圾时代

虚拟机接管时代

哪些区域需要垃圾回收?

怎么定义垃圾?

引用计数法

可达性分析算法

哪些些对象可以作为GC Root?

虚拟机栈(栈帧中的局部变量表)中的引用对象

方法区中静态类属引用的对象

怎么进行垃圾回收?

标记清除法(Mark-Sweep)

复制算法(Copying)

标记压缩法(Mark-Compact)

分代收集(Generational Collection)

GC 过程

卡表

本文脑图

前言

通过之前的篇章我们已经了解了Java运行时数据区的结构和一个类如何被加载进虚拟机的虚拟机类加载机制。那虚拟机又是如何回收资源的呢?

在Java的世界里,我们似乎对垃圾回收没有那么关注,至少在我初学Java的那几年完全不懂GC,但是依然不影响我写一个还不错的程序或者系统。但是也不能代表Java的GC不重要,当发生OutOfMemoryException的时候,如果你不了解Java虚拟机,不了解垃圾回收机制,那么你只有干瞪眼,或者说重启服务器。我一直认为对Java虚拟机的掌握是区分初级程序员和中级程序员的关键指标,可想而知它在我心目中的地位。话不多说,开始今天的讲解。

什么是垃圾回收?

说起垃圾回收(Garbage Collection,检查GC),首先要明确什么是垃圾。GC中的垃圾特指存在于内存中的、不会再被使用的对象;而“回收”,也相当于把垃圾“倒掉”。如果不及时清理内存中的垃圾,那么这些垃圾对象所占用的空间会一直保留到应用程序结束,而这部分空间又无法被其他对象使用。而真正需要内存空间时,因为空间都被垃圾对象占满,从而有可能导致内存溢出。

手动回收垃圾时代

最早学习C/C++的时候,垃圾回收基本上是手工进行的。开发人员通过new关键字申请内存,当使用结束后用delete关键字进行内存释放。

这样的释放方式是由开发人员显示指定的,这种方式可以很灵活的控制释放时间,但是一个系统中的内存申请和释放可能及其频繁,这就会给开发人员带来极大的管理负担。倘若有一处内存开发人员忘记回收,那么就会产生内存泄露,垃圾对象永远无法被清楚,随着系统运行时间的不断增长,垃圾对象所耗内存可能只需上升,直到内存溢出

虚拟机接管时代

为了将开发者从繁重的内存管理中释放出来,更加专注于业务,就需要一种垃圾回收机制可以自动识别并回收垃圾,不需要人工干预。有得必有失,有了这种自动回收技术后,垃圾释放就由虚拟机控制,释放时机可能不是那么灵活,当然优点远远大于这个不足。

垃圾回收并不是Java虚拟机独创的,早在20世纪60年代,垃圾回收就已经被Lisp语言所使用。现在,除了Java以外的C#,Python等语言都使用了垃圾回收的思想。可以说这种自动化的内存管理方式已经成为了现代开发语言必备的标准。

哪些区域需要垃圾回收?

通过对Java运行时数据区的了解,我们知道Java虚拟机的内存区域分为线程私有和线程共享两大块。其中线程私有的程序计数器、Java虚拟机栈和本地方法栈和线程同生共死;栈中的栈帧随着方法的进入和退出,有条不紊地执行入栈he出栈操作。同时每一个栈帧中分配多少内存基本上是在类结构确定下来就已知了,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多考虑回收问题,因为方法结束或线程结束,内存自然就随着回收了。

线程共享的堆和方法区则不一样,因为虚拟机类加载机制,程序只有处于运行期间才会知道创建了哪些对象,而没一个接口或者多个实现类中的内存又可能不一样。这部分的内存分配和回收都是动态的,也是垃圾所主要关注的区域。

怎么定义垃圾?

要进行垃圾回收首先要进行垃圾收集,而那些已经完成了它的使命并且不再被任何对象引用的内存,那么我们就可以认为这块内存已经“死去”,等待垃圾收集器进行收集。

引用计数法

一种古老的垃圾收集方法就是引用计数法(Reference Counting),实现也非常简单,每个对象内部都有一个引用计数器来表示该对象被引用的次数。如果有对象引用它,则引用计数器就加1,当引用失效时,引用计数器就减1。只要对象的引用计数器的值为0,就表示对象不可能再被使用。

看似很美好,但是引用计数法无法解决循环依赖问题,例如:

public class 引用计数法 {
    public static void testGC(){
        ReferenceCountingGC a = new ReferenceCountingGC("objA");
        ReferenceCountingGC b = new ReferenceCountingGC("objB");
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
    }
}
class ReferenceCountingGC{
    public Object instance;
    public ReferenceCountingGC(String name){}
}

这段程序非常简单,但是如果使用引用计数法的话,我们来分析它是不是还可以满足我们的要求。

从图中我们可以看到,最后这2个对象已经不可能再被访问了,但是由于他们还是被互相引用者,导致引用计数器永远都不会为0,因为GC收集器永远不可能回收他们,最终就会导致内存泄露。所以Java虚拟机并没有选择此算法作为垃圾回收算法。

可达性分析算法

当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GCRoots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

上图中,对象object5、object6、object7虽然互相关联,但是他们始终没有一个GC Root和他们关联,所以会被判断为可回收对象。

哪些些对象可以作为GC Root?

从上图可以看出Tracing GC的基本思路有就是,以当前一定存活的对象作为Root,遍历他们所有关联的对象,没有遍历到的对象既为非存活对象,这部分对象就可以被GC掉。

可以作为GC Root的节点包括但不限于以下几点:

而。常量引用的对象在当前可能存活,因此也可能是GC root

虚拟机栈(栈帧中的局部变量表)中的引用对象

虚拟机栈和本地方法栈都是线程私有的内存区域,不属于垃圾回收的范围,只要线程没终止,就一定能确保他们中引用的对象是存活的。

public class StackLocalParameter {
    public static void testGC(){
        StackLocalParameter s = new StackLocalParameter();
        s = null;
    }
}

上图中s为局部变量表中的对象,既为GC Root,当s置空时,那么对象也就断了个GC Root的引用,因此将被回收。

方法区中静态类属引用的对象

方法区中静态类属性引用的对象显然也是存活的,那么被引用的对象一定是存活的,因此可以作为GC Root。

public class MethodAreaStaticProperties {
    public static MethodAreaStaticProperties m;
    public MethodAreaStaticProperties(String objectName){

    }
    public static void testGC(){
        MethodAreaStaticProperties s = new MethodAreaStaticProperties("properties");
        s.m = new MethodAreaStaticProperties("parameter");
        s = null;
    }
}

s为局部变量,所以它是GC Root,经过GC后s所指向的properties对象无法与任意一个GC Root建立关系所以被回收。而m作为类的静态属性,也是属于GC Root,parameter对象依然与GC Root建立连接,所以此时parameter对象无法被回收。

3:方法区中常量引用的对象

public class MethodAreaStaticProperties {

    public static final MethodAreaStaticProperties m = new MethodAreaStaticProperties("final");
    public MethodAreaStaticProperties(String name){}
    public static void testGC(){
        MethodAreaStaticProperties s = new MethodAreaStaticProperties("staticProperties");
        s = null;
    }
}

m 即为方法区中的常量引用,也为GC Root,s 置为 null 后,final 对象也不会因没有与 GC Root 建立联系而被回收。

4:本地方法栈中JNI(既一般所得Native方法)引用的对象

5:所有被同步锁(synchronized关键字)持有的对象。

怎么进行垃圾回收?

此时的垃圾收集器已经知道那些垃圾可以被回收,接下来要做的就是如何高效的进行垃圾回收。由于《Java虚拟机规范》并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,比较常见的有如下几种:

标记清除法(Mark-Sweep)

标记清除算法是现代垃圾回收算法思想的基础。标记清楚算法将垃圾回收分成两个阶段:标记阶段和清除阶段。在标记阶的段首先通过GC Root标记所有从根节点开始的可达对象,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段清除所有未被标记的对象。

标记清除算法的好处就是实现简单,但是我们观察上图后就会发现清除过后它会产生大量不连续的内存碎片。当需要分配大对象的时候因为无法找到连续内存又不得不再触发一次GC。

复制算法(Copying)

复制算法核心思想就是:将原有的内存空间分成两块,每次只使用一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

上图A、B两块相同的内存空间,当进行垃圾回收的时候将A中存活的对象连续复制到B中。复制结束后再清空A,并将B空间设置为当前使用的空间。

如果系统中的垃圾很多,复制算法需要复制的存活对象就会相对较少。因此真正需要垃圾回收的时候,复制算法的效率很高。同时也解决的标记清除中,大量内存碎片的问题。但是又带来了新的问题,我们可使用的内存只有只有一半,内存利用率太低只有50%,单单这一点也很难让人接受。

对半分的话,需要牺牲一半的内存空间。当前的商业虚拟机的垃圾收集器,大多数都遵循“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  1. 绝大多数对象都是朝生夕灭的。
  2. 熬过越多次垃圾收集过程的对象就越难以消亡。

内存对半分对于java来说就不是那么适合的,所以采用的是将内存分成1块较大的Eden和2块较小的survivor空间,每次使用一块Eden和其中一块survivor,当回收的时候,直接将Eden和survivor中存活的对象另一块Survivor上,最后清理掉Eden和survivor的内存空间。

大对象或者多次回收依然存活的对象会直接进入老年代。eden:from:to的比例是8:1:1。那么就是说,我们每次只有10%的内存浪费,可以满足我们绝大部分的场景,但是当survivor不够时,我们需要依赖其他内存(老年代)进行分配担保。

标记压缩法(Mark-Compact)

复制算法的高性能建立在存活对象少、垃圾对象多的前提下。这种情况在新生代经常发生,但是老年代(经历多次垃圾回收依然存活的对象)更常见的是大部分对象都是存活对象。复制算法在这时候回收成本就很大。因此基于老年代的垃圾回收特性需要使用其他算法。

标记压缩是基于老年代特性的一种垃圾回收算法,和标记清除算法一样,首先从根节点开始,对所有可达对象做一次标记。但之后并不只是简单地清除未标记的对象,而是将所有存活对象压缩到内存一段。之后清理边界以外的空间。这种方式既避免了碎片产生,又不需要两块相同的内存空间,对于老年代的回收性价比非常高。

标记压缩算法,其实相当于标记整理执行完成之后,再对内存碎片进行一次整理。因此也有人称它为标记清除压缩(MarkSweepCompact)算法。

分代收集(Generational Collection)

至此我们已经了解了标记整理,复制,标记压缩等垃圾回收算法。在这些算法中,并没有一种算法可以完全替代其他算法,都具有各自独特的优势和特点。因此根据要回收对象的特点,选择合适的垃圾回收算法才是明智的决定。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了Minor GC、Major GC、Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法。在新生代中每次垃圾胡思后都有大量对象死去,少量存活,那就适合复制算法。而老年代中因为对象存活率高、没有额外空间对他进行分配担保,就必须使用标记整理来进行回收。

GC 过程

1:在初始阶段,新创建的对象都会被分到Eden区,这时候Survivor区域是空的

2:当Eden区满了的时候,这时候就会触发Minor GC进行新生代的垃圾回收,存活下来的对象将会被存入Survivor的to区域此时对象的年龄为1,之后清空Eden空间。

3:当下一次Minor GC来临时候依然会重复这个过程,只不过这次Survivor区域的from和to会交换身份。同时上次Minor GC幸存下来的对象会被复制到新的to区域此时年龄再次加1。

4:经过多次Minor GC后,当存活对象的年龄达到一个阈值之后(可通过参数配置,默认是8),就会从年轻代Promotion到老年代(大对象或者老年代对象对象会直接进入老年代,如果to空间已满,则对象也会直接进入老年代)。

随着Minor GC一次又一次的进行,不断的有新对象被promote到老年代。最终Major GC被触发回收使用标记压缩来回收老年代对象。

卡表

对于新生代和老年代来说,通常新生代回收频率很高,但是每次回收的耗时很短;而老年代回收频率比较低,但是会消耗更多的时间。为了支持新生代回收的高刷新率,虚拟机使用了一种叫卡表(Card Table)的数据结构。卡表为一个比特位集合,每一个比特位用来标识老年代某一区域中的所有对象是否持有新生代对象的引用。

这样Minor GC的时候,就不用花大量时间扫描所有老年代对象对象,来确定每一个对象的引用关系而是先扫描卡表,当卡表标记为1时,才需要扫描给定区域的老年代对象,而卡表位0所在的老年代区域一定没有任何对象指向新生代。使用这样的方式可以加速新生代的回收速度。

本文脑图

猜你喜欢

转载自blog.csdn.net/qq_25448409/article/details/106392677