Java虚拟机的自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。不过既然是自动机制,肯定没法做到像手动回收那般精准高效,而且还会带来不少与垃圾回收实现相关的问题。
引用计数法与可达性分析
在Java虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。这里便涉及了一个关键的问题:如何辨别一个对象是存是亡?
引用计数法(reference counting):它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,便可以被回收了
缺点:
-
需要额外的空间来存储计数器,以及繁琐的更新操作
-
无法处理循环引用对象
MyObject myObject1 = newMyObject(); //object1为 MyObject1的第一次引用 ,引用+1
MyObject myObject2 = newMyObject(); //object2为 MyObject2的第一次引用,引用+1
myObject1.ref = myObject2; //object1.ref 为 MyObject2的第二次引用,引用+1
myObject2.ref = myObject1; //object2.ref 为 MyObject1的第二次引用,引用+1
myObject1 = null; //MyObject1对象的引用-1
myObject2 = null; //MyObject2对象的引用-1
但是myObject1和 myObject2这俩个对象仍然还有一次引用(引用不是0)垃圾回收器就无法回收他们,就出现了循环引用而导致的内存泄漏问题
可达性分析算法:目前Java虚拟机的主流垃圾回收器,通过一系列称为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,则证明此对象不可用
上图中reference1 reference2 reference3都属于GC Roots,堆中对象实例3和5虽然互相引用但是没有任何GC Roots引用链 也就造成了不可达最终就会当做垃圾回收掉
GC Roots我们可以暂时理解为由堆外指向堆内的引用,GC Roots包括(但不限于)如下几种:
-
Java方法栈桢中的局部变量;
-
已加载类的静态变量;
-
JNI handles;
-
已启动且未停止的Java线程。
Stop-the-world以及安全点
在Java虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)
Java虚拟机中的Stop-the-world是通过安全点(safepoint)机制来实现的。当Java虚拟机收到Stop-the-world请求,它便会等待所有的线程都到达安全点,才允许请求Stop-the-world的线程进行独占的工作
安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析
举个例子,当Java程序通过JNI执行本地代码时,如果这段代码不访问Java对象、调用Java方法或者返回至原Java方法,那么Java虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。
只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
除了执行JNI本地代码外,Java线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。阻塞的线程由于处于Java虚拟机线程调度器的掌控之下,因此属于安全点。
其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间。
垃圾回收的三种方式
第一种标记清除法:
标记:垃圾回收器此时会找出内存哪些在使用中,哪些不是。垃圾回收器要检查完所有的对象,才能知道哪些有被引用,哪些没。如果系统里所有的对象都要检查,那这一步可能会相当耗时间。
清除:会把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象
-
优点:标记清除算法的特点就是简单直接,速度也非常块,特别适合可回收对象不多的场景
-
缺点:
-
会造成不连续的内存空间,空间碎片会导致后面的GC频率增加
-
性能不稳定,内存中需要回收的对象,当内存中大量对象都是需要回收的时候,通常这些对象可能比较分散,所以清除的过程会比较耗时,这个时候清理的速度就会比较慢了。
-
第二种标记压缩法:同样分为两个阶段,第一阶段标记,第二阶段即把存活的对象聚集到内存区域的起始位置,再清理掉边界以外的死亡对象,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
-
优点:解决内存碎片化的问题 适合存活对象多的场景
-
缺点:是三种之中性能最低的一种,因为标记压缩法在移动对象的时候不仅需要移动对象,还要额外的维护对象的引用的地址,这个过程可能要对内存经过几次的扫描定位才能完成,做的事情越多那么必然消耗的时间也越多
第三种复制法:即把内存区域分为两等分,分别用两个指针from和to来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to指针指向的内存区域中,并且交换from指针和to指针的内容。复制这种回收方式同样能够解决内存碎片化的问题。(下面的文章有详细介绍)
-
优点:每次清除针对的都是一整块内存,所以清除可回收对象的效率也比较高。解决内存碎片化
-
缺点:
-
堆空间的使用效率极其低下
-
存活对象多会非常耗时,因为复制移动对象的过程比较耗时 + 对象复制移动后需要维护对象的引用地址
-
需要担保机制,因为复制区总会有一块空的空间浪费而为了减少浪费空间太多,所以我们会把复制区的空间分配控制在很小的区间,但是空间太小又会产生一个问题,就是在存活的对象比较多的时候,这时复制区的空间可能不够容纳这些对象,这时就需要借一些空间来保证容纳这些对象,这种从其他地方借内存的方式我们称它为担保机制
-
当然,现代的垃圾回收器往往会综合上述几种回收方式,综合它们优点的同时规避它们的缺点
对象存活时间统计
(pmd中Java对象生命周期的直方图,红色的表示被逃逸分析优化掉的对象)
许多研究人员的假设:即大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长一段时间。
之所以要提到这个假设,是因为它造就了Java虚拟机的分代回收思想。简单来说,就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。
Java虚拟机可以给不同代使用不同的回收算法。对于新生代,我们猜测大部分的Java对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。
对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。
这时候,Java虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)
Java虚拟机的堆划分
Java虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为Eden区,以及两个大小相同的Survivor区。
默认情况下,Java虚拟机采取的是一种动态分配的策略(对应Java虚拟机参数-XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。
当然,你也可以通过参数-XX:SurvivorRatio来固定这个比例。但是需要注意的是,其中一个Survivor区会一直为空,因此比例越低浪费的堆空间将越高。
通常来说,当我们调用new指令时,它会在Eden区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的,否则,将有可能出现两个对象共用一段内存的事故。
-
新生代:一旦新生代内存满了,就会开始对死掉的对象,进行所谓的小型垃圾回收(Minor GC)过程。一片新生代内存里,死掉的越多,回收过程就越快;至于那些还活着的对象,根据所设置的阈值,并最终老到进入老年代内存。
-
老年代:用来保存长时间存活的对象。通常,设置一个阈值,当达到该年龄时,年轻代对象会被移动到老年代。最终老年代也会被回收。这个事件为 Major GC。
-
永久代:JDK8 的时候已经被彻底移除,取而代之的是元空间。永久代包含JVM用于描述应用程序中类和方法的元数据。永久代是由JVM在运行时根据应用程序使用的类来填充的。此外,Java SE类库和方法也存储在这里。
Major GC 也会触发STW(Stop the World)。通常,Major GC会慢很多,因为它涉及到所有存活对象。所以,对于响应性的应用程序,应该尽量避免Major GC。还要注意,Major GC的STW的时长受年老代垃圾回收器类型的影响。
MinorGC过程
首先,将任何新对象分配给 eden 空间。两个 survivor 空间都是空的。
当 eden 空间填满时,会触发轻微的垃圾收集
引用的对象被移动到第一个 survivor 空间。清除 eden 空间时,将删除未引用的对象
在下一次Minor GC中,Eden区也会做同样的操作。删除未被引用的对象,并将被引用的对象移动到Survivor区。然而,这里,他们被移动到了第二个Survivor区(S1)。
此外,第一个Survivor区(S0)中,在上一次Minor GC幸存的对象,会增加年龄,并被移动到S1中。待所有幸存对象都被移动到S1后,S0和Eden区都会被清空。注意,Survivor区中有了不同年龄的对象。
在下一次Minor GC中,会重复同样的操作。不过,这一次Survivor区会交换。被引用的对象移动到S0,。幸存的对象增加年龄。Eden区和S1被清空。
此幻灯片演示了 promotion。在较小的GC之后,当老化的物体达到一定的年龄阈值(在该示例中为8)时,它们从年轻一代晋升到老一代。
随着较小的GC持续发生,物体将继续被推广到老一代空间。
所以这几乎涵盖了年轻一代的整个过程。最终,将主要对老一代进行GC,清理并最终压缩该空间。
知识拓展
为什么需要Survivor区
Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生
Survivor为什么设置为两个
如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案
老年代占据着 2/3 的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。在内存担保机制下,无法安置的对象会直接进到老年代,以下几种情况也会进入老年代
-
大对象,指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。
-
长期存活对象,虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加 1 岁。当年龄增加到 15 岁时,这时候就会被转移到老年代。当然,这里的 15,JVM 也支持进行特殊设置。
-
动态对象年龄,虚拟机并不重视要求对象年龄必须到 15 岁,才会放入老年区,如果 Survivor 空间中相同年龄所有对象大小的总合大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进去老年区,无需等你“成年”。
原创文章,作者:kirin,如若转载,请注明出处:https://blog.ytso.com/272572.html