为了加深对Java语言的理解,加深对Java虚拟机工作机制、底层特性的了解和掌握,准备在闲暇时间,抽空对《深入理解Java虚拟机 JVM高级特性与最佳实践》一书进行学习。本文是学习此书第3章时的总结与笔记,加入了一些自己的理解,也希望能帮助到需要的人。
不过此文没有对垃圾收集器进行表述,因为这些垃圾收集器并没有实际去底层调试过,理解并不深刻,只讲一些理论的东西也不甚有用。所以此文只对垃圾收集算法和内存分配策略等进行表述。
1. 对象已死吗
对象实例都存放在Java堆中,垃圾收集器在堆进行回收前,得先确定这些对象哪些还“活着”,哪些已经“死去”(即不能再被任何途径使用)。
判断对象存活算法:
1.1 引用计数法:
- 给对象添加一个引用计数器,每当有一个地方引用时,计数器就加1,引用失效时,计数器减1;当计数器为0的对象就是不可再被使用的。
- 实现简单,判断效率高,但是很难解决循环引用的问题。主流虚拟机没有选用此方法来管理内存的。
public class RefrenceCounting {
public Object instance = null;
public static void test() {
RefrenceCounting objA = new RefrenceCounting();
RefrenceCounting objB = new RefrenceCounting();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
//两个对象除了相互引用外无任何引用,实际上两个对象已经不可再被访问,但是因为相互引用对方,导致引用计数不为0,所以引用计数法在此无法回收。
}
}
1.2 可达性分析算法:
通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链(Refrence Chain),当一个对象到GC Roots没有任何引用链相连(图论的话来说,这个对象到GC Roots不可达)时,证明此对象是不可用的,即可被回收的。
图中object5,6,7到GC Roots不可达,所以会被判断成可回收对象。
- 在Java语言中,可作为GC Roots的对象:
(1). 虚拟机栈(栈帧中的本地变量表)中引用的对象。
(2). 方法区中类静态属性引用的对象
(3). 方法区中常量引用的对象
(4). 本地方法栈中JNI(Native方法)引用的对象- 个人理解:GC管理的主要区域是Java堆,一般情况只对堆进行垃圾回收,栈、本地方法栈、方法区等不被GC所管理,所以选择这些区域的对象作为GC Roots。一个对象可以属于多个Root。
- GC root有以下几种(来自知乎):
(1). Class - 由系统类加载器(system class loader)加载的对象,这些类是不能够被回收的,他们可以以静态字段的方式保存持有其它对象。我们需要注意的一点就是,通过用户自定义的类加载器加载的类,除非相应的java.lang.Class实例以其它的某种(或多种)方式成为roots,否则它们并不是roots。
(2). Thread - 活着的线程
(3). Stack Local - Java方法的local变量或参数
(4). JNI Local - JNI方法的local变量或参数
(5). JNI Global - 全局JNI引用
(6). Monitor Used - 用于同步的监控对象
1.3 Java中的引用:
- 传统定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表这一个引用。
- JDK1.2后对引用进行了扩展,分为强引用、软引用、弱引用、虚引用:
(1). 强引用(Strong Reference):在程序中普遍存在,类似于“Object obj = new Object()”这类的引用,只要强引用还在,GC永远不会回收被引用的对象。
(2). 软引用(Soft Reference):描述一些还有用但并非必须的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围内进行第二次回收,如果还是没有足够内存,才会抛出内存溢出异常。JDK1.2后,提供了SoftReference类来实现软引用。
(3). 弱引用(Weak Reference):描述非必须对象,强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当前GC工作时,无论内存是否足够,多会回收掉弱引用关联的对象。JDK1.2后,提供了WeakReference类来实现软引用。
(4). 虚引用(Phantom Reference):最弱的一种引用关系,对象是否有虚引用的存在,完全不会对生存时间够成影响,也无法通过虚引用来取得一个对象实例。唯一目前是能在对象被GC回收时收到一个系统通知。JDK1.2后,提供了PhantomReference类来实现软引用。
1.4 生存还是死亡:
在可达性分析算法中不可达的对象,也不是非死不可的,一个对象真正的死亡,要经过两次标记的过程:
如果对象在可达性分析后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否还有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者此方法以及被虚拟机调用过,这两种情况都会被视为“没有必要执行”。
如果这个对象有必要执行finalize()方法,这个对象会被放置在一个F-Queue队列中,并由一个低优先级的Finalizer线程执行它(出发,不会等待运行结束)。finalize()方法是对象逃脱死亡的最后一次机会,如果对象要在finalize()中拯救自己,只需要重新与引用链上的任何一个对象建立关联即可,比如把自己(this)赋值给某个类变量或者对象成员变量。那在第二次标记时会被移出“即将回收”的集合。如果对象还没有逃脱,基本就真的被回收了。
由于此方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,所以完全不推荐使用此方法来拯救对象。
1.5 回收方法区:
方法区(或者HotSpot虚拟机中的永久代)的垃圾回收主要回收两部分内容:废弃常量和无用的类。
- 回收废弃常量:与回收Java堆中的对象非常类似。以常量池中的字面量的回收 为例,例如一个字符串“abc”已经进入了常量池中,但是当前系统中没有任何String对象是“abc”,也就是说没有任何String对象引用常量池中的这个“abc”对象,当这个时候发生垃圾回收,必要的话,这个常量就会被清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用都是这样。
- 回收无用的类:条件比判断“废弃常量”复杂许多,需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader都已被回收;
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
在大量使用反射,动态代理,CGLIB等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。
2. 垃圾回收算法
2.1 标记-清除算法
标记-清除算法分为两个二个阶段:首先标记出所有需要回收的对象;在标记完成后统一回收所有被标记的对象。
不足之处:
- 效率问题:标记和清除两个过程的效率都不高;
空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多会导致以后再程序运行过程需要分配较大对象的时候,无法找到足够连续的内存,而不得不提前触发垃圾回收动作。
出现了很多间隔的未使用的空间,所以产生大量不连续的内存碎片。
2.2 复制算法
将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还活着的对象复制到另外一块上,然后把已使用的内存空间一次性清理掉。
每次对整个半区进行内存回收,不会导致内存碎片的问题,实现简单,运行高效。
不足之处:将内存缩小为原来的一半,空间代价太高了。
现在的商业虚拟机都是采用这种算法来回收新生代;根据IBM公司的研究表明:新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的Eden区和两块较小的Survivor区,每次使用Eden区和一块Survivor区。当发生回收时:将Eden区和刚才使用的Survivor区还存活着的对象(因为此处存活的对象非常少)一次性的复制到另外一块Survivor空间上,然后清理掉Eden区和使用过的Survivor区。HotSpot虚拟机默认Eden区和Survivor区的大小是8:1,这样每次新生代中的可用内存空间只有10%会被浪费。
当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当另外的Survivor空间不够时,就需要依赖其他内存(老年代)进行分配担保。如果另外一块Survivor没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
2.3 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低,另外还需要额外的空间进行担保,以应对使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接使用此算法。
“标记-整理”算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。
2.4 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”算法,根据对象存活周期的不同将内存划分为几块。一般是把Java堆划分为新生代和老年代,再根据各个年代的特点来采用最适合的收集算法。
在新生代中,每次垃圾收集都发现有大量对象死去,只有少量存活,就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;而老年代中因为对象存活率较高,没有额外空间进行分配担保,就必须使用“标记-清除”算法或者“标记-整理”算法来进行回收。
3. 内存分配与回收策略
3.1 概述:
Java技术体系中的自动内存管理可以归结为自动化的解决了两个问题:给对象分配内存和回收分配给对象的内存。以上就是回收内存,以下即为给对象分配内存。
3.2 对象优先在Eden分配
大多数情况下,对象在新生代的Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
Minor GC和Major GC/Full GC:
- 新生代GC(Minor GC):指发生在新生代的垃圾回收动作,因为Java对象大多都具备朝生夕灭的特性,所以Monir GC非常频繁,一般回收速度也比较快。
- 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但并非绝对的,在Parallel Scavenge)收集器的收集策略里就有直接进行Major GC的策略选择过程。Major GC的速度一般比Major GC慢10倍以上。
3.3 大对象直接进入老年代
大对象是指,需要大量连续内存空间的Java对象,最典型的就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息(特别是一群“朝生夕灭”的大对象),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾回收来获取足够连续的空间来存储它们。
大对象直接在老年代进行分配的目的是避免在Eden区和两个Survivor区之间发生大量的内存复制。
3.4 长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代。
虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC之后仍然存在,并且能被Survivor容纳的话,将被移到Survivor空间中,并且对象年龄设为1,。对象在Survivor区中每熬过一次Minor GC,年龄就增加1,当它的年龄增加到一定程度(默认15),将会晋升到老年代中。
3.5 动态对象年龄判断
为了能更好的适应不同程序的内存情况,虚拟机不是永远的要求对象的年龄必须达到阈值才能晋升老年代;如果在Survivor区中的相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无须达到阈值。
3.6 空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的;如果不成立,则会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时会进行一次Major GC。
"冒险"是冒了什么风险:新生代使用复制收集算法,但为了内存利用率,只使用其他中Survivor区进行轮换备份,因此当出现大量对象在Minor GC之后仍然存活的情况(最极端的情况就是内存回收后新生代所有对象都存活),就需要老年代进行内存担保,把Survivor无法容纳的对象直接进入老年代,但是前提是老年代本身还有容纳这些对象的剩余空间;不过一共有多少对象会活下来在实际完成内存回收前是无法知道的,所以只好取之前晋升到老年代对象容量的平均大小经过经验值,与老年代剩余空间进行比较,决定是否进行Major GC让老年代腾出更多空间。
目前全部文章列表:
idea整合restful风格的ssm框架(一)
idea整合restful风格的ssm框架(二)
idea整合spring boot+spring mvc+mybatis框架
idea整合springboot+redis
JVM学习之—Java内存区域
JVM学习之—垃圾回收与内存分配策略
专题整理之—不可变对象与String的不可变
专题整理之—String的字符串常量池