本文所讲的是读了深入理解JAVA虚拟机这本书的垃圾回收部分章节,以及阅读大牛写的博客后对知识的总结(学习过程很枯燥)。
JVM虚拟机的自动内存管理,将程序中所需要的内存回收就像Spring框架的IOC/DI一样,不需要我们去管,JVM的自动内存管理会通过垃圾回收器来自动回收,但是既然是自动的,肯定没有人员手动回收那样准确高效,而且自动回收有时也会影响程序的性能。
那么既然垃圾回收是自动的,程序是通过什么方法知道这个对象是不是已经死亡了呢。在深入理解JVM虚拟机中提供了2种辨别对象是存是亡的方法.
引用计数法就是JVM为每个对象添加一个属性(引用计数器),如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。 如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1,当这个引用计数器为0的时候说明该对象没有被引用,JVM认定该对象已经死亡,可以进行回收。JVM就会自动回收掉当前对象的内存
当然为对象添加引用计数器也是有缺点的,首先我们需要实时的监控当前对象的引用更新信息,并进行频繁更新操作。引用计数器还有一大缺点就是无法处理循环对象。比如当前有A,B两个对象,A,B对象相互引用,但并没有其他对象去引用A,B 这时在引用计数器中2个对象的计数器都不为0,但实际上A,B两个对象都已经死亡了,但是JVM会认为啊A,B两个对象依旧存活,所以不会回收这2个对象的内存,这就有可能导致内存泄漏。
可达性分析法是目前JVM虚拟机主流的垃圾回收器采集算法,这个算法主要是将系列GC Roots作为初始的存活对象集合,然后从该集合出发,搜索所有能被该集合引用到的对象,并将其加入到该集合中,这个过程我们称之为标记(Mark) ,最终,未被探索到底的对象就是死亡的,是可以进行回收的。
GC Roots 可以理解为由堆外向堆内的引用:
① Java栈帧中的局部变量
② 已加载类的静态变量
③ JNI Handles
④ 已经启动且为停止的Java线程
可达性分析法可以解决引用计数器所不能解决的循环问题,即使A,B互相引用,只要从GC Roots 出发无法到达A,B两个对象那么可达性分析法便不会将他们放入存活对象集合当中,直接判定A,B两个对象已经死亡。
世界没有完美的东西,可达性分析法也是,它也是有缺点的,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为空) 或者漏报(将引用设置为未被访问过的对象),误报对程序影响可能还能接收,毕竟知识损失了一次垃圾回收的机会,漏报则很麻烦,因为漏报可能导致正在被引用的对象被回收,这样可能会导致JVM虚拟机崩溃。
Stop-the-world是什么,顾名思义,"暂定世界",在传统的JVM虚拟机中,垃圾回收算法处理的很简单粗暴,那便是Stop-the-World 停止除垃圾回收之外的所有工作,知道垃圾回收完成。这也就导致了垃圾回收所谓的暂停时间(GC pause)。
当然也不是说任何时间不计任何后果的情况下Stop-the-World。他的内部原理事通过一个叫“安全点(safepoint)”机制实现的,当JVM虚拟机收到Stop-the-World请求的时候,会等待所有线程都到达安全点,什么是到达安全点呢,就是说其他线程找到一个相对稳定的执行状态后,在这个状态下,JVM的堆栈不会发生变化的情况后才会真正的Stop-the-World.
举个例子,当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 Java 对象、调用 Java 方法 或者返回至原 Java 方法,那么JVM虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作 为同一个安全点,只要不离开这个安全点,Jvm 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码,由于本地代码需要通过 JNI 的 API 来完成上述三个操作,因此 Jvm 虚拟机仅需在 API 的入口处进 行安全点检测(safepoint poll),测试是否有其他线程请求停留在安全点里,便可以在必要的时候 挂起当前线程
除了执行 JNI 本地代码外,Java 线程还有其他几种状态:解释执行字节码、执行即时编译器生成的 机器码和线程阻塞。阻塞的线程由于处于 Java 虚拟机线程调度器的掌控之下,因此属于安全点。
其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程 可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间。
对于解释执行来说,字节码与字节码之间皆可作为安全点。Jvm 虚拟机采取的做法是,当有安全点 请求时,执行一条字节码便进行一次安全点检测
顾名思义,标记清除算法整体分为2个步骤。
① 标记算法:标记算法就是把所有需要回收的对象进行标记。文章上边已经讲述了通过程序计数法或者可达性分析法对存活或者死亡的对象进行标记。
② 清除算法:清除算法原理非常简单,直接把标记为死亡对象的内存直接通过JVM垃圾回收释放掉就没问题了。
标记-清除算法是垃圾回收机制中最基本的回收算法,它的原理非常简单,但是相对的效率就会出现问题。因为标记和清除这2个步骤的效率都不是很高,还有一个问题就是空间问题。
标记-清除算法会导致大量的不连续的内存碎片,空间碎片太多会导致以后在程序运行中如果遇到分配较大对象时候,需要找到足够的连续的内存时,不得不在此触发垃圾回收动作。因为触发垃圾回收动作程序会进行Stop-the-World,其他所有工作都会阻塞。有可能导致程序无响应问题。所以在我们应该尽量避免垃圾回收动作。
标记-清除算法是最基础的算法,因为后续的回收算法都是基于标记-清除的思路以及不足进行改进的。
复制算法整体改进了标记-回收算法的空间占用问题。
复制算法把内存划分为相同大小的2块(A,B),程序运行时只使用A内存,当发生垃圾回收的时候,会把使用A内存剩余的存活的对象复制到B内存中,之后再把A内存块一次性全部清理掉。并且把复制到B内存的对象按照顺序分配内存。
借鉴深度理解JAVA虚拟机(P70)所说 现在商用虚拟机都是采用复制算法来回收新生代。实验表明新生代中的对象98%是朝生西死的,所以不需要按照1:1的比例来划分内存空间而是将内存分成一个Eden的内存空间和2个较小的Survivor空间,每次使用一块Eden和Survivor空间,当回收时,将Eden和Survivor空间存活的对象复制到另一个Survivor空间中。然后一次性清理掉使用过的Eden和Survivor空间 空间分配比例为8:1 Eden空间为80% 2个Survivor各占百分之10。
问题来了,我们无法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要以来其他内存(老年代)进行分配担保
分配担保就好比我们去银行贷款,贷款过程我们需要抵押物。这时我们把老年代的空间进行抵押,当Survivor没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保进入老年代。
复制算法在新生代存活率比较高的情况下效率是很低的,因为需要进行叫多次的复制操作,并且复制算法如果不想浪费50%空间就需要进行分配担保,分配担保让对象直接进入老年代.
标记-整理算法和标记-清除算法过程基本一样,但是在清理方式有些不同,标记整理算法是先让所有存活的对象移动到一侧,然后直接清理存活对象边界以外的的内存空间。
目前JVM虚拟机都是采用分代收集,主要把Java堆分为新生代,和老年代这样的好处就是可以根据新生代,老年代的不同特点使用不同的收集算法,来提高效率。如新生代对象每次都很少存活就使用复制算法.而老年代可能都是在Survivor空间过来的,不会轻易死亡,存活率比较高,并且没有其他的空间给他进行分配担保,就只能使用 标记-清除算法 或者 标记-整理算法 来进行回收。
上面所讲的都是知识理论,垃圾收集器才是内存回收的具体实现。大家可以根据自己程序特点来组合不同年代的收集器
上述图片连线代表是可以组合使用
①Serial收集器:特性是单线程执行,在Serial进行回收的时候会暂停除自己之外的所有线程。
优点:简单高效(与其他收集器的单线程比较), 单个CPU的环境没有线程交互开销,Serial适用于Clinet模式下的虚拟机
缺点: 回收时会暂停除自己之外的所有线程。举例:玩游戏,玩到一半,游戏无响应几分钟。(体验极差)
②ParNew收集器:相当于Serial收集器的多线程版本,在对象分配和内存回收和Serial基本一样。
优点:可以与CMS收集器搭配使用,在多CPU环境下,ParNew的效率要比Serial更高
缺点:如Serial一样垃圾回收时会造成Stop-the-World
③ParallelScavenge收集器:特性时内部使用复制算法,又是并行的多线程收集器,可控制的吞吐量。
优点:并行多线程,精确控制吞吐量,可以根据需求来选择吞吐量(大/小)与用户线程暂停时间(长/短),在吞吐量和用户暂时时间可以做出权衡
缺点:不能用CMS搭配使用,控制了吞吐量但是GC的机制就变多了。所谓鱼和熊掌不可兼得。
①Serial Old收集器:使用标记-整理算法,Serial的老年代版本,同样是单线程
优点:简单高效(单线程环境下)
缺点:停顿时间长
②Parallel Old收集器:是Parallel Scavenge的老年代版本.使用标记-整理算法,多线程
优点:新生代使用Parallel Scavenge收集器与老年代Parallel组合使用效率更好,更可以注重吞吐量以及CPU资源敏感的场景。多线程收集器
缺点:垃圾回收时间依旧过长(GC)
③CMS收集器:通俗来讲就是最短时间完成GC,让用户基本感觉不到卡顿,使用标记-清除算法
CMS收集器收集时分为4步:1.初始标记,2.并发标记,3.重新标记,4.并发清除
CMS在初始标记与重新标记的时候仍需要Stop-the-Wrold,但是初始标记是对GC Roots能直接关联到的对象,速度很快。重新标记则会比初始标记时间稍微长一点,但比并发标记时间短。
优点:并发收集对象,低停顿。
缺点:对CPU资源敏感,大量占用CPU资源,无法处理浮动垃圾(标记之后,清除之前期间出现的死亡对象),可能会出发Full GC,如果仔细读过该文章应该知道这个缺点。没错就是CMS是基于标记-清除算法实现的,标记-清除算法在回收的时候会造成过的内存碎片,会给以后可能出现的大对象分配内存时造成麻烦。导致出发Full GC
③G1收集器:对CMS的缺点进行改进,使用标记-整理算法,并且可以控制停顿时间。支持并行并发
优点:支持并行并发:充分利用当前多核计算机性能。
分代收集:可以独自管理GC堆,根据不同的方式处理新创建的对象和存过一段时间的对象
空间整合:通过标记-整理算法,不会产生内存碎片
控制停顿时间:可以制定停顿时间在一个范围内。
由于G1收集器技术还未成熟,并且商用案例很少,所以暂不清除缺点。
对象的内存分配,往大方向讲时在堆上分配,对象主要分配在新生代的Eden空间,如果启动本地线程分配缓冲,将按线程有限在TLAB上分配,少数情况下可能会直接分配在老年代中,细节取决于哪种收集器组合以及JVM虚拟机相关参数配置。
大多数对象在Eden区分配,如Eden区空间不够,首先触发Minor GC(新生代垃圾回收),如空间还是不够存放对象那只能通过分配担保机制提前转到老年代中
大对象可能会直接进入老年代。大对象是值很长的字符串以及数组,会直接进入老年代,因为老年代只能Full GC,Full GC的速度比Minor GC 10慢10倍以上,所以尽量减少短命的大对象
长期存活的对象将进入老年代,一个对象在Eden空间经历过一次Minor GC后就会被转移到Survivor空间,并且为该对象设置年龄,对象在Survivor每熬过一次Minor GC年龄都会+1,直到该对象的年龄到一定程度(默认15岁)就会被转移到老年代。今生老年代的阀值可以通过参数-XX:MaxTenuringThresHold设置。
当然为了更好的使用不同程序的内存状况,虚拟机并不是永远的要求对象的年龄必须到达阀值才能晋升老年代,如果Survivor空间中相同年两所有对象的大小的综合大于Survivor对象的一半,年龄大于等于该年龄的对象就可以直接进入老年代,无需等到阀值要求的年龄。
文章到此就结束了,主要是参考书中以及各路大神的博客,让自己对JVM虚拟机垃圾回收方面的知识做一个巩固,如文章中有写的不对的地方希望各位大佬提醒一下。