1.垃圾收集Garbage Collection GC
》考虑3个问题:
哪些内存需要回收?
什么时候回收?
如何回收?
》哪些内存需要回收?(主要考虑java堆和方法区)
上一节了解到程序计数器,虚拟机栈,本地方法栈三个区域是线程私有的,随线程而生,随线程而灭,内存自动释放,且这些区域的内存分配和回收都具备确定性,不做多考虑。
而java堆和方法区不一样,一个接口的多个实现类需要的内存可能不一样,一个方法的多个分支需要的内存也可能不一样。只有运行时才知道创建了哪些对象,这部分内存的分配和回收都是动态的。
》java堆什么时候回收?
1)在对java堆进行回收前先判断java对象是否死去
2)java中并不是采用引用计数法(对象中添加引用计数器,被引用+1,引用失效后-1)。证明如下:
/**
* VM:-XX:+PrintGCDetails打印gc详情
* 循环引用内存释放测试
*/
static void loopGCTest(){
MyClass c1 = new MyClass();
MyClass c2 = new MyClass();
//互相引用
c1.ref = c2;
c2.ref = c1;
//释放a1,a2
c1=null;
c2=null;
//gc
System.gc();
}
static class MyClass{
//占点内存
private byte[] data = new byte[2*1024*1024];
//指针
private Object ref = null;
}
这里创建两个对象,每个对象有2M大的变量,通过ref互相引用,运行结果如下:
证明了循环引用时,引用不失效,也可以被java回收。所以java中没有采用引用计数法
3)java中采用根搜索算法(GC Root Tracing)
通过一系列名为“GC Roots”的对象作为起始点,往下搜索,走过的路径称为引用链(Reference Chain),当对象到GC Roots不可达,即判定该对象不可用(ps:注意这里只是不可用而已,并不是死亡)。
4)java中的引用
jdk1.2之前,引用的定义很窄,如果reference中的内存保存了另一块内存的起始地址就称为引用。这种定义下,只有引用和没有被引用两种状态。
jdk1.2之后,对引用的概念进行了扩充,被划分成我们熟知的强引用,软引用,弱引用,
虚引用。
强引用:比如Object a = new Object()这类,只要强引用存在,就不会被GC
软引用:如果即将出现OOM,将这类对象列入回收名单,进行二次GC,提供了SoftReference类
弱引用:已列入GC名单,只要GC就会回收。提供了WeakReference类
虚引用:相当于未引用,无法通过该引用获取对象实例,唯一目的是在其被回收时收到一个系统通知,提供了PhantomReference类
5)java对象死亡过程
GC Roots不可达的对象->判断是否有必要执行finalize()方法(PS:对象没有覆盖该方法,或者该方法已执行过一次即视为没有必要)->没有必要则死亡,有必要则该对象被放置于F-Queue队列中,并在稍后一条由虚拟机自动建立的低优先级的Finalizer线程中执行(只是触发而已,不会等待其执行完毕,个人理解是异步执行,避免卡住队列),在finalize()方法中如果重新引用则可以不死。
书中写了一段代码来验证这个过程,这里不写了,一个对象的finalize()方法只会被系统执行一次,该方法不建议使用。
》方法区的垃圾回收
方法区的回收效率比较低下,永久代的垃圾回收主要回收:废弃常量和无用的类。回收废弃常量与回收对象类似。
》如何回收?
各种平台内存操作不一样,介绍几种算法实现思想:
1)标记-清除算法
原理:标记出所有需要回收的对象,标记完成后统一回收
缺点:标记和清除的效率不高;容易产生大量内存碎片,内存不够用的时候可能导致频繁GC
2)复制算法
原理:考虑到内存碎片的问题,该算法将内存分为两块,仅使用其中一块,当GC发生时,将存活的对象依次复制到另一块内存中,然后一次性清除当前内存。这样就没有内存碎片的问题了。
缺点:实现简单,运行高效,但是代价是内存缩小为1/2。
现在的商业虚拟机采用了这种算法来回收新生代。但是经过了优化,考虑GC发生时绝大部份对象都是死去的,只要一小块内存就可以复制完毕,将原来的2块变为3块内存,1块较大的Eden空间,2块较小的Survivor空间,当GC时将Eden和一块已使用的Survivor空间的存活对象复制到另一块未使用的Survivor空间上,这样空间使用率大大增加。HotSpot虚拟机Eden与Survivor的比例是8:1,这样仅损耗10%的空间。
3)标记-整理算法
复制算法用于复制,如果对象多效率低,还考虑100%存活的这种极端情况,老年代一般不采用这种方式,对标记-清除进行优化,所有存活对象进行整理,往前移动,直接清除后面空出的部分。
4)分代收集算法
综上现在的商业虚拟机形成了分代收集算法,因地制宜。一般把java堆分为新生代和老年代,新生代中对象大批死去,采用加强版复制算法,老年代中对象存活率高,一般采用标记-清除或者标记-整理算法。
对象存活久了就由新生代变为老年代(ps:请看参考文章详细了解这一点,这样就能明白下文中垃圾回收器新生代和老年代为啥要配合使用)
参考:关于新生代和老年代https://www.cnblogs.com/E-star/p/5556188.html
2.常见垃圾回收器(HotSpot为例)
》Serial收集器
jdk1.3.1之前新生代的唯一选择,单线程,必须停掉所有工作线程stop the world,(ps:还记得安卓gc时会造成卡顿吗?),但它依然是Client模式下的默认新生代收集器,简单高效
》ParNew收集器
多线程版的Serial,Server模式下首选新生代收集器,和Serial一样能与CMS收集器(jdk1.5出的老年代收集器)配合工作,单CPU中,采用Serial更合适
》Parallel Scavenge收集器
与ParNew一样新生代,多线程,不一样的是它的目标是达到可控的吞吐量(运行用户代码时间/(运行用户代码时间+GC时间))。停顿时间短适合Client环境这种与用户交互多的情况,响应时间短,例如安卓手机,而高吞吐量则可以最高效率利用CPU时间,主要适合后台这样交互少而运算量大的场景
》Serial Old收集器
看名字就知道是老年代收集器,也是单线程的,主要用于Client模式下,如果用于Server模式它主要用于与Parallel Scavenge配合使用以及CMS的后备方案。
》Parallel Old收集器
jdk1.6出的,多线程,老年代方案,基于标记-整理算法。解决了Parallel Scavenge高吞吐量策略只能搭配Serial Old老年代单线程的问题。
》CMS收集器
以获取最短GC停顿时间为目标的老年代收集器,并发收集,基于标记-清除算法,jdk1.5版本出的,有3个著名的缺点:
一是占用cpu资源(对cpu资源敏感),默认启用线程数(cpu数量+3)/4,当cpu低于4时,占用cpu较多比如4个占了2个,且在初始标记和并发标记阶段会stop the world,导致用户程序执行速度变慢;
二是无法处理浮动垃圾,由于CMS在并发清理阶段与用户线程并行,会产生新的垃圾,这部分被称为浮动垃圾,还要预留空间存储,所以CMS不能像其他垃圾收集器一样等待老年代几乎满了进行清理,默认到了68%就会激活清理。如果内存不够就会出一次Concurrent Mode Failure,这时虚拟机会采用后备应急方案,启用Serial Old清理老年代(也就是刚才说的是CMS的后备方案)。
三是标记-清除算法的硬伤,空间碎片问题,前面提到过了。
》G1收集器
当前最好的收集器了,回收范围是整个java堆,基于标记-整理,可以精准控制停顿,将java堆划分成多个独立区域,优先垃圾最多的区域回收。
3.内存分配与回收策略
》内存分配策略
策略不一,几条普遍的内存分配规则:
1)优先在Eden分配,如果Eden空间不够,则发起Minor GC(新生代垃圾回收)
2)大对象直接进入老年代,这样避免在新生代发生大量拷贝
3)长期存活对象进入老年代
4)动态对象年龄判定,为了适应更多的内存情况,Survivor中相同年龄所有对象大小总和大于空间的一半,大于等于该年龄的对象直接进入老年代
5)空间分配担保,当发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为一次Full GC(老年代gc),如果小于,则查看HandlePromotionFailure设置是否允许担保失败,如果允许,则只会进行Minor GC,如果不允许,则改为Full GC。