• 浅谈java垃圾回收机制


    一、问题

      笔者最近遇到超级多的关于java中垃圾回收机制的问题,所以特地写一遍博客来和大家交流一下java中的垃圾回收到底是什么鬼。所谓垃圾回收即使jvm觉得你这个对象没有存在的必要,将你清理出去,那么问题来了。

    1. 如何确定某个对象是需要被回收?
    2. 典型的垃圾收集算法,是怎么回收对象的?
    3. 典型的垃圾收集器有哪些?

      下面我来一个一个看问题

    二、如何确定某个对象是需要被回收的

      这里我们先了解一个的问题:如果确定某个对象是“垃圾”?既然垃圾收集器的任务是回收垃圾对象所占的空间供新的对象使用,那么垃圾收集器如何确定某个对象是“垃圾”?—即通过什么方法判断一个对象可以被回收了。有些对象是jvm内存不足需要清理内存空间,会将下一轮需要回收的对象进行清理。

      在java中是通过引用来和对象进行关联的,也就是说如果要操作对象,必须通过引用来进行。那么很显然一个简单的办法就是通过引用计数来判断一个对象是否可以被回收。不失一般性,如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象了。这种方式成为引用计数法。

      这样的方法简单粗暴,而且效率很高。效率高必然会暴露一些问题,如果某些对象呗循环引用,即使你把对象赋值为null,这种算法照样不能回收。看下下面的代码

    public class GcTest {
    
        public Object object = null;
        
        public static void main(String[] args) {
            
            GcTest gcTest1 = new GcTest();
            GcTest gcTest2 = new GcTest();
            
            gcTest1.object = gcTest1;
            gcTest2.object = gcTest2;
            
            gcTest1 = null;
            gcTest2 = null;
        }
    }

      虽然gcTest1,gcTest2是null,他们指向的对象已经不会被访问到了,但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。

      上面的问题已经暴露出来了,下面看看jvm是怎么解决这个问题的。为了解决这个问题,在Java中采取了可达性分析法。该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。在《深入理解jvm》讲解的很仔细,笔者就简单介绍下GC Roots的概念,想深入了解的可以去读下笔者介绍的这本书。

      以下三类对象在jvm中作为GC roots,来判断一个对象是否可以被回收 (通常来说我们只要知道虚拟机栈和静态引用就够了)

       1、虚拟机栈(JVM stack)中引用的对象(准确的说是虚拟机栈中的栈帧(frames)) 。我们知道,每个方法执行的时候,jvm都会创建一个相应的栈帧(栈帧中包括操作数栈、局部变量表、运行时常量池的引用),栈帧中包含这在方法内部使用的所有对象的引用(当然还有其他的基本类型数据),当方法执行完后,该栈帧会从虚拟机栈中弹出,这样一来,临时创建的对象的引用也就不存在了,或者说没有任何gc roots指向这些临时对象,这些对象在下一次GC时便会被回收掉

      2、方法区中类静态属性引用的对象 。静态属性是该类型(class)的属性,不单独属于任何实例,因此该属性自然会作为gc roots。只要这个class存在,该引用指向的对象也会一直存在。class 也是会被回收的,在面后说明

       3、本地方法栈(Native Stack)引用的对象

       下面介绍下关于软引用(softReference)和弱引用(weakReference)的对象垃圾回收对他们做的处理

    String str = new String("hello");//A
    SoftReference<String> sr = new SoftReference<String>(new String("java"));//B
    WeakReference<String> wr = new WeakReference<String>(new String("world"));//C

       上面的几个对象中回收情况如下,B在内存不足的情况下会将String对象判定为可回收对象,C无论什么情况下String对象都会被判定为可回收对象。也就是说软引用会在内存溢出(OOM)的时候回收,而弱引用无论什么情况都会在下一轮回收的时候回收掉。

      一般jvm会对这些对象回收

      1、显示地将某个引用赋值为null或者将已经指向某个对象的引用指向新的对象。

      2、局部引用所指向的对象。

      3、上面说的弱引用(weakReference)。

    三、垃圾收集算法

      在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是开始进行垃圾回收,但是这里面涉及到一个问题是:如何高效地进行垃圾回收。由于Java虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,就以最常用的HotShot为例,所以在此只讨论几种常见的垃圾收集算法的核心思想。

    1、Mark-Sweep(标记-清除)算法

      这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。图解来自网络,很好的说明了标记-清楚算法的处理前和处理后的内存分布。

    下面所有的图是模拟内存块,红色为未使用内存块,灰色为待回收对象内存块,黄色为存活对象

    回收之前

    回收之后

      很容易看出这样的操作是有弊端的,这样讲标记的对象的清楚后,内存块就变的零零散散,如果现在有一个对象占用的内存很大,这个时候必须要在执行一遍垃圾回收,为这个大的对象腾出空间。

    2、Copying(复制)算法

      为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。

    回收之前

    回收之后

      复制算法会提前空出一般的内存,在垃圾回收的时候将存活的对象移动的另外一半内存,这样内存的移动消耗太大,虽然内存不是零散的,但是代价太高。

    3、Mark-Compact(标记-整理)算法

      为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

    回收之前

    回收之后

    4、Generational Collection(分代收集)算法

      分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,并不是回收所有,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。可以调用System.gc()方法查看回收情况。

      目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。

      而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。

      注意,在堆区之外还有一个代就是永久代(Permanet Generation),它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的类。

    三、典型的垃圾收集器

      下面都是些概率性的东西,笔者看得也似懂非懂,直接搬过来分享给大家

    1.Serial/Serial Old

      Serial/Serial Old收集器是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial收集器是针对新生代的收集器,采用的是Copying算法,Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。它的优点是实现简单高效,但是缺点是会给用户带来停顿。

    2.ParNew

      ParNew收集器是Serial收集器的多线程版本,使用多个线程进行垃圾收集。

    3.Parallel Scavenge

      Parallel Scavenge收集器是一个新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。

    4.Parallel Old

      Parallel Old是Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和Mark-Compact算法。

    5.CMS

      CMS(Current Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。

    6.G1

      G1收集器是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。

    四、总结和补充

      对象的内存分配,往大方向上讲就是在堆上分配,对象主要分配在新生代的Eden Space和From Space,少数情况下会直接分配在老年代。如果新生代的Eden Space和From Space的空间不足,则会发起一次GC,如果进行了GC之后,Eden Space和From Space能够容纳该对象就放在Eden Space和From Space。在GC的过程中,会将Eden Space和From  Space中的存活对象移动到To Space,然后将Eden Space和From Space进行清理。如果在清理的过程中,To Space无法足够来存储某个对象,就会将该对象移动到老年代中。在进行了GC之后,使用的便是Eden space和To Space了,下次GC时会将存活对象复制到From Space,如此反复循环。当对象在Survivor区躲过一次GC的话,其对象年龄便会加1,默认情况下,如果对象年龄达到15岁,就会移动到老年代中。

      一般来说,大对象会被直接分配到老年代,所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组,比如:

      byte[] data = new byte[4*1024*1024]

      这种一般会直接在老年代分配存储空间。

      当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和JVM的相关参数。

     

  • 相关阅读:
    vue打包编译报错,These dependencies were not found:core-js/modules/es
    JS 新语法「可选链」「双问号」已进入 Stage 3
    vue 本地和线上跨域的问题 个人解决方案
    vue-router懒加载或者按需加载
    brew 切换国内的源
    vue 数组、对象 深度拷贝和赋值
    全局axios默认值 和 自定义实例默认值
    npm install 报node-sass错误
    linux端口探测
    linux批量操作(一)
  • 原文地址:https://www.cnblogs.com/mxlandxt/p/6972252.html
Copyright © 2020-2023  润新知