一起学习
摘要:
过去很多年里面,许多的Java开发人员都一直在问一个问题:“一个Java对象到底耗费多少内存呢?”在本文中,Vladimir Roubtsov用以前的解决方案来解释了这个问题,在此之外,基于他的经验演示了内存的使用,并且还提供了一些技巧来让你的Java程序更加高效。
作者:Vladimir Roubtsov
近来,我们帮助开发了一个Java服务器,这是一个类似内存数据库的应用。那就是说,我们特别强调设计,因为设计要特别考虑在内存中缓存大量的数据来提高查询的性能。
一旦我们得到运行的原型,那么在数据从硬盘上宰入经过分析以后,我们自然的决定了数据存储的轮廓。不太满意的初始效果,促使我们寻找更好的解决方案。
工具:
既然Java有目的的隐藏了很多内存管理的细节信息,要发现你的对象要消耗多少内存的确要花一些功夫。你可以使用Runtime.freeMemory()方法来测量在一个或者多个对象被分配前前后的堆的变化值。有一些文章,例如(Ramchander Varadarajan's "Question of the Week No. 107" (Sun Microsystems, September 2000) and Tony Sintes's "Memory Matters" (JavaWorld, December 2001))都详细地介绍了这些方法。但是不幸的是,这些先前的文章的解决方案的失败在于实现中使用了一个错误的Runtime方法。即使后来的文章也有它的不完整性。
l 调用Runtime.freeMemory() 方法提供的功能是不够的,因为JVM可以在任何时候(只要需要,特别是在运行垃圾收集的时候)决定增加它的当前的堆大小。除非在运行的时候已经使用了参数-Xmx指定了堆的最大值,否则我们应该使用Runtime.totalMemory()-Runtime.freeMemory()作为在使用的堆大小。
l 执行单个Runtime.gc()方法并不能保证有效的请求垃圾收集。举例来说,我们可以请求对象的finalizer运行正常。既然Runtime.gc()不能保证阻塞到垃圾处理,那么一直等待到当堆大小稳定以后是一个很好的办法。
l 如果轮廓类创建了一个静态的数据作为先前的类初始化的一部分,那么堆内存对于第一个类实例的分配的空间应该包括这个数据。我们应该忽略被第一个类实例消耗的堆空间。
考虑这些问题:我们给出了一个Sizeof,作为一个工具来查看各种Java核心和应用类。
public class Sizeof
{
public static void main (String [] args) throws Exception
{
// Warm up all classes/methods we will use
runGC ();
usedMemory ();
// Array to keep strong references to allocated objects
final int count = 100000;
Object [] objects = new Object [count];
long heap1 = 0;
// Allocate count 1 objects, discard the first one
for (int i = -1; i < count; i)
{
Object object = null;
// Instantiate your data here and assign it to object
object = new Object ();
//object = new Integer (i);
//object = new Long (i);
//object = new String ();
//object = new byte [128][1]
if (i >= 0)
objects [i] = object;
else
{
object = null; // Discard the warm up object
runGC ();
heap1 = usedMemory (); // Take a before heap snapshot
}
}
runGC ();
long heap2 = usedMemory (); // Take an after heap snapshot:
final int size = Math.round (((float)(heap2 - heap1))/count);
System.out.println ("'before' heap: " heap1
", 'after' heap: " heap2);
System.out.println ("heap delta: " (heap2 - heap1)
", {" objects [0].getClass () "} size = " size " bytes");
for (int i = 0; i < count; i) objects [i] = null;
objects = null;
}
private static void runGC () throws Exception
{
// It helps to call Runtime.gc()
// using several method calls:
for (int r = 0; r < 4; r) _runGC ();
}
private static void _runGC () throws Exception
{
long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE;
for (int i = 0; (usedMem1 < usedMem2) && (i < 500); i)
{
s_runtime.runFinalization ();
s_runtime.gc ();
Thread.currentThread ().yield ();
usedMem2 = usedMem1;
usedMem1 = usedMemory ();
}
}
private static long usedMemory ()
{
return s_runtime.totalMemory () - s_runtime.freeMemory ();
}
private static final Runtime s_runtime = Runtime.getRuntime ();
} // End of class
Sizeof的关键方法是runGC() 和usedMemory()方法,我使用了runGC()这样的封装方法来调用_runGC()几次,是为了让这个方法有更加明显的效果。
注意我调用runGC()方法的地方,你可以在heap1和heap2编辑你的代码,加入你任何你感兴趣的例子。
也请注意Sizeof怎么样输出对象的大小,数据的传递闭包要求被用所得count类实例所用到,然后被count整除。对于大多数类来说,这个结果将是单个类实例对象所耗费的内存大小,包括所有它自己的成员域。内存的界限值不同于由一些商业的工具报告的影子内存界限(比如说,如果一个对象有一个int[],那么它的内存消耗将显得很特别)。
结果:
让我们来对一些类使用这个工具,察看是否结果和我们预想的一样。
注意:以下的结果都是基于Windows平台的jdk1.3.1版本,并不能保证所有的平台或者jdk版本都得到相同的信息。
l java.lang.Object
这个所有对象的基类作为我们的第一个例子。对于java.lang.Object我们将得到:
'before' heap: 510696, 'after' heap: 1310696
heap delta: 800000, {class java.lang.Object} size = 8 bytes
所以,一个简单的Object对象要占用8个字节的内存空间,当然我们不能希望它所占的空间是0,因为每个实例都至少必须包含一些最基本的操作,比如equals(), hashCode(), wait()/notify()等。
l java.lang.Integer
我和我的同事都经常封装本地的int到Integer的实例中去,以便于我们能在集合的对象中使用它们,那样做到底要耗费多少内存呢?
'before' heap: 510696, 'after' heap: 2110696
heap delta: 1600000, {class java.lang.Integer} size = 16 bytes
这个16字节的结果比我们预想的要糟糕,因为一个int值恰好是4个字节,但是使用了Integer以后,多使用了3倍的空间。
l java.lang.Long
Long看起来应该比Integer使用更多的空间,可是事实并非如此:
'before' heap: 510696, 'after' heap: 2110696
heap delta: 1600000, {class java.lang.Long} size = 16 bytes
很明显,因为一种特别的JVM实现必须符合特定的CPU类型,所以事实上的对象大小在堆中所占的空间必须和低级的内存边界对齐。看起来一个Long是一个8字节的大小的Object对象加上8字节用来保存long值。相比之下,Integer就有4个字节没有使用的空间。所以,应该是JVM强制对象使用8字节作为字的边界。
l Arrays
接下来比较一些基本类型的数组,比较有指导意义,能够部分的发现一些隐藏的信息和证明另一些流行的诡计:使用一个size-1的数组封装基本类型来当作对象。通过修改Sizeof.main()来使用一个循环增加数组的长度。然后能够得到int数组:
length: 0, {class [I} size = 16 bytes
length: 1, {class [I} size = 16 bytes
length: 2, {class [I} size = 24 bytes
length: 3, {class [I} size = 24 bytes
length: 4, {class [I} size = 32 bytes
length: 5, {class [I} size = 32 bytes
length: 6, {class [I} size = 40 bytes
length: 7, {class [I} size = 40 bytes
length: 8, {class [I} size = 48 bytes
length: 9, {class [I} size = 48 bytes
length: 10, {class [I} size = 56 bytes
还有一些char数组:
length: 0, {class [C} size = 16 bytes
length: 1, {class [C} size = 16 bytes
length: 2, {class [C} size = 16 bytes
length: 3, {class [C} size = 24 bytes
length: 4, {class [C} size = 24 bytes
length: 5, {class [C} size = 24 bytes
length: 6, {class [C} size = 24 bytes
length: 7, {class [C} size = 32 bytes
length: 8, {class [C} size = 32 bytes
length: 9, {class [C} size = 32 bytes
length: 10, {class [C} size = 32 bytes
从以上可以看出,8个字节的边界很明显的表现出来了。同时,肯定包含不可避免的8个字节的Object头部,然后基本数据类型的数组占用其它的8个字节。使用int[1]和Integer相比,看起来不能提供任何的内存使用,除了可以作为一个同样数据的可变版本。
l 多维数组
多维数组有另外的一个惊人之处。开发者普遍的使用一个构造函数例如int[dim1][dim2]用于数字或者科学计算。在一个int[dim1][dim2]的数组实例中,每一个嵌套的int[dim2]都是一个对象,并且每一个对象都加上一个16字节的数组对象头。当我不需要一个三角的或者粗糙的数组,那个代表着纯粹的头部。当维数增加时,影响增加很大。举例来说,一个int[128][2]的实例占用3600字节,使用着246%的头部。在特别的例子byte[256][1]中,这个头部因素已经是19!和C/C 的解决方案相比,同样的语法不会增加这么多的内存消耗。
l java.lang.String
让我们来测试一个空串,现构造一个new String():
'before' heap: 510696, 'after' heap: 4510696
heap delta: 4000000, {class java.lang.String} size = 40 bytes
结果提供了一种相当不好的现象,就是一个空的String就要占用40字节的大小,足够用来保存20个字符了。
在我们使用包含内容的字符串以前,我们使用一个帮组方法来创建一个字符串。不过使用以下文字来创建:
object = "string with 20 chars";
将不会工作,因为所有的这样的对象操作将结束于同一个字符串实例。语言规范中明确表明如此的行为(java.lang.String.intern()),因此使用如下:
public static String createString (final int length)
{
char [] result = new char [length];
for (int i = 0; i < length; i) result [i] = (char) i;
return new String (result);
}
在这样的创建函数以后,得到如此的结果:
length: 0, {class java.lang.String} size = 40 bytes
length: 1, {class java.lang.String} size = 40 bytes
length: 2, {class java.lang.String} size = 40 bytes
length: 3, {class java.lang.String} size = 48 bytes
length: 4, {class java.lang.String} size = 48 bytes
length: 5, {class java.lang.String} size = 48 bytes
length: 6, {class java.lang.String} size = 48 bytes
length: 7, {class java.lang.String} size = 56 bytes
length: 8, {class java.lang.String} size = 56 bytes
length: 9, {class java.lang.String} size = 56 bytes
length: 10, {class java.lang.String} size = 56 bytes
结果很明显的表明了字符串的内存增加轨迹。但是字符串要增加一个24字节的头部。对于非空的字符串,如果字符个数少于10个或者更少,这个增加的头部将消耗相对于有效的负载(2个字节对于每个字符,加上4个作为长度)在100%到400%之间变化。
我们能做点什么呢?
“这很好,但是我们没有任何选择除了使用String和其它Java提供的类型,是不是这样呢?”我听到你们再问,那么让我们来找找答案吧。
l 封装类
封装类比如java.lang.Integer,看起来保存大量的数据在内存中像一个坏的选择。如果你尽力为了内存的经济,那么就要避免这么做。使用你自己的针对int的向量类并不难。当然,如果Java的核心函数库已经包含了这个那就最好不过了。或许这种情况在Java拥有特殊类型的时候将会大大改观。
l 多位数组
对于使用多维数组的大型的数据结构,你可以时常的通过简单的索引变换减少额外的维数/例如:转换int[dim1][dim2]的实例到一个int[dim1*dim2]的实例,改变所有形如a[i][j]的表达式为a[i*dim1 j]。这样你就不必花功夫在dim1上的索引检查可以提高效率。
l java.lang.String
你可以使用一些小技巧去减少你的应用中字符串的静态内存大小。
首先,你可以尝试一种很平常的技术,就是当一个应用从一个数据文件或者网络连接中载入或者缓存很多的字符串,并且这种字符串的值是有限变化的。举个例子:如果你想分析一个XML文件,在这个文件中,你经常遇到某种属性,但是这个属性仅仅被限制在两个可能的值。你的目标:通过一个散列映射过滤所有的字符串,减少所有相同的但是明显字符串和目标对象引用一样的。
public String internString (String s)
{
if (s == null) return null;
String is = (String) m_strings.get (s);
if (is != null)
return is;
else
{
m_strings.put (s, s);
return s;
}
}
private Map m_strings = new HashMap ();
如果适用成功,这个技巧能够成倍的减少你的静态内存需求。一个富有经验的读者应该能够观察到这个技巧复制java.lang.String.intern()的功能性。有无数的理由存在来让你避免使用String.intern()方法。其中一个就是现在的JVM几乎没有能实现大量数据的保留。
如果你的字符串是完全不同的,会发生什么情况呢?这就是要介绍的第二个技巧,重新收集那些小的字符串空间,这些空间潜在的隐藏于char数组中,因为使用数组大概只占了字符串封装所占用的内存的一半。因此,当我们的应用缓存许多独特的字符传值,我们仅仅只要保持在内存中的数组,在需要的时候转换为字符串。如果这个字符串只是作为暂时的,很快就会抛弃,这将很有效果。一个简单的实验就是从一个字典文件中选出作为缓存的90000个单词,这些数据大约5.6M的大小,如果是char的话,只需要3.4M的空间,只占用了以前的65%。
第二个技巧明显的包含一个不利条件,就是你不能支持通过一个构造函数转换一个char[]成为字符串,因为这个构造函数没有复制这个数组而将拥有这个数组。为什么呢?因为这个完全的public的字符串API确保每一个字符串是不可变的,所以每个字符串的构造函数显然要复制输入的数据然后传入它的参数。
然后,我们将使用第三个技巧。这个技巧用在当转换一个char数组为一个字符串的代价证实太高的时候。该技巧使用java.lang.String.substr()的功能避免数据复制:这个方法的是显示用了字符串的不变性,并且创建的一个影子字符串对象来共享字符内容,但是它的内部的开始位置和结束位置都是正确的。我们还是写一个例子,new String(“smiles”).substring(1,5)是一个字符串,这个字符串是字符缓冲从位置1到位置4的字符结束,并且这个字符缓冲将共享原来的字符串构造函数指向的字符缓冲。你可以象一下这样使用:给出一个大的字符串集合,你可以合并它的字符内容到一个大的字符数组,在它之上创建一个字符串,并且使用这个主串的子串来重新创建一个原来的字符串。如以下描述:
public static String [] compactStrings (String [] strings)
{
String [] result = new String [strings.length];
int offset = 0;
for (int i = 0; i < strings.length; i)
offset = strings [i].length ();
// Can't use StringBuffer due to how it manages capacity
char [] allchars = new char [offset];
offset = 0;
for (int i = 0; i < strings.length; i)
{
strings [i].getChars (0, strings [i].length (), allchars, offset);
offset = strings [i].length ();
}
String allstrings = new String (allchars);
offset = 0;
for (int i = 0; i < strings.length; i)
result [i] = allstrings.substring (offset,
offset = strings [i].length ());
return result;
}
以上方法返回一个新的字符串集等同于输入的字符串集,但是在内存中更加得紧凑。重新获得每个字符串数组的16个字节的头部,在方法中被有效的移除。这个存储在缓存压缩大多数短的字符串时比较有效果。当这个方法用于同样的90000个单词的字典时,内存从5.6M节约到4.2M,大概节约了30%。
l 这些努力是否值得呢?
我这里提到的方法看起来都是很细微的优化,是否值得花时间去实现呢?但是,记住我们脑子里面应该记住:服务端的应用程序能够缓存大量的数据在内存中的话讲能够大大的提高从磁盘和数据库提取数据的性能和效率。在当前32位的JVM中,几百兆的缓存数据代表堆中很引人注意的位置。减少30%或者更多不应该被嘲笑,它能将系统的可测性质中能提高很显著的水平。当然这些技巧不适用于一开始就很好设计的数据结构,事实的决定要由hotspots来决定。无论如何,你现在应该更加了解你的对象消耗了多少内存。
关于作者:
Vladimir Roubtsov拥有超过12年的多种语言的编程经验,其中包括从1995年就开始用得Java。目前,它作为资深开发者在Austin, Texas.为Trilogy开发企业级软件。平时的业余爱好就是开发一些关于Java字节代码或源程序代码的工具。
下载本文示例代码
你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?你知道数据大小吗?