垃圾回收算法

学习的作用

  • 在高并发的情况下的代码优化就需要垃圾回收、并且需要了解何时回收

如何判断对象为垃圾对象

  • 引用计数法
    • 在对象中添加一个引用计数器,当有地方引用这个对象的时候,引用计数器的值就+1,当引用失效的时候,引用计数器的值就-1。当引用计数器的值为0的时候,就会被回收。
    • 引用计数法很少被使用,因为如果对象A在堆内存中被另一个对象B所引用,对象B又被对象A所引用,那么对象A和对象B的引用计数器的值都是1,但是对象A和对象B都是垃圾对象。
  • 可达性分析法
    • 该算法的基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象不可用的。
    • OOPMap 用于枚举GC Roots
      • OOPMap记录了栈上本地变量到堆上的引用关系。
      • 作用:
        • 垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference 类型。如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。但问题是,栈上的本地变量表里面只有一部分数据是 Reference 类型的(它们是我们所需要的),那些非 Reference 类型的数据对我们而言毫无用处,但我们还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。
        • 一个很自然的想法是,能不能用空间换时间,在某个时候把栈上代表引用的位置全部记录下来,这样到真正 gc 的时候就可以直接读取,而不用再一点一点的扫描了。事实上,大部分主流的虚拟机也正是这么做的,比如 HotSpot ,它使用一种叫做 OopMap 的数据结构来记录这类信息。
        • 我们知道,一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。 gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap ,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。
        • 通过上面的解释,我们可以很清楚的看到使用 OopMap 可以避免全栈扫描,加快枚举根节点的速度。但这并不是它的全部用意。它的另外一个更根本的作用是,可以帮助 HotSpot 实现准确式 GC (个人感觉这才是 OopMap 被设计出来的根本原因,提高 GC Roots Enumeration 速度更像是一个“意外的惊喜”
    • RememberedSet 用于可达性分析算法

垃圾回收算法

如上图所示,Object1~Object4对GC Roots都是可达的,说明不可被回收,Object5和Object6对GC Roots节点不可达,说明可被回收

  • 作为GC Roots的对象
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象
    • 方法区的常量所引用的对象
    • 方法区的类属性所引用的对象

如何回收

  • 回收策略
    • 为何需要回收策略
      • 可达性分析法可以解决我们应该回收哪些对象的问题,但是它显然不能承担垃圾搜集的作用。因为我们在程序(程序也就是指我们运行在JVM上的Java程序)运行期间如果想进行垃圾回收,就必须让GC线程与程序当中的线程相互配合,才能在不影响程序运行的前提下,顺利将垃圾回收。
    • 标记-清除算法
      • 概述
        • 当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项:标记,第二项:清除。
          • 标记:遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。
          • 清除:将遍历堆中所有的对象,将没有标记的对象全部清除掉。
        • 总的来说就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,然后将依旧存活的对象标记一遍,最终将堆中没有标记的对象全部清除,接下来让程序重新运行。
        • 为什么需要停止程序
          • 假设我们的程序与GC线程是一起运行的,假设我们刚标记完一个对象A,结果此时程序中又new了一个新的对象B,且对象A可以到达对象B,但是对象B错过了标记阶段,所以对象B没有被标记,那么轮到清除阶段的时候,对象B就会被清除掉,如果一来,不难想象结果,GC线程将会导致程序无法正常工作。
      • 缺点
        • 效率问题
          • 需要递归与全堆对象遍历
          • 在进行GC的时候,需要停止应用程序,会造成用户体验非常差劲
        • 空间问题
          • 这种算法清理出来的内存是不连续的,非常难用。
    • 复制算法
      • 概述
        • 第一步:就是将内存划为两个区间,在任意时间点 ,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(即空闲区间)则是空闲的。
        • 第二步:当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,并且严格按照内存地址依次排列,于此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。
        • 第三步:此时空闲区间已经与活动区间交换,而垃圾对象现在全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转为空闲区间的同时,垃圾对象已经被一次性全部回收。
      • 缺点
        • 浪费了一半的内存
        • 如果对象存活率很高,复制这一项工作所费的时间就会变得不可忽视。
      • 所以复制算法想要使用的话,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%的内存浪费。
      • 现在的商业的虚拟机大多采用这种算法来回收新生代,但不需要讲内存按照1:1来划分,而是将新生代分为一块较大的Eden区域和两块较小的Survivor区域。
    • 标记-整理-清除算法
      • 概述
        • 和标记-清除算法类似,也是分为两个阶段:标记和整理。
          • 标记:与标记清除算法的标记阶段一模一样,均是遍历GC Roots,然后将存活的对象标记。
          • 整理:移动所有存活对象,且按照内存地址次序一次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才成为整理阶段。
      • 缺点
        • 效率不高:不仅要标记所有存活对象,还要整理 虽有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
      • 主要针对老年代
    • 前三种算法总结
      • 共同点
        • 三个算法都是基于根搜索算法(可达性分析法)去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是算法中变量作用域的相关内容。因此,要想防止内存泄漏,最根本的办法就是掌握好变量作用域。
        • 在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)
      • 效率比较
        • 复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
      • 内存整齐度
        • 复制算法=标记/整理算法>标记/清除算法。
      • 内存利用率
        • 标记/整理算法=标记/清除算法>复制算法。