JVM实战 - 垃圾收集算法
0 联系我
1.Q群【Java开发技术交流】:https://jq.qq.com/?_wv=1027&k=5UB4P1T
2.简书博客:www.shishusheng.com
3.知乎:http://www.zhihu.com/people/shi-shu-sheng-
4.微信公众号:JavaEdge
5.Github:https://github.com/Wasabi1234
Java会对内存进行自动分配与回收管理,使上层业务更加安全,方便地使用内存实现程序逻辑
在不同的JVM实现及不同的回收机制中,堆内存的划分方式是不一样的
这里简要介绍垃圾回收( Garbage Collection, GC)。垃圾回收的主要目的是清除不再使用的对象,自动释放内存.
静态内存分配和回收
在程序开始运行时由编译器分配的内存
在被编译时就已经能够确定需要的空间,当程序被加载时系统把内存一次性分配给它,这些内存不会在程序执行时发生变化,直到程序执行结束时才回收内存.
包括原生数据类型及对象的引用
这些静态内存空间在栈上分配,方法运行结束,对应的栈帧撤销,内存空间被回收.
每个栈帧中的本地变量表都是在类被加载的时候就确定的,每一个栈帧中分配多少内存基本上是在类结构确定时就已知了,因此这几块区域内存分配和回收都具备确定性,就不需要过多考虑回收问题了
动态内存分配和回收
- 在程序执行时才知道要分配的存储空间大小,对象何时被回收也是不确定的,只有等到该对象不再使用才会被回收.
堆和方法区的内存回收具有不确定性,因此垃圾收集器在回收堆和方法区内存的时候花了一点心思.
1 Java堆内存的回收
1.1 判定回收的对象
GC是如何判断对象是否可以被回收的呢?为了判断对象是否存活,JVM引入了GC Roots
如果一个对象与GC Roots之间没有直接或间接的引用关系,比如某个失去任何引用的对象,或者两个互相环岛状循环引用的对象等,判决这些对象“死缓”,是可以被回收的
在对堆进行对象回收之前,首先要判断哪些是无效对象(一个对象不被任何对象或变量引用)需要被回收
一般有两种判别方式:
- 引用计数法 (Reference Counting)
每个对象都有一个整型的计数器,当这个对象被一个变量或对象引用时,该计数器加一;当该引用失效时,计数器值减一.当计数器为0时,就认为该对象是无效对象. - 可达性分析法 (Reachability Analysis)
所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象.
GC Roots对象- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈JNI(native方法)引用的对象
GC Roots并不包括堆中对象所引用的对象!这样就不会出现循环引用.
- 两者对比
引用计数法虽然简单,但存在无法解决对象之间相互循环引用的严重问题,且伴随加减法操作的性能影响.
因此,目前主流语言均使用可达性分析方法来判断对象是否有效.
2 回收无效对象的过程
当经可达性算法筛选出失效的对象之后,并不是立即清除,而是再给对象一次重生的机会
判断是否覆盖finalize()
- 未覆盖该或已调用过该方法,直接释放对象内存
- 已覆盖该方法且还未被执行,则将finalize()扔到F-Queue队列中
- 未覆盖该或已调用过该方法,直接释放对象内存
执行F-Queue中的finalize()
虚拟机会以较低的优先级执行这些finalize(),不会确保所有的finalize()都会执行结束
如果finalize()中出现耗时操作,虚拟机就直接停止执行,将该对象清除对象重生或死亡
如果在执行finalize()方法时,将this赋给了某一个引用,则该对象重生
如果没有,那么就会被垃圾收集器清除
注意:强烈不建议使用finalize()进行任何操作!
如果需要释放资源,请用try-finally或者其他方式都能做得更好.
因为finalize()不确定性大,开销大,无法保证各个对象的调用顺序.
以下代码示例看到:一个对象的finalize被执行,但依然可以存活
/**
* 演示两点:
* 1.对象可以在被GC时自救
* 2.这种自救机会只有一次,因为一个对象的finalize()最多只能被系统自动调用一次,因此第二次自救失败
* @author sss
* [@since](/profile/2564806) 17-9-17 下午12:02
*
*/
public class FinalizeEscapeGC {
private static FinalizeEscapeGC SAVE_HOOK = null;
private void isAlive() {
System.out.println("yes,I am still alive :)");
} [@Override](/profile/992988) protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize methodd executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
// 对象第一次成功自救
SAVE_HOOK = null;
System.gc();
// 因为finalize方法优先级很低,所以暂停0.5s以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,I am dead :(");
}
// 自救失败
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,I am dead :(");
}
}
}
运行结果
finalize methodd executed!
yes,I am still alive :)
no,I am dead :(
3 方法区的内存回收
使用复制算法实现堆的内存回收,堆被分为新生代和老年代
- 新生代中的对象"朝生夕死",每次垃圾回收都会清除掉大量对象
- 老年代中的对象生命较长,每次垃圾回收只有少量的对象被清除
由于方法区中存放生命周期较长的类信息、常量、静态变量.
因此方法区就像堆的老年代,每次GC只有少量垃圾被清除.
方法区中主要清除两种垃圾
- 废弃常量
- 无用类
3.1 回收废弃常量
回收废弃常量和回收对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除.
3.2 回收无用类
判定无用类的条件则较为苛刻
- 该类所有实例都已被回收
即Java堆不存在该类的任何实例 - 加载该类的ClassLoader已被回收
- 该类的java.lang.Class对象没有被任何对象或变量引用,无法通过反射访问该类的方法
只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class.这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除.
4 垃圾收集算法
4.1 标记-清除(Mark-Sweep)
最基础的收集算法,后续算法也都是基于此并改进其不足而得.
该算***从每个GC Roots出发,依次标记有引用关系的对象,最后将没有被标记的对象清除
不足
这种算***带来大量的空间碎片,导致需要分配一个较大连续空间时容易触发FullGC,降低了空间利用率.
为了解决这个问题,又提出了“标记-整理算法”,该算法类似计算机的磁盘整理,首先会从GC Roots出发标记存活的对象,然后将存活对象整理到内存空间的一端,形成连续的已使用空间,最后把已使用空间之外的部分全部清理掉,这样就不会产生空间碎片的问题
4.2 复制算法(Mark-Copy)
为了能够并行地标记和整理将空间分为两块,每次只激活其中一块,垃圾回收时只需把存活的对象复制到另一块未激活空间上,将未激活空间标记为已激活,将已激活空间标记为未激活,然后清除原空间中的原对象
将内存分成大小相等两份,只将数据存储在其中一块上
当需要回收时,首先标记废弃数据
然后将有用数据复制到另一块内存
最后将第一块内存空间全部清除
4.2.1 分析
- 这种算法避免了空间碎片,但内存缩小了一半.
- 每次都需将有用数据全部复制到另一片内存,效率不高
4.2.2 解决空间利用率问题
堆内存空间分为较大的Eden和两块较小的Survivor,每次只使用Eden和Survivor区的一块。这种情形下的“ Mark-Copy"减少了内存空间的浪费。“Mark-Copy”现作为主流的YGC算法进行新生代的垃圾回收。
在新生代中,由于大量对象都是"朝生夕死",也就是一次垃圾收集后只有少量对象存活
因此我们可以将内存划分成三块
- Eden、Survior1、Survior2
- 内存大小分别是8:1:1
分配内存时,只使用Eden和一块Survior1.
- 当发现Eden+Survior1的内存即将满时,JVM会发起一次Minor GC,清除掉废弃的对象,
- 并将所有存活下来的对象复制到另一块Survior2中.
- 接下来就使用Survior2+Eden进行内存分配
通过这种方式,只需要浪费10%的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题.
4.2.3 分配担保
准备为一个对象分配内存时,发现此时Eden+Survior中空闲的区域无法装下该对象
就会触发MinorGC(新生代 GC 算法),对该区域的废弃对象进行回收.
但如果MinorGC过后只有少量对象被回收,仍然无法装下新对象
- 那么此时需要将Eden+Survior中的所有对象都转移到老年代中,然后再将新对象存入Eden区.这个过程就是"分配担保".
在发生 minor gc 前,虚拟机会检测老年代最大可用连续空间是否大于新生代所有对象总空间
若成立,minor gc 可确保安全
若不成立,JVM会查看HandlePromotionFailure是否允许担保失败
- 若允许
那么会继续检测老年代最大可用的连续空间是否 > 历次晋升到老年代对象的平均大小- 若大于
则将尝试进行一次 minor gc,尽管这次 minor gc 是有风险的 - 若小于或 HandlePromotionFailure 设置不允许冒险
改为进行一次 full gc (老年代GC)
- 若大于
4.3 标记-压缩算法(Mark-Compact)
在回收前,标记过程仍与"标记-清除"一样
但后续不是直接清理可回收对象,而是
将所有存活对象移到一端
直接清掉端边界之外内存
分析
这是一种老年代垃圾收集算法.
老年代中对象一般寿命较长,每次垃圾回收会有大量对象存活
因此如果选用"复制"算法,每次需要較多的复制操作,效率低
而且,在新生代中使用"复制"算法
当 Eden+Survior 都装不下某个对象时,可使用老年代内存进行"分配担保"
而如果在老年代使用该算法,那么在老年代中如果出现 Eden+Survior 装不下某个对象时,没有其他区域给他作分配担保
因此,老年代中一般使用"标记-压缩"算法
4.4 分代收集算法(Generational Collection)
当前商业虚拟机都采用此算法.
根据对象存活周期的不同将Java堆划分为老年代和新生代,根据各个年代的特点使用最佳的收集算法.
- 老年代中对象存活率高,无额外空间对其分配担保,必须使用"标记-清除"或"标记-压缩"算法
- 新生代中存放"朝生夕死"的对象,用复制算法,只需要付出少量存活对象的复制成本,就可完成收集
5 Java中引用的种类
Java中根据生命周期的长短,将引用分为4类
- 强引用
我们平时所使用的引用就是强引用
类似A a = new A();
即通过关键字new创建的对象所关联的引用就是强引用
只要强引用还存在,该对象永远不会被回收 - 软引用
一些还有用但并非必需的对象
只有当堆即将发生OOM异常时,JVM才会回收软引用所指向的对象.
软引用通过SoftReference类实现
软引用的生命周期比强引用短一些 - 弱引用
也是描述非必需对象,比软引用更弱
所关联的对象只能存活到下一次GC发生前.
只要垃圾收集器工作,无论内存是否足够,弱引用所关联的对象都会被回收.
弱引用通过WeakReference类实现. - 虚引用
也叫幽灵(幻影)引用,最弱的引用关系.
它和没有引用没有区别,无法通过虚引用取得对象实例.
设置虚引用唯一的作用就是在该对象被回收之前收到一条系统通知.
虚引用通过PhantomReference类来实现.