JVM-垃圾回收机制
一、引言
首先在Java内存运行时的各个部分,其中程序计数器、虚拟机栈和本地方法栈三个区域随线程而生,随线程而死,我们知道一个线程主要有两部分来存储信心,一部分是各自的栈,就是上面提到的那几个东西,另一部分就是共享内存,如果连续到JMM(JAVA内存模型的话,就是保证数据可见性的那个共享数据的内存地址)。而另一部分JAVA堆和方法去就不一样了,一个接口中的多个实现类需要的内存不一样,一个方法中的多个分支需要的内存也不一样,我们只能在程序运行期间才能知道创建哪些对象。这部分的对象创建和回收都是动态的。所以接下来讲一下JVM是怎么样来判断一个对象要把他干掉了,什么时候干掉。
二、回收JAVA堆对象
判断对象是否存活:
要想回收一个对象,首先要判断这个对象是否可以回收吧,而对象能回收的条件就是这个对象是否已经没有被引用了。
2.1 引用计数算法
在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。
引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的 Java 虚拟机里没有选用引用计数算法来管理内存,主要是因为它很难解决对象之间循环引用的问题。
举个例子,举个栗子????对象 objA 和 objB 都有字段 instance,令 objA.instance = objB 并且 objB.instance = objA,由于它们互相引用着对方,导致它们的引用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。
2.2 可达性分析算法
这个算法的基本思路就是通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始往下搜索,搜索所经过的路径称为引用链。当一个对象到GC roots没有任何对象引用链相连,则证明此对象是不可用的,其实也就是跟GC Root不在同一个图里面。
可作为GC Roots的有:
Java 虚拟机栈(栈帧中的本地变量表)中引用的对象
本地方法栈中引用的对象
方法区中常量引用的对象
方法区中类静态属性引用的对象
那么这就有一个问题了,什么时候开始对这个GC roots做检查呢,这里就涉及到一个安全点和OopMap的问题了.
2.2.1 oopMap
我们从前面可以知道可以作为安全点的有上面四种可以作为GC Roots,如果要逐一检查这里面的每个对象的引用,那消耗的时间也太大了,这也就是保守式GC,所以我们一般不做全部的扫描,而是虚拟机应当有办法直接得知哪些地方存放着对象引用,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用,而这个过程就是使用一组称为OopMap的数据结构来达到这个目的的。
那么好了,现在我们知道要在哪个位置开始检查,这时候我们又有一个问题了,当我们在做检查的时候,线程也在运作,对象之间的引用关系也在变化,那这样子你还能做检查吗,所以在做GC的时候系统应该停止执行线程(sun将这件事情称为“Stop the world”)。还有另外一个问题:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap的话,那就会需要大量的额外空间,这时我们考虑在特定的位置生成OopMap,这个特定的位置就是安全点。
2.2.2 安全点
作为安全点的主要有以下区域:
1、循环的末尾
2、方法临返回前 / 调用方法的call指令后
3、可能抛异常的位置
选用一些比较关键的点来记录就能有效的缩小需要记录的数据量,但仍然能达到区分引用的目的。因为这样,HotSpot中GC不是在任意位置都可以进入,而只能在safepoint处进入。
而仍然在解释器中执行的方法则可以通过解释器里的功能自动生成出OopMap出来给GC用。平时这些OopMap都是压缩了存在内存里的;在GC的时候才按需解压出来使用。
HotSpot是用“解释式”的方式来使用OopMap的,每次都循环变量里面的项来扫描对应的偏移量。
而对Java线程中的JNI方法,它们既不是由JVM里的解释器执行的,也不是由JVM的JIT编译器生成的,所以会缺少OopMap信息。那么GC碰到这样的栈帧该如何维持准确性呢?
HotSpot的解决方法是:所有经过JNI调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄”(handle)包装起来。JNI需要调用Java API的时候也必须自己用句柄包装指针。在这种实现中,JNI方法里写的“jobject”实际上不是直接指向对象的指针,而是先指向一个句柄,通过句柄才能间接访问到对象。这样在扫描到JNI方法的时候就不需要扫描它的栈帧了——只要扫描句柄表就可以得到所有从JNI方法能访问到的GC堆里的对象。
但这也就意味着调用JNI方法会有句柄的包装/拆包装的开销,是导致JNI方法的调用比较慢的原因之一。
2.2.3 引用
无论是引用计数器方法还是可达性分析算法,都要用到引用,在JDK1.2以前只有两种状态,引用/为引用,但这存在一些问题,当存在这样的一些类对象:
当内存空间还足够时,则能保存在内存之中。如果内存空间在进行垃圾收集后还是非常紧张。则抛弃这些对象,这个在很多缓存系统中都符合这样的场景。所以在JDK1.2之后推出了四种引用:强引用、软引用、弱引用、虚引用。
1、强引用(StrongReference)
强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。如下:
Object o=new Object(); // 强引用
当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。如果不使用时,要通过如下方式来弱化引用,如下:
o=null; // 帮助垃圾收集器回收此对象
显式地设置o为null,或超出对象的生命周期范围,则gc认为该对象不存在引用,这时就可以回收这个对象。具体什么时候收集这要取决于gc的算法。
2、软引用(SoftReference)
如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
String str=new String("abc"); // 强引用
SoftReference<String> softRef=new SoftReference<String>(str); // 软引用
当内存不足时,等价于:
If(JVM.内存不足()) {
str = null; // 转换为软引用
System.gc(); // 垃圾回收器进行回收
}
软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。
(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
这时候就可以使用软引用
Browser prev = new Browser(); // 获取页面进行浏览
SoftReference sr = new SoftReference(prev); // 浏览完毕后置为软引用
if(sr.get()!=null){
rev = (Browser) sr.get(); // 还没有被回收器回收,直接获取
}else{
prev = new Browser(); // 由于内存吃紧,所以对软引用的对象回收了
sr = new SoftReference(prev); // 重新构建
}
这样就很好的解决了实际的问题。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
3、弱引用(WeakReference)
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
String str=new String("abc");
WeakReference<String> abcWeakRef = new WeakReference<String>(str);
str=null;
当垃圾回收器进行扫描回收时等价于:
str = null;
System.gc();
如果这个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用 Weak Reference 来记住此对象。
4、虚引用(PhantomReference)
“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。
Java4种引用的级别由高到低依次为:
强引用 > 软引用 > 弱引用 > 虚引用
通过图来看一下他们之间在垃圾回收时的区别:
2.3 生存还是死亡
但是要宣告一个对象是否死亡,至少要经过两次标记过程:如果对象在进行可达性分析后发现没有与GC roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者这个方法已经被虚拟机调用过了,虚拟机将这两种情况视为“没有必要执行”。如果这个对象被判定为有必要执行finalize()方法,那么这个对象会被放在一个F-Queue队列之中,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象在这个过程中没有引用上链,则会被回收回去。
三 回收方法区
方法区中存放生命周期较长的类信息、常量、静态变量,每次垃圾收集只有少量的垃圾被清除。方法区中主要清除两种垃圾:
废弃常量
无用的类
判定废弃常量
只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。比如,一个字符串 “bingo” 进入了常量池,但是当前系统没有任何一个 String 对象引用常量池中的 “bingo” 常量,也没有其它地方引用这个字面量,必要的话,"bingo"常量会被清理出常量池。
判定无用的类
判定一个类是否是“无用的类”,条件较为苛刻:
该类的所有对象都已经被清除
加载该类的 ClassLoader 已经被回收
该类的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区时创建,在方法区该类被删除时清除。
四 垃圾回收算法
学会了如何判定无效对象、无用类、废弃常量之后,剩余工作就是回收这些垃圾。常见的垃圾收集算法有以下几个:
4.1、标记-清除算法
判断哪些数据需要清除,并对它们进行标记,然后清除被标记的数据。
这种方法有两个不足:
效率问题:标记和清除两个过程的效率都不高。
空间问题:标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
4.2、复制算法(新生代)
为了解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾收集时,就将存活者的对象复制到另一块上面,然后将第一块内存全部清除。这种算法有优有劣:
优点:不会有内存碎片的问题。
缺点:内存缩小为原来的一半,浪费空间。
为了解决空间利用率问题,可以将内存分为三块: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。这样只有 10% 的内存被浪费。
但是我们无法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够,需要依赖其他内存(指老年代)进行分配担保。
分配担保
为对象分配内存空间时,如果 Eden+Survivor 中空闲区域无法装下该对象,会触发 MinorGC 进行垃圾收集。但如果 Minor GC 过后依然有超过 10% 的对象存活,这样存活的对象直接通过分配担保机制进入老年代,然后再将新对象存入 Eden 区。
4.3、标记-整理算法(老年代)
在回收垃圾前,首先将废弃对象做上标记,然后将未标记的对象移到一边,最后清空另一边区域即可。
这是一种老年代的垃圾收集算法。老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制大量存活的对象,效率很低。
4.4 、分代收集算法
根据对象存活周期的不同,将内存划分为几块。一般是把 Java 堆分为新生代和老年代,针对各个年代的特点采用最适当的收集算法。
新生代:复制算法
老年代:标记-清除算法、标记-整理算法
此外还有垃圾收集器的内容,就不写了,这种东西每个公司可能用的都不一样吗。。。。
参考资料:
https://rednaxelafx.iteye.com/blog/1044951 hotspot员工的博客好像,对OopMAP讲的超级好,值得看的一个博客
https://www.javazhiyin.com/1437.html
http://www.cnblogs.com/fengbs/p/7019687.html 四种引用