JVM的内存结构模型由方法区、堆、虚拟机栈、本地方法区和程序计数器五个部分组成。虚拟机栈、本地方法和程序计数器是线程私有的,随着方法或线程的结束,对应的内存也被回收了,而Java虚拟机规范也指出可不对方法区(jdk8叫元空间)做垃圾收集,因而可以说垃圾收集主要关注的是堆空间。
一、对象是否需要被回收
内存回收前需要先确定那些对象已经“死亡”(没有被其他对象引用)。判断对象的存活方式有2中,分别是“引用计数算法”和“可达性分析算法”。
引用计数算法的原理跟它的名字一样明了,通过在对象中维护一个计数器判断对象是否被使用。当有一个地方引用它,则计数器+1,当引用失效,则计数器-1,任何时刻计数器值为0的对象则是不可被使用的。该算法实现简单,效率也高,但无法处理对象之间的相互循环引用的问题。
public class ReferenceCounterGC {
public Object instance = null;
private static final int SIZE= 1024 * 1024;
//占点内存
private byte[] socket = new byte[2 * SIZE];
public static void main(String[] args) {
ReferenceCounterGC objA = new ReferenceCounterGC();
ReferenceCounterGC objB = new ReferenceCounterGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc(); //手动GC
}
}
通过GC日志发现,内存还是被回收了,可见JVM用的并非引用计数算法。
可达性分析算法,也叫根搜索算法。通过一些“GCRoots”对象做为起点,向下搜索对象,走过的路径称为“引用链”。当一个对象到达“GC Roots”没有任何引用链的时候,表明该对象不可用,可被回收。JVM规定可作为“GC Roots”对象的包括:虚拟机栈中的引用对象、方法区中类静态属性引用的对象,方法区中常量引用的对象,Native方法引用的对象。
二、对象逃脱
但事实上当一个对象不存在引用链时候,也不一定就会被回收。判断对象真正“死亡”还需要经过标记。
第一次标记会进行筛选,判断对象是否有必要执行finalize(),若对象没有覆盖finalize()或者finalize()方法已经被调用过,则被视作没有必要,对象会被直接回收。若有必要执行,该对象会被放置在一个F-Queue队列里,由一条低优先级的线程去执行finalize(),再回收内存。若此时对象获得新的引用链,则可逃脱被清除的命运,不过运行代价高,不确定性比较大,所以一般不推荐这种方法拯救对象。
三、关于引用
垃圾收集算法判定对象的存活其实是跟“引用”有关,一个对象存在被引用和没有被引用这两种状态。若对象没有存在引用状态,此时JVM的内存还很充足,是可以考虑保留对象在内存之中,等到内存不足时再回收。jdk通过定义不同等级的引用对这类“食之无味,弃之可惜”的对象进行划分。
不同等级的引用包含:强引用、软引用、弱引用、虚引用(也叫幻像引用),关于他们的介绍网上资料很多,这里就不再累述了,主要区别在于对象存活生命周期的不同。
四、内存分配策略
Java自动内存管理归结到底就两点:给对象分配内存以及回收对象的内存。内存区域分为新生代和老年代,新生代包括1个Eden区和2个Survivor。运行的时候可以通过添加对应参数分配指定内存空间,举个例子:
-Xms2048M -Xmx2048M -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m -XX:SurvivorRatio=8 -XX:+PrintGCDetails -Xloggc:../logs/gc.log -XX:+HeapDumpOnOutOfMemoryError
这里限制了Java堆大小为2048M,且不可扩展,Eden区和Survivor的空间比例是8:1。但整个新生代可用的内存空间其实只有9/10,因为新生代采用的是复制算法,需要保留一个Survivor作为轮换。
对象优先分配在Eden,大对象(很长的字符串以及数组)直接进入老年代。新生代连续的内存空间不足以存储对象时会触发Minor GC,老年代同理则会出发Full GC。出现Full GC会至少伴随一次Minor GC且速度比Minor GC慢10倍,因而应该尽量避免Full GC,减少Minor GC。但Minor GC时也是有可能会引发Full GC,因为存在内存空间分配担保。
新生代中的对象熬过一次Minor GC,年龄就会+1,当年龄大于一定程度(默认是15)就会进入老年代,可通过-XX:MaxTenuringThreshold设置。但也有特殊情况,当Surivor空间中相同年龄所有对象大小总和大于Surivor空间的一半,年龄大于等于该年龄的对象可以直接进入老年代,无须受到限制。
虚拟机也提供了参数 -XX:PretenureSizeThreshold,设置老年代分配的阈值,当对象大于该值则直接分配在老年代,可避免在Eden区和两个Survivor区之间发生大量的内存复制。
五、理解GC日志
最后以JDK1.8版本为例子说一说如何理解GC日志:
//这里是GC类型,Full GC表明这次GC发生了STW
//方括号内6115K->728K(9216K),含义:GC前该区域使用情况->GC后该区域使用情况(该区域总容量)
//方括号外6115K->736K(19456K),含义:GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)
//0.0039696 secs表示该区域GC占用时间,单位是秒
[GC (System.gc()) [PSYoungGen: 6115K->728K(9216K)] 6115K->736K(19456K), 0.0039696 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 728K->0K(9216K)] [ParOldGen: 8K->623K(10240K)] 736K->623K(19456K), [Metaspace: 3426K->3426K(1056768K)], 0.0083509 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
//新生代,包括eden和survivor的大小和使用情况
PSYoungGen total 9216K, used 166K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 2% used [0x00000000ff600000,0x00000000ff629998,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
//老年代内存使用信息
ParOldGen total 10240K, used 623K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 6% used [0x00000000fec00000,0x00000000fec9bcc0,0x00000000ff600000)
//元空间使用信息
Metaspace used 3441K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 375K, capacity 388K, committed 512K, reserved 1048576K
七、总结
以上便是对JVM内存分配策略的一个总结,学而时习之,不亦乐乎。很高兴你能看到了这里,如果对文章内容有疑问,欢迎留言、私信交流。