java虚拟机——对象存活判断与垃圾回收算法

本文主要讲述在java虚拟机垃圾回收机制中,如何判断对象是否存活和图解垃圾回收算法。

image-20190114221443140

一、概述

对于java程序员来说,多少听过GC、垃圾回收机制这些名词。不过到底什么是垃圾回收,哪些是垃圾,怎么进行回收呢?本文将会给出答案。

二、垃圾回收机制

垃圾回收(英语:Garbage Collection,缩写为GC),在计算机科学中是一种自动的存储器管理机制。当一个计算机上的动态存储器不再需要时,就应该予以释放,以让出存储器,这种存储器资源管理,称为垃圾回收

image-20190110225255641

为了方便大家理解,我就画了一个形象的图,一家饭店有好多桌子(连续的内存区域),顾客(对象)来店里吔饭,但是这些顾客很社会,自己不会吃完了就走,得让店家往外面赶。以前是老板娘来干这活(手动释放内存),现在引进了吃完饭滚蛋机器人(垃圾回收机制)来叫吃完的顾客滚蛋。

产生:垃圾回收并不是java的伴生产物。最早使用垃圾回收的语言是1960年诞生的Lisp,垃圾回收器的目的是减轻程序员的负担,同时也减少程序员犯错的机会。现在,经过半个多世纪的发展,目前垃圾回收技术已经相当成熟,并且大多数语言都支持垃圾回收,例如Python、Erlang、C#、Java等。

为什么要了解GC和内存分配?
当我们需要排查各种内存泄漏、内存溢出,当垃圾收集成为系统达到高并发的瓶颈时,就需要对这种自动化技术进行监督和调节。(吃完饭滚蛋机器人也不是万能的,也需要老板娘来调节机器人参数)

三、哪些内存需要回收

首先,我们知道程序计数器、虚拟机栈、本地方法栈这三个区域是线程私有的,它们是与线程同生共死的;栈帧是伴随着方法执行进栈,方法结束出栈,在类结构确定后,每个栈帧占多大内存基本确定。所以这几个区域并不需要进行管理。

然后,java堆和方法区是内存共享的,一个接口有多个实现类,不同的类需要的内存可能不同,一个方法的不同的分支需要的内存可能不同。我们只有在系统运行时才能确定需要创建哪些对象,这里是垃圾回收器的主战场。

垃圾收集策略

引用计数算法(Reference Counting)

给对象添加一个计数器,每当一个地方引用它时,计数器就加1,引用失效是就减1。当计数器为0时,这个对象就不会就不会再被使用了——对象死亡。

引用计数算法实现容易,效率很不错,在Python、Ruby等语言都使用了这种算法。但是主流java虚拟机并没有使用这种算法来管理内存,因为无法解决对象的循环引用问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ReferenceCounting {
public static void main(String[] args) {
Dog dog1 = new Dog();
Dog dog2 = new Dog();
// 狗1和狗2对象之间互相引用
dog1.setSon(dog2);
dog2.setSon(dog1);
// 将两个对象的引用设置为空
dog1 = null;
dog2 = null;
System.gc();
}
}
class Dog {
private Dog son;

public Dog getSon() {
return son;
}

public void setSon(Dog son) {
this.son = son;
}
}

在启动参数里设置-XX:+PrintGCDetails这个参数,打印日志

1
2
[GC 7926K->480K(502784K), 0.0023280 secs]
[Full GC 480K->316K(502784K), 0.0098820 secs]

可以清楚的看到尽管两个对象互相引用,但仍被回收,所以hotspot并不是引用计数算法算法。

跟踪收集器(Tracing garbage collection)

目前主流的虚拟机java、C#都是使用Tracing garbage collection来判断对象是否存活的,以致于当人们提到垃圾回收时就会想到Tracing garbage collection。

基本思想:定义一些GC Roots的对象为起始点,追踪对象是否能通过一个引用链(a chain of references )达到这些确定的GC Roots对象上,那些无法达到这些跟对象(root object)的对象将被视为已死亡。这种算法实际实现会复杂多变。

image-20190113091042584

开始画图,现在我们设置GC Roots,有面的碗和点菜单。那些碗里是空的在点菜单上还没名字的人会被标记为绿色,存活下来的有,左上角碗里有面的人,点完菜等待上饭的非单身狗整整齐齐一家人虽然左右两个都是空面,点菜单上也没有,但是缺被中间的人引用,而中间的人恰好碗里有面!这就是“追踪吃完饭不走的人方法”。

在java中,会设置如下对象为GC Roots:

  • 虚拟机栈(栈帧的本地变量表)中引用的对象:也就是局部变量引用的对象

  • 方法区中类静态属性引用的对象:public static Dog dog= new Dog();

  • 方法区中常量引用对象:public static final HashMap map = new HashMap();

  • 本地方法栈JNI中引用的对象。
可达性分析算法(Reachability analysis):
如果大家读过周志明老师的深入了解java虚拟机一定会知道可达性分析这个名词,也就是这里的Tracing garbage collection。开始我以为是两种不同的叫法,不过我使用google搜索Reachability analysis时并没有找到和垃圾回收相关的信息,百度查到的可达性分析算法基本全部出自深入了解java虚拟机wiki百科里对可达性分析的描述是用于确定分布式系统是否可以全局可达。而java的垃圾回收策略是Tracing garbage collection。所以我怀疑可能是深入了解java虚拟机用错了名词。
相关连接:

Reachability analysis

Tracing garbage collection

逃逸分析(Escape analysis)

逃逸分析将对象堆上分配(heap allocations)转到栈上分配(Stack allocations),从而减少很多垃圾回收的工作。在编译时判定在函数内分配的对象是否被外部方法或线程调用,如果没有则会将对象分配到栈中,减少垃圾回收工作。

引用

在jdk1.2之后,java对引用的概念进行了扩充,将引用分为了强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种。

  • 强引用就是指在程序代码之中普遍存在的,类似”Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用是用来描述一些还有用但并非必需的对象,对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象,只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用
  • 虚引用也成为幽灵引用或者幻影引用,它是最弱的一中引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供给了PhantomReference类来实现虚引用

一个可以被遗忘的关键字——finalize

当一个决定一个对象是否需要被回收时需要经历两个标记过程。第一次是追踪对象是否与GC Roots相连,如果没有进行标记,第二次是判断对象未重写finalize方法,或者finalize方法已经被调用过,此时对象彻底死亡。

finalize方法如果重写且未被调用会将对象放到一个低优先级甚至不执行的队列F-Queue中,之后调用对象的finalize方法,如果在方法中对象被GC Roots引用,对象自救成功。但是F-Queue可能不会执行,所以这种子救方法并这可靠。有些教程推荐finallize来释放资源,那为什么不用try-finally来做呢?

这个关键字可以忘记了。

四、垃圾收集算法

标记-清除(Mark-Sweep)算法

标记清除算法包括两个阶段,首先标记出需要回收的对象(标记方法就在上面),在标记完成后,统一回收所有被标记的对象。标记清除算法是一所有垃圾回收算法的基础,后续算法都是根据其不足进行改新。

缺点:

  1. 效率低,标记和清除两个过程效率都不高;
  2. 空间零碎,标记清除之后会产生大量不连续的内存碎片,空间碎片太多,当有大对象需要分配空间时会提前触发gc。

image-20190113201346903

空桌子是未使用的内存,被绿色标记的是可以清除的对象,这是清除前的状态,整整齐齐一家人是比较大的对象需要占据连续的区域。

image-20190113201825351这是清除之后的状态,内存碎片太多,当分配比较大的整整齐齐一家人时就会提前触发新的GC。

复制(Copying)算法

为了解决效率问题,出现了复制算法,可以将内存划分为大小相等的两块,每次只使用其中一块,当这块内存用完将存活的对象复制到另一块内存上去,将使用过的内存一次清除掉。这种算法效率高,但太浪费空间。

image-20190113202618844

如上图所示,现在使用下半部分内存。当清理时把未被标记的复制到上面的内存,然后一次清除下半部分内存。

image-20190113212116930

现在商业虚拟机大多都采用这种算法来回收新生代。但并不是按照1:1来分配内存的,因为IBM做过专门研究,在新生代中对象98%都是朝生幕死的。

将内存划分为一块较大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中一块Survivor,回收时将存活的对象复制到另一块Survivor中,清除Eden和被使用的Survivor。一般Eden,Survivor1,Survivor2比例为8:1:1,这样只有10%的内存会被浪费。

这里如果将Eden翻译为伊甸,对象出生的地方,Survivor幸存者,回收后幸存的对象,会比较好理解吧。

如果回收后对象对象真的超过了10%,Survivor空间不够时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。

标记整理(Mark-Compact)算法

复制收集算法并不适用于对象存活率较高的情况。当对象存活过多,需要复制的对象就会变多,效率将会下降。而且如果不想浪费50%的空间,就需要利用额外的空间进行分配担保,所以老年代并不适用这种算法。
根据老年代的特点,有人提出的标记整理算法,将对象标记后,会将存活的对象都向一端移动,然后直接清除掉边界以外的内存。

image-20190114220320073

这个是回收之前

image-20190114220530500这个是回收之后

分代收集算法

这种算法是指根据对象的存活周期将内存划分为几块,一般是把java堆分为新生代和老年代。对于每次垃圾收集都有大量对象死亡的新生代,采用复制算法;对于存活代高,又没有额外空间担保的老年代采用标记-清除或标记-整理算法。

增量收集器

需要将所拥有的内存空间分成若干分区。程序运行所需的存储对象会分布在这些分区中,每次只对其中一个分区进行回收操作,从而避免程序全部运行线程暂停来进行回收,允许部分线程在不影响回收行为而保持运行,并且降低回收时间,增加程序响应速度。

五、总结

本文介绍了什么是垃圾回收,java虚拟机的垃圾回收策略,包括引用计数法、追踪垃圾回收和逃逸分析,又用饭店的形式介绍了几种垃圾回收算法,包括标记-清除、复制算法、标记-整理算法。


参考资料:

  1. 深入了解Java虚拟机,周志明
0%