Java的内存布局

简介:

from:https://www.ibm.com/developerworks/cn/java/j-codetoheap/

从 Java 代码到 Java 堆

理解和优化您的应用程序的内存使用

Chris Bailey, Java 支持架构师, IBM

简介: 本文将为您提供 Java™ 代码内存使用情况的深入见解,包括将 int 值置入一个Integer 对象的内存开销、对象委托的成本和不同集合类型的内存效率。您将了解到如何确定应用程序中的哪些位置效率低下,以及如何选择正确的集合来改进您的代码。

发布日期: 2012 年 3 月 29 日 
级别: 中级 
原创语言: 英文 
访问情况 : 8573 次浏览 
评论: 4 (查看 | 添加评论 - 登录)

平均分 5 星 共 33 个评分 平均分 (33个评分)
为本文评分

优化应用程序代码的内存使用并不是一个新主题,但是人们通常并没有很好地理解这个主题。本文将简要介绍 Java 进程的内存使用,随后深入探讨您编写的 Java 代码的内存使用。最后,本文将展示提高代码内存效率的方法,特别强调了 HashMap 和 ArrayList等 Java 集合的使用。

背景信息:Java 进程的内存使用

参考知识

如需进一步了解 Java 应用程序的进程内存使用,请参阅 Andrew Hall 撰写的 developerWorks 文章 “内存详解”。这篇文章介绍了 内存详解 以及 AIX® 提供的布局和用户空间,以及 Java 堆和本机堆之间的交互。

通过在命令行中执行 java 或者启动某种基于 Java 的中间件来运行 Java 应用程序时,Java 运行时会创建一个操作系统进程,就像您运行基于 C 的程序时那样。实际上,大多数 JVM 都是用 C 或者 C++ 语言编写的。作为操作系统进程,Java 运行时面临着与其他进程完全相同的内存限制:架构提供的寻址能力以及操作系统提供的用户空间。

架构提供的内存寻址能力依赖于处理器的位数,举例来说,32 位或者 64 位,对于大型机来说,还有 31 位。进程能够处理的位数决定了处理器能寻址的内存范围:32 位提供了 2^32 的可寻址范围,也就是 4,294,967,296 位,或者说 4GB。而 64 位处理器的可寻址范围明显增大:2^64,也就是 18,446,744,073,709,551,616,或者说 16 exabyte(百亿亿字节)。

处理器架构提供的部分可寻址范围由 OS 本身占用,提供给操作系统内核以及 C 运行时(对于使用 C 或者 C++ 编写的 JVM 而言)。OS 和 C 运行时占用的内存数量取决于所用的 OS,但通常数量较大:Windows 默认占用的内存是 2GB。剩余的可寻址空间(用术语来表示就是用户空间)就是可供运行的实际进程使用的内存。

对于 Java 应用程序,用户空间是 Java 进程占用的内存,实际上包含两个池:Java 堆和本机(非 Java)堆。Java 堆的大小由 JVM 的 Java 堆设置控制:-Xms 和 -Xmx 分别设置最小和最大 Java 堆。在按照最大的大小设置分配了 Java 堆之后,剩下的用户空间就是本机堆。图 1 展示了一个 32 位 Java 进程的内存布局:


图 1. 一个 32 位 Java 进程的内存布局示例
一个 32 位 Java 进程的内存布局示例视图 

在 图 1 中,可寻址范围总共有 4GB,OS 和 C 运行时大约占用了其中的 1GB,Java 堆占用了将近 2GB,本机堆占用了其他部分。请注意,JVM 本身也要占用内存,就像 OS 内核和 C 运行时一样,而 JVB 占用的内存是本机堆的子集。

回页首

Java 对象详解

在您的 Java 代码使用 new 操作符创建一个 Java 对象的实例时,实际上分配的数据要比您想的多得多。例如,一个 int 值与一个Integer 对象(能包含 int 值的最小对象)的大小比率是 1:4,这个比率可能会让您感到吃惊。额外的开销源于 JVM 用于描述 Java 对象的元数据,在本例中也就是 Integer

根据 JVM 的版本和供应的不同,对象元数据的数量也各有不同,但其中通常包括:

  • :一个指向类信息的指针,描述了对象类型。举例来说,对于 java.lang.Integer 对象,这是 java.lang.Integer 类的一个指针。
  • 标记:一组标记,描述了对象的状态,包括对象的散列码(如果有),以及对象的形状(也就是说,对象是否是数组)。
  • :对象的同步信息,也就是说,对象目前是否正在同步。

对象元数据后紧跟着对象数据本身,包括对象实例中存储的字段。对于 java.lang.Integer 对象,这就是一个 int

如果您正在运行一个 32 位 JVM,那么在创建 java.lang.Integer 对象实例时,对象的布局可能如图 2 所示:


图 2. 一个 32 位 Java 进程的 java.lang.Integer 对象的布局示例
一个 32 位 Java 进程的 java.lang.Integer 对象的布局示例 

如 图 2 所示,有 128 位的数据用于存储 int 值内的 32 位数据,而对象元数据占用了其余 128 位。

回页首

Java 数组对象详解

数组对象(例如一个 int 值数组)的形状和结构与标准 Java 对象相似。主要差别在于数组对象包含说明数组大小的额外元数据。因此,数据对象的元数据包括:

  • :一个指向类信息的指针,描述了对象类型。举例来说,对于 int 字段数组,这是 int[] 类的一个指针。
  • 标记:一组标记,描述了对象的状态,包括对象的散列码(如果有),以及对象的形状(也就是说,对象是否是数组)。
  • :对象的同步信息,也就是说,对象目前是否正在同步。
  • 大小:数组的大小。

图 3 展示了一个 int 数组对象的布局示例:


图 3. 一个 32 位 Java 进程的 int 数组对象的布局示例
一个 32 位进程的 int 数组对象的布局示例 

如 图 3 所示,有 160 位的数据用于存储 int 值内的 32 位数据,而数组元数据占用了其余 160 位。对于 byteint 和 long 等原语,从内存的方面考虑,单项数组比对应的针对单一字段的包装器对象(ByteInteger 或 Long)的成本更高。

回页首

更为复杂数据结构详解

良好的面向对象设计与编程鼓励使用封装(提供接口类来控制数据访问)和委托(使用 helper 对象来实施任务)。封装和委托会使大多数数据结构的表示形式中包含多个对象。一个简单的示例就是 java.lang.String 对象。java.lang.String 对象中的数据是一个字符数组,由管理和控制对字符数组的访问的 java.lang.String 对象封装。图 4 展示了一个 32 位 Java 进程的java.lang.String 对象的布局示例:


图 4. 一个 32 位 Java 进程的 java.lang.String 对象的布局示例
一个 32 位 Java 进程的 java.lang.String 对象的布局示例 

如 图 4 所示,除了标准对象元数据之外,java.lang.String 对象还包含一些用于管理字符串数据的字段。通常情况下,这些字段是散列值、字符串大小计数、字符串数据偏移量和对于字符数组本身的对象引用。

这也就意味着,对于一个 8 个字符的字符串(128 位的 char 数据),需要有 256 位的数据用于字符数组,224 位的数据用于管理该数组的 java.lang.String 对象,因此为了表示 128 位(16 个字节)的数据,总共需要占用 480 位(60 字节)。开销比例为 3.75:1。

总体而言,数据结构越是复杂,开销就越高。下一节将具体讨论相关内容。

回页首

32 位和 64 位 Java 对象

之前的示例中的对象大小和开销适用于 32 位 Java 进程。在 背景信息:Java 进程的内存使用 一节中提到,64 位处理器的内存可寻址能力比 32 位处理器高得多。对于 64 位进程,Java 对象中的某些数据字段的大小(特别是对象元数据或者表示另一个对象的任何字段)也需要增加到 64 位。其他数据字段类型(例如 intbyte 和 long )的大小不会更改。图 5 展示了一个 64 位 Integer 对象和一个 int 数组的布局:


图 5. 一个 64 位进程的 java.lang.Integer 对象和 int 数组的布局示例
一个 64 位 Java 进程的 java.lang.Integer 对象和 int 数组的布局示例 

图 5 表明,对于一个 64 位 Integer 对象,现在有 224 位的数据用于存储 int 字段所用的 32 位,开销比例是 7:1。对于一个 64 位单元素 int 数组,有 288 位的数据用于存储 32 位 int 条目,开销比例是 9:1。这在实际应用程序中产生的影响在于,之前在 32 位 Java 运行时中运行的应用程序若迁移到 64 位 Java 运行时,其 Java 堆内存使用量会显著增加。通常情况下,增加的数量是原始堆大小的 70% 左右。举例来说,一个在 32 位 Java 运行时中使用 1GB Java 堆的 Java 应用程序在迁移到 64 位 Java 运行时之后,通常需要使用 1.7GB 的 Java 堆。

请注意,这种内存增加并非仅限于 Java 堆。本机堆内存区使用量也会增加,有时甚至要增加 90% 之多。

表 1 展示了一个应用程序在 32 位和 64 位模式下运行时的对象和数组字段大小:


表 1. 32 位和 64 位 Java 运行时的对象中的字段大小
字段类型 字段大小(位)
对象 数组
32 位 64 位 32 位 64 位
boolean 32 32 8 8
byte 32 32 8 8
char 32 32 16 16
short 32 32 16 16
int 32 32 32 32
float 32 32 32 32
long 32 32 64 64
double 32 32 64 64
对象字段 32 64 (32*) 32 64 (32*)
对象元数据 32 64 (32*) 32 64 (32*)

* 对象字段的大小以及用于各对象元数据条目的数据的大小可通过 压缩引用或压缩 OOP 技术减小到 32 位。

压缩引用和压缩普通对象指针 (OOP)

IBM 和 Oracle JVM 分别通过压缩引用 (-Xcompressedrefs) 和压缩 OOP (-XX:+UseCompressedOops) 选项提供对象引用压缩功能。利用这些选项,即可在 32 位(而非 64 位)中存储对象字段和对象元数据值。在应用程序从 32 位 Java 运行时迁移到 64 位 Java 运行时的时候,这能消除 Java 堆内存使用量增加 70% 的负面影响。请注意,这些选项对于本机堆的内存使用无效,本机堆在 64 位 Java 运行时中的内存使用量仍然比 32 位 Java 运行时中的使用量高得多。

回页首

Java 集合的内存使用

在大多数应用程序中,大量数据都是使用核心 Java API 提供的标准 Java Collections 类来存储和管理的。如果内存占用对于您的应用程序极为重要,那么就非常有必要了解各集合提供的功能以及相关的内存开销。总体而言,集合功能的级别越高,内存开销就越高,因此使用提供的功能多于您需要的功能的集合类型会带来不必要的额外内存开销。

其中部分最常用的集合如下:

除了 HashSet 之外,此列表是按功能和内存开销进行降序排列的。(HashSet 是包围一个 HashMap 对象的包装器,它提供的功能比HashMap 少,同时容量稍微小一些。)

Java 集合:HashSet

HashSet 是 Set 接口的实现。Java Platform SE 6 API 文档对于 HashSet 的描述如下:

一个不包含重复元素的集合。更正式地来说,set(集)不包含元素 e1 和 e2 的配对 e1.equals(e2),而且至多包含一个空元素。正如其名称所表示的那样,这个接口将建模数学集抽象。

HashSet 包含的功能比 HashMap 要少,只能包含一个空条目,而且无法包含重复条目。该实现是包围 HashMap 的一个包装器,以及管理可在 HashMap 对象中存放哪些内容的 HashSet 对象。限制 HashMap 功能的附加功能表示 HashSet 的内存开销略高。

图 6 展示了 32 位 Java 运行时中的一个 HashSet 的布局和内存使用:


图 6. 32 位 Java 运行时中的一个 HashSet 的内存使用和布局
32 位 Java 运行时中的一个 HashSet 的内存使用和布局 

图 6 展示了一个 java.util.HashSet 对象的 shallow 堆(独立对象的内存使用)以及保留堆(独立对象及其子对象的内存使用),以字节为单位。shallow 堆的大小是 16 字节,保留堆的大小是 144 字节。创建一个 HashSet 时,其默认容量(也就是该集中可以容纳的条目数量)将设置为 16 个条目。按照默认容量创建 HashSet,而且未在该集中输入任何条目时,它将占用 144 个字节。与HashMap 的内存使用相比,超出了 16 个字节。表 2 显示了 HashSet 的属性:


表 2. 一个 HashSet 的属性
默认容量 16 个条目
空时的大小 144 个字节
开销 16 字节加 HashMap 开销
一个 10K 集合的开销 16 字节加 HashMap 开销
搜索/插入/删除性能 O(1):所用时间是一个常量时间,无论要素数量如何都是如此(假设无散列冲突)

Java 集合:HashMap

HashMap 是 Map 接口的实现。Java Platform SE 6 API 文档对于 HashMap 的描述如下:

一个将键映射到值的对象。一个映射中不能包含重复的键;每个键仅可映射到至多一个值。

HashMap 提供了一种存储键/值对的方法,使用散列函数将键转换为存储键/值对的集合中的索引。这允许快速访问数据位置。允许存在空条目和重复条目;因此,HashMap 是 HashSet 的简化版。

HashMap 将实现为一个 HashMap$Entry 对象数组。图 7 展示了 32 位 Java 运行时中的一个 HashMap 的内存使用和布局:


图 7. 32 位 Java 运行时中的一个 HashMap 的内存使用和布局
32 位 Java 运行时中的一个 HashMap 的内存使用和布局 

如 图 7 所示,创建一个 HashMap 时,结果是一个 HashMap 对象以及一个采用 16 个条目的默认容量的 HashMap$Entry 对象数组。这提供了一个 HashMap,在完全为空时,其大小是 128 字节。插入 HashMap 的任何键/值对都将包含于一个 HashMap$Entry 对象之中,该对象本身也有一定的开销。

大多数 HashMap$Entry 对象实现都包含以下字段:

  • int KeyHash
  • Object next
  • Object key
  • Object value

一个 32 字节的 HashMap$Entry 对象用于管理插入集合的数据键/值对。这就意味着,一个 HashMap 的总开销包含 HashMap 对象、一个HashMap$Entry 数组条目和与各条目对应的 HashMap$Entry 对象的开销。可通过以下公式表示:

HashMap 对象 + 数组对象开销 + (条目数量 * ( HashMap$Entry 数组条目 +  HashMap$Entry 对象))

对于一个包含 10,000 个条目的 HashMap 来说,仅仅 HashMapHashMap$Entry 数组和 HashMap$Entry 对象的开销就在 360K 左右。这还没有考虑所存储的键和值的大小。

表 3 展示了 HashMap 的属性:


表 3. 一个 HashMap 的属性
默认容量 16 个条目
空时的大小 128 个字节
开销 64 字节加上每个条目 36 字节
一个 10K 集合的开销 ~360K
搜索/插入/删除性能 O(1):所用时间是一个常量时间,无论要素数量如何都是如此(假设无散列冲突)

Java 集合:Hashtable

Hashtable 与 HashMap 相似,也是 Map 接口的实现。Java Platform SE 6 API 文档对于 Hashtable 的描述如下:

这个类实现了一个散列表,用于将键映射到值。对于非空对象,可以将它用作键,也可以将它用作值。

Hashtable 与 HashMap 极其相似,但有两项限制。无论是键还是值条目,它均不接受空值,而且它是一个同步集合。相比之下,HashMap 可以接受空值,且不是同步的,但可以利用 Collections.synchronizedMap() 方法来实现同步。

Hashtable 的实现同样类似于 HashMap,也是条目对象的数组,在本例中即 Hashtable$Entry 对象。图 8 展示了 32 位 Java 运行时中的一个 Hashtable 的内存使用和布局:


图 8. 32 位 Java 运行时中的一个 Hashtable 的内存使用和布局
32 位 Java 运行时中的一个 Hashtable 的内存使用和布局 

图 8 显示,创建一个 Hashtable 时,结果会是一个占用了 40 字节的内存的 Hashtable 对象,另有一个默认容量为 11 个条目的Hashtable$entry 数组,在 Hashtable 为空时,总大小为 104 字节。

Hashtable$Entry 存储的数据实际上与 HashMap 相同:

  • int KeyHash
  • Object next
  • Object key
  • Object value

这意味着,对于 Hashtable 中的键/值条目,Hashtable$Entry 对象也是 32 字节,而 Hashtable 开销的计算和 10K 个条目的集合的大小(约为 360K)与 HashMap 类似。

表 4 显示了 Hashtable 的属性:


表 4. 一个 Hashtable 的属性
默认容量 11 个条目
空时的大小 104 个字节
开销 56 字节加上每个条目 36 字节
一个 10K 集合的开销 ~360K
搜索/插入/删除性能 O(1):所用时间是一个常量时间,无论要素数量如何都是如此(假设无散列冲突)

如您所见,Hashtable 的默认容量比 HashMap 要稍微小一些(分别是 11 与 16)。除此之外,两者之间的主要差别在于 Hashtable 无法接受空键和空值,而且是默认同步的,但这可能是不必要的,还有可能降低集合的性能。

Java 集合:LinkedList

LinkedList 是 List 接口的链表实现。Java Platform SE 6 API 文档对于 LinkedList 的描述如下:

一种有序集合(也称为序列)。此接口的用户可以精确控制将各元素插入列表时的位置。用户可以按照整数索引(代表在列表中的位置)来访问元素,也可以搜索列表中的元素。与其他集合 (set) 不同,该集合 (collection) 通常允许存在重复的元素。

实现是 LinkedList$Entry 对象链表。图 9 展示了 32 位 Java 运行时中的 LinkedList 的内存使用和布局:


图 9. 32 位 Java 运行时中的一个 LinkedList 的内存使用和布局
32 位 Java 运行时中的一个 LinkedList 的内存使用和布局 

图 9 表明,创建一个 LinkedList 时,结果将得到一个占用 24 字节内存的 LinkedList 对象以及一个 LinkedList$Entry 对象,在LinkedList 为空时,总共占用的内存是 48 个字节。

链表的优势之一就是能够准确调整其大小,且无需重新调整。默认容量实际上就是一个条目,能够在添加或删除条目时动态扩大或缩小。每个 LinkedList$Entry 对象仍然有自己的开销,其数据字段如下:

  • Object previous
  • Object next
  • Object value

但这比 HashMap 和 Hashtable 的开销低,因为链表仅存储单独一个条目,而非键/值对,由于不会使用基于数组的查找,因此不需要存储散列值。从负面角度来看,在链表中查找的速度要慢得多,因为链表必须依次遍历才能找到需要查找的正确条目。对于较大的链表,结果可能导致漫长的查找时间。

表 5 显示了 LinkedList 的属性:


表 5. 一个 LinkedList 的属性
默认容量 1 个条目
空时的大小 48 个字节
开销 24 字节加上每个条目 24 字节
一个 10K 集合的开销 ~240K
搜索/插入/删除性能 O(n):所用时间与元素数量线性相关。

Java 集合:ArrayList

ArrayList 是 List 接口的可变长数组实现。Java Platform SE 6 API 文档对于 ArrayList 的描述如下:

一种有序集合(也称为序列)。此接口的用户可以精确控制将各元素插入列表时的位置。用户可以按照整数索引(代表在列表中的位置)来访问元素,也可以搜索列表中的元素。与其他集合 (set) 不同,该集合 (collection) 通常允许存在重复的元素。

不同于 LinkedListArrayList 是使用一个 Object 数组实现的。图 10 展示了一个 32 位 Java 运行时中的 ArrayList 的内存使用和布局:


图 10. 32 位 Java 运行时中的一个 ArrayList 的内存使用和布局
32 位 Java 运行时中的一个 ArrayList 的内存使用和布局 

图 10 表明,在创建 ArrayList 时,结果将得到一个占用 32 字节内存的 ArrayList 对象,以及一个默认大小为 10 的 Object 数组,在 ArrayList 为空时,总计占用的内存是 88 字节。这意味着 ArrayList 无法准确调整大小,因此拥有一个默认容量,恰好是 10 个条目。

表 6 展示了一个 ArrayList 的属性:


表 6. 一个 ArrayList 的属性
默认容量 10
空时的大小 88 个字节
开销 48 字节加上每个条目 4 字节
一个 10K 集合的开销 ~40K
搜索/插入/删除性能 O(n):所用时间与元素数量线性相关

其他类型的 “集合”

除了标准集合之外,StringBuffer 也可以视为集合,因为它管理字符数据,而且在结构和功能上与其他集合相似。Java Platform SE 6 API 文档对于 StringBuffer 的描述如下:

线程安全、可变的字符序列……每个字符串缓冲区都有相应的容量。只要字符串缓冲区内包含的字符序列的长度不超过容量,就不必分配新的内部缓冲区数组。如果内部缓冲区溢出,则会自动为其扩大容量。

StringBuffer 是作为一个 char 数组来实现的。图 11 展示了一个 32 位 Java 运行时中的 StringBuffer 的内存使用和布局:


图 11. 32 位 Java 运行时中的一个 StringBuffer 的内存使用和布局
32 位 Java 运行时中的一个 StringBuffer 的内存使用和布局 

图 11 展示,创建一个 StringBuffer 时,结果将得到一个占用 24 字节内存的 StringBuffer 对象,以及一个默认大小为 16 的字符数组,在 StringBuffer 为空时,数据总大小为 72 字节。

与集合相似,StringBuffer 拥有默认容量和重新调整大小的机制。表 7 显示了 StringBuffer 的属性:


表 7. 一个 StringBuffer 的属性
默认容量 16
空时的大小 72 个字节
开销 24 个字节
一个 10K 集合的开销 24 个字节
搜索/插入/删除性能 不适用

回页首

集合中的空白空间

拥有给定数量对象的各种集合的开销并不是内存开销的全部。前文的示例中的度量假设集合已经得到了准确的大小调整。然而,对于大多数集合来说,这种假设都是不成立的。大多数集合在创建时都指定给定的初始容量,数据将置入集合之中。这也就是说,集合拥有的容量往往大于集合中存储的数据容量,这造成了额外的开销。

考虑一个 StringBuffer 的示例。其默认容量是 16 个字符条目,大小为 72 字节。初始情况下,72 个字节中未存储任何数据。如果您在字符数组中存储了一些字符,例如 "MY STRING" ,那么也就是在 16 个字符的数组中存储了 9 个字符。图 12 展示了 32 位 Java 运行时中的一个包含 "MY STRING" 的 StringBuffer 的内存使用和布局:


图 12. 32 位 Java 运行时中的一个包含 "MY STRING" 的 StringBuffer 的内存使用
32 位运行时中的一个包含 'MY STRING' 的 StringBuffer 的内存使用 

如 图 12 所示,数组中有 7 个可用的字符条目未被使用,但占用了内存,在本例中,这造成了 112 字节的额外开销。对于这个集合,您在 16 的容量中存储了 9 个条目,因而填充率 为 0.56。集合的填充率越低,因多余容量而造成的开销就越高。

回页首

集合的扩展和重新调整

在集合达到容量限制时,如果出现了在集合中存储额外条目的请求,那么会重新调整集合,并扩展它以容纳新条目。这将增加容量,但往往会降低填充比,造成更高的内存开销。

各集合所用的扩展算法各有不同,但一种通用的做法就是将集合的容量加倍。这也是 StringBuffer 采用的方法。对于前文示例中的StringBuffer,如果您希望将 " OF TEXT" 添加到缓冲区中,生成 "MY STRING OF TEXT",则需要扩展集合,因为新的字符集合拥有 17 个条目,当前容量 16 无法满足其要求。图 13 展示了所得到的内存使用:


图 13. 32 位 Java 运行时中的一个包含 "MY STRING OF TEXT" 的 StringBuffer 的内存使用
32 位运行时中的一个包含 'MY STRING OF TEXT' 的 StringBuffer 的内存使用 

现在,如 图 13 所示,您得到了一个 32 个条目的字符数组,但仅仅使用了 17 个条目,填充率为 0.53。填充率并未显著下滑,但您现在需要为多余的容量付出 240 字节的开销。

对于小字符串和集合,低填充率和多余容量的开销可能并不会被视为严重问题,而在大小增加时,这样的问题就会愈加明显,代价也就愈加高昂。例如,如果您创建了一个 StringBuffer,其中仅包含 16MB 的数据,那么(在默认情况下)它将使用大小设置为可容纳 32MB 数据的字符数组,这造成了以多余容量形式存在的 16MB 的额外开销。

回页首

Java 集合:汇总

表 8 汇总了集合的属性:


表 8. 集合属性汇总
集合 性能 默认容量 空时的大小 10K 条目的开销 准确设置大小? 扩展算法
HashSet O(1) 16 144 360K x2
HashMap O(1) 16 128 360K x2
Hashtable O(1) 11 104 360K x2+1
LinkedList O(n) 1 48 240K +1
ArrayList O(n) 10 88 40K x1.5
StringBuffer O(1) 16 72 24 x2

Hash 集合的性能比任何 List 的性能都要高,但每条目的成本也要更高。由于访问性能方面的原因,如果您正在创建大集合(例如,用于实现缓存),那么最好使用基于 Hash 的集合,而不必考虑额外的开销。

对于并不那么注重访问性能的较小集合而言,List 则是合理的选择。ArrayList 和 LinkedList 集合的性能大体相同,但其内存占用完全不同:ArrayList 的每条目大小要比 LinkedList 小得多,但它不是准确设置大小的。List 要使用的正确实现是 ArrayList 还是LinkedList 取决于 List 长度的可预测性。如果长度未知,那么正确的选择可能是 LinkedList,因为集合包含的空白空间更少。如果大小已知,那么 ArrayList 的内存开销会更低一些。

选择正确的集合类型使您能够在集合性能与内存占用之间达到合理的平衡。除此之外,您可以通过正确调整集合大小来最大化填充率、最小化未得到利用的空间,从而最大限度地减少内存占用。

回页首

集合的实际应用:PlantsByWebSphere 和 WebSphere Application Server Version 7

在 表 8 中,创建一个包含 10,000 个条目、基于 Hash 的集合的开销是 360K。考虑到,复杂的 Java 应用程序常常使用大小为数 GB 的 Java 堆运行,因此这样的开销看起来并不是非常高,当然,除非使用了大量集合。

表 9 展示了在包含五个用户的负载测试中运行 WebSphere® Application Server Version 7 提供的 PlantsByWebSphere 样例应用程序时,Java 堆使用的 206MB 中的集合对象使用量:


表 9. WebSphere Application Server v7 中的 PlantsByWebSphere 的集合使用量
集合类型 实例数量 集合总开销 (MB)
Hashtable 262,234 26.5
WeakHashMap 19,562 12.6
HashMap 10,600 2.3
ArrayList 9,530 0.3
HashSet 1,551 1.0
Vector 1,271 0.04
LinkedList 1,148 0.1
TreeMap 299 0.03
总计 306,195 42.9

通过 表 9 可以看到,这里使用了超过 30 万个不同的集合,而且仅集合本身(不考虑其中包含的数据)就占用了 206MB 的 Java 堆用量中的 42.9MB(21%)。这就意味着,如果您能更改集合类型,或者确保集合的大小更加准确,那么就有可能实现可观的内存节约。

回页首

通过 Memory Analyzer 查找低填充率

IBM Java 监控和诊断工具(Memory Analyzer 工具是在 IBM Support Assistant 中提供的)可以分析 Java 集合的内存使用情况(请参阅 参考资料 部分)。其功能包括分析集合的填充率和大小。您可以使用这样的分析来识别需要优化的集合。

Memory Analyzer 中的集合分析位于 Open Query Browser -> Java Collections 菜单中,如图 14 所示:


图 14. 在 Memory Analyzer 中分析 Java 集合的填充率
在 Memory Analyzer 中分析 Java 集合的填充率 

在判断当前大小超出需要的大小的集合时,图 14 中选择的 Collection Fill Ratio 查询是最有用的。您可以为该查询指定多种选项,这些选项包括:

  • 对象:您关注的对象类型(集合)
  • 分段:用于分组对象的填充率范围

将对象选项设置为 "java.util.Hashtable"、将分段选项设置为 "10",之后运行查询将得到如图 15 所示的输出结果:


图 15. 在 Memory Analyzer 中对 Hashtable 的填充率分析
在 Memory Analyzer 中对 Hashtable 的分析 

图 15 表明,在 java.util.Hashtable 的 262,234 个实例中,有 127,016 (48.4%) 的实例完全未空,几乎所有实例都仅包含少量条目。

随后便可识别这些集合,方法是选择结果表中的一行,右键单击并选择 list objects -> with incoming references,查看哪些对象拥有这些集合,或者选择 list objects -> with outgoing references,查看这些集合中包含哪些条目。图 16 展示了查看对于空Hashtable 的传入引用的结果,图中展开了一些条目:


图 16. 在 Memory Analyzer 中对于空 Hashtable 的传入引用的分析 
在 Memory Analyzer 中对于空 Hashtable 的传入引用的分析 

图 16 表明,某些空 Hashtable 归 javax.management.remote.rmi.NoCallStackClassLoader 代码所有。

通过查看 Memory Analyzer 左侧面板中的 Attributes 视图,您就可以看到有关 Hashtable 本身的具体细节,如图 17 所示:


图 17. 在 Memory Analyzer 中检查空 Hashtable 
在 Memory Analyzer 中检查孔 Hashtable 

图 17 表明,Hashtable 的大小为 11(默认大小),而且完全是空的。

对于 javax.management.remote.rmi.NoCallStackClassLoader 代码,可以通过以下方法来优化集合使用:

  • 延迟分配 Hashtable:如果 Hashtable 为空是经常发生的普遍现象,那么仅在存在需要存储的数据时分配 Hashtable 应该是一种合理的做法。
  • 将 Hashtable 分配为准确的大小:由于使用默认大小,因此完全可以使用更为准确的初始大小。

这些优化是否适用取决于代码的常用方式以及通常存储的是哪些数据。

PlantsByWebSphere 示例中的空集合

表 10 展示了分析 PlantsByWebSphere 示例中的集合来确定哪些集合为空时的分析结果:


表 10. WebSphere Application Server v7 中 PlantsByWebSphere 的空集合使用量
集合类型 实例数量 空实例 空实例百分比
Hashtable 262,234 127,016 48.4
WeakHashMap 19,562 19,465 99.5
HashMap 10,600 7,599 71.7
ArrayList 9,530 4,588 48.1
HashSet 1,551 866 55.8
Vector 1,271 622 48.9
总计 304,748 160,156 52.6

表 10 表明,平均而言,超过 50% 的集合为空,也就是说通过优化集合使用能够实现可观的内存占用节约。这种优化可以应用于应用程序的各个级别:应用于 PlantsByWebSphere 示例代码中、应用于 WebSphere Application Server 中,以及应用于 Java 集合类本身。

在 WebSphere Application Server 版本 7 与版本 8 之间,我们做出了一些努力来改进 Java 集合和中间件层的内存效率。举例来说,java.util.WeahHashMap 实例的开销中,有很大一部分比例源于其中包含用来处理弱引用的 java.lang.ref.ReferenceQueue 实例。图 18 展示了 32 位 Java 运行时中的一个 WeakHashMap 的内存布局:


图 18. 32 位 Java 运行时中的一个 WeakHashMap 的内存布局
32 位 Java 运行时中的一个 WeakHashMap 的内存布局 

图 18 表明,ReferenceQueue 对象负责保留占用 560 字节的数据,即便在 WeakHashMap 为空、不需要 ReferenceQueue 的情况下也是如此。对于 PlantsByWebSphere 示例来说,在空 WeakHashMap 的数量为 19,465 的情况下,ReferenceQueue 对象将额外增加 10.9MB 的非必要数据。在 WebSphere Application Server 版本 8 和 IBM Java 运行时的 Java 7 发布版中,WeakHashMap 得到了一定的优化:它包含一个 ReferenceQueue,这又包含一个 Reference 对象数组。该数组已经更改为延迟分配,也就是说,仅在向ReferenceQueue 添加了对象的情况下执行分配。

回页首

结束语

在任何给定应用程序中,都存在着数量庞大(或许达到惊人的程度)的集合,复杂应用程序中的集合数量可能会更多。使用大量集合往往能够提供通过选择正确的集合、正确地调整其大小(或许还能通过延迟分配集合)来实现有时极其可观的内存占用节约的范围。这些决策最好在设计和开发的过程中制定,但您也可以利用 Memory Analyzer 工具来分析现有应用程序中存在内存占用优化潜力的部分。


参考资料

学习

获得产品和技术

讨论

  • IBM 的 Java 应用程序故障诊断:阅读 Chris Bailey 和他的同事撰写的这篇博客文章,获得有关 IBM 提供的 Java 应用程序故障诊断工具的新闻和信息。 

  • 加入 developerWorks 中文社区。与其他 developerWorks 用户联系,浏览开发人员推动的博客、论坛、小组和 wiki。

目录
相关文章
|
9月前
|
存储 缓存 Java
创造与布局:剖析 Java 对象创建过程以及内存布局
创造与布局:剖析 Java 对象创建过程以及内存布局
174 0
|
存储 NoSQL Java
面试必问:说一下 Java 虚拟机的内存布局?
面试必问:说一下 Java 虚拟机的内存布局?
104 0
|
存储 Java
Java对象内存布局和对象头
Java对象内存布局和对象头
Java对象内存布局和对象头
|
存储 安全 Java
Java对象内存布局
Java一切皆对象,那么一个对象里到底包含什么呢
135 0
Java对象内存布局
|
Java
第13篇:Java继承的内存布局
🍀 ① 加载类信息: 会先加载当前类的父类的类信息(包括 Object 基类) 🍀 ② Son 对象中会拥有其所有父类的属性: 父类属性和子类属性名字一样是可以的 🍀 ③ 访问属性的时候:先看当前类中是否有该属性?若有,直接访问;⚡ 若没有,查看直接父类是否有该属性,并且该属性是否可访问?若有并且属性没有被 private 修饰,可访问直接父类的属性;⚡ 若有但属性被 private 修饰,直接报错;⚡ 若没有,继续查找父类的父类是否有该属性,并且是否可访问 ...【① 看自己有没有,有就直接用;若自己没有,就看父亲有没有,若父亲没有,就看爷爷有没有,... 一直向上找,直达找到 Obj
146 0
第13篇:Java继承的内存布局
|
存储 Java 对象存储
第7篇:Java 对象在 JVM 中的内存布局(详细说明)
① 方法被调用则栈帧创建,方法执行结束则栈帧销毁 ② 栈帧中存储了方法的局部变量信息 ③ 栈帧是分配给方法的一段栈空间
87 0
第7篇:Java 对象在 JVM 中的内存布局(详细说明)
|
存储 缓存 算法
理解Java对象:要从内存布局及底层机制说起,话说....
理解Java对象:要从内存布局及底层机制说起,话说....
理解Java对象:要从内存布局及底层机制说起,话说....
|
存储 Java 程序员
图文详解Java对象内存布局
图文详解Java对象内存布局
163 0
图文详解Java对象内存布局
Java内存区域对象的内存布局和访问定位(Header、Instance、Padding)
Java普通对象被创建出以后,就需要关注下它在JVM堆中的内存布局是什么样子的。 大致分为3个区域: 1.对象头(Header) 2.实例数据(Instance) 3.对齐补充(Padding) 1.对象头(Header) 对象头在JVM这本书中有个专门的章节去讲Class文件的布局,这一章还没有去看,因此,对于这个暂时没有什么概念。
7276 0
|
6天前
|
存储 Java 数据库连接
java多线程之线程通信
java多线程之线程通信