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

获取Java垃圾回收,仅此一项!

时间:2023-03-21 17:23:14 科技观察

我在学校的时候,有这个表情包。上面写着在食堂吃饭,吃完就收拾盘子的是C++程序员,吃完饭立马走的是Java程序员。图片来自Pexels确实,在Java的世界里,我们似乎不需要那么关注垃圾回收。很多新手不懂GC,照样能写出能用甚至不错的程序或系统。但其实这并不代表Java的GC不重要。反而重要复杂到出了问题,那些新手除了打开GC日志看一堆0101天文,什么也做不了。今天我们就从头到尾说说Java的垃圾回收。什么是垃圾收集垃圾收集(GC),顾名思义,就是释放垃圾占用的空间,防止内存泄漏。有效利用可用内存,清除和回收内存堆中已经消亡或长时间未使用的对象。在Java语言出来之前,大家都在拼命的写C或者C++的程序,但是这个时候出现了很大的矛盾。C++等语言需要不断开辟空间来创建对象,需要在不用的时候不断释放控件。必须编写构造函数和析构函数。很多时候,它们被重复分配然后被销毁。.因此,有人问是否可以写一个程序来实现这个功能,在每次控件的创建和释放时都重用这段代码,而不用重复写?1960年,基于MIT的Lisp首先提出了垃圾回收的概念。概念,而此时Java还没有诞生!所以其实GC并不是Java的专利,GC的历史远远大于Java的历史!如何定义垃圾既然要做垃圾回收,首先要搞清楚垃圾的定义是什么,需要回收哪些内存。引用计数算法可达性计数算法(ReachabilityCounting)是通过在对象头中分配一个空间来保存对象被引用的次数(ReferenceCount)。如果该对象被其他对象引用,则其引用计数加1。如果删除对该对象的引用,则其引用计数减1。当该对象的引用计数为0时,该对象将被回收。Stringm=newString("杰克");首先创建一个字符串,此时“jack”有一个引用,就是m。然后将m设置为空。此时“jack”的引用计数等于0,在引用计数算法中,意味着这块内容需要被回收。m=空;引用计数算法将垃圾收集分发到整个应用程序的运行中,而不是在垃圾收集期间暂停整个应用程序的运行,直到堆中所有对象的处理完成。因此,使用引用计数的垃圾收集严格来说并不是一种“Stop-The-World”垃圾收集机制。看起来很好,但是我们知道JVM垃圾回收是“Stop-The-World”,那么到底是什么原因导致我们最终放弃了引用计数算法呢?publicclassReferenceCountingGC{publicObjectinstance;publicReferenceCountingGC(Stringname){}}publicstaticvoidtestGC(){ReferenceCountingGCa=newReferenceCountingGC("objA");ReferenceCountingGCb=newReferenceCountingGC("objB");a.instance=b;b.instance=a;a=null;b=null;}看上面的例子:定义2个对象相互清空各自的语句引用。我们可以看到最后两个对象已经不能访问了,但是因为它们相互引用,所以它们的引用计数永远不会为0。通过引用计数算法,换句话说,GC收集器永远无法通知回收它们.可达性分析算法可达性分析算法(ReachabilityAnalysis)的基本思想是以一些称为引用链(GCRoots)的对象为起点,从这些节点开始向下搜索。搜索所经过的路径称为参考链。当一个对象没有通过任何引用链连接到GCRoots时(即从GCRoots节点到该节点不可达),证明该对象不可用。通过可达性算法,成功解决了引用计数无法解决的问题——“循环依赖”。只要你不能与GCRoot建立直接或间接的联系,系统就会判定你为可回收对象。那么这就引出了另一个问题,哪些属于GCRoot。Java内存区在Java语言中,可以作为GCRoot的对象包括以下四种:虚拟机栈中引用的对象(栈帧中的局部变量表)方法区中类静态属性引用的对象被引用的对象constantsintheobjectmethodarea对象的本地方法栈中JNI引用的对象(也就是一般的Native方法)①此时s在虚拟机栈(栈帧中的局部变量表)中引用的对象是GC根。当s为空时,localParameter对象也会与GCRoot断开引用链,将被回收。publicclassStackLocalParameter{publicStackLocalParameter(Stringname){}}publicstaticvoidtestGC(){StackLocalParameters=newStackLocalParameter("localParameter");s=null;}②方法区类静态属性引用的对象s为GCRoot,s设置为null,GC之后,s指向的properties对象因为无法与GCRoot建立关系而被回收。m作为类的静态属性,也属于GCRoot,参数对象仍然连接到GCRoot,所以此时参数对象不会被回收。publinClassMethoDareastAicProperties{publicStaticMethodareastAicPropertiesm;publicMethodareastAicProperties(stringName){}}}}}publicStaticVoidTestGc(){MethodareastAiCpropertiess=NewMethodareastAiCproperties;该区域中的常量引用也是GCRoot,s设置为null后,final对象不会被回收,因为它与GCRoot没有联系。publicclassMethodAreaStaicProperties{publicstaticfinalMethodAreaStaicPropertiesm=MethodAreaStaicProperties("final");publicMethodAreaStaicProperties(Stringname){}}publicstaticvoidtestGC(){MethodAreaStaicPropertiess=newMethodAreaStaicProperties("staticProperties");s=null;}④本地方法栈中引用的对象任何Native接口都会使用某如果一个native方法栈是使用C连接模型实现的,那么它的native方法栈就是C栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并将其压入Java栈。然而,当它调用一个本地方法时,虚拟机保持Java堆栈不变,并且不再将新帧压入线程的Java堆栈。虚拟机只是动态连接,直接调用指定的native方法。如何回收垃圾在确定了哪些垃圾可以回收后,垃圾收集器要做的就是开始垃圾收集,但是这里涉及到一个问题:如何高效地收集垃圾。由于Java虚拟机规范并没有明确规定如何实现垃圾收集器,因此各个厂商的虚拟机可以通过不同的方式实现垃圾收集器。这里我们讨论几种常见的垃圾回收算法的核心思想。标记-清除算法标记-清除算法(Mark-Sweep)是最基本的垃圾收集算法。它分为两部分。首先在内存区标记对象,哪些是可回收的,然后标记出来。取出垃圾并清理干净。就像上图一样,清理干净的垃圾变成了一块未使用的内存区域,等待再次使用。这个逻辑再清晰不过了,操作也简单,但是有一个很大的问题,就是内存碎片。上图中中间的正方形假设为2M,较小的为1M,较大的为4M。我们回收之后,内存会被切割成很多段。我们知道,在开辟内存空间的时候,需要一块连续的内存区域。这时候我们需要一块2M的内存区域,其中两个1M的内存是不能用的。这就导致了,其实我们还有那么多内存,但是我们不能使用。复制算法复制算法(Copying)由标记清除算法演化而来,解决标记清除算法的内存碎片问题。它根据容量将可用内存分成大小相等的两块,一次只使用其中一块。当这块内存用完后,将存活的对象复制到另一块中,然后一次性清理掉已使用的内存空间。保证内存的持续可用,分配内存时无需考虑内存碎片等复杂情况。逻辑清晰,运行高效。上图已经很清楚了,也很明显的暴露了另外一个问题。我140平的大三房只能当70平的小两房?价格太高了。标记-紧凑算法标记-紧凑算法(Mark-Compact)的标记过程仍然和标记-清除算法一样,只是后面的步骤并不直接清理可回收对象,而是让所有存活的对象移动到一个结束,然后清理字节序边界之外的内存区域。一方面,标记排序算法在标记清除算法上进行了升级,解决了内存碎片问题,避免了复制算法只能使用一半内存区域的缺点。看起来很漂亮,但是从上图可以看出,它更改内存比较频繁,需要整理所有存活对象的引用地址,效率上比复制算法差很多。严格来说,GenerationalCollection算法不是一种思想或理论,而是结合以上三种基本算法思想产生的针对不同情况的不同算法组合。对象生命周期的不同,将内存分成若干块。Java堆一般分为新生代和老年代,这样可以根据各个年代的特点采用合适的收集算法。在新生代中,发现每次垃圾回收都有大量对象死亡,只有少量对象存活。然后采用复制算法,只需付出复制少量存活对象的代价即可完成收集。在老年代,由于对象存活率高,没有额外的空间为其分配保证,所以需要使用mark-clean或mark-compact算法进行回收。那么,又来了一个问题,内存区域分为哪些部分,每个部分适合用什么算法呢?内存模型和回收策略Java堆(JavaHeap)是JVM管理的最大的一块内存,堆是垃圾收集器管理的主要区域。这里主要分析Java堆的结构。Java堆主要分为两个区域,年轻代和老年代。新生代分为Eden区和Survivor区,Survivor区又分为From和To。可能这时候大家会有疑问,为什么需要Survivor区,Survivor为什么要分成2个区。别着急,让我们看看这个物体??从头到尾是怎么来的,又是怎么消失的。根据IBM对伊甸区的专业研究,将近98%的对象都是活的和死的,所以针对这种情况,大多数情况下,对象会分配到新一代的伊甸区。当Eden区没有足够的空间可供分配时,虚拟机就会发起一次MinorGC。与MajorGC相比,MinorGC更频繁,回收速度更快。通过MinorGC后,Eden会被清空,Eden区的大部分对象会被回收,那些存活下来的不需要回收的对象会进入Survivor的From区(如果From区不是够了,直接进入老区)。SurvivorDistrictSurvivorDistrict相当于EdenDistrict和OldDistrict之间的缓冲区,类似于我们红绿灯中的黄灯。Survivor又分为2个区域:From区To区每次MinorGC时,Eden区和From存活下来的对象会被放到Survivor的To区(如果To区不够,直接进入Old区域)。①为什么需要?不就是从新一代到老一代吗?直接从伊甸园去旧地不好吗?为什么这么复杂。想想如果没有Survivor区,每次在Eden区进行一次MinorGC,存活下来的对象就会被送到老年代,很快老年代就会被填满。并且有很多对象没有被MinorGC淘汰过一次,但是很长时间都不会跳。也许第二次或第三次他们需要被清除。这个时候搬进老年区显然不是一个明智的决定。所以Survivor的存在就是为了减少送往老年代的对象数量,从而减少MajorGC的发生。Survivor预筛选保证只有在16次MinorGC中存活下来并且存活在年轻代的对象才会被送到老年代。②为什么需要两个?设置两个Survivor区最大的好处就是解决内存碎片问题。让我们首先假设如果Survivor只有一个区域会发生什么。MinorGC执行完毕后,Eden区被清空,存活的对象放入Survivor区。Survivor区域中的一些对象也可能需要清除。问题来了,这个时候我们怎么清除它们呢?在这种场景下,我们只能进行mark和clear,而我们知道mark和clear最大的问题就是内存碎片。在新生代经常死掉的区域,使用mark和clearmust会造成严重的内存碎片。因为Survivor有2个区,所以每次MinorGC都会把之前Eden区和From区的存活对象复制到To区。在第二次MinorGC期间,交换了From和To的职责。此时Eden区和To区存活的对象会被复制到From区,以此类推。这种机制最大的好处是在整个过程中,一个Survivor空间一直是空的,另一个非空的Survivor空间没有碎片。那么,Survivor为什么不分成更多的块呢?比如分成三份、四份、五份?显然,如果将Survivor区进一步细分,每块的空间就会比较小,容易导致Survivor区被占满。幸存者区可能是权衡后的最佳方案。Old区的老年代占据堆内存空间的2/3,只有在MajorGC时才会被清理,每次GC都会触发“Stop-The-World”。内存越大,STW时间越长,所以内存不仅越大越好。由于复制算法在对象存活率高的老年代会进行多次复制操作,效率很低,所以这里在老年代采用标记-排序算法。除了上述之外,在内存保证机制下,无法放置的对象会直接进入老年代,以下几种情况也会进入老年代。①大对象大对象是指需要大量连续内存空间的对象。不管这些对象是不是“生死存亡”,都会直接进入老年代。这主要是为了避免Eden区和两个Survivor区之间出现大量的内存拷贝。当你的系统有很多“生死攸关”的大对象时,要小心了。②长期存活对象虚拟机为每个对象定义一个对象年龄(Age)计数器。一般情况下,物体会在Survivor的From区域和To区域之间不断移动。对象在Survivor区每经历一次MinorGC,年龄就会增加1年。当age增加到15时,这个时候会转移到oldgeneration。当然这里是15,JVM也支持特殊设置。③动态对象年龄虚拟机没有注意对象年龄必须达到15岁才会放入老年区的要求。如果Survivor空间中所有同龄对象的大小之和大于Survivor空间的一半,则年龄大于等于这个年龄的对象可以直接去老年区,不用等你去“成人”。这其实有点类似于负载均衡。轮询是一种负载平衡,可确保每台机器获得相同的请求。看似平衡,但是每台机器的硬件和健康状态是不一样的。我们还可以根据每台机器接受的请求数或每台机器的响应时间来调整我们的负载均衡算法。作者:聂小龙(花名:水哥)简介:阿里巴巴高级开发工程师,本文部分内容参考书籍:《深入理解 Java 虚拟机》。