一,JVM的垃圾回收机制
IDEA 控制台输出JVM的GC日志,在 VM options 添加
-XX:+PrintGCDetails
即可
1.1 如何判定垃圾对象
1.1.1 引用计数法
在每个对象都维护着一个内存字段来统计它被多少"部分"使用---引用计数器,每当有一个新的引用指向该对象时,引用计数器就+1 ,每当指向该引用对象失效时该计数器就-1 ,当引用数量为0的时候,则说明对象没有被任何引用指向,可以认定是"垃圾"对象.
案例:
java
public static void main(String[] args) {
GcObject obj1 = new GcObject(); //Step1
GcObject obj2 = new GcObject();//Step2
obj1.instance = obj2; //Step3
obj2.instance = obj1;// //Step4
obj1 = null; //Step5
obj2 = null; //Step6
}
第一步:GcObject实例1被obj1引用,所以它的引用数+1,为1
第二步:GcObject实例2被obj2引用,所以它的引用数+1,为1
第三步:obj1的instance属性指向obj2,而obj2指向GcObject实例2,故GcObject实例2引用+1,为2
第四步:obj2的instance属性指向obj1,而obj1指向GcOjbect实例1,故GcObject实例1引用+1,为2
到此前4步, GcOjbect实例1和GcOjbect实例2的引用数量均为2,此时结果图如下:
第五步:obj1不再指向GcOjbect实例1,其引用计数减1,结果为1.
第六步:obj2不再指向GcOjbect实例2,其引用计数减1,结果为1.
到此,发现GcObject实例1和实例2的计数引用都不为0,他们的成员便令之间相互引用,但是外界没有办法访问,如果采用的引用计数算法的话,因为它们的计数引用都不为0,那么这两个实例所占的内存将得不到释放,这便产生了内存泄露。
1.1.2 可达性分析法
可达性分析法基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。
案例:
如上图所示,object1~object4对GC Root都是可达的,说明不可被回收,object5和object6对GC Root节点不可达,说明其可以被回收。
可作为GC Root的对象:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的Native方法)引用的对象
1.2 如何回收垃圾对象
1.2.1 标记清除算法(基础)
思想:顾名思义,本算法需要先标记出所有需要回收的对象,待标记完成后,再统一回收所有被标记的对象。
缺点:效率问题,清除效率不高;空间问题,会产生内存碎片
1.2.2 复制算法(针对新生代)
为了方便垃圾回收,将java堆分为新生代,老年代;将新生代划分为Eden(伊甸园),Survivor(存活区2块From和To);
一开始就会将可用内存分为两块,from域和to域, 每次只是使用from域和to域中的一个域。当From域或者To内存不够了,开始执行GC操作,这个时候,会把from域存活的对象拷贝到to域,然后直接把from域进行内存清理。
- 当Eden区满的时候,会触发第一次young gc,把还活着的对象拷贝到Survivor From区;当Eden区再次触发young gc的时候,会扫描Eden区和From区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden和From区域清空。
- 当后续Eden又发生young gc的时候,会对Eden和To区域进行垃圾回收,存活的对象复制到From区域,并将Eden和To区域清空。
- 可见部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认值和最大值都是15(jvm对象头中GC标记是4bit)),最终如果还是存活,就存入到老年代。
1.2.3 标记整理算法(针对老年代)
标记整理算法和标记清除算法很像,唯一不同的是,当标记完成后,不是清理掉需要回收的对象,而是将所有存活的对象向一端移动,然后将边界以外的内存全部清理掉,这样可以有效避免空间碎片的产生。
1.2.4 分代收集算法
分代算法(Generational Collection),现代的虚拟机大都采用了这种gc回收算法,通过将内存根据存活时间的不同划分为不同代,来选择最合适的对象回收算法,一般来说是分为新生代和老年代。
- 新生代的对象更新很快,朝生夕死,所以使用复制算法.
- 老年代的对象存活率很高,所以采用标记整理算法.
二,其它
2.1 如何判断一个类是无用的类
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
类需要同时满足下面3个条件才能算是 "无用的类" :
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
2.2 常见引用类型
java的引用类型一般分为四种:强引用、软引用、弱引用、虚引用
①,强引用:普通的变量引用
java
public static User user = new User();
②,软引用:软引用是用来描述一些非必需但仍有用的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。软引用可用来实现内存敏感的高速缓存。
java
public static SoftReference user = new SoftReference(new User());
③,弱引用:弱引用的引用强度比软引用要更弱一些,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收,将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用 。
java
public static WeakReference user = new WeakReference(new User());
④,虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用。
2.3 内存担保
新生代Minor GC后剩余存活对象太多,无法放入Survivor区中,此时由老年代做内存担保,将这些存活对象直接转移到老年代去。
- 执行任何一次Minor GC之前,JVM会先检查一下老年代可用内存空间,是否大于新生代所有对象的总大小,因为在极端情况下,可能新生代Minor GC之后,新生代所有对象都需要存活,那就会造成新生代所有对象全部要进入老年代;
- 如果老年代的可用内存大于新生代所有对象总大小,此时就可以放心大胆的对新生代发起一次Minor GC,因为Minor GC之后即使所有对象都存活,Survivor区放不下了,也可以转移到老年代去;
- 如果老年代的可用空间已经小于新生代的全部对象总大小,那么就会进行下一个判断,判断老年代的可用空间大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小.
- 如果判断发现老年代的内存大小,大于之前每一次Minor GC后进入老年代的对象的平均大小,那么就是说可以冒险尝试一下Minor GC,但是此时真的可能有风险,那就是Minor GC过后,剩余的存活对象的大小,大于Survivor空间的大小,也大于老年代可用空间的大小,老年代都放不下这些存活对象了,此时就会触发一次"Full GC";
内存担保机制通过,冒险尝试一下Minor GC,Minor GC过后,剩余的存活对象的大小,如果小于老年代可用空间,可以减少一次full GC,所以老年代空间分配担保机制的目的,也是为了避免频繁进行Full GC.
2.4 记忆集与卡表
在垃圾收集过程中,会存在一种现象,即跨代引用,在G1垃圾收集器中,又叫跨Region引用。如果是年轻代指向老年代的引用我们不用关心,因为即使Minor GC把年轻代的对象清理掉了,程序依然能正常运行,而断掉引用链的老年代对象,老年代对象会被后续的Major GC回收,但是如果是老年代指向年轻代的引用,年轻代的对象在Minor GC阶段是不能被回收掉的,那如何解决这个问题呢?
首先,跨代引用相对于同代引用来说仅占极少数。原因是跨代引用的对象应该倾向于同时生存或者同时死亡的,例如:如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,变成同代引用,这时跨代引用也随即被消除了。
依据上面说所,就不应再为了少量的跨代引用去扫描整个老年代中的每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构,该结构被称为记忆集。通过老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用,此后当发生Minor GC时,通过判断标识,将标识为存在跨代引用的小块内存里的对象,加入到GCRoots进行扫描,虽然这种方法需要在对象改变引用关系如老年代中对象某个属性赋值更改时,需要维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构,如下面代码所示:
java
//以对象指针来实现记忆集的伪代码
Class RememberedSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
这种记录全部含跨代引用对象,无论是空间占用还是维护成本都相当高昂,而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本:
-
字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个 精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
-
对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
-
卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
"卡精度"所指的是用一种称为"卡表"(Card Table)的方式去实现记忆集,这也是目前最常用的记忆集的实现形式
HotSpot虚拟机定义的卡表只是一个字节数组。以下这行代码是HotSpot默认的卡表标记逻辑:
java
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个 内存块被称作"卡页"(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可 以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块,如下图所示:
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的卡页,把它们加入GC Roots中一并扫描。
那虚拟机是何时让卡表元素变脏呢?又是如何维护卡表元素的呢?
写屏障
卡表元素何时变脏的答案是很明确的------有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻,把维护卡表的动作放到每一个赋值操作之中。
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对"引用类型字段赋值"这个动作的AOP切 面,在引用对象赋值时会产生一个环形(Around)通知,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)。下面是简化的代码逻辑:
java
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
总结
记忆集与卡表
HotSpot虚拟机是用记忆集来记录某块内存区域是否包含跨代引用的对象。记忆集是抽象概念,而卡表是记忆集的实现,卡表是用字节数组实现的,卡表数组的每个元素都是代表某块具体内存区域,这个内存区域叫卡,卡页的大小是512字节,代表一块特定大小的内存块,若在这块内存块中有一个或多个的跨代指针,则将对应的卡表元素标为1,代表"变脏",否则为0。当虚拟机扫描卡表元素为1时,便将对应的卡页内存区域加入到GC ROOT中一并扫描。
写屏障
使用写屏障来实现卡表元素变脏。写屏障分为写前屏障和写后屏障,大多数垃圾收集器都是使用写后屏障(G1使用写前屏障)。写后屏障具体表现在对引用对象赋值时,如果是跨代引用,则通过写后屏障将对应的卡表元素变脏。