Java性能权威指南-总结27
- 数据库性能的最佳实践
- Java集合类API
- 同步还是非同步
- 设定集合的大小
- 集合与内存使用效率
数据库性能的最佳实践
Java集合类API
Java
的集合类API
有很大的选择余地;Java 7
至少提供了58个不同的集合类。在编写应用时,选择恰当的集合类,以及恰当地使用集合类,是一个重要的性能考量。
使用集合类的第一条规则是,选择适合应用的算法需求的集合类。LinkedList
不适合做搜索;如果需要访问一段随机的数据,应该将集合保存到HashMap
中。如果数据需要有序排列,则应使用TreeMap
,而不是尝试在应用中做排序。如果会用索引访问数据,则使用ArrayList
;但如果会频繁地向该数组中间插入数据,则不要使用它,诸如此类。根据算法选择要使用哪个集合类,这非常重要,但是在Java
中做选择和在其他编程语言中做选择并没有多少区别。然而在使用Java的集合类时,还有一些特殊的地方需要考虑。
同步还是非同步
默认情况下,几乎所有的Java集合类都是非同步的(主要的例外是Hashtable、Vector及与其相关的类)。
同步的集合类
如果想了解为什么Vector和Hashtable(及相关类)是同步的,就得先来看一点历史。在Java早期,它们是JDK中仅有的集合类。当时(在Java 1.2之前)还没有
集合类框架(Collections Framework)的正式定义;它们只是最初的Java平台提供的几个有用的类。
在Java发布第一个版本时,大部分开发者对多线程知之甚少,而Java试图让开发者能够更容易地避免在多线程环境中编程的某些陷阱。因此,这些类就设计成
了线程安全的。
遗憾的是,在早期的Java版本中,同步——甚至是不存在竞争时的同步——是个很大的性能问题,所以当第一个重大修订版本发布时,集合类框架采用了相反的做
法:所有新的集合类默认都是非同步的。即使从那时开始同步性能已经有了显著提高,但仍然不是没有成本的;能够选择非同步的集合类,可以帮助大家编
写更快的程序(偶尔会出现因并发修改某个非同步的集合而导致的bug)。
前面的一个微基准测试,比较了基于CAS
的保护和传统的同步。这个例子在多线程的情况下不太实用,但如果问题中的数据只会由一个线程访问,又会怎么样呢?如果不使用任何同步,效果又会如何?下列出了比较情况。因为这里没有试图考虑竞争,所以在这样一种情况下,这里的微基准测试是有效的:没有竞争,手头上的问题是研究同步访问资源有些多余时的损失。
同步访问和非同步访问的性能
从第2列可以很明显地看出,与简单的非同步访问相比,如果使用了任何一种数据保护技术,都会有比较小的性能损失。然而,这是执行了5亿次操作的一个微基准测试,所以平均到每次操作,差别就只在15纳秒的量级上。如果相关操作在目标应用中执行得足够频繁,性能损失就会有点明显。在大部分情况下,这种差别会被应用中其他更为低效的地方抵消掉。还要记住,这里的绝对数字完全是由测试所运行的目标机器决定的;要获得更为真实的测量结果,测试需要在与目标环境相同的硬件上运行。
那么,如果要在同步的Vector
和非同步的ArrayList
之间做出选择,该选择哪个呢?访问ArrayList
会稍微快一些,这与访问这个列表的频繁程度有关,性能差异是可以测量的。另一方面,这里假设代码不会被多个线程访问。今天可能确实如此,那明天会怎么样呢?如果情况可能会变,那更好的办法是现在就使用同步的集合,并减轻它所带来的性能影响。这是一个设计选择,为使代码经受住时间的考验而将其设计为线程安全的,在这上面投入时间和精力是不是值得,取决于开发应用时的情况。
如果要在非同步集合和使用了CAS
法则的集合之间做出选择(比如在HashMap
和ConcurrentHashMap
之间),它们的性能差别会微乎其微。当基于CAS
的类用于不存在竞争的环境中时,几乎没有什么开销。
设定集合的大小
集合类的用途是保存任意数量的数据元素,并随着集合中新条目的添加,在必要时进行扩展。性能方面有两点需要考虑。
尽管Java中的集合类提供的数据类型非常丰富,但是在基本层面上,这些类都必须仅使用Java基本的数据类型来保存其数据:数值(整型、双精度浮点型等)、对象引用和这些类型的数组。因此,ArrayList
中包含一个真正的数组:
private transient Object[] elementData;
随着在ArrayList
中添加和移除条目,这些条目会保存在elementData
数组内的期望位置(可能会导致数组中的其他条目变更位置)。类似地,HashMap
中包含着一个由内部数据类型HashMap$Entry
组成的数组,HashMap
会将每个键-值对映射到这个数组中,具体位置根据键的哈希码值来确定。
并非所有的集合类都使用数组保存其元素;比如LinkedList
,它以内部定义的Node
类保存每个数据元素。但是使用数组保存元素的集合类都会涉及一个问题,就是要考虑数组的大小。如何确定某个特定的类是不是属于这个范畴呢?可以看看它的构造函数:如果它有一个构造函数支持指定该集合的初始大小,那它内部就使用了某个数组来存储元素。
对于这样的集合类,精确地指定初始大小非常重要。以ArrayList
作为一个简单的例子:elementData
数组默认的初始大小为10。当向某个ArrayList
实例中插入第11个元素时,它就会扩展elementData
数组。这意味着分配一个新数组,将原来的内容复制到这个数组中,然后添加新元素。可以说HashMap
使用的数据结构和算法更复杂一些,但基本原理是一样的:在某一时刻,必须重新调整内部数据结构的大小。
ArrayList
类调整数组大小的方法是,在现有基础上增加约一半。所以elementData
数组的大小最初是10,然后是15,22,33,以此类推。不管使用何种算法调整数组大小(参见后面方框内的文字),都会导致一些内存被浪费(这反过来又会影响应用花在执行GC上的时间)。此外,每当数组必须调整大小时,都伴随一个成本很高的数组复制操作,将老数组中的内容转移到新数组中。
要减少这些性能损失,必须尽可能准确地估计一下集合最终的大小,并用这个值来构建集合。
非集合类中的数据扩展
很多非集合类也会在内部数组中保存大量数据。比如,ByteArrayOutputStream类必须把写入到该流中的所有数据保存到一个内部缓冲区中;类似地,
StringBuilder和StringBuffer类也必须将所有字符保存到一个内部的字符数组中。
这些类大多会使用同样的算法调整内部数组的大小:需要调整时就加倍。这意味着,平均而言,内部的数组要比当前包含的数据多25%。
这里的性能考量是相似的:使用的内存量多于ArrayList这个例子,需要复制数据的次数要少一些,但原理是一样的。在构建某个对象时,
如果可以设置其大小,可以评估一下这个对象最终会保存多少数据,然后选择接受大小参数的那个构造函数。
集合与内存使用效率
集合的内存使用效率没有达到最佳的例子:在用于保存集合中的元素的底层存储中,往往会浪费一些内存。
对于元素比较稀疏的集合(只有一两个元素),这存在较大的问题。如果这样的集合用得非常多,则会浪费大量内存。解决方案之一就是在创建集合时设定其大小。另一种方案是,考虑一下这种情况是不是真的需要集合。
大部分开发者被问及如何快速地排序任意一个数组时,答案都会是快速排序(quicksort
)。而好的性能工程师希望了解数组的大小:如果数组足够小,那最快的方式是使用插入排序(insertion sort
)。(对于较小的数组来说,基于快速排序的算法通常会使用插入排序;就Java
而言,Arrays.sort()
方法的实现就假定,少于47个元素的数组用插入排序比用快速排序更快。)数组大小至关重要。
JDK 7u40中集合类内存大小
很多应用中经常出现因集合类使用不当而导致的问题,所以JDK 7u40向ArrayList和HashMap的实现中引入了一个新的优化:默认情况下(比如在调用构造函数
时没有使用大小参数),这些类不再为数据分配任何底层存储,而是在向该集合中插入第一个元素时才分配。
这就是延迟初始化技术的一个例子,在测试一些常见的应用时,因为减少了对GC的需求,所以性能有所改进。这些应用中有很多从来没用过的集合;
所以延迟分配其底层存储在性能方面有优势。因为每次访问时本来就要检查底层存储的大小,所以检查底层存储是不是已经分配,并没有性能损失
(不过创建最初的底层存储所需要的时间从创建对象时变成了向对象中插入第一个数据时)。
类似地,在基于某个键值查找数据时,HashMap
是最快的;但如果只有一个键,与使用一个简单的对象引用相比,使用HashMap
就是大材小用了。即使有几个键,维护几个对象引用所需要的内存也比一个完整的HashMap
对象少,而且这样对GC
也有积极的影响。
除了以上这些,关于集合类的内存使用还有很重要的一点区别需要了解,那就是HashMap
对象和ConcurrentHashMap
对象大小的差别。在Java 7
之前,一个空的或者元素稀疏的ConcurrentHashMap
对象非常大:超过1KB(即便向其构造函数传了一个很小的大小)。在Java7
中,其大小只有208字节(与之相比,构造时没有指定大小的空HashMap
占128字节,指定大小为1的HashMap
占72字节)。
在存在很多小型Map
的应用中,大小的差别仍然非常重要,但是Java 7
中引入的优化使得这种差别不那么显著了。为提高性能,在内存非常重要且存在大量Map
的应用中,有人建议避免使用ConcurrentHashMap
类。这些建议的核心其实是两个因素之间的取舍:是要更快地访问Map
(如果存在竞争),还是要小心更大的Map
所引发的对垃圾收集器的压力。这个取舍如今仍然存在,但重心已更多地偏向了使用ConcurrentHashMap
。
快速小结
- 仔细考虑如何访问集合,并为其选择恰当的同步类型。不过,在不存在竞争的条件下访问使用了内存保护的集合(特别是使用了基于
CAS
的保护的集合),性能损失会极小;有时候,保证安全性才是上策。 - 设定集合的大小对性能影响很大:集合太大,会使得垃圾收集器变慢;集合太小,又会导致大量的大小调整与复制。