在上篇文章中简单介绍了JVM内部结构,线程隔离区域随着线程而生,随着线程而忘。线程共享区域因为是共享,所以可能多个线程都用到,不能轻易回收,与C语言不同,在Java虚拟机自动内存管理机制的帮助下,不再需要为每个new操作去写配对的delte/free代码,能够帮助程序员更好的编写代码。那么JVM是如何进行对象内存分配以及回收分配给对象内存呢?
几乎所有的对象实例都分配在堆中,为了进行高效的垃圾回收,虚拟机把堆划分成新生代(Young Generation)、老年代(Old Generation)。
新生代又分为1个Eden区和2个survivor区(S0,S1),Eden区与Survivor区的内存大小比例默认为8:1。
Eden伊甸园,在大多数情况下对象优先在Eden区中分配
Survivor幸存者,当Eden区没有足够内存进行分配,会触发一次Minor GC,会将幸存的对象移动到内存区域S0区域,并清空Eden区域。当再次发生Minor GC时,将Eden和S0中幸存的对象移动到S1内存区域。
幸存对象会反复在S0和S1之间移动,当对象从Eden移动到Survivor或者在Survivor之间移动时,对象的GC年龄自动累加,当GC年龄超过默认阈值15时,会将该对象移动到老年代,可以通过参数-XX:MaxTenuringThreshold 对GC年龄的阈值进行设置。
除了长期存活的对象会分配到老年代,还有以下情况对象会分配到老年代:
①大对象(需要大量连续内存空间的Java对象)直接进入老年代,可以通过参数
-XX:PretenureSizeThreshold设定对象大小阈值,超过其值进入老年代
②若Survivor区域中所有相同GC年龄的对象大小超过Survivor空间的一半,年龄不小于该年龄的对象就直接进入老年代
假设Java堆中内存是完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。
事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。
对象创建是一个非常频繁的行为,进行堆内存分配时还需要考虑多线程并发问题,可能出现正在给对象A分配内存,指针或记录还未更新,对象B又同时分配到原来的内存,解决这个问题有两种方案:
1、采用CAS保证数据更新操作的原子性;
2、把内存分配的行为按照线程进行划分,在不同的空间中进行,每个线程在Java堆中预先分配一个内存块,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB);
如何判断哪些对象占用的内存需要回收?虚拟机有如下方法:
给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;当计数器为0时就代表此对象已死,需要回收。
此方法无法解决对象之间相互引用的问题
public class ReferenceCountingGC {
public Object instance = null; private static final int _1MB = 1024 * 1024; /** * 方便GC能看清楚是否被回收 */ private byte[] bigSize = new byte[2 * _1MB]; public static void testGC(){ ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; System.gc(); }
}
结果:
[GC 6758K->632K(124416K), 0.0016573 secs]
[Full GC 632K->530K(124416K), 0.0148864 secs]
从结果可以看出这两个对象依然被回收通过一些节点开始搜索,当一个对象到 GC Roots 没有任何引用链(通过路径)时,代表该对象可以被回收
GC Roots的对象包括:
①本地变量表中引用的对象
②方法区中类静态属性引用的对象
③方法区中常量引用的对象
④Native方法引用的对象
判定一个对象是否可回收,至少要经历两次标记过程:
①若对象与GC Roots没有引用链,则进行第一次标记
②若此对象重写了finalize()方法,且还未执行过,那么它会被放到F-Queue队列中,并由一个虚拟机自动创建的、低优先级的Finalizer线程去执行此方法(并非一定会执行)。finalize方法是对象逃脱死亡的最后机会,GC对队列中的对象进行第二次标记,若该对象在finalize方法中与引用链上的任何一个对象建立联系,那么在第二次标记时,该对象会被移出"即将回收"集合。
自我救赎示例:
public class FinalizeGC { public static FinalizeGC obj; public void isAlive() { System.out.println("yes, i am still alive"); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("method finalize executed"); obj = this; } public static void main(String[] args) throws Exception { obj = new FinalizeGC(); // 第一次执行,finalize方法会自救 obj = null; System.gc(); Thread.sleep(500); if (obj != null) { obj.isAlive(); } else { System.out.println("I'm dead"); } // 第二次执行,finalize方法已经执行过 obj = null; System.gc(); Thread.sleep(500); if (obj != null) { obj.isAlive(); } else { System.out.println("I'm dead"); } } }
结果:
method finalize executed
yes, i am still alive
I'm dead
从结果来看,第一次GC时,finalize方法执行,在回收之前成功自我救赎
第二次GC时,finalize方法已经被JVM调用过,所以无法再次逃脱
知道了如何判断对象为"垃圾",接下来就是如何清理这些对象
对"垃圾"对象进行标记并删除
算法缺点:
效率问题,标记和清除这个两个过程的效率都不高
空间问题,标记清除后会产生大量不连续的内存碎片,不利于大对象分配
将可用内存一分为二,每次只用其中一块,当一块内存用完了,就把存活的对象复制到另一块去,并清空已使用过的内存空间。相对于复制算法不需要考虑内存碎片等复杂问题,只要移动指针,按顺序分配内存即可。
缺陷:总有一块空闲区域,空间浪费
在老年代中,对象存活率较高,复制算法效率较低。基于标记-清除,让所有存活对象都移动到一端,然后直接清理边界以外的内存。
Serial是一个单线程,基于复制算法,串行GC的新生代收集器。在GC时必须停掉所有其他工作线程直到它收集完成。对于单CPU环境来说,Serial由于没有线程交互的开销,可以很高效的进行垃圾收集,是Clinet模式下新生代默认的收集器
ParNew是Serial收集器的多线程版本(并行GC),除了使用多条线程进行GC以外,其余行为与Serial一样
Parallel Scavenge是一个多线程,基于复制算法,并行GC的新生代收集器。其关注点在于达到一个可控的吞吐量。
吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾收集时间)
Parallel Scavenge提供两个参数用于精确控制吞吐量:
① -XX:MaxGCPauseMillis 控制垃圾收集的最大停顿时间
② -XX:GCTimeRatio 设置吞吐量大小
Serial Old是基于标记-整理算法的Serial收集器的老年代版本,是Client模式下老年代默认收集器
CMS收集器是一种以获取最短回收停顿时间为目标的老年代收集器(并发GC),基于标记-清除算法,整个过程分为以下4步:
①初始标记:只标记与GC Roots直接关联到的对象,仍然会Stop The World
②并发标记:进行GC Roots Tracing的过程,可以和用户线程一起工作
③重新标记:用于修正并发标记期间因用户程序继续运行而导致标记产生变动的那部分对象记录,此过程会暂停所有线程,但停顿时间,比初始标记阶段稍长,远比并发标记的时间短
④并发清理:清理"垃圾对象",可以与用户线程一起工作
CMS收集器缺点:
①对CPY资源非常敏感, 在并发阶段,虽然不会导致用户停顿,但是会占用一部分线程资源(或者说CPU资源)而导致应用程序变慢,总吞吐量降低
②无法处理浮动垃圾,在并发清理阶段用户线程还在运行依然会产生新的垃圾,这部分垃圾出现在标记过程之后,只能在下一次GC时回收
③CMS基于标记-清除算法实现,即可能收集结束会产生大量空间碎片,导致出现老年代还有很大空间剩余,不得不提前触发一次Full GC
G1垃圾收集器被视为JDK1.7中HotSpot虚拟机的一个重要进化特征(JDK9默认垃圾收集器),基于"标记-整理"算法实现。
G1收集器优点:
①并行与并发:充分利用多CPU来缩短Stop-The-World(停用户线程)停顿时间
②分代收集:不需要其他收集器配合,采用不同的方式处理新建的对象和已经存活一段时间、熬过多次GC的旧对象来获取更好的收集效果
③空间整合:因为基于"标记-整理"算法实现,避免了内存空间碎片问题,有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发一次GC
④可预测停顿:G1建立了可预测的停顿时间模型,能让使用者明确指定在M毫秒时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
G1运行步骤:
①初始标记:只标记与GC Roots直接关联到的对象,仍然会Stop The World
②并发标记:从GC Root开始对堆中对象进行可达性分析,找出存活对象,可与用户线程并发执行
③最终标记:修正在并发标记期间因用户程序继续运作而导致标记产生变动的那部分标记记录,虚拟机将对象变化记录在线程Remembered Set Logs里,并合并到Remembered Set中,此过程会暂停所有线程。
④筛选回收:对各个Region的回收价值与成本进行排序,根据用户所期望的GC停顿时间来指定回收计划
注:并行:多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
并发:用户线程与垃圾收集线程同时执行(不一定是并行,可能交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上