来说说垃圾回收怎么样~
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 的堆划分,大概就是这样子:
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 虚拟机
以上是 来说说垃圾回收怎么样~ 的全部内容, 来源链接: utcz.com/a/131286.html