我们为什么要去了解垃圾收集(GC)和内存分配?
当需要排查各种内存呢溢出、内存泄露问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们需要对现在的“自动化技术”实施必要的监控和调节。
要理解并学习垃圾收集(GC),首先要了解GC需要完成的三件事情:
哪些内存需要回收?
什么时候需要回收?
如何回收?
堆中几乎存放着Java世界中所有的对象实例,垃圾收集器在对堆进行回收钱,第一件事情就是要确定这些对象有哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
概念:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1;当引用失效时,计数器值就减1;任何时刻计数器都为0的对象就是不可能再被使用的。
Java语言中没有选用引用计数法来管理内存,其中最主要的原因是它很难解决对象之间的相互循环引用的问题。
代码验证:
互相引用着对方的的两个对象,它们的引用计数都不为0,假设在此时发生了GC,那么这两个对象能否被回收。
package com.GarbageCollection;
/**
* @description: 引用计数算法测试 (互相引用着对方的的两个对象,它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们)
* @author: Mr.Wang
* @create: 2019-02-06 21:55
**/
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;
// 假设在此时发生了GC 那么objA和objB能否被回收
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
注意:在执行以上代码之前,需要配置JVM参数---XX:+PrintGCDetails。此参数代表,当前方法在执行GC时,会将GC执行的日志打印到控制台。如图:
控制台输出结果:
[GC (System.gc()) [PSYoungGen: 9341K->851K(76288K)] 9341K->859K(251392K), 0.0016015 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 851K->0K(76288K)] [ParOldGen: 8K->697K(175104K)] 859K->697K(251392K), [Metaspace: 3019K->3019K(1056768K)], 0.0083399 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 76288K, used 1966K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
eden space 65536K, 3% used [0x000000076ab00000,0x000000076aceb9e0,0x000000076eb00000)
from space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
to space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
ParOldGen total 175104K, used 697K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
object space 175104K, 0% used [0x00000006c0000000,0x00000006c00ae750,0x00000006cab00000)
Metaspace used 3044K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 332K, capacity 388K, committed 512K, reserved 1048576K
从运行结果可以看到GC日志中包含“9341K->859K”,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。
Java和C#,甚至包括前面提到的古老的Lisp,都是使用根搜索算法判定对象是否存活的。这个算法的基本思路就是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(从GC Roots到这个对象不可达)时,则证明此对象是不可用的。所以这样的对象将会被判定为是可回收的对象。
在Java语言中,可作为GC Roots的对象包括以下几种:
虚拟机栈(栈帧中的本地变量表)中的引用的对象。
方法区中的类静态属性引用的对象。
方法区中的常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)的引用的对象。
详细的解释一下根搜索算法是如何判断一个对象“彻底死亡”的。
要宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的,低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不会承诺会等待它运行结束。 这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个类变量或对象的成员变量,那在第二次标记时它将被移除“即将回收”的集合。
编写一个简单的demo来证明此论点:
package com.GarbageCollection;
/**
* @description: 一次对象自我拯救的演示
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
* @author: Mr.Wang
* @create: 2019-02-07 20:15
**/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes , i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
// 对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no , i am dead :(");
}
}
}
以上代码的控制台打印结果为:
finalize method executed!
yes , i am still alive :)
no , i am dead :(
结果解释:
SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。
另外就是,代码中有两段完全一样的代码片段,执行结果是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。
一点建议:
尽量避免使用finalize()方法,因为它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。try-finally可以做的更好。
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)四种,这四种引用强度依次逐渐减弱。
接下来介绍一下这四种引用的概念与区别:
强引用:只要某个对象有强引用与之关联,JVM必定不会回收这个对象,即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。比如:Object object = new Object();或者String str ="hello";但是如果强引用所在的方法执行完毕,并且强引用的对象不存在了,那么他们指向的对象会被JVM回收。如果想强行中断强引用与对象的关联,可以手动将这个对象赋值为null。这样一来,JVM就会在合适的时候回收该对象。比如Vector类的clear方法中就是通过将引用赋值为null来实现清理工作的
软引用:用来描述一些有用但是不必要的对象,在Java中用java.lang.ref.SoftReference类来表示。对于软引用的对象,JVM只有下内存不足的情况下才会对其进行回收。因此,可以利用这一特性解决OOM的问题,并且这个特性还可以实现缓存(图片缓存、网页缓存)。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。
弱引用:也是用来描述非必须对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。不过要注意的是,这里所说的被弱引用关联的对象是指只有弱引用与之关联,如果存在强引用同时与之关联,则进行垃圾回收时也不会回收该对象(软引用也是如此)。弱引用也可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。
虚引用:虚引用不影响对象的声明周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则和没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。但是,虚引用必须与引用队列关联使用。当垃圾回收器回收一个对象时,如果发现它有虚引用,就会把这个虚引用加到与之关联的队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解引用对象是否将要被垃圾回收器回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在对象被回收之前采取一定的措施。
以及这四种引用的目的:
通过代码的方式决定某些对象的生命周期
有利于JVM进行垃圾回收
要注意的是:
在使用软引用和弱引用的时候,我们可以显示地通过System.gc()来通知JVM进行垃圾回收,但是要注意的是,虽然发出了通知,JVM不一定会立刻执行,也就是说这句是无法确保此时JVM一定会进行垃圾回收的。
如何利用软引用和弱引用解决OOM问题:
用一个HashMap来保存图片的路径 和 相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占用的空间,从而有效地避免了OOM的问题。在Android开发中对于大量图片下载会经常用到。
一般在方法区中进行垃圾收集的“性价比”比较低:在堆中,尤其是新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收java堆中的对象非常类似。当这个常量身处常量池中,却没有被任何对象引用,那么此时可以称这样的常量为废弃常量。那么什么可以算做是“无用的类”呢?
该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
加载该类的ClassLoader已经被回收
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。主要缺点:1.效率低;2.空间碎片太多。
将可用内存按容量分为大小相等的两块,每次只用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。主要缺点:内存缩小为原来的一半。
现在的商业虚拟机都是用这个收集算法来回收新生代。将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性的拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%被浪费掉了。
根据老年代的特点,有人提出了另外一种“标记-整理”算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法根据对象的存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
五、垃圾收集器参数
Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。以下是几条最普通的内存分配规则,并通过代码去验证这些规则:
大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC
注意:Minor GC和Full GC
Minor GC是新生代GC,指发生在新生代的垃圾收集动作,因为java对象大多都是具备朝生夕灭的特性,所以Minor GC非常频发,一般回收速度比较快。
Full GC是老生代GC,指发生在老年代的GC,出现了Full GC,经常会伴随至少一次的Minor GC。(此非绝对)Full GC的速度会比Minor GC的速度慢十倍以上。
所谓的大对象就是指需要大量连续内存空间的Java对象,最典型的大对象就是那些很长的字符串及数组。虚拟机提供一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代中分配。这样做的目的是避免在Eden区以及两个Survivor区之间发生大量的内存拷贝。
虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将会被移动到Survivor空间中,并将对象年龄设为1.对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(15岁),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。
如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC。如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC;如果不允许,则也要改为进行一次Full GC.