1、Dead Or Alive
我们都知道对象死亡的时候需要进行垃圾回收来回收这些对象从而释放空间,那么什么样的对象算是死亡呢,有哪些方法可以找出内存中的死亡对象呢?一般来说,我们可以这样认为:如果内存中不存在对当前对象的引用,则此对象一定是死亡状态;但是死亡状态的对象并不一定没有其他对象进行引用(可能存在死亡对象循环引用的情况)。这里需要说明一下,死亡的对象并不一定会被回收释放占用的空间,这种情况就是常称的"内存泄漏"。判定对象存活的算法一般是以下两种。
1.1 引用计数法
引用计数法,即在对象内放置一个变量来表示这个对象被引用的次数,如果其他对象引用了当前对象则变量值+1,如果失去引用则-1,当变量值为0的时候表示没有引用,应当回收。此算法并没有被Java采用,因为其存在着一个致命的问题——循环引用。
如上图中,栈中没有任何堆中两个对象的引用,而堆中的两个对象则互相持有对方的引用,如果使用引用计数法的话引用变量值永远不会为0,从而造成内存泄漏,两个互相引用的对象无法释放空间。
public class TestForGc { TestForGc testInstance; // 模拟上图的现象 public static void main(String[] args) { TestForGc testA = new TestForGc(); TestForGc testB = new TestForGc(); testA.testInstance = testB; testB.testInstance = testA; testA = null; testB = null; // 建议垃圾回收器进行回收操作 System.gc(); } }
然后设置-XX:+PrintGCDetails打印GC日志:
最终新生代的对象全部被回收,说明JVM使用的并不是使用引用计数法来实现垃圾回收。
1.2 可达性分析算法(GCRoots)
GCRoots,大意为选中一些特定的对象作为根节点,然后从这些根节点出发寻找可以引用到的所有对象,形成一条引用链(引用网),不在这条链中的对象则标记为死亡,进行回收。根节点的特定对象从下列四种产生:
1、虚拟机栈中引用的对象。
2、本地方法栈中引用的对象。
3、方法区中静态变量引用的对象。
4、方法区中常量引用的对象。
使用GCRoots便不会出现循环引用的问题,如图,虽然A、B相互引用,但是由于不在根节点的引用链中,所以会被标记为可回收对象。
在Hotspot虚拟机对GCRoots算法的实现中,大致可以分为三个部分理解。
1.2.1 枚举根节点
如上所说,根节点的选取对象有四处,如果虚拟机对这些位置进行全盘扫描的话,效率自然要影响不少,所以Hotspot采用一种数据结构——OopMap来解决这个问题。在类加载完成的时候,虚拟机将对象的什么偏移量有什么对象计算出来,在JIT编译过程中在特定的位置记录下栈和寄存器中哪些位置是引用。这样一来GC在扫描的时候就可以直接得到这些引用的信息,从而减少GC的停顿时间。顺便一提,在枚举根节点的时候,为了保持“一致性”,不能再扫描的时候还出现对象引用变化的情况,所以需要暂停所有Java执行线程(被称为"STOP-THE-WORLD"),即便在具有划时代意义、可以并发执行的CMS收集器中在枚举根节点的时候也需要STW。
1.2.2 安全点的设置
OopMap数据结构可以说为GC的扫描减少了不少的时间,但是随之而来的还有一个问题,如果每条指令都生成对应的OopMap,那么想必需要大量的额外空间,GC的空间成本将十分巨大,就是何时生成对应OopMap成为当前面临的问题。之前说过在特定的位置会记录下引用的位置,这个特定的位置就是OopMap的生成时机,也就是“安全点(SafePoint)”,在Sop-The-World的时候线程要先跑到安全点才可以进行线程的停顿。那该如何判断这个特定的位置呢?如果设置的太少可能会导致GC时间变长,设置的太多会增大运行时的负荷。Hotspot给出的答案是以程序“是否具有让程序长时间执行的特征”为标准进行选定。"长时间执行"的明显特征就是指令复用,例如方法调用、循环跳转、异常处理等,只有这些指令才能产生安全点。
对于安全点来说,另外一个问题就是采用什么样的方式让所有的线程跑到最近的安全点停顿。有两种实现的方式:
1、抢先式中断:在GC发生的时候首先暂停所有线程,如果发现有线程没在安全点的话,则恢复线程,让其跑到最近的安全点再进行暂停。现在已经很少有使用抢先式的了。
2、主动式中断:GC发生的时候不强制暂停线程,而是设置一个标识变量,线程会去轮询这个标志,如果为true则将自己中断挂起。这个轮询的位置和安全点是重合的,还有创建对象时需要分配内存的地方。
1.2.3 安全区域
上面安全点的设置几乎已经解决了问题,但是还少了一点,就是建立在线程都是执行状态的时候,那线程不执行的时候呢,例如进入休眠状态的线程,这时候自己不能跑到安全点也不能等待JVM分配时间。此时就需要安全区域来解决这一点。
安全区域指的是在一段代码块中,引用关系不会发生变化。当程序走到安全区域的时候,则标识当前线程进入了安全区域。这时候发生GC的时候则可以不用管有安全区域标识的线程,而这些线程在快离开安全区域的时候必须要检查是否完成了根节点的枚举(或者整个GC的过程),如果完成了才可以离开安全区域,否则必须待到完成为止。
2、垃圾回收算法
现在我们知道哪些对象是死亡的,哪些对象应该回收,而这个回收有许多种实现的方式(算法),有的算法对死亡对象进行标记最后一并清除、有的算法将内存分块然后将存活对象从一头搬到另一头,还有算法在清除完死亡对象贴心的将存活的对象整放在一块儿,这些都是我们接下来要说的。
2.1 标记-清除算法
正如这个算法的名称一般,其总共有两个阶段——"标记"和"清除":首先其会对所有的死亡对象进行标记,最后再一起将这些对象回收。
这个算法是基础的算法,后续的算法都是对其缺点的一些改进。此算法有两个不足的地方,其一从上图也可以看得出来,垃圾回收后的内存空间不连续,造成许多的内存碎片。其二就是其效率问题,标记和清除的效率并不是太高,所以后续出现的算法都对两个缺点的调整和改进。
2.2 复制算法
为了解决效率和内存碎片的问题,一种称作"copy"的算法出现,这个算法将内存空间分成两份或以上,一份存放对象,一份空白,当进行垃圾回收的时候将所有的存活对象复制到空白的一份中,然后清空之前存放对象的空间。
上图也可以看得出,对此算法最重要的是内存空间的切分,如果切分不当可能会浪费大量的空间。当然使用得当也有十分值得的优点:一定范围内的高效率和没有内存碎片。
缺点:
1、适用于存活对象相较死亡对象少的情况,例如新生代,如果存活的对象较多的话可能得到相反的效果。所以才说是一定范围的高效率。
2、需要划分内存空间。如果本身的内存空间比较小还去划分的话那可能会导致频繁的GC,停顿时间增多,影响用户体验。
另:此算法一般用在新生代做垃圾回收算法,并且将新生代分成三个部分——两个Survivor和一个Eden区,其比例默认为1:1:8(可以通过虚拟机参数改变)。当我们生成一个对象(通过关键字new或者反射)的时候,对象首先会分配在Eden区,等到Eden区放不下的时候则触发一次MinorGC,将Eden和其中一个Survivor中的存活对象一起移到另一个Survivor中,然后清空。顺带一提,有存活对象的Survivor总是称作From区,空白的Suvivor总是称作To区,一般新生代存活对象占5%左右。
2.3 标记-整理法
复制算法是一个非常优秀的算法,但其只在存活对象想多较少的情况下表现良好,而对于其他例如年老代中这些存活对象较多的区域则可能是一种糟糕的选择。所以,需要一个更加合适的算法——标记-整理法。这个算法原理和步骤基本跟标记-清除一样,但是多出一个整理的步骤,也就是说整个过程为标记-清除-整理,结束之后不会产生内存碎片。
2.4 分代算法
严格来说这不能算是一种算法,应该是一种理念。其把整个内存空间分为两个区域——新生代和年老代(1.8之前还有一个永久代,也就是方法区,但是在1.8之后已经删除)。并且虚拟机对对象定义了年龄的概念,表示该对象熬过了多少次GC,以此来作为对象放在新生代还是年老代的标准之一,默认新生代的对象15岁之后就可以进入年老代了。对于两个区域采用的回收算法也是不同的,新生代一般采用复制算法,年老代一般采用标记-整理法,当然具体还是得看使用的垃圾回收器,如果年老代使用的是CMS的话那么就是标记-清除了。
It is an honor if I could get some advices or corrections from you guys.