深入理解 Java 之 GC 到底如何工作

当程序创建对象,数组引用类型实体时,系统都会在堆内存中为之分配一块内存区,对象就保存在这块内存区,当这块内存不再被任何变量引用时,这块内存就变成垃圾,系统就要回收。

  • 只回收堆内存中对象,不会回收物理资源
  • 程序无法精确控制回收时机。
  • 在垃圾回收机制回收任何对象之前,总会先调用它的 finlize() 方法,可能导致垃圾回收机制取消

如何判断对象是否已经死亡?

  • 引用计数算法

古老的判断对象是否存活的算法是:给对象添加一个计算器,一旦有地方引用该对象,则计数器加一,当引用失效时,就减一。任何计数器为 0 的对象,就不能再使用了。这种算法虽然经典,但是其并不能解决一个对象之间相互循环引用的问题。

public class RefreenceCount{
  
  class GcObject{
  	public Object instance = null;
}
  
  public static void main(String[] args){
	GcObject o1 = new GcObject(); 
    GcObject o2 = new GcObject();
    
    o1.instance = o2; // 1
    o2.instance = o1; // 2
    
    o1 = null;
    o2 = null;
}
}

如上我们在最后注释一和注释二处还是无法释放对方。这样会造成堆溢出。

  • 可达性分析算法

主流的语言中都是通过可达性算法分析对象是否存活的:通过一系列称为 “GC Roots” 对象作为起始点,从这些节点开始向下搜索,其所走过的路径称为引用链,当一个对象到 GC Roots 不可达时,则该对象是不可用的,也就是判断这些对象为可回收的。

在 Java 语言中,如下可作为 GC Roots 对象:

  • 虚拟机栈中引用的本地对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象

而可达性算法,我们可以看一个非常好的示例。垃圾回收机制中,引用计数法是如何维护所有对象引用的

再谈引用

前面所说的两种都是跟引用有关系。在 JDK1.2 之前,引用的定义很狭隘,一个对象只有被引用和未被引用的两种状态。在 JDK1.2 之后,对引用的定义进行了扩充,我们希望:当内存空间还足够时候,则对象能够保留在内存中;如果内存空间在进行垃圾回收之后,内存空间还是非常紧张,则抛弃这些对象。适合于很多缓存功能的应用场景。分成四种强度递减的引用:

  • 强引用:这种引用很常见,类似于 :

    A a = new A()// 强引用
    

    只要引用存在,就不会回收引用对象。如果显示的将对象置为 null 或者其超出对象的生命范围,则被回收。当内存空间不足,Java虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题

  • 软引用:如果一个对象具有软引用,内存足够时,gc不会回收它;内存不足时,gc就会回收这个对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

  • 弱引用:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存

    public class WeakRefrenceDemo {
    
        public static void main(String[] args) {
    
            String s = new String("hello");// 1
            WeakReference<String> wrf = new WeakReference<String>(s);
            s = null;// 2
            System.out.println(wrf.get());// 3
            System.gc();// 4
            System.runFinalization();
            System.out.println(wrf.get());// 5
        }
    }
    

    如上,我们逐行分析。在注释 1 处,我们不能自以为聪明的用:

    String s = "hello"// 6
    

    来替代 1 中的语句,因为按照 6 中的做法,系统会采用常量池来管理这个字符串直接量,会采用强引用来引用它。同时在 1 处我们用 s 引用变量引用 “hello” 字符串对象,接下来创建一个弱引用对象来引用 s 引用的同一个对象。执行到 2 处时候,切断了 s 和引用对象的之间的联系。而我们不切断的话,会发生什么呢?自然 gc 机制无法发挥出实质性的作用,导致并没回收任何引用对象,故输出的还是字符串对象。

    执行到 3 处时候,由于系统内存还足够,不会回收 wrf 对象,因此会输出 “hello” 对象。而后通知程序进行强制垃圾回收,自然弱引用对象 wrf 就会被系统回收了,此时输出的就为 null 了。

    下面再看一个例子,弱引用在 Android 中的应用,我们希望在 Activity 中新建一个线程获取数据:

    public class MainActivity extends Activity {
    
        //...
        private int page;
        private Handler handler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                if (msg.what == 1) {
                    //...
                    page++;
                } else {
                    //...
                }
            };
        };
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
    
            //...
            new Thread(new Runnable() {
                @Override
                public void run() {
                    //..
                    Message msg = Message.obtain();
                    msg.what = 1;
                    //msg.obj = xx;
                    handler.sendMessage(msg);
                }
            }).start();
            //...
        }
    }
    

    Activity 具有自身的生命周期,Activity 中新开启的线程运行过程中,可能此时用户按下了Back键,或系统内存不足等希望回收此 Activity, 由于 Activity 中新起的线程并不会遵循 Activity 本身的什么周期,也就是说,当 Activity 执行了onDestroy,由于线程以及 Handler 的 HandleMessage 的存在, 使得系统本希望进行此Activity 内存回收不能实现,因为非静态内部类中隐性的持有对外部类的引用,导致可能存在的内存泄露问题。

    因此,在 Activity 中使用 Handler 时,一方面需要将其定义为静态内部类形式,这样可以使其与外部类(Activity)解耦,不再持有外部类的引用, 同时由于 Handler 中的 handlerMessage 一般都会多少需要访问或修改 Activity 的属性,此时,需要在 Handler 内部定义指向此 Activity 的 WeakReference, 使其不会影响到 Activity 的内存回收同时,可以在正常情况下访问到 Activity 的属性。

    google 官方推荐的一个示例写法如下:

    public class MainActivity extends Activity {
    
        //...
        private int page;
        private MyHandler mMyHandler = new MyHandler(this);
        private static class MyHandler extends Handler {
            private WeakReference<MainActivity> wrActivity;
            public MyHandler(MainActivity activity) {
                this.wrActivity = new WeakReference<MainActivity>(activity);
            }
    
            @Override
            public void handleMessage(Message msg) {
                if (wrActivity.get() == null) {
                    return;
                }
                MainActivity mActivity = wrActivity.get();
                if (msg.what == 1) {
                    //...
                    mActivity.page++;
                } else {
                    //...
                }
            }
        }
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            //...
            new Thread(new Runnable() {
                @Override
                public void run() {
                    //..
                    Message msg = Message.obtain();
                    msg.what = 1;
                    //msg.obj = xx;
                    mMyHandler.sendMessage(msg);
                }
            }).start();
            //...
        }
    }
    

  • 虚引用:是一种最弱的引用关系,其存在的必要就是在该虚引用对象被垃圾回收器回收时候,系统能够接收到一个通知。

生存还是死亡

当一个对象被创建之后,根据它是否被引用变量所引用的状态,可以将其所处状态分为如下三种:

  • 可达状态:当一个变量被创建之后,有一个或者以上的变量引用它,其就处于可达状态。
  • 可恢复状态:没有任何对象来引用它,就处于可恢复状态。这种状态下,系统的垃圾回收机制准备来回收该对象所占用的内存,在回收该对象之前,系统会调用所有可恢复对象的 finalize() 方法来进行资源的清理,如果系统在清理资源的同时,重新让一个引用变量引用该对象,则该对象会再次变为可达对象,反之则该对象进入不可达状态。
  • 不可达状态:没用引用变量指向该对象,并且不能被恢复,则成为不可达状态。只有一个对象在不可达状态时候,垃圾回收器才会回收该对象。

然而即使某个对象被标记为不可达的情况下,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与 GC Roots 相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。如果该对象被判定为有必要执行 finalize() 方法,那么这个对象将会被放置在一个名为 F-Queue 队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize() 方法是对象逃脱死亡命运的最后一次机会(因为一个对象的 finalize() 方法最多只会被系统自动调用一次),稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果要 在 finalize() 方法中成功拯救自己,只要在 finalize() 方法中让该对象重引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。接下来,我们顺便看一下 finalize() 方法:

在垃圾回收机制回收某个对象之前,通常会调用 finalize() 方法来回收一些资源。在没有明确指定回收方法之前,调用默认的 finalize() 放法,只有在调用了该方法之后,垃圾回收机制才真正的开始执行。但是我们始终要注意该方法不一定会得到执行,故我们不要指望在该方法中去执行垃圾清理的工作。finalize() 方法具有以下特点:

  • 不要主动调用某个对象的 finalize() 方法
  • finalize() 方法是否被调用具有不确定性
  • JVM 执行可恢复对象的 finalize() 方法时候,可能会是该方法重新变为可达状态
  • JVM 执行 finalzie() 方法出现异常时,不会抛出异常

接下来,我们看一个对象自我拯救的示例:

public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive(){
        System.out.println("yes i am still live");
    }

    // 留个心眼,这个方法每个对象只会被系统自动调用一次
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method called");
        FinalizeEscapeGC.SAVE_HOOK = this;// 注释一
    }

    public static void main(String[] args) throws Throwable {

        SAVE_HOOK = new FinalizeEscapeGC();

        // 对象第一次自我拯救成功
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if (SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no,i am dead");
        }

        // 对象第二次拯救自己失败
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if (SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no,i am dead");
        }
    }
}

输出结果:

finalize method called // 标注 finalize 被调用
yes i am still live // 逃脱成功
no,i am dead // 逃脱失败

为什么会出现对象的复活?又为什么会出现同样的代码,而对象只会复活一次?首先解决第一个问题,对象复活的奥秘完全在注释一处,我们在 finalize() 方法中执行了这样一句:

 FinalizeEscapeGC.SAVE_HOOK = this;// 注释一

上面就将一个可恢复的对象变成了可达对象。而后面只能复活一次的原因是因为一个对象的 finalize() 方法只能被调用一次。

如何去回收垃圾

  • 标记-清除算法

    首先标记所有需要被清除的对象,然后进行回收,至于判定哪些对象该被回收,前面已经说过了。由于该算法是比较基础的算法,所以不可避免的带来两个问题:标记和清除效率不高的问题、空间碎片化的问题。

  • 复制算法

    复制算法是针对标记—清除算法的缺点,在其基础上进行改进而得到的,它讲可用内存按容量分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面,然后再把已使用过的内存空间一次清理掉。其缺点很明显,将可用内存缩小到了一半。复制算法有如下优点:

    • 每次只对一块内存进行回收,运行高效。
    • 只需移动栈顶指针,按顺序分配内存即可,实现简单。
    • 内存回收时不用考虑内存碎片的出现。
  • 标记-整理算法

    其标记过程仍然跟标记-清除算法一样。但是该算法不会直接对可回收对象进行清理,而是让所有存活的对象都向一段移动,然后直接清理端边界以外的内存。

  • 分代收集算法

    当前用的比较多的垃圾收集算法都是分代收集。根据对象存活周期的不同,将内存分为几块。一般是将 Java 堆分为新生代和老生代,根据各个年代的特点采用合适的垃圾回收算法。在新生代中每次都有大批对象死去,只有少量存活,故而采用复制算法。只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对其进行分配担保,就必须使用标记-整理或者标记-清理算法来进行回收。

内存分配策略

  • 对象优先在 Eden 分配

    大多数情况下,对象优先在新生代 Eden 区中分配,当该区域没有足够空间时候,将发生一次 MinorGC。其中我们在复制算法中提到了:新生代 98% 对象是朝生夕死,所以没必要 1:1 分布内存空间,从而产生了一块较大的 Edge 和两块 Survivor 空间。

  • 大对象直接进入老年代

  • 长期存活的对象将进入老年代

    虚拟机采用了分代会回收的思想来管理内存,故判断新老对象的标准就很重要。虚拟机给每个对象定义了一个对象年龄,如果对象在 Eden 区 出身,并且经过第一个 Minor GC 之后仍然存活,并且能被 Survivor 区域容纳的话,将被移动到 Survivor 区域中,并将对象年龄设置为1.对象在 S 区域中每熬过一次 MInor GC,年龄就增加1.当年龄增加到一定 15岁时候,就会被存入到老年代。年龄阈值可以通过设置指定参数来确定。

  • 动态对象年龄判定

  • 空间分配担保

HotSpot算法的实现

如何快速枚举GC Roots? GC Roots主要在全局性的引用(常量或类静态属性)与执行上下文(本地变量表中的引用)中,很多应用仅仅方法区就上百兆,如果进行遍历查找,效率会非常低下。

在HotSpot中,使用一组称为OopMap的数据结构进行实现。类加载完成时,HotSpot把对象内什么偏移量上是什么类型的数据计算出来存储到OopMap中,通过JIT编译出来的本地代码,也会记录下栈和寄存器中哪些位置是引用。GC发生时,通过扫描OopMap的数据就可以快速标识出存活的对象。

如何安全的GC? 线程运行时,只有在到达安全点(Safe Point)才能停顿下来进行GC。

基于OopMap数据结构,HotSpot可以快速完成GC Roots的遍历,不过HotSpot并不会为每条指令都生成对应的OopMap,只会在Safe Point处记录这些信息。

所以Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。

关于Safe Point更多的信息,可以看看这篇文章 JVM的Stop The World,安全点,黑暗的地底世界

发生GC时,如何让所有线程跑到最近的Safe Point再暂停? 当发生GC时,不直接对线程进行中断操作,而是简单的设置一个中断标志,每个线程运行到Safe Point的时候,主动去轮询这个中断标志,如果中断标志为真,则将自己进行中断挂起。

这里忽略了一个问题,当发生GC时,运行中的线程可以跑到Safe Point后进行挂起,而那些处于Sleep或Blocked状态的线程在此时无法响应JVM的中断请求,无法到Safe Point处进行挂起,针对这种情况,可以使用安全区域(Safe Region)进行解决。

Safe Region是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。 1、当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程; 2、当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;

垃圾回收期的特点与分类

Serial 收集器

串行回收器主要有两个特点: 第一,它仅仅使用单线程进行垃圾回收; 第二,它独占式的垃圾回收。

在串行回收器进行垃圾回收时,Java 应用程序中的线程都需要暂停,等待垃圾回收的完成,这样给用户体验造成较差效果。虽然如此,串行回收器却是一个成熟、经过长时间生产环境考验的极为高效的 回收器。新生代串行处理器使用复制算法,实现相对简单,逻辑处理特别高效,且没有线程切换的开销。在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,它的性能表现可以超过并行回收器和并发回收器。在 HotSpot 虚拟机中,使用-XX:+UseSerialGC 参数可以指定使用新生代串行回收器和老年代串行回收器。当 JVM 在 Client 模式下运行时,它是默认的垃圾回收器。老年代串行回收器使用的是标记-压缩算法。和新生代串行回收器一样,它也是一个串行的、独占式的垃圾回收器。由于老年代垃圾回收通常会使用比新生代垃圾回 收更长的时间,因此,在堆空间较大的应用程序中,一旦老年代串行回收器启动,应用程序很可能会因此停顿几秒甚至更长时间。虽然如此,老年代串行回收器可以 和多种新生代回收器配合使用,同时它也可以作为 CMS 回收器的备用回收器。若要启用老年代串行回收器,可以尝试使用以下参数:-XX:+UseSerialGC: 新生代、老年代都使用串行回收器。

CMS特点: 获取最短的回收停顿时间为目标的垃圾收集器。基于“标记-清除”算法实现的,主要分为四个步骤: 1:初始标记 2:并发标记 3:重新标记 4:并发清除

缺点就是: 1:CMS收集器对CPU资源非常敏感 2:CMS无法处理浮动垃圾 3:由于其基于“标记-清除”,故很容易产生内存碎片

G1具备如下特点: 1:并行与并发:充分利用多核、多CPU来缩短STW的时间 2:分代收集: 3:空间整合:能够保证较少或者不出现内存碎片 4:可预测的停顿。

G1之前其他的垃圾收集器的回收范围是整个新生代或者老年代,而G1将整个Java堆划分成多哦独立的区域region。之所以能够有可预测的停顿是因为可以有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟随各个region里面的垃圾堆价值大小,在后台维护一个优先级列表,优先回收价值较大的region,故名思议得到Garbage-First。核心的思想还是一个化整为零的思想。

如果觉得本文对你有帮助,请打赏以回报我的劳动