英文原文:Code Instructions,翻译:ImportNew - 郑雯
原文于2008年11月13日发表, 2008年12月18日更新:这里还有一篇关于Java的Sizeof运算符的实用库的文章。
学C/C++出身的我,对Java有一点非常困惑,那就是缺乏计算对象占用内存大小的机制。而在C++中就可以通过sizeof运算符来获得基本类型以及类实例的大小。C和C++中的这个操作符对于指针运算、内存拷贝和IO操作都非常有用。
Java中并没有一个类似的运算符。事实上,Java也不需要这种运算符。Java中基本类型的大小在语言规范中已经定义了,而C/C++中基本类型大小则跟平台相关。Java有自己的通过序列化构建的IO框架。再者,由于Java中没有指针,因此指针运算和内存块拷贝之类的操作也不存在。
但是,Java程序员有时还是希望能知道一个Java对象到底用了多少内存的。不过这个问题的答案并不简单。
首先要区分清楚的是shallow size和deep size。Shallow size是指对象自身占用的内存大小,其引用对象的大小不算在内。而deepsize,则是自身所占内存大小和其递归引用的所有对象所占内存大小的总和。大多数情况下,你会希望获得一个对象的deep size,但是为了知道这个值,首先要知道怎么算shallowsize,下面我来介绍一下。
有人抱怨JVM规范中没有针对运行时Java对象的内存结构的说明,这也就是说JVM供应商可以按照自己的需要来实现这一点。后果就是,同一个类在不同的JVM上运行的实例对象占用的内存大小会有差别。好在是世界上大部分人(包括我在内)都使用Sun HotSpot虚拟机,这就大大简化了这个问题。我们接下来的讨论也会基于32位的Sun公司的JVM。下面我介绍一些规则来辅助解释JVM如何组织对象在内存中的布局的。
没有实例属性的类的内存布局
在Sun JVM中,(除了数组之外的)对象都有两个机器字(words)的头部。第一个字中包含这个对象的标示哈希码以及其他一些类似锁状态和等标识信息,第二个字中包含一个指向对象的类的引用。另外,任何对象都是8个字节为粒度进行对齐的。这就是对象内存布局的第一个规则:
规则1:任何对象都是8个字节为粒度进行对齐的。
比如,如果调用new Object(),由于Object类并没有其他没有其他可存储的成员,那么仅仅使用堆中的8个字节来保存两个字的头部即可。
继承了Object的类的内存布局
除了上面所说的8个字节的头部,类属性紧随其后。属性通常根据其大小来排列。例如,整型(int)以4个字节为单位对齐,长整型(long)以8个字节为单位对齐。这里是出于性能考虑而这么设计的:通常情况下,如果数据以4字节为单位对齐,那么从内存中读4字节的数据并写入到处理器的4字节寄存器是性价比更高的。
为了节省内存,Sun VM并没有按照属性声明时的顺序来进行内存布局。实际上,属性在内存中按照下面的顺序来组织:
1.双精度型(doubles)和长整型(longs)
2.整型(ints)和浮点型(floats)
3.短整型(shorts)和字符型(chars)
4.布尔型(booleans)和字节型(bytes)
5.引用类型(references)
内存使用率会通过这个机制得到优化。例如,如下声明一个类:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class MyClass {
byte a;
int c;
boolean d;
long e;
Object f;
} |
如果JVM并没有打乱属性的声明顺序,其对象内存布局将会是下面这个样子:
1 2 3 4 5 6 7 8 9 |
[HEADER: 8 bytes] 8 [a: 1 byte ] 9 [padding: 3 bytes] 12 [c: 4 bytes] 16 [d: 1 byte ] 17 [padding: 7 bytes] 24 [e: 8 bytes] 32 [f: 4 bytes] 36 [padding: 4 bytes] 40 |
此时,用于占位的14个字节是浪费的,这个对象一共使用了40个字节的内存空间。但是,如果用上面的规则对这些对象重新排序,其内存结果会变成下面这个样子:
1 2 3 4 5 6 7 8 |
[HEADER: 8 bytes] 8 [e: 8 bytes] 16 [c: 4 bytes] 20 [a: 1 byte ] 21 [d: 1 byte ] 22 [padding: 2 bytes] 24 [f: 4 bytes] 28 [padding: 4 bytes] 32 |
这次,用于占位的只有6个字节,这个对象使用了32个字节的内存空间。
因此,对象内存布局的第二个规则是:
规则2:类属性按照如下优先级进行排列:长整型和双精度类型;整型和浮点型;字符和短整型;字节类型和布尔类型,最后是引用类型。这些属性都按照各自的单位对齐。
现在我们知道如何计算一个继承了Object的类的实例的内存大小了。下面这个例子用来做下练习:java.lang.Boolean。这是其内存布局:
1 2 3 |
[HEADER: 8 bytes] 8 [value: 1 byte ] 9 [padding: 7 bytes] 16 |
Boolean类的实例占用16个字节的内存!惊讶吧?(别忘了最后用来占位的7个字节)。
继承其他类的子类的内存布局
JVM所遵守的下面3个规则用来组织有父类的类的成员。对象内存布局的规则3如下:
规则3:不同类继承关系中的成员不能混合排列。首先按照规则2处理父类中的成员,接着才是子类的成员。
举例如下:
1 2 3 4 5 6 7 8 9 |
class A { long a; int b; int c; }
class B extends A { long d; } |
类B的实例在内存中的存储如下:
1 2 3 4 5 |
[HEADER: 8 bytes] 8 [a: 8 bytes] 16 [b: 4 bytes] 20 [c: 4 bytes] 24 [d: 8 bytes] 32 |
如果父类中的成员的大小无法满足4个字节这个基本单位,那么下一条规则就会起作用:
规则4:当父类中最后一个成员和子类第一个成员的间隔如果不够4个字节的话,就必须扩展到4个字节的基本单位。
举例如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
class A { byte a; }
class B { byte b; } [HEADER: 8 bytes] 8 [a: 1 byte ] 9 [padding: 3 bytes] 12 [b: 1 byte ] 13 [padding: 3 bytes] 16 |
注意到成员a被扩充了3个字节以保证和成员b之间的间隔是4个字节。这个空间不能被类B使用,因此被浪费了。
最后一条规则在下面情况下用来节省一些空间:如果子类成员是长整型或双精度类型,并且父类并没有用完8个字节。
规则5:如果子类第一个成员是一个双精度或者长整型,并且父类并没有用完8个字节,JVM会破坏规则2,按照整形(int),短整型(short),字节型(byte),引用类型(reference)的顺序,向未填满的空间填充。
举例如下:
1 2 3 4 5 6 7 8 9 |
class A { byte a; }
class B { long b; short c; byte d; } |
其内存布局如下:
1 2 3 4 5 6 7 |
[HEADER: 8 bytes] 8 [a: 1 byte ] 9 [padding: 3 bytes] 12 [c: 2 bytes] 14 [d: 1 byte ] 15 [padding: 1 byte ] 16 [b: 8 bytes] 24 |
在第12字节处,类A“结束”的地方,JVM没有遵守规则2,而是在长整型之前插入一个短整型和一个字节型成员,这样可以避免浪费3个字节。
数组的内存布局
数组有一个额外的头部成员,用来存放“长度”变量。数组元素以及数组本身,跟其他常规对象同样,都需要遵守8个字节的边界规则。
下面是一个有3个元素的字节数组的内存布局:
1 2 3 4 5 |
[HEADER: 12 bytes] 12 [[0]: 1 byte ] 13 [[1]: 1 byte ] 14 [[2]: 1 byte ] 15 [padding: 1 byte ] 16 |
下面是一个有3个元素的长整型数字的内存布局:
1 2 3 4 5 |
[HEADER: 12 bytes] 12 [padding: 4 bytes] 16 [[0]: 8 bytes] 24 [[1]: 8 bytes] 32 [[2]: 8 bytes] 40 |
内部类的内存布局
非静态内部类(Non-static inner classes)有一个额外的“隐藏”成员,这个成员是一个指向外部类的引用变量。这个成员是一个普通引用,因此遵守引用内存布局的规则。内部类因此有4个字节的额外开销。
最后的一点想法
我们已经学习了在32位Sun JVM中如何计算Java对象的shallow size。知道内存是如何组织的有助于理解类实例占用的内存数。
下一篇文章中,会有些示例代码,这些代码会把相关内容整理到一起,用反射(reflection)来计算一个对象的deep size。如果你感兴趣,请订阅此源或者等待这个博客的更新吧!
英文原文:Code Instructions,翻译:ImportNew - 郑雯
译文链接: http://www.importnew.com/1305.html
Java的内存模型分为主存储区和工作存储区。主存储区保存了Java中所有的实例。也就是说,在我们使用new来建立一个对象后,这个对象及它内部的方法、变量等都保存在这一区域,在MyThread类中的n就保存在这个区域。主存储区可以被所有线程共享。而工作存储区就是我们前面所讲的线程栈,在这个区域里保存了在run方法以及run方法所调用的方法中定义的变量,也就是方法变量。在线程要修改主存储区中的变量时,并不是直接修改这些变量,而是将它们先复制到当前线程的工作存储区,在修改完后,再将这个变量值覆盖主存储区的相应的变量值。
在了解了Java的内存模型后,就不难理解为什么n++也不是原子操作了。它必须经过一个拷贝、加1和覆盖的过程。这个过程和在MyThread类中模拟的过程类似。大家可以想象,如果在执行到getfield时,thread1由于某种原因被中断,那么就会发生和MyThread类的执行结果类似的情况。要想彻底解决这个问题,就必须使用某种方法对n进行同步,也就是在同一时间只能有一个线程操作n,这也称为对n的原子操作。