Java性能权威指南-总结13
- 堆内存最佳实践
- 减少内存使用
- 减少对象大小
- 延迟初始化
堆内存最佳实践
减少内存使用
减少对象大小
对象会占用一定数量的堆内存,所以要减少内存使用,最简单的方式就是让对象小一些。考虑运行程序的机器的内存限制,增加10%的堆有可能是无法做到的,但是堆中一半对象的大小减少20%,能够实现同样的目标。
减少对象大小有两种方式:减少实例变量的个数(效果很明显),或者减少实例变量的大小(效果没那么明星)。下表列出了Java中不同类型实例变量的大小:
这里的引用类型指的是指向任何类型Java对象(包括类或数组的实例)的引用。这个空间存储的只是参数本身。如果对象中包含指向其他对象的引用,其大小会因想考虑Shallow Size,Deep Size还是Retained size(保留大小)而有所不同,不过其中都会包含一些隐藏的对象头字段。对于普通对象,对象头字段在32位JVM上占8字节,在64位JVM上占16字节(跟堆大小无关)。对于数组,对象头字段在32位JVM以及堆小于32GB的64位JVM上占16字节,其他情况下是64字节。
例如,考虑这几个类定义:
public class A {
private int i;
}
public class B {
private int i
private Locale l = Locale.US;
}
public class C {
private int i;
private ConcurrentHashMap chm = new ConcurrentHashMap();
}
在堆小于32GB的64位Java7JVM上,这几个类的实例实际大小如下表所示:
在B类中,定义Locale应用将对象的大小增加了8字节,但至少在这个例子中,实际的Locale对象是与其他一些类共享的。如果该类实际上从来没用到这个Locale对象,那将这个实例包含进来,只会浪费引用所占的额外空间。当然,如果应用创建了大量B类的实例,还是会积少成多。
另一方面,定义并创建一个ConcurrentHashMap,除了对象应用会消耗额外的字节,这个HashMap对象还会增加200字节。如果这个HashMap从来不用,C的实例就非常浪费。
仅定义需要的实例变量,这是节省对象空间的一种方式。还有一种效果不那么明显的方案,就是使用更小的数据类型。如果某个类需要记录8个可能的状态之一,用一个字节就可以了,而不需要一个int,这就可能会节省3字节。使用float代替double,int代替long,诸如此类,都可以帮助节省内存,特别是在那些会频繁地实例化的类中。使用大小适当的集合类(或者使用简单的实例变量代替集合类)可以达到类似的节省空间的目的。
对象对齐与对象大小
上表中的类,都包含一个额外的整型字段,讨论中并没有引用到。为什么要放这么一个变量呢?
事实上,这个变量的目的是让讨论更容易理解:B类比A类多8字节,正是所期望的(这样更明确)。
这掩盖了一个重要细节:为使对象大小是8字节的整数倍(对齐),总是会有填充操作。如果在A类中没有定义i,A的实例仍然会消耗16字节,其中4字节只是用于填充,
使得对象大小是8的整数倍,而不是用于保存i。如果没有定义i,B类的实例将仅消耗16字节,和A一样,即便B中还有额外的对象引用。B中仅包含一个额外的4字节引用,
为什么其实例会比A的实例多8字节呢,也是填充的问题。
JVM也会填充字节数不规则的对象,这样不管底层架构最适合什么样的地址边界,对象的数组都能优雅地适应。
因此,去掉某个实例字段或者减少某个字段的大小,未必能带来好处,不过没有理由不这么做。
去掉对象中的实例字段,有助于减少对象的大小,不过还有一个灰色地带:有些字段会保存基于一些数据计算而来的结果,这该如何处理呢?这就是计算机科学中典型的时间空间权衡问题:是消耗内存(空间)保存这个值更好,还是在需要时花时间(CPU周期)计算这个值更好?不过在Java中,权衡还会考虑CPU时间,因为额外的内存占用会引发GC消耗更多CPU周期。
比如,String
的哈希码值(hashcode)就是对一个涉及该字符串中每个字符的式子求和计算而来的;计算会消耗一点时间。因此,String
类会把这个值存在一个实例变量中,这样哈希码值只需要计算一次:最后,与不存储这个值而节省的内存空间相比,重用几乎总能获得更好的性能。另一方面,大部分类的toString()
方法不会把对象的字符串表示保存在一个实例变量中,因为实例变量及其引用的字符串都会消耗内存。相反,与保存字符串引用所需的内存相比,计算一个新的字符串所花的时间通常不是很多,性能更好。(还有一个因素,String
对象的哈希码值用的较为频繁,而对象的toString()
表示使用却很少。)当然,这种情况必定是因人而异的。就时间/空间的连续体而言,究竟是使用内存来存储值,还是重新计算值,都是取决于许多具体因素的。如果目标是减少GC,则更倾向于采用重新计算。
快速小结
- 减小对象大小往往可以改进GC效率。
- 对象大小未必总能很明显地看出来:对象会被填充到8字节的边界,对象引用的大小在32位和64位JVM上也有所不同。
- 对象内部即使为null的实例变量也会占用空间。
延迟初始化
很多时候,决定一个特定的实例变量是否需要并不是非黑即白的问题。某个特定的类可能只有10%时间需要一个Calendar
对象,但是Calendar
对象创建成本很高,所以保留这个对象备用,而不是需要的时候再重新创建,绝对是有意义的。这种情况下,延迟初始化可以带来帮助。
到目前为止,所作讨论的前提是假定实例变量很早就会初始化。需要使用一个Calendar
对象(不需要线程安全)的类看上去可能是这样的:
public class CalDateInitialization {
private Calendar calendar = Calendar.getInstance();
private DateFormat df = DateFormat.getDateInstance();
private void report(Nriter w) {
w.write("On" + df.format(calendar.getTine()) + ":" + this);
}
}
要延迟初始化其字段,在计算性能上会有一点小小的损失,代码每次执行时都必须测试变量的状态:
public class CalDateInitialization {
private Calendar calendar;
private DateFormat df;
private void report(Writer w) {
if (calendar == null){
calendar = Calendar.getInstance();
df = DateFormat.getDateInstance();
}
w.write("On" + df.format(calendar.getTime()) + ":" + this);
}
}
如果问题中的这个操作使用不太频繁,那延迟初始化最适合:如果操作很常用,实际上没有节省内存(总是会分配这些实例),而常用操作又有轻微的性能损失。
当所涉及的代码需要保证线程安全时,延迟初始化会更为复杂。第一步,最简单的方式是添加传统的同步机制:
public class CalDateInitialization {
private Calendar calendar;
private DateFormat df;
private synchronized void report(Writer w) {
if(calendar == null) {
calendar = Calendar.getInstance();
df = DateFormat.getDateInstance();
}
w.write("On" + df.format(calendar.getTime()) + ":" + this);
}
在解决方案中引入同步,会使得同步也有可能成为性能瓶颈。不过这种情况很罕见。对于问题中的对象而言,只有当初始化这些字段的几率很低时,延迟初始化才有性能方面的好处。因为,如果一般情况下都会初始化这些字段,那实际上也不会节省内存。因此对于延迟初始化的字段,当不常用的代码路径突然被大量线程同时使用时,同步就会成为瓶颈。这种情况是可以想象的,不过好在并不多见。
只有延迟初始化的变量本身是线程安全的,才有可能解决同步瓶颈。DateFormat
对象不是线程安全的,所以在现在的这个例子中,锁中是否包含Calendar
对象并不重要:如果延迟初始化的对象突然被频频使用,那无论如何,围绕DateFormat
对象所需的同步都会成为问题。线程安全的代码应该是这样的:
public class CalDateInitialization {
private Calendar calendar;
private DateFormat df;
private void report(Writer w) {
unsychronizedCalendarInit();
synchronized(df) {
w.write("On" + df.format(calendar.getTime()) + ":" + this);
}
}
}
涉及非线程安全的实例变量的延迟初始化,总会围绕这个变量做同步(例如,像前面所示的那样使用方法的同步版本)。
考虑一个有点不一样的例子,其中有一个比较大的ConcurrentHashMap
对象,就采用了延迟初始化:
public class CHMInitialization {
private ConcurrentHashMap chm;
public void dooperation() {
synchronized(this) {
if(chm == null) {
chv = new ConcurrentHashMap();
}
}
}
}
因为多个线程可以安全地访问ConcurrentHashMap
,所以这个例子中的多余的同步,就是一种不太常见的情况,因为即便是恰当地使用延迟初始化,也引入了同步瓶颈。(不过这种瓶颈应该极为少见;如果这个HashMap访问非常频繁,那就应该考虑延迟初始化到底有什么好处了。)该瓶颈可以使用双重检查锁这种惯用法来解决:
public class CHMInitialization {
private volatile ConcurrentHashMap instanceChm;
public void dooperation() {
ConcurrentHashMap chm = instanceChm;
if (chm == null) {
synchronized(this) {
chm = instanceChm;
if (chm == null) {
chm = new ConcurrentHashMap();
instanceChm = chm;
}
}
}
这里有些比较重要的多线程相关的问题:实例变量必须用volatile
来声明,而且将这个实例变量赋值给一个局部变量,性能会有些许改进。
尽早清理
从延迟初始化变量可以推出另一种行为,即通过将变量的值设置为null,实现尽早清理,从而使问题中的对象可以更快地被垃圾收集器回收。不过这只是理论上听着不错,真正能发挥作用的场合很有限。
可以选择延迟初始化的变量,可能看上去也可以选择尽早清理:在上面的例子中,一完成report()
方法,Calendar和DateFormat对象就可以设置为null了。然而,如果后面再调用到这个方法(或者同一个类中的其他地方)时,并没有用到该变量,那最初就没有理由将其设计为实例变量:在方法中创建一个局部变量就可以了,而且当方法完成时,局部变量就会离开作用域,然后垃圾收集器就可以释放它了。
不需要尽早清理变量,这个规则有个很常见的例外情况,即对于类似Java集合类框架中的那些类:它们会在较长的时间内保存一些指向数据的引用,当问题中的数据不再需要时会通知它们。考虑JDK中ArrayList类的remove()
方法的实现(部分代码有所简化):
public E remove(int index) {
E oldValue = elementData(index);
int numMoved = size - index - 1;
if(numMoved>θ)
System.arraycopy(elementData, index+1,
elementData, index,numMoved);
elementData[--size]= null;//清理,让GC完成其工作
return oldValue;
}
JDK源代码中有一行关于GC的注释:像这样将某个变量的值设置为null,这种操作并不常见,需要解释一下。在这种情况下,我们可以看看当数组的最后一个元素被移除时,会发生什么。仍然存在于数组中的条目数,也就是实例变量size,会被减1。比如说size从5减少到4。现在不管elementData中存的是什么,都不能访问了:它超出了数组的有效范围。
在这种情况下,elementData是一个过时的引用。elementData数组可能仍会存活很长时间,因此对于不需要再引用的元素,应该主动将其设置为null。
过时引用的概念是这里的关键:如果一个长期存活的类会缓存以及丢弃对象引用,那一定要仔细处
理,以避免过时引用。否则,显式地将一个对象引用设置为null在性能方面基本没什么好处。
快速小结
- 只有当常用的代码路径不会初始化某个变量时,才去考虑延迟初始化该变量。
- 一般不会在线程安全的代码上引入延迟初始化,否则会加重现有的同步成本。
- 对于使用了线程安全对象的代码,如果要采用延迟初始化,应该使用双重检查锁。