7

[JVM]来说说垃圾回收怎么样~

 3 years ago
source link: https://www.dynamic-zheng.com/posts/a228901d.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

JVM 的自动内存管理,让原本应该是开发人员去做的事情,变成了垃圾回收器来做的事情

既然是别人帮忙做的事情,那么可能就不是自己想要的,所以就需要我们了解一下垃圾回收相关的内容

引用计数法与可达性分析

垃圾回收,垃圾回收,那就是有的内存分配给了一些对象,但是这些对象已经用完了,那么它所占用的内存也就应该该释放掉了,却还没有释放
那么,这里就有个问题:该如何确定一个对象用完了呢?
其中一种方法就是引用计数法

引用计数法就是给每个对象添加一个引用计数器,来统计指向该对象的引用个数
比如:如果有一个引用,被赋值为某一个对象,那么这个对象的引用计数器就 +1 ,如果一个指向这个对象的引用,被赋值为了其他的值,那么这个对象的引用计数器就 -1 ,这样如果这个对象的引用计数器为 0 ,我们就可以认为这个对象已经使用完毕,它所占用的内存空间可以回收掉了
这种方案听上去无懈可击,但是有一个致命的漏洞,就是没办法处理循环引用的问题
比如说: A 和 B 互相引用,除此之外也没有其他的引用指向 A 或者 B ,在这种情况下,其实 A 和 B 所占用的内存就可以释放掉了,但是因为它们互相都有引用,所以此时的引用计数器并不为 0 ,在这种情况下,就不能对它们进行回收
现在只是两个对象,如果再来两个,再来两个,这样循环引用的对象多了之后,就会造成内存泄露

基于引用计数法的弊端,当前 JVM 主流的垃圾回收器采取的是可达性分析算法
这个算法本质就是将一系列的 GC Roots 作为初始的存活对象合集( live set ),然后从这个合集出发,探索所有能够被该集合引用到的对象,并把这些对象加入到集合中来,这个过程就叫做标记( mark ),遍历到最后,没有被探索到的对象就是可以回收的对象
那么什么是 GC Roots 嘞?一般包括(但不限于)以下几种:

  • Java 方法栈桢中的局部变量
  • 已加载类的静态变量
  • JNI handles
  • 已启动并且没有停止的 Java 线程

刚才说因为引用计数法存在循环引用的问题,所以目前主流垃圾回收器选用的都是可达性分析法,也就是说,它解决了循环引用问题,其实这一点也比较好理解,虽然 A 和 B 相互引用,但是这个时候从 GC Roots 开始出发,是没有办法到达 A 和 B 的,那么就不会把它们放到存活对象合集之中,自然也就会被回收掉
但是在实际中还是会有问题的,比如:在多线程环境下,就会有其他线程更新已经访问过的对象中的引用,但是是多线程并行的嘛,这个时候可达性分析法已经把这个引用设置成了 null ,或者这个对象还在使用,但可达性分析法把它标记为了没有被访问过的对象,被回收掉了,这种情况可能直接导致 JVM 崩溃掉

Stop-the-world & safepoint

既然可达性分析法也有自己的一些缺陷,总得有解决方案吧?比较暴力的一种方法就是 Stop-the-world ,估计听名字也能知道,就是让全世界都停下来,也就是说,在进行垃圾回收的时候,其他所有非垃圾回收线程的工作都需要停下来,先让垃圾回收器工作完毕再说.这就是所谓的暂停时间( GC pause )
Stop-the-world 是通过安全点( safepoint )机制来实现的.啥意思嘞?咱先想个场景,现在你敲代码敲的特别开心,又有思路,状态又好,美滋滋的正在工作,突然毫无缘由的就让你现在不准敲代码,你会不会不开心?好不容易思路来了对吧,就一点儿理由都不给的就让我停下,不合理吧?
同样的场景,一个线程现在跑的特别 happy ,而且再有一秒钟就完成了任务,这个时候 JVM 收到了 Stop-the-world 请求,二话不说就把所有的线程给停掉,不太好吧?那么这个时候安全点( safepoint )机制就登场了.有了安全点机制,当 JVM 收到 Stop-the-world 请求的时候,它就会等待所有的线程都达到安全点,才允许请求 Stop-the-world 的线程进行独占的工作
那么,什么时候是安全点呢?举个例子来说:当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 Java 对象,不调用 Java 方法,不返回到原 Java 方法,那么 Java 虚拟机的堆栈就不会发生改变,那这段本地代码就可以作为一个安全点.只要不离开这个安全点, JVM 就可以在垃圾回收的同时,继续运行这段本地代码
因为本地代码需要通过 JNI 的 API 来完成上述三个操作,因此 JVM 只需要在 API 的入口处进行安全点检测( safepoint poll ),看看有没有其他线程请求停留在安全点这里,就可以在必要的时候挂起当前线程

垃圾回收的三种方式

当标记好存活的对象之后,就可以进行垃圾回收了
主流的垃圾回收方式,可以分为三种:清除( sweep ),压缩( compact ),复制( copy ).

清除,就是把死亡对象所占据的内存标记成空闲内存,并把它记录在一个空闲列表( free list )中,当需要新建对象的时候,就直接在空闲列表中寻找空闲内存,划分给新建的对象就完了
但是这里会产生一个问题,因为死亡的对象所占据的内存可能是随机的,回收完毕之后,内存就是碎片化的,如果此时有对象申请一块连续的内存空间,尽管碎片化的内存空间是够用的,也没办法进行分配

压缩,就是把存活的对象聚集到内存区域的起始位置,这样就可以留下一段连续的内存空间.这样去做的话,可以解决内存碎片化的问题,代价就是压缩算法带来的性能开销

复制,就是把内存区域分成两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存.当进行垃圾回收时,就把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容.
复制这种方式也可以解决内存碎片化的问题,但是它的缺点也是比较明显的,因为把内存区域分成了两等分嘛,那利用率就比较低咯,最高也是 50% 了,不能再高了

垃圾回收在 JVM 中的应用

上面说的三种垃圾回收方式是理论上的,那么在 JVM 中是如何应用的呢?
这就先要来了解下 JVM 的堆划分
大概就是这样子:

%E5%86%85%E5%AD%98%E5%8C%BA%E5%9F%9F%E5%88%92%E5%88%86.jpg

JVM 将堆划分为新生代和老年代,在新生代中又划分为 Eden 区,还有两个大小相同的 Survivor 区
当程序调用 new 指令时,会在 Eden 区中划出一块作为存储对象的内存,但是因为堆空间是线程共享的,所以在这里面划分空间的话就需要同步,要不然出现了两个对象共用一段内存,那不就该打架了嘛
JVM 为了避免两个对象打架的事情发生,就让每个线程向 JVM 申请一段连续的内存,来作为线程私有的 TLAB ( Thread Local Allocation Buffer ,对应虚拟机参数 -XX:+UseTLAB ,默认开启的)
Eden 区一直进行分配,总有空间分配完毕的时候,该怎么办?此时 JVM 就会触发一次 Minor GC ,来收集新生代的垃圾,存活下来的对象就会被送到 Survivor 区
在图中可以看到, Survivor 区有两个,一个是 from ,一个是 to ,其中 to 指向的 Survivor 区是空的

当发生 Minor GC 时, Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区,然后交换 from 和 to 指针,这样就保证了下一次 Minor GC 时, to 指向的 Survivor 区还是空的
同时 JVM 会记录 Survivor 区的对象一共被来回复制了几次,如果一个对象被复制的次数为 15 (对应虚拟机参数 -XX:+MaxTenuringThreshold ),这个对象就会被晋升( promote )到老年代

那么在发生 Minor GC 时,采用哪种垃圾回收方式会比较好一些呢?采用复制方式,也就是 标记-复制 算法会好一些.为什么呢?因为在新生代中,大部分的 Java 对象只存活一小段时间,那么我们就可以采用耗时比较短的垃圾回收算法,让大部分的垃圾都能在新生代被回收掉.使用 标记-复制 算法的话,理想情况下就是 Eden 区中的对象基本都死亡了,那么需要复制的数据非常少,此时这种算法的优势就被极大的体现了出来

以上,非常感谢您的阅读~

参考:
极客时间 – 深入拆解 Java 虚拟机


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK