`
RednaxelaFX
  • 浏览: 3048924 次
  • 性别: Icon_minigender_1
  • 来自: 海外
社区版块
存档分类
最新评论

[标题党] 跑得好好的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编译,执行,却出现
引用
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at TestOOM.main(TestOOM.java:5)

咋了,这两段代码不是几乎一样的么,怎么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

两者基本上是一致的,下面每行代码的意义都对应:
ldc.i4   0x2000                | sipush  8192
newarr   uint8[]               | anewarray #2; //class "[B"
ldc.i4.0                       | iconst_0
ldlen                          | arraylength
newarr   [mscorlib]System.Byte | newarray byte
stelem.ref                     | aastore
ret                            | return

注意到两个版本中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的规范里同样没有规定堆的大小和对象占用空间的大小。概念上说,堆空间可以看成是无限大小的、无序的大块存储空间。实际运行程序时,对象占用空间的大小是虚拟机的实现细节,而有多少堆空间可用也与实现和系统配置相关。

以前一帖里提到过32位CLR 2中int[]的内存布局。CLR的GC堆中对象按4字节边界对齐。对象header占2个DWORD:前一个位于对象起始地址-4的位置,是指向SyncBlock的索引,兼用于GC标记;后一个位于对象起始地址+0的位置,是指向该对象所属类型的MethodTable的指针。
看看本例涉及的两个数组类型的状况。
32位CLR 2中,byte[][]在内存中的布局如下:(括号中数字表示距离数组起始地址的偏移量)
-----------------------
|      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)
-----------------------
|         ...          |
-----------------------

所以一个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(=32784,arrays指向的数组所占空间)
+ (4*3 + 1*8*1024 + 0)(=8204,arrays里每个元素指向的数组所占空间) * 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[][]在内存中的布局如下:
-----------------------
|        _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)
-----------------------
|         ...          |
-----------------------

所以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支持,有java.lang.instrument.Instrumentation接口,其中有个getObjectSize()方法可以得到对象大小。问题是要获取这个接口的实例需要点功夫。详细可以参考这篇文章:Maxim Zakharenkov: Again about determining size of Java object。有趣的是jhat报告的对象大小跟JVMTI报告的不一致,从我调试的实际情况看JVMTI的结果应该是对的。
综上,在Java版的例子中,代码里显式new出来的对象至少需要占用GC堆上的这么多空间:(单位为字节)
  4*3 + 4*8*1024 + 4(=32784,arrays指向的数组所占空间)
+ (4*3 + 1*8*1024 + 4)(=8208,arrays里每个元素指向的数组所占空间) * 8*1024
------------------------
= 67272720
这个大小约等于64.156MB。加上虚拟机和标准类库内部使用的GC堆空间,要正常运行这个测试需要大于64MB的GC堆空间。测试失败,抛出了OOM,那到底是在“什么时候”抛的呢?
把测试例子的代码改为:
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 ^ ^
14
2
分享到:
评论
21 楼 iaimstar 2009-11-05  
fandayrockworld 写道
博主多大啊,掌握这么多东西,还是个女的,强!!

20 楼 RednaxelaFX 2009-10-18  
mikeandmore 写道
mike-laptop% javac TestOOM.java
mike-laptop% java TestOOM
mike-laptop% uname -a
Linux mike-laptop 2.6.31-11-generic #38-Ubuntu SMP Fri Oct 2 11:55:55 UTC 2009 i686 GNU/Linux
mike-laptop% java -version
java version "1.6.0_15"
Java(TM) SE Runtime Environment (build 1.6.0_15-b03)
Java HotSpot(TM) Server VM (build 14.1-b02, mixed mode)

咦?i686么,那就是32位系统。
我是在32位XP、Java 1.6.0u11和u14上测试的,包括client和server;没有在更高版本的JVM上测试。莫非新版本的默认值又改了么……多谢回复,我得重新读一次u14之后的版本的release notes了
19 楼 mikeandmore 2009-10-18  
mike-laptop% javac TestOOM.java
mike-laptop% java TestOOM
mike-laptop% uname -a
Linux mike-laptop 2.6.31-11-generic #38-Ubuntu SMP Fri Oct 2 11:55:55 UTC 2009 i686 GNU/Linux
mike-laptop% java -version
java version "1.6.0_15"
Java(TM) SE Runtime Environment (build 1.6.0_15-b03)
Java HotSpot(TM) Server VM (build 14.1-b02, mixed mode)
18 楼 ddbird 2009-10-13  
结论不稀奇,稀奇的是分析过程,不错!
17 楼 visualcatsharp 2009-10-13  
我承认我被忽悠了。
16 楼 energykey 2009-10-12  
楼主我瞥了一眼你左边的分类。。。被吓着了,您火星来的吧,会这么多,大部分category我连听都没听过,杯具了啊...
15 楼 energykey 2009-10-12  
平时遇到过着问题,一般是把那个限制改大一点就OK了,没有深入原理,你说的这些我看的有点迷糊...不过明白你的意思。
14 楼 fanchangyong 2009-10-12  
好强大啊!
13 楼 RednaxelaFX 2009-10-11  
fandayrockworld 写道
博主多大啊,掌握这么多东西,还是个女的,强!!

Umm...我不是女的,谢谢。我现在用的头像也是某漫画的男主角来的 =v=|||
12 楼 fandayrockworld 2009-10-11  
博主多大啊,掌握这么多东西,还是个女的,强!!
11 楼 fandayrockworld 2009-10-11  
wk,博主好牛13啊,怎么练得?
10 楼 RednaxelaFX 2009-10-09  
火星叔叔马丁 写道
没看之前 猜到是jvm gc64m的问题
果然猜对了

原谅我的实用主义 推理过程被我一笔带过了

呵呵,本来我刚开始写这篇的时候没打算写中间的部分……是因为正好有人碰到了把.NET程序照搬到Java那边遇到了OOM,我正好看到了问题插一腿进去分析了一下告诉他是Xmx的问题,然后想记下来。结果写的时候无聊了想忽悠……才写了中间的部分 orz
我原本是想说.NET跟Java虽然很像,但就在那么小小的细节上不同就得让迁移中的程序员头疼,杯具啊。
9 楼 satanest 2009-10-09  
博主真厉害,微机一定学得很好吧
8 楼 EQualizer 2009-10-08  
当硬核文看的
7 楼 avi2 2009-10-07  
不错,看了开头,猜对了结果,没有明白过程
6 楼 liu78778 2009-10-07  
杯具了
5 楼 elementstorm 2009-10-07  
内牛满面
刚看也觉得是参数问题
看到中间立马受到巨大的打击啊...
4 楼 java.lang.Object 2009-10-07  
果然够标题党的。
3 楼 Saito 2009-10-06  
    每天晚上学日语.. 坚持了3天了.. 

      . .. . .. .  .顺便看看ruby ..
2 楼 RednaxelaFX 2009-10-06  
Saito 写道

居然还没睡……放假还那么刻苦啊?今晚有球赛么?

相关推荐

    C#程序设计——Windows项目开发

    C#,作为微软.NET框架的一部分,为开发者提供了丰富的功能和强大的工具,使得构建高效、稳定的Windows应用变得更为简单。本文将深入探讨C#在Windows项目开发中的关键知识点,并以源程序为例,解析其背后的编程原理。...

    C#调用java程序的方法

    C#调用Java程序的方法 C#调用Java程序的方法是指将Java类转化成dotnet类,在C#项目直接调用。这种方法可以使得C#项目可以调用Java类的方法,从而实现C#和Java之间的交互。 在这个过程中,我们需要使用IKVM(ynamic...

    c#写得内存监控程序

    c# 内存监控程序 物理内存 虚拟内存 c# 内存监控程序 物理内存 虚拟内存

    java调用c#样例

    标题"java调用c#样例"指的是使用Java语言通过Jacob库调用C#编写的动态链接库(DLL)。C# DLL通常包含.NET Framework中的方法和功能,而Java应用程序可以借助Jacob库来访问这些功能,从而实现跨语言的互操作性。 ...

    C#程序设计课程作业——计算器

    【C#程序设计课程作业——计算器】 在C#编程语言中,开发一个计算器小程序是一项常见的学习任务,旨在帮助学生深入理解和应用基本的编程概念。这个项目通常会涵盖以下几个关键知识点: 1. **基本语法和控制结构**...

    C#————连连看程序设计

    【标题】:“C#——连连看程序设计” 在本文中,我们将深入探讨如何使用C#编程语言设计一款经典的连连看游戏。C#是一种强大的、面向对象的编程语言,由微软公司开发,广泛应用于Windows平台上的应用程序开发,包括...

    C# To JAVA Converter v17.10.6

    C# To Java converter是一款将C#代码片段或者C#项目转换为JAVA的工具。 转换所有版本的C#代码 评估所有引用的程序集和.NET项目,以便更完整地解析外部引用 许多转换和格式化选项 将C#代理和lambdas转换为Java接口...

    C# 程序设计 初级者天堂——入门教程 P

    《C#程序设计 初级者天堂——入门教程 P》是一份专为初学者设计的教程,旨在帮助新手快速掌握C#编程语言的基础知识。在3.02m的容量中,它涵盖了C#编程的核心概念,使得学习过程既实用又易懂。以下是本教程可能涉及的...

    截屏程序——源码C#实现

    本项目“截屏程序——源码C#实现”就是利用C#的强大功能来创建一个用户友好的屏幕捕捉工具。这个程序不仅能够实现基本的屏幕截图功能,还通过集成钩子程序DLL实现了键盘事件的监听,以实现特定快捷键触发截屏。 1. ...

    使用C#实现共享内存

    VS2005 C# 共享内存 源代码

    C#和java 之间基于Socket的通信

    本话题主要探讨了两种常用编程语言——Java和C#之间如何利用Socket进行通信。Socket是网络编程的基本接口,允许应用程序通过网络发送和接收数据。以下是关于"Java和C#之间基于Socket的通信"的详细知识点: 1. **...

    JAVA类库转换成C#类库工具

    - IKVM.NET还包含一个Java标准库的.NET实现,这样C#程序就可以使用诸如`java.lang`、`java.util`等Java标准包中的类。 2. **C#引用JAVA类库**: - 使用IKVM,C#开发者可以通过添加对IKVM的引用,并且指定Java库的...

    c#代码转java代码工具

    标题"**C#代码转Java代码工具**"所暗示的知识点是,存在一种工具或技术能够帮助开发者将C#的源代码转化为等效的Java源代码。这通常是因为项目需求变化、跨平台开发或者对不同语言特性的利用。这种转换工具的工作原理...

    C#应用程序开发全程演练——从灵感到实现PDF文件

    《C#应用程序开发全程演练——从灵感到实现》是一本深度探讨C#编程技术与实践应用的书籍。这本书旨在引导读者从创意萌发到实际项目落地的全过程,通过丰富的实例和详细步骤,帮助读者深入理解C#编程语言,并掌握如何...

    C#memo内存监控程序MemCacheMonitor

    “C#memo内存监控程序MemCacheMonitor”是一个使用C#编程语言编写的内存监控工具,专门用于监视和分析系统中的内存使用情况,特别是针对Memcached缓存服务的性能监控。Memcached是一种广泛使用的分布式内存对象缓存...

    C#RSA加密与JAVA解密,实现相互通信

    RSA是一种非对称加密算法,它使用一对密钥——公钥和私钥,公钥用于加密,私钥用于解密,确保了即使数据在网络中被截获,也无法被未经授权的第三方解密。 首先,我们来理解RSA的核心原理。RSA算法基于大数因子分解...

    Java vs C# —— JSP与ASP.NET简单之比较! .doc

    Java vs C# —— JSP与ASP.NET简单之比较! 在这篇文章中,我们将比较Java和C#这两种编程语言,并对比JSP和ASP.NET这两种技术栈的优缺点。 Java vs C# Java和C#都是面向对象的编程语言,它们都继承了面向对象编程...

    JAVA通过JNI调用C#dll的整个项目工程

    Java通过JNI调用C# DLL是一个跨平台、跨语言的技术实践,主要应用于需要利用Java的稳定性和C#的高性能场景。JNI(Java Native Interface)是Java平台标准的一部分,它允许Java代码和其他语言写的代码进行交互。C# ...

    C#程序设计代码——经典案例

    在C#编程语言中,这里提供了几个经典案例,涵盖了判断质数、找出1到100之间的所有质数以及实现基本的计算器功能。以下是对这些案例的详细解释: 1. **判断一个数是否为质数**: - 第一种方法是通过循环检查输入的...

    TimeClock——用C#语言编写的一个时钟程序

    【标题】"TimeClock——用C#语言编写的一个时钟程序"揭示了这是一个利用C#编程语言实现的桌面时钟应用。C#是Microsoft开发的一种面向对象的编程语言,广泛用于构建Windows桌面应用程序、Web应用以及游戏等。在这个...

Global site tag (gtag.js) - Google Analytics