0%

JVM - 自动内存管理

Java基于C++实现,与C++的一大区别在于它能够进行自动内存管理。

先从垃圾说起,所谓的垃圾就是无用的对象,主要存放在堆区中。对于C++来说,创建对象的时候需要malloc,用完对象之后需要free,以免无用对象占据内存空间,这一切的操作都是手动的。对于Java来说,通过Java虚拟机垃圾收集器可以自动化的实现对象内存的分配及回收

jvm-gc

那么,如何标识哪些内存对象是无用的呢?

判断对象是否应该存活的算法有:引用计数算法、根搜索算法。

引用计数算法,给每一个对象添加一个引用计数器,只要一个对象被引用了,那么计数值就加1,如果引用失效了,那么计数值就减1,直到为0,那么这个对象就可以被回收了,这种方式还是比较简单高效的。但是,如果存在循环引用引用计数算法就无能为力了,引用会一直存在,计数值不会为0,从而造成内存无法被回收。

jvm-ref-d

根搜索算法,通过一系列名为GC Roots的对象为起始点,从这些节点向下搜索,如果对象是可达的,那么这个对象就是存活的,反之,就是不存活的,这个对象就是可回收的。

jvm-ref-r

决定对象是否应该存活的直接影响因素就是引用,在Java中存在有四种引用:强引用、软引用、弱引用、虚引用。

强引用 普遍存在,类似Object obj = new Object(),只要对象引用还存在,对象的内存就不会被回收。
软引用 表示有用但非必需的对象,当内存不够的时候,软引用对象就可以被回收。
弱引用 表示非必需对象,只要发生了GC,弱引用对象就可能被回收。
虚引用 用于在对象被回收的时候能够收到通知。

在标识对象存活的基础上,需要利用垃圾收集算法来实现内存的回收。

首先是标记-清除算法,分为标记清除两个阶段。标记的对象是可回收对象,先进行标记,然后再去执行清除。这种算法的缺点就是会产生大量的内存碎片,使得连续内存不足,导致GC的触发,并且标记、清除阶段效率都不高。

jvm-ms

为了解决标记-清除算法的缺点,又产生了复制算法。将内存空间分为2个区域,各占50%,1个区域正常使用,1个区域空闲着,每次发生GC的时候,会将使用着的区域的存活对象复制到另一个区域,并按照顺序存放。通过这种方式,解决了内存碎片的问题,但同时又浪费了50%的内存空间。

jvm-gc-cp由于新生代的对象,98%左右都是朝生夕死的,将新生代分为Eden区、Surviror0区、Survivor1区,并且比例为8:1:1,使用复制算法也不至于浪费过多内存空间,并且不会产生内存碎片。

对于老年代里头的对象,一般存活率都是比较高的,如果采用标记-清除算法,那还不如复制算法呢,但如果采用复制算法,又因为老年代对象存活率高,频繁的移动内存中的对象,难免会造成回收效率的下降。因此,又产生了一种标记-整理算法,这种算法不会产生内存碎片,但效率比起复制算法来说也不高,但适用于老年代。

jvm-mc

根据以上信息可以得知,复制算法更适用于新生代的内存回收,而标记-清除算法标记-整理算法更适用于老年代的内存回收,因此,垃圾收集器基本都是基于分代收集算法,将内存区域划分为不同年代,按照每个区域合适的垃圾回收算法回收内存。

实际上,垃圾收集器在分代收集算法的基础上以串行、并行、并发方式提供。
新生代老年代
串行Serial收集器Serial Old收集器
并行ParNew收集器、Parallel Scavenge收集器Parallel Old收集器
并发CMS收集器
其它G1收集器G1收集器

新生代的内存回收(YGC、Minor GC、Young GC)使用的复制算法。Serial收集器是单线程的,适用于Client模式,而ParNew收集器与Serial收集器相比除了采用多线程没有多大区别,适用于Server模式,Parallel Scavenge收集器表面上看起来和ParNew收集器没有多大区别,但实际上Parallel Scavenge收集器更关注的是吞吐量,通过减少Stop The World时间来提高吞吐量,同时可能导致GC次数更加频繁

jvm-gc-new

老年代的内存回收(Major GC、FGC、Full GC同时也会回收新生代)使用的标记-整理算法、标记-清除算法。Serial Old收集器是单线程的,采用标记-整理算法,适用于Client模式。Parallel Old收集器是Parallel Scavenge收集器的老年代版本,是多线程的,注重吞吐量,采用的标记-整理算法。CMS收集器采用的标记-清除算法,注重低停顿,在初始标记重新标记阶段会Stop The World,而并发标记并发清除阶段与用户线程并行存在,由于采用的标记-清除算法,因此会产生内存碎片,导致出现Concurrent Mode Failure,触发Serial Old收集器来执行标记-整理。

jvm-gc-old在CMS收集器的基础上又产生了G1收集器,与之不同的是G1收集器采用的标记-整理算法,不会产生内存碎片并且没有明显的分代概念,而是将内存划分为若干个固定大小区域,可以在保证吞吐量的同时完成低停顿的内存回收,在回收内存的时候不会全区域的去回收,而是优先回收内存垃圾比较多的区域。

jvm-gc-g1

内存的分配也随着JDK的发展与各种技术的提升而更加智能。

对象优先在Eden区分配
大部分的对象都具备朝生夕死的特点,更适合在新生代中分配,并且Minor GC速度比较快。

大对象直接进入老年代
由于新生代采用的复制算法,Minor GC会比较频繁,因此大对象最好直接进老年代,避免发生频繁的内存复制。当然,也有一些朝生夕死的大对象,如果过多这种大对象进入了老年代可能会导致Major GC的频繁发生,甚至导致Full GC的出现。可以通过配置-XX:PretenureSizeThreshold参数来定义直接进入老年代的大对象的大小门槛。

长期存活对象将进入老年代
如果对象长期存活,那么可能是有用的,最好是晋升到老年代,否则随着多次的Minor GC会不断的被复制来复制去,同时也比较占据新生代的内存空间。可以通过配置-XX:MaxTenuringThreshold参数来修改新生代中的对象晋升到老年代的历经GC次数,默认是15次。

动态年龄判断
通过动态年龄判断,只要Surviror区中相同年龄的对象大小总和超过Surviror区大小的一半,那么就允许新生代对象不必历经MaxTenuringThreshold配置的GC次数提前晋升到老年代。

空间分配担保
在发生新生代Minor GC的时候,会去判断要晋升到老年代的对象大小总和是否超过老年代剩余空间的大小,如果超过了,并且配置了HandlePromotionFailure,那么就会进行Minor GC,否则的话,会执行一次Full GC。

逃逸分析、栈上分配
逃逸分析分为线程逃逸、方法逃逸。对于线程逃逸来说,如果一个对象只在一个线程中使用,那么这个对象就逃逸了,不需要在堆中分配内存。对于方法逃逸来说,如果一个对象只在方法内部使用,并且被外部方法所引用,那么这个对象就逃逸了,不需要在堆中分配内存。