7.2 减少内存使用
在 Java 中,第一种更高效使用内存的方式是减少堆内存的使用。这句话不难理解:堆内存用的越少,堆被填满的几率就越低,需要的 GC 周期也越少。而且有倍乘效应:新生代回收的次数更少,对象的晋升年龄也就不会很频繁地增加,这意味着对象被提升到老年代的可能性也降低了。因此,Full GC 周期(或者是并发 GC 周期)也会减少。而且,如果这些 Full GC 周期能够清理更多内存,它们发生的频率也会降低。
本节将研究三种减少内存使用的方式:减少对象大小、对象的延迟初始化以及使用规范化对象。
7.2.1 减少对象大小
对象会占用一定数量的堆内存,所以要减少内存使用,最简单的方式就是让对象小一些。考虑运行程序的机器的内存限制,增加 10% 的堆有可能是无法做到的,但是堆中一半对象的大小减少 20%,能够实现同样的目标。
减少对象大小有两种方式:减少实例变量的个数(效果很明显),或者减少实例变量的大小(效果没那么明星)。表 7-1 列出了 Java 中不同类型实例变量的大小。
表7-1:Java实例变量的大小
| 类型 | 大小 |
|---|---|
byte
| 1 |
char
| 2 |
short
| 2 |
int
| 4 |
float
| 4 |
long
| 8 |
double
| 8 |
reference
| 在 32 位 JVM 以及堆小于 32 GB 的 64 位 JVM 上是 4;在启用大堆的 64 位 JVM 上是 8a |
注 a:更多细节,参见 8.2.2 节“压缩的 oop”。
这里的引用类型指的是指向任何类型 Java 对象(包括类或数组的实例)的引用。这个空间存储的只是参数本身。如果对象中包含指向其他对象的引用,其大小会因我们想考虑 Shallow Size、Deep Size 还是 Retained size(保留大小)而有所不同,不过其中都会包含一些隐藏的对象头字段。对于普通对象,对象头字段在 32 位 JVM 上占 8 字节,在 64 位 JVM 上占 16 字节(跟堆大小无关)。对于数组,对象头字段在 32 位 JVM 以及堆小于 32 GB 的 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();}
在堆小于 32 GB 的 64 位 Java 7 JVM 上,这几个类的实例实际大小如表 7-2 所示。
表7-2:简单对象的大小
| Shallow size | Deep size | Retained size | |
|---|---|---|---|
| A | 16 | 16 | 16 |
| B | 24 | 216 | 24 |
| C | 24 | 200 | 200 |
在 B 类中,定义 Locale 应用将对象的大小增加了 8 字节,但至少在这个例子中,实际的 Locale 对象是与其他一些类共享的。如果该类实际上从来没用到这个 Locale 对象,那将这个实例包含进来,只会浪费引用所占的额外空间。当然,如果应用创建了大量 B 类的实例,还是会积少成多。
另一方面,定义并创建一个 ConcurrentHashMap,除了对象应用会消耗额外的字节,这个 HashMap 对象还会增加 200 字节。如果这个 HashMap 从来不用,C 的实例就非常浪费。
仅定义需要的实例变量,这是节省对象空间的一种方式。还有一种效果不那么明显的方案,就是使用更小的数据类型。如果某个类需要记录 8 个可能的状态之一,用一个字节就可以了,而不需要一个 int,这就可能会节省 3 字节。使用 float 代替 double,int 代替 long,诸如此类,都可以帮助节省内存,特别是在那些会频繁地实例化的类中。第 12 章将讨论,使用大小适当的集合类(或者使用简单的实例变量代替集合类)可以达到类似的节省空间的目的。
对象对齐与对象大小
表 7-2 中的类,都包含一个额外的整型字段,讨论中并没有引用到。为什么要放这么一个变量呢?
事实上,这个变量的目的是让讨论更容易理解:
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,则更倾向于采用重新计算。
快速小结
1. 减小对象大小往往可以改进 GC 效率。
2. 对象大小未必总能很明显地看出来:对象会被填充到 8 字节的边界,对象引用的大小在 32 位和 64 位 JVM 上也有所不同。
3. 对象内部即使为
null的实例变量也会占用空间。
7.2.2 延迟初始化
正如前面几节所介绍的,很多时候,决定一个特定的实例变量是否需要并不是非黑即白的问题。某个特定的类可能只有 10% 时间需要一个 Calendar 对象,但是 Calendar 对象创建成本很高,所以保留这个对象备用,而不是需要的时候再重新创建,绝对是有意义的。这种情况下,延迟初始化可以带来帮助。
到目前为止,我们所作讨论的前提是假定实例变量很早就会初始化。需要使用一个 Calendar 对象(不需要线程安全)的类看上去可能是这样的:
public class CalDateInitialization {private Calendar calendar = Calendar.getInstance();private DateFormat df = DateFormat.getDateInstance();private void report(Writer w) {w.write("On " + df.format(calendar.getTime()) +": " + 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);}}
如果问题中的这个操作使用不太频繁,那延迟初始化最适合:如果操作很常用,实际上没有节省内存(总是会分配这些实例),而常用操作又有轻微的性能损失。
延迟初始化运行时性能
检查要进行延迟初始化的变量是不是已经被初始化了,未必总会有性能损失。考虑来自 JDK 的
ArrayList类的一个例子。这个类维护着一个所存储元素的数组,在 JDK 7u40 之前,这个类的伪代码看上去就是下面这样:
public class ArrayList {private Object[] elementData = new Object[16];int index = 0;public void add(Object o) {ensureCapacity();elementData[index++] = o;}private void ensureCapacity() {if (index == elementData.length) {……重新分配数组并把老数据复制进来……}}}在 JDK 7u40 中, 这个类有所修改,
elementData数组被延迟初始化了。但是因为ensureCapacity()方法已经需要检查数组大小,这个类的常用方法就不用承受性能损失了:检查是否初始化的代码和检查数组大小是否需要增加的代码是一样的。新的代码使用了一个静态的、共享的 0 长度数组,因此性能也是一样的:
public class ArrayList {private static final Object[] EMPTY_ELEMENTDATA = {} ;private Object[] elementData = EMPTY_ELEMENTDATA;}这意味着
ensureCapacity()方法基本不需要修改,因为index和elementData.length都是从 0 开始的。
当所涉及的代码需要保证线程安全时,延迟初始化会更为复杂。第一步,最简单的方式是添加传统的同步机制:
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) {chm = new ConcurrentHashMap();…… 填充这个map的代码 ……}}……使用chm……}}
因为多个线程可以安全地访问 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();…… 填充这个map的代码instanceChm = chm;}}……使用chm……}}}
这里有些比较重要的多线程相关的问题:实例变量必须用 volatile 来声明,而且将这个实例变量赋值给一个局部变量,性能会有些许改进。第 9 章会介绍更多细节;在多线程代码的延迟初始化确实有意义的特殊场合,应该遵循这种设计模式。
尽早清理
从延迟初始化变量可以推出另一种行为,即通过将变量的值设置为 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 > 0)System.arraycopy(elementData, index+1,elementData, index, numMoved);elementData[--size] = null; // 清理,让GC完成其工作return oldValue;}
JDK 源代码中有一行关于 GC 的注释:像这样将某个变量的值设置为 null,这种操作并不常见,需要解释一下。在这种情况下,我们可以看看当数组的最后一个元素被移除时,会发生什么。仍然存在于数组中的条目数,也就是实例变量 size,会被减 1。比如说 size 从 5 减少到 4。现在不管 elementData[4] 中存的是什么,都不能访问了:它超出了数组的有效范围。
在这种情况下,elementData[4] 是一个过时的引用。elementData 数组可能仍会存活很长时间,因此对于不需要再引用的元素,应该主动将其设置为 null。
过时引用的概念是这里的关键:如果一个长期存活的类会缓存以及丢弃对象引用,那一定要仔细处理,以避免过时引用。否则,显式地将一个对象引用设置为 null 在性能方面基本没什么好处。
快速小结
1. 只有当常用的代码路径不会初始化某个变量时,才去考虑延迟初始化该变量。
2. 一般不会在线程安全的代码上引入延迟初始化,否则会加重现有的同步成本。
3. 对于使用了线程安全对象的代码,如果要采用延迟初始化,应该使用双重检查锁。
7.2.3 不可变对象和标准化对象
在 Java 中,很多对象类型都是不可变的。这包括那些有相应的基本类型的类,如 Integer、Double 和 Boolean 等,以及其他一些基于数值的类型,如 BigDecimal。当然,最常见的 Java 对象当属不可变的 String。从程序设计的角度看,用定制类来表示不可变的对象,往往是个不错的主意。
如果这些对象会快速创建然后丢弃,它们会对 Young GC 多少有些影响;不过如我们在第 5 章所介绍,影响有限。但是和任何对象一样,如果有大量的不可变对象被提升到老年代,性能就会出现问题。
因此,没有理由不设计和使用不可变对象,即使对象无法改变、必须重新创建等特性使其看上去有点事与愿违。不过处理这些对象时往往可以进行一项优化,那就是避免创建同一对象的不同冗余副本。
最好的例子就是 Boolean 类。在任何 Java 应用中,其实只需要两个 Boolean 示例,一个表示 true,一个表示 false。遗憾的是,Boolean 设计得很差。因为它有一个 public 的构造器,应用喜欢创建多少这类对象就能创建多少,即时它们和两个标准化的 Boolean 对象其中之一是完全相同的。更好的设计方案应该是,让 Boolean 类只有一个 private 的构造器,通过 static 方法根据其参数返回 Boolean.TRUE 或 Boolean.FALSE。如果自己的不可变类有这样的一个模型可以遵循,就可以防止它们占用应用中额外的堆空间。(很明显,绝对不应该创建 Boolean 对象;必要的时候应该使用 Boolean.TRUE 或 Boolean.FALSE。)
像这类不可变对象的单一化表示,就被称为对象的标准化(canonical)版本。
创建标准化对象
即便某个特定类的全体对象几乎是无限制的,使用标准化的值通常也可以节省内存。JDK 为大部分常见的不可变对象提供了实现此功能的方法:比如字符串可以调用 intern() 方法找到该字符串的一个标准化版本。下一节将介绍字符串保留(intern)的更多细节,现在我们先看一下对于定制的类如何实现同样功能。
要标准化某个对象,创建一个 Map 来保存该对象的标准化版本。为防止内存泄漏,务必保证使用弱引用处理 Map 中的对象。这样一个类的骨架看上去会是这样的:
public class ImmutableObject {WeakHashMap<ImmutableObject, ImmutableObject> map = new WeakHashMap();public ImmutableObject canonicalVersion(ImmutableObject io) {synchronized(map) {ImmutableObject canonicalVersion = map.get(io);if (canonicalVersion == null) {map.put(io, io);canonicalVersion = io;}return canonicalVersion;}}}
在多线程环境中,此处的同步可能会成为瓶颈。如果想坚持使用 JDK 的类,并没有简单的解决方案,因为 JDK 没有提供支持弱引用的并发 Hashmap。不过,目前有提议向 JDK 中添加一个 CustomConcurrentHashMap 类(最初是 JSR 166 的一部分),另外还可以找一下这种类的各种第三方实现。
快速小结
1. 不可变对象为标准化(canonicalization)这种特殊的生命周期管理提供了可能性。
2. 通过标准化去掉不可变对象的冗余副本,可以极大减少应用消耗的堆内存。
7.2.4 字符串的保留
字符串无疑是最常见的 Java 对象;应用的堆中几乎到处都是字符串。
如果有大量的字符串是相同的,那很大一部分空间都是浪费的。因为字符串是不可变的,所以对于同样的字符序列,没有理由存在多个字符串表示。不过就编程而言,很难确定是不是正在创建重复的字符串。
要知道是不是有大量重复的字符串,需要对堆进行一些分析。方式之一就是在 Eclipse Memory Analyzer 中加载堆转储文件,计算所有 String 对象的保留大小(Retained Size),并按照其最大保留大小将这些对象排序。图 7-6 就是一个这样的堆转储信息。看上去前 3 个字符串是相同的,保留它们能够节省 650 KB 内存。(可以在验证工具中检查这些字符串。)第 4 个和第 5 个,第 7 个到第 9 个,也是这样,当然也有差别,那就是列表中越小的对象,通过保留字符串能节省的内存越少。
这种情况下,保留特定的字符串有优势;仅保留一个标准化版本,可以节省掉副本对象所消耗的空间。这可以用上一节例子中标准化例子的一个变种来实现,不过 String 类提供了自己的标准化方法:intern() 方法。
和大部分优化一样,保留字符串不能随意进行;但是如果有大量重复的字符串,占据了很大一部分堆,这时就很有效果了。关于保留太多字符串,应该注意一点:保留字符串的表是保存在原生内存中的,它是一个大小固定的 Hashtable。在 Java 7u40 之前的版本中,这个表默认有 1009 个桶;平均而言,在因为链接而出现冲突之前,预计可以保存 500 个字符串。在 64 位版本的 Java 7u40 及更新的版本中,默认大小为 60 013。

图 7-6:String 对象保留的内存
大小固定的 Hashtable
如果尚不熟悉 Hashtable 和 Hashmap,你可能想知道到底什么是大小固定的 Hashtable(特别是,这些类的 Java 实现大小都是不固定的)。
从概念上讲,一个 Hashtable 包含一个数组,它会保存一些条目(数组中的每个元素叫作一个桶)。当要将一个对象保存到 Hashtable 中时,可以用该对象的哈希值对桶的数目取余,以此确定对象在数组中的存储位置。这种情况下,两个哈希值不同的对象很有可能被映射到同一个桶中,每个桶实际就是一个链表,其中按顺序存储了映射到该桶的条目。当两个对象映射到一个桶时,这就叫“冲突”。
随着越来越多的对象被插入到这个表中,冲突也会越来越多;进而会有更多的条目被插入到每个链表中。要找到一个条目,就变成了在一个链表中搜索。这可能会非常慢,特别是随着链表越来越长,速度会更慢。
解决方案是设置 Hashtable 的大小,以便它有更多的桶(当然,结果就是冲突会减少)。很多实现都是动态处理的;实际上,Java 的
Hashtable和HashMap也是这么工作的。其他实现,像这里讨论的 JVM 内部的这个,就不能重新设置 Hashtable 的大小;其数组的大小是在创建时就固定的。
其他实现,像这里讨论的 JVM 内部的这个,就不能重新设置 Hashtable 的大小;其数组的大小是在创建时就固定的。
从 Java 7 中开始,这个表的大小可以在 JVM 启动时使用 -XX:StringTableSize=N(如前面所介绍的,默认值为 1009 或 60 013)。如果某个应用会保留大量字符串,就应该增加这个值。如果这个值是个素数,字符串保留表的效率最高。
intern() 方法的性能是由表大小的调优程度所决定的。作为一个例子,表 7-3 列出了在不 同场景下创建和保留 1 千万个随机创建的字符串的总时间:
表7-3:保留1千万个字符串的时间
| 调优 | 用时 |
|---|---|
| 字符串表大小为 1009 | 2.3 小时 |
| 字符串表大小为 1 百万 | 30.4 秒 |
| 字符串表大小为 1 千万 | 25.2 秒 |
| 自定义方式 | 26.4 秒 |
注意,如果字符串保留表的大小设置不当,性能损失会相当严重。一旦根据预期数据设置了该表的大小,性能会极大改善。
最后一个测试用例没有使用 intern() 方法,而是使用了前面介绍的示例 canonicalVersion() 方法,它是用 CustomConcurrentHashMap 类实现的(出自 JSR 166 的一个早期版本),而且用的是非强引用的键和值。与精心优化过的字符串保留表相比,这对性能没什么帮助。不过这种方案也有一个优势,即开发者根本不需要调节其大小。CustomConcurrentHashMap 的初始大小是 1009,它会根据需要动态调整大小。与最大程度优化过的字符串表大小相比,还是有比较小的性能损失,但是运行要容易得多。(不过在那种情况下,代码必须调用定制类的 canonicalVersion() 方法,而不是简单地替换掉 intern() 方法。)
如果想看看字符串表的执行过程,可以使用 -XX:+PrintStringTableStatistics 参数(这个标志要求 JDK 7u6 或更新版本,默认为 false)运行应用。当 JVM 退出时,它会打印一个这样的列表:
StringTable statistics:Number of buckets : 1009Average bucket size : 3008Variance of bucket size : 2870Std. dev. of bucket size: 54Maximum bucket size : 3186
这个命令行也会显示符号表的信息,但是这里我们感兴趣的是字符串表。(符号表用于保存一些类信息。JDK 8 有一个调整该表大小的实验性选项,但是一般不会调整它。)在这个例子中,有 3 035 072 个保留的字符串(因为有 1009 个桶,每个桶平均有 3008 个字符串)。理想情况下,桶的平均大小应该是 0 或 1。这个大小实际上不会为 0,可能会小于 0.5,但是因为计算时用的是整型运算,所以报告中会向下取整。如果平均值大于 1,则需要增大字符串表的大小。
某个应用中已经分配的保留字符串个数(及其总大小),可以使用如下的 jmap 命令获得(这也需要 JDK 7u6 或更新版本):
% jmap -heap process_id…… 其他输入 ……36361 interned Strings occupying 3247040 bytes.
如果将字符串表设得特别大,其损失是非常小的:每个桶只需要 4 字节或 8 字节(取决于使用的是 32 位还是 64 位 JVM),所以比最优的情况多几千,只是一次性消耗一些原生内存(不是堆内存)。
字符串的 Intern 和 Equals
在谈到保留字符串这个主题时,因为保留的字符串可以通过
==操作符比较,那使用intern()方法让程序跑得快一些怎么样呢?这种想法很常见,但是大部分情况下并非如此。String.equals()方法是相当快的。首先要知道,长度不相等的字符串肯定不会相同;即使长度相同,还要扫描字符串,比较所有的字符(至少要找到不匹配的地方)。不可否认,通过==操作比较字符串确实会快一些,但是保留字符串的成本也要考虑进去。这需要(还有其他方面)计算字符串的哈希编码,这意味着要扫描整个字符串,并在每个字符上执行一个操作(就像equals()所做的那样)。只有一种情况下会有好处:应用会在一组长度相同的字符串上执行大量的重复比较。如果字符串都已经保留了,那用
==作比较更快;调用intern()的代价是只需要计算一次。但是一般而言,性能差不多。
快速小结
1. 如果应用中有大量字符串是一样的,那通过保留实现字符串重用收效很大。
2. 要保留很多字符串的应用可能需要调整字符串保留表的大小(除非是运行在 Java 7u40 及更新的 64 位服务器 JVM 上)。
