当前位置: 首页 > 科技观察

你是垃圾,你心里没点数吗?

时间:2023-03-14 22:35:11 科技观察

这篇文章我们来谈谈GC和我们的垃圾收集器。我们知道Java的垃圾回收机制和C++是不一样的。作为Java程序员,你不需要自己在程序中释放内存,而是自己管理。内存,似乎对内存的使用“肆无忌惮”。然而,这一切背后的原因是JVM的GC已经帮我们完成了这些事情,可以帮我们自动管理这些事情。当内存吃紧的时候,就会触发垃圾回收机制,腾出足够的空间给我们的程序使用。.但是JVM的GC也不是万能的,也有翻车的时候,比如发生内存泄漏时,会导致GC内存回收效率低下,甚至会出现OOM异常。作为Java程序员,我们要做的就是保证GC正常工作。基于这种情况,我们不得不了解GC的工作原理和GC的使用场景。下面开始我们的正文。这里我画了一张思维导图来介绍一下这篇文章的主要内容:首先说说哪些对象应该被GC。JVM是如何判断一个对象是否存活的呢?判断对象是否存活判断对象是否存活有两种方法:引用计数法可达性分析算法引用计数法第一种引用计数法实现简单,效率高。它的原理是在对象内部维护一个计数器。当在某处被引用时,计数器为+1,当不再被引用时,计数器为-1。这样,当计数器为零的时候,就说明没有地方可以引用了,那么这个对应就应该被GC回收了。不过这个算法很少被Java虚拟机使用。主要是它有漏洞:不能解决循环引用的问题:可达性分析算法第二种是可达性分析算法,从一组GCRoots开始,根据引用链的关系往下查找,如果一个对象和GCRoot之间没有引用链,这个对象是不可达的,以后会被回收。这个算法用在主流的Java虚拟机中,比如HotSpot,哪些对象可以作为GCRoots?以下对象可用作GCRoots:虚拟堆栈中引用的对象。方法区中的静态变量。方法区中的常量。而本地方法栈JNI引用的对象(这个可以忽略,我们几乎没有接触)。上面比较常见的是方法区中静态变量和常量的引用对象。知道了如何判断对象是否存活,下面就是可达性分析算法的使用和具体的垃圾回收算法。垃圾回收算法对于垃圾回收算法,我这里不做太详细的介绍,只是简单介绍一下,因为之前写过一篇比较详细的文章,大家可以参考一下:还在学JVM吗?给大家总结一下吧(附脑图)常见的垃圾回收算法有3种:而复制的成本Low,根据这个分代模型理论,垃圾收集器的Eden区、FromSurvivor区、ToSurvivor区(默认8:1:1)出现的更晚。在新生代中,每次只有Eden和其中一个S区可用。当Eden区满了,存活的对象会被复制到其中一个S区。如果S区也已满,则该区不符合推广条件。该对象将被复制到另一个S区。这样每经过一次MinorGC,对象的年龄就会+1。当达到提升年龄阈值且该对象还没有被垃圾回收时,它将被放入“老年代”。老年代使用mark-clear或者mark-organize。对于mark-clear,我们知道它最大的缺点就是内存碎片,但是它也有自己的优点(相对于mark-organize),就是不需要移动对象,所以效率比mark-sort高。完整的mark-sort过程应该是mark-sort-clear三步,需要把存活的对象移到一边,然后清理不可达的对象,所以效率会比较低,尤其是老年代。如果区域内存在大量活着的物体,移动物体所消耗的性能也是相当可观的。还有一点比较重要:新对象的内存分配角度。这是很多技术博文都忽略的一点。这一点也是比较重要的,在《深入JVM虚拟机 第三版》中也强调了。从内存分配的角度来看:对于标记组织和复制算法来说,它们都是组织的规则空间,所以它们在为新生成的对象分配内存时,是比较简单和高效的,尤其是对于一些大对象。分配和分配连续的内存对象(数组)。mark-sort和copy算法只需要将内存地址指针移动到与对象相同大小即可完成内存分配,高效简单。对于mark-sweep方法,由于内存碎片,它必须记住哪些地方可用,哪些地方不可用,因此内存分配的效率会低很多。知道了具体的垃圾回收算法,下面说说具体的垃圾回收器。垃圾收集器根据分离代理模型,针对不同的区域设计了不同的垃圾收集器。经典的垃圾收集器主要有几种类型:Serial(新生代)SerialOld(老年代)PS(新生代)PO(老年代)ParNew(新生代)CMS(老年代)G1对于以上几种垃圾收集器,不同oldgeneration和younggeneration可以选择使用。主要搭配方式如下:SerialSerial系列垃圾收集器,现在基本没人用了。它的使用原理是使用单线程进行垃圾回收,所以STW时间会比较长,实现也简单。SerialOld的老一代使用标记排序算法来收集垃圾。它来自深入JVM虚拟机。如果你的服务器还处于单核时代,内存也只有几十到几百M,Serial可能是最好的选择。Serial的相关JVM参数是:-XX:+UseSerialGC(使用Serial垃圾收集器)。当Parallel发展到多线程时代,出现了PS和PO的结合。与Serial相比,PS和PO使用多线程进行垃圾回收。其他的都是一样的,包括使用的垃圾回收算法。同样,所以在多核时代,它的时间比SerialSTW更短:这里有一个吞吐量的概念:吞吐量=用户应用运行时间/(应用运行时间+垃圾回收时间),因为结合PS+PO是一个追求吞吐量的垃圾收集器。因此,PS+PO的组合更适合在后台快速完成计算任务,不需要太多与用户交互的场景。PS+PO相关的JVM参数如下:-XX:+UseParallelGC:EnableParallelGC。-XX:+UseParallelOldGC:启用老年代的ParallelGC,开启其中一项。-XX:ParallelGCThreads:指定线程数。-XX:MaxGCPauseMillis:控制最大垃圾回收暂停时间(以毫秒为单位)-XX:GCTimeRatio:直接设置吞吐量大小(大于0小于100的整数)-XX:+UseAdaptiveSizePolicy:当这个参数被激活时,它不不需要指定新生代的大小(-Xmn)、Eden占S区的比例(-XX:SurvivorRatio)、晋升到老年代的对象大小(-XX:PretenureSizeThreshold)等参数)由虚拟机本身动态调整。PS和PO是Java8默认的垃圾收集器。不知道各位读者的Java版本,但是你们已经使用Java8很久了。ParNewParNew的实现原理与Parallel基本相同。唯一的区别是它可以和CMS一起使用,但是PS不能和CMS一起使用。这也让ParNew大受欢迎。当JVM设置为使用CMS作为老年代的垃圾回收器时,新生代的垃圾回收器默认为ParNew。CMSCMS可以说是跨时代的垃圾收集器。它实现了垃圾回收和用户线程并发。它是一个以获取最短垃圾停顿时间为目的的垃圾收集器。特别适用于用户交互频繁的场景。其实现过程分为以下四个阶段:初始标记、并发标记、重标记、并发清理。初始标记和重新标记需要STW,并发标记和并发清理垃圾收集线程与用户进行交互。线程并发执行。初始标记阶段只标记与GCRoot直接关联的对象,不会遍历整个对象图,所以速度非常快。并发标记阶段是从GCRoot开始遍历整个对象图的过程。这个过程是四个阶段中最耗时的过程。所以这个阶段也是和用户线程并发执行的,不需要暂停用户线程。重标记阶段是对并发标记阶段由于用户线程的运行而引起的部分对象引用关系变化的标记记录进行修正。因为在并发标记阶段,用户线程和垃圾回收线程是并发执行的,所以之前可能已经被标记过了。引用关系又改了,现阶段需要重新修复。并发清理可以和用户线程并发执行,因为不需要移动用户对象,最后清理不可达的对象。四个阶段中最复杂的是并发标记的第三阶段。其中涉及到的一个重要概念就是三色标记法。第三阶段,在标记过程中,对象可能被漏标或多标。那么CMS如何解决这两个问题呢?我们先来详细了解一下三色标示法。以三色表示法将物体分类为白色、灰色和黑色的过程。白色:白色是对象的默认颜色。扫描从GCRoot开始。如果不可达对象是白色的,会在并发清理阶段清理掉。灰色:灰色表示当前对象已经被扫描,但是当前对象依赖的其他对象还没有被扫描。黑色:黑色表示当前对象及其依赖的对象均已扫描。那么它是如何产生多标签和遗漏的呢?我们画个图看看:一开始有3个对象,分别是对象1、对象2和对象3,这三个对象和GCRoot之间有引用链。当开始标记后,扫描将从GCRoot开始。当扫描对象1和对象2时,对象2会变黑,因为对象2没有依赖的引用,而对象1仍然引用对象3,对象3还没有被扫描,所以对象1变成灰色。如果是的话,此时用户线程改变了对象3和对象1的引用关系,变成了对象2和对象3的引用关系,因为对象2已经扫描过了,对象3还没有扫描到,所以应该be如果对象2处于灰色状态,对象3处于白色状态,则对象3将被回收,从而导致丢失标记。多标签情况是对象1和对象3之间存在引用链,并且都标记为黑色。此时用户线程将对象3设置为null,按理说这时候对象3应该被回收了,但是因为它是黑色的,不会被回收,所以有多重标记。在multi-marking的情况下,可以在下次垃圾回收的时候重新标记,重新回收,所以multi-marking并不是GC回收过程中的bug。缺失标签需要解决,否则GC回收会出现bug。标签丢失的解决方案CMS是一种增量更新的方法。它的原理是,如果对象3的引用变成了对象2,那么对象2就会变灰,对象2就会被包含在集合中,在relabeling阶段以对象2为根节点向下扫描。这样,CMS解决了标签缺失的问题,并且在实现整个GCRoot对象图时,可以和用户线程并发执行,大大减少了STW时间。那为什么CMS会选择mark-clear算法呢?因为如果选择mark-clear算法,在并发清理阶段,因为需要排序,涉及到对象的移动,此时不能和用户线程并发操作,所以清理阶段必须STW,违背了CMS设计的初衷:获取最短的恢复停顿时间。CMS相关的JVM参数如下:-XX:+UseConcMarkSweepGC:使用CMS垃圾收集器(设置该参数后,新生代默认开启ParNew)。-XX:+UseCMSCompactAtFullCollection:当CMS收集器要进行FullGC时,用于启动内存碎片的整理过程。由于此内存合并必须移动幸存的对象,因此清理阶段不能并发。这个参数从JDK9开始就被废弃了。-XX:CMSFullGCsBefore-Compaction:FullGC后压缩多少次,默认值为0,表示每次进入FullGC时进行碎片整理,从JDK9开始废弃该参数。-XX:CMSInitiatingOccupancyFraction:老年代的使用率达到这个比例就会触发FullGC,默认92。全GC。如果设置,第一次FullGC时只会使用-XX:CMSInitiatingOccupancyFraction的值,之后会自动调整。-XX:+CMSScavengeBeforeRemark:在FullGC之前启动一次MinorGC,目的是减少老年代对年轻代的引用,减少CMSGC标记阶段的开销。一般CMS80%的GC时间都在标记阶段。-XX:+CMSParallellnitialMarkEnabled:默认初始标记为单线程。该参数允许多线程执行,可以减少STW。-XX:+CMSParallelRemarkEnabled:使用多线程进行remark,目的也是为了减少STW。CMS的出现意义重大。为更智能的垃圾收集器G1和ZGC的出现奠定了基础。第一次实现了用户线程和垃圾回收线程并发执行,但是慢慢的CMS也退出了舞台。你会发现很多关于CMS的相关JVM参数在jdk9中都被抛弃了,而且在jdk9中,默认的垃圾收集器不再是PS+PO,而是变成了G1,说明JVM设计团队认为G1可以替代之前的garbagecollector(我还停留在jdk8,手动狗头),相信jdk8的人应该很多吧,哈哈哈。G1终于说到G1了,G1因为它的优势成为了jdk9默认的垃圾收集器。它与其他收集器不同的是,它把整个堆分成了很多Region,每个Region的大小大概在1M-32M。它不再像之前的垃圾收集器那样将整个堆分为新生代和老年代。它的衡量标准是哪个地区最有利于回收利用。这就是G1的MixedGC模式。G1的阶段过程和CMS类似,也分为四个阶段:初始标记并发标记最终标记筛选和回收初始标记和CMS一样,需要STW,只标记与GCRoots直接相关的对象,而且时间会很短。并发标记也是与用户线程并发执行的。它需要从GCRoot开始遍历整个对象图,这也是最耗时的阶段。最终标记阶段用于处理并发阶段结束后剩下的最后少量SATB记录,即在并发标记阶段再次改变引用关系的对象。最后一个阶段是回收阶段。因为G1使用了mark-sorting算法,涉及到对象的移动,所以这个阶段需要STW,必须挂起用户线程,多个线程会进行垃圾回收。最后说一下G1实现的一些小细节。一是它如何解决并发标记阶段为新对象分配内存的问题。另一个最重要的细节是它如何建立一个可预测的停顿时间模型?G1和CMS如何选择?我们先来看第一个问题。用户线程也在并发标记阶段执行,执行过程中会产生新的对象。G1是为每个区域设计的。两个名为TAMS(TopatMarkStart)的指针。并分配Region中的一部分空间用于新对象的内存分配。在并发恢复期间,新分配的对象的地址必须在这两个指针位置之上。然后第二个细节就是G1在垃圾回收过程中会记录每个Region的回收时间和代价,并根据多次计算平均值,这样就可以估算出每个Region的垃圾时间,然后根据程序中设置的最短垃圾收集时间估计哪些Region最有利于回收。那么G1和CMS如何选择呢?对于小内存(1G-4G)CMS可能会比G1好,而对于大内存(6G-8G)可能G1会表现出自己的优势。最后,与G1相关的JVM参数如下:-XX:G1HeapRegionSize:设置每个Region的大小,取值范围为1MB到32MB。-XX:MaxGCPauseMillis:设置垃圾收集器的暂停时间,默认值为200毫秒。好了,这里说说垃圾收集器,还有一个很经典的,ZGC。有兴趣的可以自己去了解一下。限于篇幅,关于JVM的这段垃圾就先说到这里吧。Next这篇文章继续深入讲JVM。我是李杜,我们下期再见。本文转载自微信公众号“丽都编程”,可通过以下二维码关注。转载本文请联系李度编程熊公众号。