简述
在上篇文章中简单介绍了JVM内部结构,线程隔离区域随着线程而生,随着线程而忘。线程共享区域因为是共享,所以可能多个线程都用到,不能轻易回收,与C语言不同,在Java虚拟机自动内存管理机制的帮助下,不再需要为每个new操作去写配对的delte/free代码,能够帮助程序员更好的编写代码。那么JVM是如何进行对象内存分配以及回收分配给对象内存呢?
内存分配
几乎所有的对象实例都分配在堆中,为了进行高效的垃圾回收,虚拟机把堆划分成新生代(Young Generation)、老年代(Old Generation)。
新生代
新生代又分为1个Eden区和2个survivor区(S0,S1),Eden区与Survivor区的内存大小比例默认为8:1。
幸存对象会反复在S0和S1之间移动,当对象从Eden移动到Survivor或者在Survivor之间移动时,对象的GC年龄自动累加,当GC年龄超过默认阈值15时,会将该对象移动到老年代,可以通过参数-XX:MaxTenuringThreshold 对GC年龄的阈值进行设置。
老年代
除了长期存活的对象会分配到老年代,还有以下情况对象会分配到老年代:
①大对象(需要大量连续内存空间的Java对象)直接进入老年代,可以通过参数
-XX:PretenureSizeThreshold设定对象大小阈值,超过其值进入老年代
②若Survivor区域中所有相同GC年龄的对象大小超过Survivor空间的一半,年龄不小于该年龄的对象就直接进入老年代
分配方法
对象创建是一个非常频繁的行为,进行堆内存分配时还需要考虑多线程并发问题,可能出现正在给对象A分配内存,指针或记录还未更新,对象B又同时分配到原来的内存,解决这个问题有两种方案:
1、采用CAS保证数据更新操作的原子性;
2、把内存分配的行为按照线程进行划分,在不同的空间中进行,每个线程在Java堆中预先分配一个内存块,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB);
内存回收
如何判断哪些对象占用的内存需要回收?虚拟机有如下方法:
此方法无法解决对象之间相互引用的问题
结果: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的对象包括:
①本地变量表中引用的对象
②方法区中类静态属性引用的对象
③方法区中常量引用的对象
④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调用过,所以无法再次逃脱
垃圾回收算法
知道了如何判断对象为"垃圾",接下来就是如何清理这些对象
算法缺点:
效率问题,标记和清除这个两个过程的效率都不高
空间问题,标记清除后会产生大量不连续的内存碎片,不利于大对象分配
缺陷:总有一块空闲区域,空间浪费
垃圾收集器
垃圾收集器组合:
吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾收集时间)
Parallel Scavenge提供两个参数用于精确控制吞吐量:
① -XX:MaxGCPauseMillis 控制垃圾收集的最大停顿时间
② -XX:GCTimeRatio 设置吞吐量大小
①初始标记:只标记与GC Roots直接关联到的对象,仍然会Stop The World
②并发标记:进行GC Roots Tracing的过程,可以和用户线程一起工作
③重新标记:用于修正并发标记期间因用户程序继续运行而导致标记产生变动的那部分对象记录,此过程会暂停所有线程,但停顿时间,比初始标记阶段稍长,远比并发标记的时间短
④并发清理:清理"垃圾对象",可以与用户线程一起工作
CMS收集器缺点:
①对CPY资源非常敏感, 在并发阶段,虽然不会导致用户停顿,但是会占用一部分线程资源(或者说CPU资源)而导致应用程序变慢,总吞吐量降低
②无法处理浮动垃圾,在并发清理阶段用户线程还在运行依然会产生新的垃圾,这部分垃圾出现在标记过程之后,只能在下一次GC时回收
③CMS基于标记-清除算法实现,即可能收集结束会产生大量空间碎片,导致出现老年代还有很大空间剩余,不得不提前触发一次Full GC
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上
感谢
《深入理解JAVA虚拟机》
https://www.jianshu.com/p/eaef248b5a2c