javaspecialists 的173期 出了一道很有意思的题目,例子很直白,行为很诡异,原因很简单.这是关于一个OOM的问题,涉及了GC, 对象的可触及, 作用域等等,官方答案可以在174期 找到,这个解释其实还是有点抽象,这里我加上点图片解释下。(以下代码摘录于javaspecialists 的173期 )
先看看程序做了什么, 先分配当前可用堆栈的60%,然后再分配60%,抛OOM错误是合情合理的,因为堆内存不够用了.但是为什么程序加上了一个System.out.print的循环调用就能正常运行了? 直观的想法是GC这种情况下起作用了,那他又是如何起作用的呢?
我们先来回顾一下Java 内存模型的一些基本原理:
- GC触发的时机:我们知道Java虚拟机会有一个线程在后台等待,帮我们释放不再需要的内存(即所谓托管式运行时)。对于堆中不可触及的对象,GC在适当的时候会被唤醒,整理堆空间,将这些不可触及的对象清空。当JVM无法从堆中分配所需的连续 内存时(如果零碎内存总量足够,但是不连续也有可能触发GC,这时GC需要进行碎片整理,从而分配连续的大内存),GC就会被触发,释放内存,如果内存还是不够,OOM即被抛出。
- 不可触及:所有引用都有一“起点”,从这些起点开始能传递的被强引用的对象都是可触及对象,也就是不能被GC释放的对象(所谓“垃圾对象”就是不再被使用的对象,我们总不会扔掉还能使用的东西吧?)。这些“起点”包括:所有方法栈中的帧上的引用(包括局部变量和操作数栈上的引用);已加载类的引用(注:已被解析过的才算);JNI的局部和全局引用,如果通过这都无法找到的对象,就是所谓的不可触及对象。
- 局部变量:Java方法调用是基于栈的,虚拟机调用方法时,会创建一个方法帧,并为这个帧分配一个元素大小为字长的数组,用于存放局部变量(字长足够存放所有引用,而primitive则直接贮存),他也是"起点"的一部分。
再回过头来看看我们的例子,没有注释掉for循环时,程序正常运行,这意味着上述代码成功的触发了GC(在第二次分配60%内存时,堆内存不够了), 并且GC成功的释放了第一次分配的内存,即byte [] data被成功释放了。好,问题归结到了为什么没了这个for循环, byte [] data就无法被释放了?
光从Java代码上我们是看不出什么了,透过现象看本质,这时候字节码就发挥功效了.让我们用JDK提供的javap工具看看这两个f()函数有什么不同!
- 正常运行(有for循环)
解释下这里的字节码
4: newarray_byte // 分配byte [] data
6: astore_1 // 将data引用写到局部变量数组的1位置 (0位置放什么?this!)
7: iconst_0 // 常量0压栈,用来初始化局部变量i
8: istore_1 // 将7压栈的常量0写入局部变量数组1位置
(而后面的几行其实可以忽略,即System.out不起任何作用)
通过方法栈中f()方法帧的快照能更容易理解这个过程。
第6行和第8行重复使用了局部变量的1位置,8将6的引用覆盖了,第33行将在堆中分配byte [] data2,堆内存不够,GC被触发并释放了byte [] data (因为这个时候byte [] data已经不可触及了!),程序正常运行。
- 运行失败
再看看把for循环注释掉后的字节码,第0~6行同上例一样,分配了byte [] data并放在局部变量1位置,第11行分配byte [] data2,这是个时候堆内存不够,于是GC被触发。但是同时GC又发现byte [] data仍在使用(localvariable[1]依旧指向他),GC无法释放他又无法分配更多的内存,于是OOM被抛出,程序被终止。