《深入理解Java虚拟机》第三章读书笔记(一)——垃圾回收算法

时间:2023-01-30 07:10:02
参考书籍《深入理解java虚拟机》周志明著

系列文章目录和关于我

本文主要介绍垃圾回收理论知识

《深入理解Java虚拟机》第三章读书笔记(一)——垃圾回收算法

1.jvm哪些区域需要进行垃圾回收

《深入理解Java虚拟机》第三章读书笔记(一)——垃圾回收算法

  • 虚拟机栈,本地方法栈,程序计数器都是线程私有的,随线程而生,随线程而灭。其中栈中的栈帧随着方法的进入和退出而有条不紊的执行出栈和入栈操作,每一个栈帧需要分配内存基本上在类结构确定下来的时候就已知了,因此这几个区域的内存分配时具备确定性的,这个几个区域不需要考虑如何回收,当方法或者线程结束的时候,这些区域自然随之被回收。

  • 堆是存储对象的为止,线程共享。程序员在方法中new一个对象,通常在这个栈帧中的本地变量存在一个reference类型指向堆中一个对象实例数据。这部分是垃圾回收的重灾,毕竟java中万物皆对象

    《深入理解Java虚拟机》第三章读书笔记(一)——垃圾回收算法

  • 方法区

    在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。java可以使用动态代理生成类,可以在运行的时候加载类到方法区。

    java 8 使用元空间实现方法区,我们可以使用MaxMetaspaceSize这个虚拟机参数现在元空间大小,对于不会再使用的类,和类加载器以及废弃的常量可以进行回收。

1.1 回收堆

这部分下面会进行着重介绍,首先我们要判断一个对象是否为垃圾,然后使用特定算法进行垃圾收集

1.2 回收方法区

  • 回收常量

    如果一个常量曾经进入到方法区,但是后续再也没用被引用到,那么垃圾收集器觉得合适的时候将进行清理

  • 回收类

    回收类的条件十分苛刻,首先必须保证该类的实例对象都以及被回收,其次保证加载类的类加载器也已经被回收该类对应的Class对象没有在任何位置被引用,无法在任何位置通过反射访问该类的方法

    正因如此,回收方法区性价比很低,以下是一些控制方法区垃圾回收的虚拟机参数

    参数 含义
    -Xnoclassgc 是否对类型进行回收
    -XX:TraceClassLoading 打印类加载信息
    -XX:TraceClassUnLoading 打印类卸载信息
    -verbose:class 打印类加载和卸载信息

    大量使用反射,动态代理,CGLib字节码框架,动态生成jsp等技术,通常需要java虚拟机具备类型卸载的能力。避免方法区的溢出(jdk7之前,可以使用-XX:MaxPermSize设置方法区大小,-XX:PermSize设置方法区初始大小,方法区溢出通常日志信息为java.lang.OutOfMemoryError:PermGen pace。jdk7之后方法区使用基于本地内存的元空间实现可以使用-XX:MaxMetaspaceSize设置元空间最大值,-XX:MetaSpaceSize指定元空间初始大小,-XX:MinMetaSpaceFreeRatio设置在垃圾收集后控制元空间最小剩余百分比,-XX:MaxMetaSpaceFreeRatio 设置在垃圾收集后控制元空间最大小剩余百分比)

2.什么样的对象是垃圾

不会被使用到的对象就是垃圾,那么使用什么方法定位到不会被使用到的对象呢?

2.1.引用计数器法

在对象中添加一个引用计数器,每当一个对象引用了它,计数器的值就加1,当引用失效时就减1,任何时候引用计数器为0的对象就是不可以再使用的。

引用计数器法占用少许额外内存,但是原理简单,判定效率也很高,大多数情况下都是一个不错的选择,但是存在循环引用的问题,对象A引用了对象B,对象B引用了对象A,二者不再被其他对象引用,这时候二者计数器都为1,但是已然是垃圾

A a = new A();
B b = new B()
a.f1 = b;
b.f2 = a;

a = null;
b = null;

//这时候发生GC 对象A 和 对象B互相引用彼此,但是可以被回收

Java虚拟机并非使用 引用计数器法 来判断对象是否存活。

2.2.可达性分析算法

通过一些类称为GC Roots的根对象为起始节点集合,从这些节点起,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链",如果某个对象到GC Roots没有任何引用链相连,即GC Roots到这个对象不可达,那么证明此对象不能再被使用。

《深入理解Java虚拟机》第三章读书笔记(一)——垃圾回收算法

可以作为GC Roots的对象有以下这些:

  • 虚拟机栈(栈中的本地变量表)中引用的对象,譬如各个线程被调用的方法中使用的参数,局部变量,临时变量
  • 方法区中的类的静态属性引用的对象,譬如java类的引用类型静态变量
  • 方法区中的常量引用对象,比如字符串常量池里的引用
  • 本地方法栈中JNI(native 方法)引用的对象
  • java虚拟机中内部引用,如基本数据类型对应的class对象,常驻异常对象(NullPointerException,OutOfMemorryError)以及系统类加载器
  • 所有被synchronized 持有的对象

2.3 强引用,软引用,弱引用,虚引用

  • 强引用

    指代码间的引用赋值,Object a = new Object(),这种关系,只要存在强引用关系,那么垃圾回收器就无法回收被引用的对象

  • 软引用

    使用SoftReference类实现的引用关系,只被软引用关联的对象在,在系统将发生内存溢出异常前,会将这些对象列入垃圾回收的范围进行第二次回收,如果回收后还没有足够的内存,才会抛出内存溢出异常。下面这个代码不断向集合中加入对象,但是不会发生OOM。另外配合ReferenceQueue可以实现基于软引用的缓存,在mybatis中的SoftCache存在类似的使用

    public static void main(String[] args) {
        ArrayList<Object> objects = new ArrayList<>();
    
         while (true) {
                 objects.add(new SoftReference<>(new byte[1024*1024],q));
            }
    }
    
  • 弱引用

    用来描述非必须的对象,使用WeakReference实现这种关系,它的强度比软引用更弱一些,关联的对象只能生存到下一次垃圾收集为止,当垃圾收集器开始工作,无论内存是否足够,都会回收只被弱引用关联的对象。

    同样配合ReferenceQueue 可以实现弱引用缓存,在WeakHashMap,mybatis中的WeakCache,guava的缓存中均有类似使用

  • 虚引用

    使用PhantomReference实现这种引用关系,是最弱的一种关系,虚引用的存在丝毫不影响对象的回收,也无法根据虚引用来获取一个对象实例,为对象设置虚引用的唯一目的就是能在对象被回收的时候收到一个系统通知(搭配ReferenceQueue实现)

2.4.finalize方法

一个对象确保需要回收,需要进行两次筛选

  1. 第一次筛选:可达性分析发现对象和GC Roots不具备联系
  2. 第二次筛选:待对象执行完finalize方法,后对F-Queue中对象进行第二次筛选,依旧和GC Roots无联系的对象将被回收

经历第一次筛选后,如果对象没有必要执行finalize方法(对象没有覆盖finalize方法,或者该对象的finalize方法已经被虚拟机调用过)那么不会将对象放置在F-Queue。被放置到F-Queue队列中的对象,稍后有虚拟机自动创建的,低优先级的Finalizer线程去执行finalize方法,但是并不一定保证等待执行完成(因为不能由于对象本身finalize方法执行时间长,而导致垃圾不能及时回收造成oom),稍后收集器将对F-Queue中的对象进行第二次小规模标记,如果任然没有和GC Roots建立联系,那么将被回收。(finalize方法只会被调用一次)

3.如何回收

3.1分代收集理论

大部分垃圾收集器都是使用分代收集的策略实现堆的垃圾回收。

  • 为什么需要分代

    分代存在两个重要的假说促使了,垃圾收集器进行分代收集。

    1. 弱分代假说

      大部分对象都是朝生夕灭的

      像我们再方法中局部变量引用的对象,方法结束就会死去,因为再也没用任何引用可以指向它了。如:

      void m(){
          Object o = new Object();
      }
      //这里的o m方法结束,new出的object对象将不会被任何引用指向
      //这种对象在java这种是占绝大多数的
      
    2. 强分代假说

      熬过越多垃圾收集的对象越是难以消亡

      可以熬过多次垃圾收集,说明和GC Roots关联非常紧密,后续肯定更加难被收集,类似我们定义的 static final 属性指向的对象

    在两个假说促使收集器将堆划分出不同的区域,然后根据对象的年龄(年龄指熬过gc的次数)分配到不同的区域存储。如是便有了新生代和老年代

    《深入理解Java虚拟机》第三章读书笔记(一)——垃圾回收算法

    新生代对象和老年代存亡特征存在差异,促使这两部分使用不同的算法进行收集(标记清除,标记整理,标记复制,见后续讲解)

    • 分代收集解决跨代引用的问题

    分代收集也具备缺点——跨代引用,老年代和新生代对象存在关联。这时候加入只想进行 新生代的gc难道需要扫描所有老年代的对象,确保新生代对象是否存在跨代引用么?(老年代对象全部遍历是一个非常耗时的操作)。

    为了解决这个问题,提出了第三个假说:跨代引用假说:存在跨代引用的对象相对于同代引用的对象是很少的。因为如果存在跨代引用的两个对象往往是趋向一起消亡的,比如老年代引用了新生代,由于老年代对象不容易被回收,促使这个新生代对象也不易被回收,进而新生代对象熬过多次gc最终进入老年代,消除了跨代引用。

    根据这个假说,指导了我们不应该为了少量跨代引用对象去扫描所有老年代对象,只需要在新生代建立一个全局的数据结构:记忆集将老年代分为多个小块,标识哪一块的老年代内存存在跨代引用,当发生新生代gc的时候,将包含跨代引用的小块内存中的对象加入到GC Roots中进行扫描。使用少量空间减少了扫描整个老年代的开销。

    • Minor GC,Major GC,Full GC

      • Minor GC

        新生代收集,针对新生代(又称young gc)

      • Major GC

        老年代GC,针对老年代(又称old gc)

      • Full GC

        回收整个堆和方法区的垃圾收集

3.2 标记清除

算法分为标记和清除两个步骤,十分好理解,首先标记所有需要回收的对象,在标记完成后,统一回收掉被标记的对象。也可以反过来,标记不需要回收的对象,然后回收没被标记的对象。

标记清除的缺点

  • 效率不稳定

    需要被标记的对象越多,标记和回收过程越耗时。

  • 容易造成内存碎片

    标记清除后容易产生大量内存碎片,导致后续运行的时候无法为大对象分配连续内存,而*再次进行GC。

3.3 标记复制

将可用内存分为两部分,每次只使用其中一部分,当这一部分A被使用完后,标记出存活的对象复制到另外一块B上面,然后将A全部清理,后续继续使用B,B使用完后标记B然后将可用对象复制到A上,循环往复。由于需要复制的对象是存活的对象,这部分对象根据"弱分代假说"是较少的部分,每次都是对一半的区域进行回收,所以可避免内存碎片的产生。但是缺点也很明显:"过于浪费空间,每次只能使用一半"

java新生代并没有采用上面的算法,而是使用改进后的算法:即将新生代分为 一个Eden两个Survivor(from区和to区),三者的比值是8:1:1,发生新生代垃圾收集的时候,将EdenSurvivor from中仍然存活的对象,一次性复制到Survivor to,然后清理掉Eden 和 Survivor from,然后下次使用EdenSurvivor to分配内存给新对象,然后标记复制,循环往复。

这种算法的优点是,浪费的空间比较少,只浪费10%的空间,获得复制然后直接删除Eden 和一个Survivor内存的效率。

缺点是:如果一个Eden和Survivor标记后存活的对象,大于另外一个Survivor空间的小,这时候需要使用逃生门——当Survivor容纳不下一个Minor GC后存活的对象的时候,需要依赖另外的空间(老年代)进行担保,让这部分对象直接进行老年代

3.4 标记整理

标记复制算法,在对象存活率高的时候需要进行较多复制操作,效率将会降低。如果不使用一半使用,一半浪费的传统标记复制算法,那么需要老年代需要担保,所以老年代一般不使用这种算法(没人为老年代担保)。

标记整理算法,标记过程和标记清除算法一样,但是后续步骤不是清理所有存活对象,而是让所有存活对象向内存空间的一侧进行移动,然后清理边界外的对象

这种算法的缺点是:如果存活的对象比较多,需要更新指向这些对象的引用为移动后的地址,这是一个比较耗时的操作,必须停止所有用户线程才能进行——"Stop the world"。但是如果不进行移动的话,将造成大量内存碎片,必须使用更复杂的结构(空闲链表)来解决内存分配问题。因此存在另外一种和稀泥的方式——先使用标记清理,然后碎片化程度无法容忍的时候,再使用标记整理收集一次。

对于基于分代收集理论的垃圾回收器,下图成立。

《深入理解Java虚拟机》第三章读书笔记(一)——垃圾回收算法

后面将继续总结,hotspot虚拟机如何实现这些算法,以及常见的经典垃圾收集器