Java虚拟机的自动内存管理,将原本需要手动回收的内存交给垃圾器来自动回收。既然是自动机制,肯定没有那么高效,且也会带来不少与垃圾回收相关的问题。今年主要来回顾一下垃圾回收的基础知识,下一篇文章将会深入探索Java虚拟机中的垃圾回收器。。。
引用计数法与可达性分析
垃圾回收,简单来说就是将分配出去但不再使用的内存回收回来,以便能再次分配。在Java虚拟机的语境下垃圾指的是死亡对象所占据的堆空间。此时涉及到了一个问题:如何辨别一个对象是存是亡???
先来将一个古老的辨别方法:引用计数法(reference counting)。做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象引用计数为0,则说明该对象已经死亡便可以回收。。
具体实现是这样的:如果有一个引用被赋值为某一对象,将该对象的引用计数器+1,如果一个指向某一对象的引用,被赋值为其他值那么将该对象的引用计数器-1。就是说需要截获所有的引用更新操作,并且相应的增减目标对象的引用计数器。。。
但是也是有缺点的除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,就是无法处理循环引用对象。。。
举个例子假设对象a与b相互引用,除此之外没有其他引用指向a或者b。这种情况下a和b实际上已经没用了但是因为引用计数不是0引用计数法认为两个对象还活着。因此循坏引用对象所占据的空间将不可回收从而造成了内存泄漏。
目前Java虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列GC Roots作为初始的存活对象合集(live set), 然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中这个过程称之为标记(mark)。最终未被探索到的对象便是死亡的可以回收的。
GC Roots可以暂时理解为由堆外指向堆内的引用,包括如下几种:
- Java方法栈帧中的局部变量
- 已加载类的静态变量
- JNI handles
- 已启动且未停止的Java线程
可达性分析可以解决循环引用问题。举例来说即便对象a和b相互引用,只要从GC Roots出发无法到达a或者b,那么可达性分析便不会将它们加入存活对象合集之中。
可达性分析还是存在很多问题的,例如多线程环境下其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为null)或漏报(将引用设置为未被访问过的对象)。。
误报的危害至多是Java虚拟机损失了部分垃圾回收的机会。漏报比较麻烦,垃圾回收器可能回收事实上仍被引用的对象内存,一旦从原引用访问已经被回收了的对象可能导致Java虚拟机崩溃。。。
Stop-the-world以及安全点
Java虚拟机中传统的垃圾回收算法采用的是一种简单粗暴的方式就是Stop-the-world停止其他非垃圾回收回收线程的工作,直到完成垃圾回收因此造成了垃圾回收所谓的暂停时间(GC pause)。
Java虚拟机中的Stop-the-world是通过安全点机制来实现的。当Java虚拟机收到Stop-the-world请求,便会等待所有线程到达安全点,才允许请求Stop-the-world的线程进行独占工作。。。
安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态,Java虚拟机的堆栈不会发生变化,这样垃圾回收器便能够安全执行可达性分析。。。
举个例子,当运行本地方法的时候,如果不涉及任何java对象和方法,那么堆栈就不会改变,也就代表着这段本地代码可以作为一个安全点。
只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续执行这段本地代码。
同时,Java虚拟机仅仅需要在API的入口进行安全点检测,测试是否有其他线程请求停留在安全点里,便可以在必要的时候挂机当前线程。
除了执行JNI本地代码外Java线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。阻塞的线程由于处于Java虚拟机线程调度器的掌握之下属于安全点。
其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则垃圾回收线程可能长期处于等待所有线程进入安全点的状态从而变相地提高了垃圾回收的暂停时间。
- 解释执行
字节码与字节码之间皆可作为安全点Java虚拟机采取的做法是当有安全点请求时,执行一条字节码便进行一次安全点检测。
- 执行即时编译器生成的机器码
由于这些代码直接运行在底层硬件之上,不受Java虚拟机掌握,因此在生成机器码时,即时编译器需要插入安全点检查,以避免机器码长时间没有安全点检测的情况。HotSpot虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。
垃圾回收的三种方式
第一种是清除(sweep),即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,划分给新建的对象。
清除这种回收方式的原理比较简单,但是有两个缺点:一是会造成内存碎片,由于Java虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
第二种是压缩(compact),即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
第三种是复制(copy),即把内存区域分为两等分,分别用两个指针from和to来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to指针指向的内存区域中,并且交换from指针和to指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点极其明显:即堆空间的使用效率极其低下。