JVM 第二篇

   上一章写了一下jvm的分区,和对象的初始化操作,这篇主要说一下GC算法。

    说到GC那么首先想到的就是对象(堆),一个死亡的对象,那么GC是如何确定一个对象已经死了的。

1.引用计数算法

    通过对象的引用计数器来判断对象是否可回收,每当有一个地方引用的时候,计数器就会+1,当引用失效时,计数器就会-1,如果计数器为0则代表这个对象可以回收,但是在大多的JVM都没有选用这个算法,因为这个算法无法解决相互引用的问题。

A=null; B=null; A=B;B=A; 这样GC没办法回收。

2.可达性分析算法

通过一系列称为"GC Roots"的对象作为起点,当一个对象到GC Roots没有任何的引用链相连时,则说明这个对象不可用。5,6,7这三个对象都是不可用的。


可作为GC Roots的对象包括下面几种:

    1.虚拟机栈(栈帧中的本地变量表)中引用的对象。

    2.方法区中类静态属性引用的对象。

    3.方法区中常量引用的对象

    4.本地方法栈中Native方法引用的对象。

3.引用计数(对引用新的定义):引用强度依次减弱,GC回收的从弱到强

强引用: 就是最常用A a=new A() 只有当引用不存在时,才会被回收

软引用: 在JVM将要发生内存溢出的时候,会回收,没有回收了还是没有足够的内存才会抛出内存溢出的异常

弱引用:只能生存到下一次GC回收之前

虚引用:虚引用对对象的生存时间没有影响,也不能通过虚引用来获取对象,设置虚引用的唯一目的就是在这个对象被GC回收的时候获取一个通知。

4.对象的生存还是死亡

    就算是被可达性算法标记不可达的对象,也不是马上就死亡(是可以抢救的,但是不推荐抢救,如果对抢救没有需求最好禁用)。要真正宣告一个对象死亡,至少要经过两次标记。在对象被判断为不可达时,会被第一次标记并且进行一次晒选,筛选的条件是此对象是否有必要执行finalize()方法,判断对象是否覆盖了finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没必要执行”。

    如果对象被判断为需要执行,则会将对象放入F-Queue队列中,然后GC会对F-Queue队列中的对象进行二次标记,如果对象在finalize()中将自己和引用链连接起来,那么第二次标记的时候就会将对象从F-Queue队列中移出。如果对象在finalize()方法中没有与引用链连接,那么这个对象就会被回收。

5.对方法区的回收

方法区也可以称为永久代,在方法区中垃圾收集主要是废弃的常量和无用的类。废弃的常量就是在任何地方都没有对这个常量的引用,就可以被回收。回收类的话条件就会多许多:

1.该类的所有实例都已经被回收,也就是java堆中不存在该类的任何实例。

2.加载该类的ClassLoader已经被回收

3.该类对象的java.lang.Class对象没有在任何地方引用,无法在任何地方通过反射访问该类。

    该类就可以被回收,但是不一定会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用-verbose:class已经-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。在大量使用反射、动态代理、CGLib等框架或者需要频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,来保证永久代不会溢出。

二 垃圾收集算法

1.最基础的收集算法:标记-清除算法  

算法分为两个阶段,标记和清除,算法的标记阶段就是之前对对象是否死亡的判断。然后就清除可以确认死亡的对象。

算法主要是有两个缺点:

1.效率,标记和清除的效率都不高

2.空间问题,标记清除后会产生大量的不连续的内存碎片,当需要大量的内存空间的时候,如果无法找到足够的连续的内存,就会触发GC的垃圾回收动作。

2.复制算法

    算法将内存分了两块,每次只使用其中的一块。当这一块用完的时候,就将活着的对象复制到另外一块区域,然后将这块区域都进行回收。这样在内存分配的时候就不需要考虑内存碎片等情况,只需要移动堆顶的指针就可以了。但是这种算法实现的代价太高了。


    现在的商业虚拟机都采用这种算法来回收新生代,一般都是讲内存划分为一块较大的Eden空间和两个较小的Survivor空间,每次使用Eden和一个Survivor空间,然后将Eden和一个Survivor中还存活的对象复制到另一个Survivor中。在HotSpot虚拟机中默认Eden和Survivor的比例是8:1也就是说每次可以回收90%的空间。当然也可能存活的对象超过了10%那么就需要使用内存的担保分配。内存的分配担保一般都是借用老年代的内存空间,将现有存活的对象都放置到老年代中。

3.标记-整理算法

    复制算法在每次GC回收的时候,存活对象率较高的时候就不在适合。所以在老年代中一般不在选用复制算法(老年代中对象的回收率不高)。所以根据老年代的特点提出了标记-整理算法。同样是先标记,但是标记完成后,先让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。


    这样就会留出大量规整的内存空间。

4.分代收集算法

    根据对象的存活周期的不同将内存划分为几块。一般分为新生代和老年代。在新时代中一般选用复制回收算法,老年代中一般是标记-整理或者标记-清除。

三:对象的分配

    对象的分配,大部分在堆中(前面已经说到,对象的逃逸会导致在栈上分配),主要是分配在堆中的新生代Eden区上,如果启用了TLAB(本地线程分配缓冲)那么会优先在TLAB上分配。也可能直接分配到老年代中,分配的规则不是固定的,分配的细节取决于当前使用的哪一种垃圾收集器组合(下一章会说),还有虚拟机中与内存相关的参数的设置。

    大多数的情况下,对象在新生区Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(新生代GC,java对象很多都是朝生夕死,所以Minor GC非常频繁,一般回收速度也比较快)。

    设置-Xms20M、-Xmx20M、-Xmn10M 这个三个参数限制了java堆的大小为20M,不能扩展。其中10M分给新生代,剩余10M分给老年代。-XX:SurvivorRatio=8决定了新生代Eden区与Survivor区的空间比例是8:1。然后此时Eden是8M,Survivor两个1M,老年代10M。

    先放入3个2M的对象,在放入一4M的对象,发现Eden中没有内存了,就会触发一次Minor GC,但是Minor GC时发现无法将存活的对象放入Survivor中,所以只能通过担保机制将3个2M的对象提前转移到老年代中。GC结束后4M的对象成功的放入Eden中,此时内存所占Eden中4M,老年代中6M。

    担保机制是有一定风险的,此时需要老年代担保6M的对象,如果老年代中最大可用的连续内存大于等于6M(6M中可能有在下次Minor GC中会被回收的对象,这里拿最大和是为了保证安全性),那么Minor GC是安全的。但是如果不到6M,虚拟机就会去查看HandlePromotionFailure的设置值是否允许担保失败。如果允许失败就会检查老年代最大可用的连续空间是否大于之前晋升到老年代对象的平均值(这个值是动态变化的)。如果大于则就尝试进行一次Minor GC,这次Minor GC是有风险的。如果小于或者HandlePromotionFailure设置不允许则会进行一次Full GC(老年代的GC),来扩大老年代的担保空间。

    但是一些需要大量连续内存空间的java对象,最典型的就是长字符串和数组,这样就直接放入老年代中,避免在Eden区和Survivor区之间发生大量的内存复制。

    长期存活的对象也会进入老年代中,如何分辨什么对象为长期存活的对象。虚拟机为每个对象定义了一个对象年龄。如果对象在Eden出生并经过一次Minor GC后仍然存活的对象,并且能被Survivor容纳的话,就会被移动到Survivor空间中,而且设置对象的年龄为1,在Survivor中每经历一次Minor GC,年龄+1,如果年龄到15就会被移动到老年区。可以通过-XX:MaxTenuringThreshold设置移动到老年代的年龄。也可以动态判断对象的年龄来移动到老年区,一般默认的动态设置的年龄是如果Survivor中相同年龄对象个数的总和大于Survivor对象总和的一半,那么大于或者等于该年龄的对象会直接进入到老年代。

这一章说了如何判断对象是否存活和对象的分配(新生代,老年代),以及回收的一些算法。

努力吧,皮卡丘

猜你喜欢

转载自blog.csdn.net/yidan7063/article/details/79702386