[标题党] 跑得好好的C#程序咋移植为Java就不够内存用了呢?——忽悠一把

国庆假期玩得晕晕乎乎的,就不写啥硬核文了,来点轻松点的,顺带标题党+忽悠党一把 ^ ^

一段看似很无辜的C#测试代码:
public class TestOOM {
public static void Main(string[] args) {
byte[][] arrays = new byte[8*1024][];
for (int i = 0; i < arrays.Length; i++) {
arrays[i] = new byte[8*1024];
}
}
}

编译,在桌面32位CLR 2上运行没问题。但是把几乎一样的逻辑“移植”到Java,
public class TestOOM {
public static void main(String[] args) {
byte[][] arrays = new byte[8*1024][];
for (int i = 0; i < arrays.length; i++) {
arrays[i] = new byte[8*1024];
}
}
}

用Sun的32位JDK 6编译,执行,却出现
[quote]Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at TestOOM.main(TestOOM.java:5)[/quote]
咋了,这两段代码不是几乎一样的么,怎么Java版就会出错呢?
你可以高呼:啊啊啊,Java(或者说HotSpot VM)太差劲啦!!
不过事实是:你只是被忽悠了而已。
语言规范与VM规范都不能保证解决所有现实问题。这里遇到的就是特定于实现的问题。

先看看忽悠的部分——表象。C#版代码被编译为MSIL后,Main方法的字节码是:
IL_0000: ldc.i4     0x2000
IL_0005: newarr uint8[]
IL_000a: stloc.0
IL_000b: ldc.i4.0
IL_000c: stloc.1
IL_000d: br.s IL_0020
IL_000f: ldloc.0
IL_0010: ldloc.1
IL_0011: ldc.i4 0x2000
IL_0016: newarr [mscorlib]System.Byte
IL_001b: stelem.ref
IL_001c: ldloc.1
IL_001d: ldc.i4.1
IL_001e: add
IL_001f: stloc.1
IL_0020: ldloc.1
IL_0021: ldloc.0
IL_0022: ldlen
IL_0023: conv.i4
IL_0024: blt.s IL_000f
IL_0026: ret

Java版代码被编译为字节码后为:
0:   sipush  8192
3: anewarray #2; //class "[B"
6: astore_1
7: iconst_0
8: istore_2
9: iload_2
10: aload_1
11: arraylength
12: if_icmpge 29
15: aload_1
16: iload_2
17: sipush 8192
20: newarray byte
22: aastore
23: iinc 2, 1
26: goto 9
29: return

两者基本上是一致的,下面每行代码的意义都对应:
newarr   uint8[]               | anewarray #2; //class "[B"
ldc.i4.0 | iconst_0
ldlen | arraylength
newarr [mscorlib]System.Byte | newarray byte
stelem.ref | aastore
ret | return[/code]
注意到两个版本中8*1024都被常量折叠为8192了。
主要区别有两点,其实都没多少影响:
1、微软的C#编译器为for循环生成的代码是将检查条件放在循环末尾,而在循环体开头处用一个无条件跳转指令跳到循环条件处;Sun的Java编译器则是将循环条件放在循环体开头处,在循环末尾放无条件跳转。请注意,字节码中的控制流与最终JIT出来的机器码中的控制流形式未必相同。
2、虚拟机的虚拟架构有细节上的差异。CLI将参数与局部变量区别看待,C#版的变量arrays位于局部变量的第一个槽里(local 0),变量i位于第二个槽里(local 1);JVM则把参数与局部变量都放在“局部变量区”,则main的参数args位于局部变量的第一个槽里(local 0),变量arrays位于第一个槽里,变量i位于第二个槽里;因此虽然字节码的load/store参数看似不同,实际上意思是一样的。另外,MSIL里指令通常不带类型(除加载常量、转换类型等的指令外),而JVM字节码多数指令带有类型;这个其实影响不大,意思还是保持一致的。还有的话就是C#的byte实际对应到无符号的uint8,而Java的byte对应到的是带符号的int8,在这个例子里也没什么影响。

OK,C#版代码与Java版源代码看起来几乎一样,而编译出来得到的字节码也基本一致,那还有啥问题呢?悬念继续留着,看看规范里关于“堆空间”的说明。C#与Java的规范中都没有提到“堆”到底有多大,而每个对象到底会占用多少空间;只是说当需要在堆上分配空间却没有足够剩余空间时会抛出OutOfMemoryException/OutOfMemoryError(统称OOM吧)。CLI与JVM的规范里同样没有规定堆的大小和对象占用空间的大小。概念上说,堆空间可以看成是无限大小的、无序的大块存储空间。实际运行程序时,对象占用空间的大小是虚拟机的实现细节,而有多少堆空间可用也与实现和系统配置相关。

在[url=http://rednaxelafx.iteye.com/blog/461787]以前一帖[/url]里提到过32位CLR 2中int[]的内存布局。CLR的GC堆中对象按4字节边界对齐。对象header占2个DWORD:前一个位于对象起始地址-4的位置,是指向SyncBlock的索引,兼用于GC标记;后一个位于对象起始地址+0的位置,是指向该对象所属类型的MethodTable的指针。
看看本例涉及的两个数组类型的状况。
32位CLR 2中,byte[][]在内存中的布局如下:(括号中数字表示距离数组起始地址的偏移量)
[code="">-----------------------
| SyncBlk索引 | (-4)
-----------------------
| 指向MethodTable的指针 | (+0)
-----------------------
| 数组长度 Length | (+4)
-----------------------
| 元素的MethodTable指针 | (+8)
-----------------------
| 下标为0的元素 | (+12+4*0)
-----------------------
| 下标为1的元素 | (+12+4*1)
-----------------------
| ... |
-----------------------
| 下标为n的元素 | (+12+4*n)
-----------------------
| ... |
-----------------------

所以一个byte[n][]对象占用内存大小为:4*4 + 4*n。
(开头是2个DWORD的对象header加上2个DWORD的对象数组header;后面是n个元素,每个元素都是一个DWORD;最后没有padding,因为这个大小肯定是4个倍数)

byte[]在内存中的布局如下:
|      SyncBlk索引     | (-4)
-----------------------
| 指向MethodTable的指针 | (+0)
-----------------------
| 数组长度 Length | (+4)
-----------------------
| 下标为0的元素 | (+8+1*0)
-----------------------
| 下标为1的元素 | (+8+1*1)
-----------------------
| ... |
-----------------------
| 下标为n的元素 | (+8+1*n)
-----------------------
| ... |
-----------------------[/code]
所以一个byte[n]对象占用内存大小为:4*3 + 1*n + (n%4 == 0 ? 0 : 4 - n%4)。
(开头是2个DWORD的对象header加上1个DWORD的byte数组header;接着是n个元素,每个元素都是一个byte;最后是padding,填充到4字节边界)
通过SOS调试扩展的!ObjSize命令可以验证对象实际大小是否符合上文描述。
综上,在C#版的例子中,代码里显式new出来的对象至少需要占用GC堆上的这么多空间:(单位为字节)
4*4 + 4*8*1024[color=darkred](=32784,arrays指向的数组所占空间)[/color]
+ (4*3 + 1*8*1024 + 0)[color=darkred](=8204,arrays里每个元素指向的数组所占空间)[/color] * 8*1024
------------------------
= 67239952
这个大小约等于64.125MB。显然,CLR的其它一些地方也需要用到GC堆上的空间。既然C#版例子能正常完成测试,说明运行该例子时GC堆要有64MB以上。

换到Java版例子。32位JDK 6的HotSpot VM中,Java对象按8字节对齐。对象header占两个DWORD:前一个位于对象起始地址+0的位置,是一个markOop,用于记录对象的hash、“年龄”、锁状态等信息;后一个位于对象起始地址+4的位置,是指向类型信息的指针。大致看来跟CLR的对象header挺像的。
看看本例涉及的两个数组类型的状况。
在32位的HotSpot VM version 14.0(JDK 6u14开始使用)中,byte[][]在内存中的布局如下:
[code="">-----------------------
| _mark | (+0)
-----------------------
| _metadata | (+4)
-----------------------
| 数组长度 length | (+8)
-----------------------
| 下标为0的元素 | (+12+4*0)
-----------------------
| 下标为1的元素 | (+12+4*1)
-----------------------
| ... |
-----------------------
| 下标为n的元素 | (+12+4*n)
-----------------------
| ... |
-----------------------

所以byte[n][]对象占用的内存大小为:4*3 + 4*n + ((n+3) % 2 == 0 ? 0 : 4)。
(开头是2个DWORD的对象header加上1个DWORD的对象数组header;接着是n个元素,每个元素是一个DWORD;最后是padding,填充到8字节边界)

byte[]在内存中的布局如下:
|        _mark         | (+0)
-----------------------
| _metadata | (+4)
-----------------------
| 数组长度 length | (+8)
-----------------------
| 下标为0的元素 | (+12+1*0)
-----------------------
| 下标为1的元素 | (+12+1*1)
-----------------------
| ... |
-----------------------
| 下标为n的元素 | (+12+1*n)
-----------------------
| ... |
-----------------------[/code]
所以byte[n][]对象占用的内存大小为:4*3 + 1*n + ((n-4)%8 == 0 ? 0 : 8 - (n-4)%8)。
(开头是2个DWORD的对象header加上1个DWORD的对象数组header;接着是n个元素,每个元素是一个DWORD;最后是padding,填充到8字节边界)
要如何验证上文描述的对象大小计算公式是否正确呢?幸好,从Java 5开始有JVMTI支持,有[url=http://java.sun.com/javase/6/docs/api/java/lang/instrument/Instrumentation.html]java.lang.instrument.Instrumentation[/url]接口,其中有个getObjectSize()方法可以得到对象大小。问题是要获取这个接口的实例需要点功夫。详细可以参考这篇文章:[url=http://www.jroller.com/maxim/entry/again_about_determining_size_of]Maxim Zakharenkov: Again about determining size of Java object[/url]。有趣的是[url=http://java.sun.com/javase/6/docs/technotes/tools/share/jhat.html]jhat[/url]报告的对象大小跟JVMTI报告的不一致,从我调试的实际情况看JVMTI的结果应该是对的。
综上,在Java版的例子中,代码里显式new出来的对象至少需要占用GC堆上的这么多空间:(单位为字节)
4*3 + 4*8*1024 + 4[color=darkred](=32784,arrays指向的数组所占空间)[/color]
+ (4*3 + 1*8*1024 + 4)[color=darkred](=8208,arrays里每个元素指向的数组所占空间)[/color] * 8*1024
------------------------
= 67272720
这个大小约等于64.156MB。加上虚拟机和标准类库内部使用的GC堆空间,要正常运行这个测试需要大于64MB的GC堆空间。测试失败,抛出了OOM,那到底是在“什么时候”抛的呢?
把测试例子的代码改为:
[code="java">public class TestOOM {
public static void main(String[] args) {
byte[][] arrays = new byte[8*1024][];
for (int i = 0; i < arrays.length; i++) {
try {
arrays[i] = new byte[8*1024];
} catch (Throwable t) {
System.err.println(i);
break;
}
}
}
}

看到输出为8098,也就是说arrays[8097] = new byte[8*1024];都还正常执行了;此时我们在代码里显式new出来的数组一共占了66492960字节,约等于63.413MB的GC堆空间。

===========================================================================

知道了至少需要的空间大小,却还未能解释为啥C#版正常的测试移植到Java就OOM了。在C#/Java源码一级很相似,在MSIL/JVM字节码一级很相似,甚至到VM内部的某些设计还是比较相似,占用的空间也差不了多少。问题在哪里呢?

不要想靠强制GC来解决问题哦。这例子里arrays是局部变量,是个强引用,属于GC的根集合;而所有在例子中显式new出来的byte[]都被arrays引用着,也就是说它们都是“可到达”的,强制GC完全达不到释放它们的目的。

其实上面忽悠了半天,我故意省略了一点没有提:默认情况下,CLR不限制GC堆的最大大小,依赖系统或者host来限制;没有什么“启动参数”之类的东西来限定GC堆的大小,除非自己写host来限制。而HotSpot VM却有-Xmx启动参数用于指定GC堆的最大大小,在32位的version 14.0里,Windows上该参数无论是client还是server默认值都为64M。
JVM的规范里可没有提到过什么-Xmx参数。光看Java和JVM规范都无法发现这点。纯粹是实现的问题。

于是为啥OOM了呢?不是memory leak,不是哪里的代码出错了,而是纯粹在人为的限制下HotSpot真的没办法为新建对象申请空间了。

至于解决办法嘛,自然是把GC堆的最大空间设大点就行,例如设置-Xmx256m就不会出错了。

你被忽悠到了吗?呵呵,祝大家在国庆假期最后几天保持开心~
Have fun ^ ^
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值