Java gc中的那些事
我们已经知道Java堆是被所有线程共享的一块内存区域,所有对象实例和数组都在堆栈进行内存分配。为了进行高效的垃圾回收,虚拟机把堆内存划分成新生代年代(旧一代)和永久代(永久代)3个区域。
新生代
新生代由Eden与Survivor Space(S0,S1)构成,大小通过-Xmn参数指定,Eden与Survivor Space的内存大小比例默认为8:1,可以通过-XX:SurvivorRatio参数指定,比如新生代为10M时,伊甸园分配8M,S0和S1各分配1M。
Eden:希腊语,意思为伊甸园,在圣经中,伊甸园含有乐园的意思,根据“旧约·创世纪”记载,上帝耶和华照自己的形像造了第一个男人亚当,再用亚当的一个肋骨创造了一个女人夏娃,并安置他们住在了伊甸园。
大多数情况下,对象在Eden中分配,当Eden没有足够空间时,会触发一次Minor GC,虚拟机提供了-XX:+ PrintGCDetails参数,告诉虚拟机在发生垃圾回收时打印内存回收日志。
Survivor:意思为幸存者,是新生代和老年代的缓冲区域。
当新生代和老年代的缓冲区域。当新生代发生GC(Minor GC)时,会将存活的对象移动到S0内存区域,并清空Eden区域,当再次发生Minor GC时,将伊甸园和S0中存活的对象移动到S1内存区域。
存活对象会反复在S0和S1之间移动,当对象从伊甸园移动到幸存者或者在幸存者之间移动时,对象的GC年龄自动累加,当GC年龄超过默认阈值15时,会将该对象移动到老年代,可以通过参数-XX:MaxTenuringThreshold对GC年龄的阈值进行设置。
老年代
老年代的空间大小即-Xmx与-Xmn两个参数之差,用于存放经过几次Minor GC之后依旧存活的对象。当老年代的空间不足时,会触发主GC / Full GC,速度一般比小GC慢10倍以上。
永久代
在JDK8之前的热点实现中,类的元数据如方法数据,方法信息(字节码,栈和变量大小),运行时常量池,已确定的符号引用和虚方法表等被保存在永久代中,32位默认永久代的大小为64M,64位默认为85M,可以通过参数-XX:MaxPermSize参数进行设置,一旦类的元数据超过了永久代大小,就会抛出OOM异常。
虚拟机团队在JDK8的热点中,把永久代从Java的堆中移除了,并把类的元数据直接保存在本地内存区域(堆外内存),称之为元空间。
这样做有什么好处?
有经验的同学会发现,对永久代的调优过程非常困难,永久代的大小很难确定,其中涉及到太多因素,如类的总数,常量池大小和方法数量等,而且永久代的数据可能会随着每一次Full GC而发生移动。
而在JDK8中,类的元数据保存在本地内存中,元空间的最大可分配空间就是系统可用内存空间,可以避免永久代的内存溢出问题,不过需要监控内存的消耗情况,一旦发生内存泄漏,会占用大量的本地内存。
ps:JDK7之前的HotSpot,字符串常量池的字符串被存储在永久代中,因此可能导致一系列的性能问题和内存溢出错误。在JDK8中,字符串常量池中只有保存字符串的引用。
如何判断对象是否存活
GC动作发生之前,需要确定堆内存中哪些对象是存活的,一般有两种方法:引用计数法和可达性分析法。
1,引用计数法
在对象上添加一个引用计数器,每当有一个对象引用它时,计数器加1,当使用完该对象时,计数器减1,计数器值为0的对象表示不可能再被使用。
引用计数法实现简单,判定高效,但不能解决对象之间相互引用的问题。
public class GCtest {
private Object instance = null;
private static final int _10M = 10 * 1 << 20;
// 一个对象占10M,方便在GC日志中看出是否被回收
private byte[] bigSize = new byte[_10M];
public static void main(String[] args) {
GCtest objA = new GCtest();
GCtest objB = new GCtest();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}
通过添加-XX:+PrintGC参数,运行结果:
[GC (System.gc()) [PSYoungGen: 26982K->1194K(75776K)] 26982K->1202K(249344K), 0.0010103 secs]
从GC日志中可以看出objA和objB虽然相互引用,但是它们所占的内存还是被垃圾收集器回收了。
2,可达性分析法
通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索路径称为“引用链”,以下对象可能为GC Roots:
- 本地变量表中引用的对象
- 方法区中静态变量引用的对象
- 方法区中常量引用的对象
- 本机方法引用的对象
当一个对象到GC Roots没有任何引用链接,意味着该对象可以被回收。
图片
在可达性分析法中,判定一个对象objA是否可回收,至少要经历两次标记过程:
1,如果对象objA到GC Roots没有引用链,则进行第一次标记
。2,如果对象objA重写了finalize()方法,并且还未执行过,那么objA会被插入到F队列队列中,由一个虚拟机自动创建的,低优先级的终结线程触发其的finalize()方法.finalize()方法是对象逃脱死亡的最后机会,GC会对队列中的对象进行第二次标记,如果objA在敲定()方法中与引用链上的任何一个对象建立联系,那么在第二次标记时,objA会被移出“即将回收”集合。
看看具体实现
public class FinalizerTest {
public static FinalizerTest object;
public void isAlive() {
System.out.println("I'm alive");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("method finalize is running");
object = this;
}
public static void main(String[] args) throws Exception {
object = new FinalizerTest();
// 第一次执行,finalize方法会自救
object = null;
System.gc();
Thread.sleep(500);
if (object != null) {
object.isAlive();
} else {
System.out.println("I'm dead");
}
// 第二次执行,finalize方法已经执行过
object = null;
System.gc();
Thread.sleep(500);
if (object != null) {
object.isAlive();
} else {
System.out.println("I'm dead");
}
}
}
执行结果:
method finalize is running I'm alive I'm dead
从执行结果可以看出:
第一次发生GC时,最终化方法的确执行了,并且在被回收之前成功逃脱;
第二次发生GC时,由于最终化方法只会被JVM调用一次,对象被回收。
当然了,在实际项目中应该尽量避免使用的finalize方法。
收集算法
垃圾收集算法主要有:标记 - 清除,复制和标记 - 整理。
1,标记 - 清除算法
。回收对待的对象进行标记
算法缺点:效率问题,标记和清除过程效率都很低;空间问题,收集之后会产生大量的内存碎片,不利于大对象的分配。
2,复制算法
复制算法将可用内存划分成大小相等的两块甲和B,每次只使用其中一块,当甲的内存用完了,就把存活的对象复制到B,并清空甲的内存,不仅提高了标记的效率,因为只需要标记存活的对象,同时也避免了内存碎片的问题,代价是可用内存缩小为原来的一半。
3,标记 - 整理算法
在老年代中,对象存活率较高,复制算法的效率很低在标记 - 整理算法中,标记出所有存活的对象,并移动到一端,然后直接清理边界以外的内存。
对象标记过程
在可达性分析过程中,为了准确找出与GC Roots相关联的对象,必须要求整个执行引擎看起来像是被冻结在某个时间点上,即暂停所有运行中的线程,不可以出现对象的引用关系还在不断变化的情况。
如何快速枚举GC Roots?
GC Roots主要在全局性的引用(常量或类静态属性)与执行上下文(本地变量表中的引用)中,很多应用仅仅方法区就上百兆,如果进行遍历查找,效率会非常低下。
在热点中,使用一组称为OopMap的数据结构进行实现。类加载完成时,热点把对象内什么偏移量上是什么类型的数据计算出来存储到OopMap中,通过JIT编译出来的本地代码,也会记录下栈和寄存器中哪些位置是引用.GC发生时,通过扫描OopMap的数据就可以快速标识出存活的对象。
如何安全的GC?
线程运行时,只有在到达安全点(Safe Point)才能停顿下来进行GC。
基于OopMap的数据结构,HotSpot可以快速完成GC Roots的遍历,不过,Hotspot并不会为每条指令都生成对应的OopMap,只会在安全点处记录这些信息。
所以如果太多,可能导致运行时的性能问题。如果太多可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会选择一些执行时间较长的指令作为安全点,如方法调用,循环跳转和异常跳转等。
发生GC时,如何让所有线程跑到最近的安全点再暂停?
当发生GC时,不直对线程进行中断操作,而是简单的设置一个中断标志,每个线程运行到安全点的时候,主动去轮询这个中断标志,如果中断标志为真,则将自己进行中断挂起。
这里忽略了一个问题,当发生GC时,运行中的线程可以跑到安全点后进行挂起,而那些处于睡眠或阻塞状态的线程在此时无法响应JVM的中断请求,无法到安全点处进行挂起,针对这种情况,可以使用安全区域(Safe Region)进行解决。
Safe Region是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。
1,当线程运行到Safe Region的代码时,首标标识已经进入了安全区域,如果这段时间内发生GC,JVM会忽略标识为安全区域状态的线程;
2,当线程即将离开安全区域时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;
垃圾收集器
Java的虚拟机规范并没有规定垃圾收集器应该如何实现,用户可以根据系统特点对各个区域所使用的收集器进行组合使用。
上图展示了7种不同分代的收集器,如果两两之间存在连线,说明可以组合使用。
如图1所示,串行收集器(串行GC)
Serial是一个采用单线程并且基于复制算法工作在新生代的收集器,进行垃圾收集时,必须暂停其他所有的工作线程。对于单CPU环境来说,Serial由于没有线程交互的开销,可以很高效的进行垃圾收集动作,是客户端模式下新生代默认的收集器。
2,ParNew收集器(并行GC)
ParNew其实是串行的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为与串行一样。
3,Parallel Scavenge收集器(并行回收GC)
Parallel Scavenge是一个采用多线程基础复制算法并工作在新生代的收集器,其关注点在于达到一个可控的吞吐量,经常被称为“吞吐量优先”的收集器。
吞吐量=用户代码运行时间/(用户代码运行时间+垃圾收集时间)
Parallel Scavenge提供了两个参数用于精确控制吞吐量:
1,-XX:MaxGCPauseMillis设置垃圾收集的最大停顿时间
2,-XX:GCTimeRatio设置吞吐量大小
4,Serial Old收集器(串行GC)
Serial Old是一个采用单线程基于标记 - 整理算法并工作在老年人的收集器,是客户端模式下老年人默认的收集器。
5,Parallel Old收集器(并行GC)
Parallel Old是一个采用多线程基于标记 - 整理算法并工作在老年的收集器。在注重吞吐量以及CPU资源敏感的场合,可以优先考虑Parallel Scavenge和Parallel Old的收集器组合。
6,CMS收集器(并发GC)
CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器,工作于老年代,基于“标记 - 清除”算法实现,整个过程分为以下4步:
1,初始标记:这个过程只是标记以下GC Roots能够直接关联的对象,但是仍然会停止世界;
2,并发标记:进行GC Roots Tracing的过程,可以和用户线程一起工作
。3,重新标记:用在修正并发标记期间由于用户程序继续运行而导致标记产生变动的那部分记录,这个过程会暂停所有线程,但其停顿时间远比并发标记的时间短;
4,并发清理:可以和用户线程一起工作。
CMS收集器的缺点:
1,对CPU资源比较敏感,在并发阶段,虽然不会导致用户线程停顿,但是会占用一部分线程资源,降低系统的总吞吐量。
2,无法处理浮动垃圾,在并发清理阶段,用户线程的运行依然会产生新的垃圾对象,这部分垃圾只能在下一次GC时收集。
3,CMS是基于标记-清除算法实现的,意味着收集结束后会造成大量的内存碎片,可能导致出现老年代剩余空间很大,却无法找到足够大的连续空间分配当前对象,不得不提前触发一次Full GC。
JDK1.5实现中,当老年代空间使用率达到68%时,就会触发CMS收集器,如果应用中老年代增长不是太快,可以通过-XX:CMSInitiatingOccupancyFraction参数提高触发百分比,从而降低内存回收次数提高系统性能。
JDK1.6实现中,触发CMS收集器的阈值已经提升到92%,要是CMS运行期间预留的内存无法满足用户线程需要,会出现一次“并发模式失败”失败,这是虚拟机会启动Serial Old收集器对老年代进行垃圾收集,当然,这样应用的停顿时间就更长了,所以这个阈值也不能设置的太高,如果导致了“并发模式失败”失败,反而会降低性能,至于如何设置这个阈值,还得长时间的对老年代空间的使用情况进行监控。
7,G1收集器
G1(Garbage First)是JDK1.7提供的一个工作在新生代和老年代的收集器,基于“标记 - 整理”算法实现,收集结束后可以避免内存碎片问题。
G1优点:
并行与并发:充分利用多CPU来缩短停止世界的停顿时间;
2,分代收集:不需要其他收集配合就可以管理整个的Java堆,采用不同的方式处理新建的对象,已经存活一段时间和经历过多次GC的对象获取更好的收集效果;
3,空间整合:与CMS的”标记-清除”算法不同,G1在运行期间不会产生内存空间碎片,有利于应用的长时间运行,且分配大对象时,不会导致由于无法申请到足够大的连续内存而提前触发一次Full GC;
4,停顿预测:G1中可以建立可预测的停顿时间模型,能让使用者明确指定在中号毫秒的时间片段内,消耗在垃圾收集上的时间不得超过Ñ毫秒。
使用G1收集器时,Java的堆的内存布局与其他收集器有很大区别,整个Java的堆会被划分为多个大小相等的独立区域地区,新生代和老年代不再是物理隔离了,都是一部分区域(不需要连续)的集合.G1会跟踪各个地区的垃圾收集情况(回收空间大小和回收消耗的时间),维护一个优先列表,根据允许的收集时间,优先回收价值最大的区域中,避免在整个的Java堆上进行全区域的垃圾回收,确保了G1收集器可以在有限的时间内尽可能收集更多的垃圾。
不过问题来了:使用G1收集器,一个对象分配在某个区域中,可以和Java的堆上任意的对象有引用关系,那么如何判定一个对象是否存活,是否需要扫描整个Java的堆其实这个问题在之前收集器中也存在,如果回收新生代的对象时,不得不不同时扫描老年代的话,会大大降低Minor GC的效率。
针对这种情况,虚拟机提供了一个解决方案:G1收集器中区域之间的对象引用关系和其他收集器中新生代与老年代之间的对象引用关系被保存在Remenbered Set数据结构中,用来避免全堆扫描.G1中每个区域都有一个对应的Remenbered Set,当虚拟机发现程序对参考类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查参考引用的对象是否处于相同的区域中,如果不是,则通过CardTable把相关引用信息记录到被引用对象所属区域的Remenbered Set中。
在互联网公司面试中,架构的底层一定是面试官会问问的问题,针对面试官一般会提到的问题,我录制了一些分布式,微服务,性能优化等技术点底层原理的录像视频,加群619881427可以免费获取这些录像,里面还有些分布式,微服务,性能优化,春天设计时,MyBatis的等源码知识点的录像视频。这些视频都是 找一些资深架构师朋友一起录制出来的,这些视频帮助以下几类程序员:
1.对现在的薪资不满,想要跳槽,却对自己的技术没有信心,不知道如何面对面试官。
2.想从传统行业转行到互联网行业,但没有接触过互联网技术。
3.工作1 - 5年需要提升自己的核心竞争力,但学习没有系统化,不知道自己接下来要学什么才是正确的,踩坑后又不知道找谁,百度后依然不知所以然。
4.工作5 - 10年无法突破技术瓶颈(运用过很多技术,在公司一直写着业务代码,却依然不懂底层实现原理)
如果你现在正处于我上述所说的几个阶段可以加下我的群来学习。而且我也能够提供一些面试指导,职业规划等建议。