Java优化 - 集合
大多数编程语言库提供了最少两种通用容器:
- Sequential containers:数据保存在一定位置上,使用数字索引
- Associative containers:由对象自己决定,保存在集合内的什么地方
为了让容器方法正确工作,要保存的对象必须具有可比性和相等性概念。Java的集合API中,这些对象必须实现hashCode()和equals()。
我们知道,引用类型的成员保存在堆内。因此,我们说顺序存储,不是说对象自己存储在容器内,而是它的引用是顺序存储的。这意味着,不能达到C/C++的数组或者vector的相同性能。
Java放弃了对内存系统的低级控制,来实现垃圾的自动回收。我们看到,使用Java不但放弃了对分配和释放的手动控制,也放弃了对低级内存布局的控制。
列表
有两种列表可供选择:ArrayList和LinkedList。
ArrayList
ArrayList是基于数组的,它有固定的大小。条目能被加到数组,如果数组满了,就分配一个新的大的数组,把旧数据拷贝到新数组。
所以,注重性能的程序员必须平衡resize的成本和不知道列表会变多大的灵活性。ArrayList基于空数组,默认的初始容量是10。在构造器中指定初始容量可以防止resize,也可以使用ensureCapacity()增加容量来避免resize。
我们使用JMH看一下影响:
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
public class ArrayListBenchmark {
private String item = "ArrayListBenchmark";
@Benchmark
public List<String> properlySizedArrayList() {
List<String> list = new ArrayList<>(1_000_000);
for (int i = 0; i < 1_000_000; i++) {
list.add(item);
}
return list;
}
@Benchmark
public List<String> resizingArrayList() {
List<String> list = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
list.add(item);
}
return list;
}
}
执行结果:
Benchmark Mode Cnt Score Error Units
ArrayListBenchmark.properlySizedArrayList thrpt 10 316.915 ± 2.014 ops/s
ArrayListBenchmark.resizingArrayList thrpt 10 209.429 ± 2.417 ops/s
可以看到,properlySizedArrayList测试每秒能够多执行100次操作。
LinkedList
LinkedList更适合动态生长场景,它实现了一个双端链表,扩展成本是O(1)。
ArrayList 和 LinkedList 对比
应该使用ArrayList还是LinkedList,依赖于访问和修改数据的模式。在ArrayList和LinkedList的结尾插入数据,都是常量时间操作-对于ArrayList,摊销掉resize的影响。
但是,在ArrayList的某个位置增加元素,需要把后面的元素全都向后移动一个位置。对于LinkedList,需要遍历节点,找到应该插入的位置,不过增加节点的操作倒是很简单,只要增加一个节点,设置两个引用就可以了。下面的代码显示了在开始处插入数据的性能差异:
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
public class InsertBeginBenchmark {
private static final String item = "*";
private static final int N = 1_000_000;
private static List<String> arrayList = new ArrayList<>();
private static List<String> linkedList = new LinkedList<>();
@Setup(Level.Invocation)
public static final void setup() {
arrayList = new ArrayList<>();
linkedList = new LinkedList<>();
for (int i = 0; i < N; i++) {
arrayList.add(item);
linkedList.add(item);
}
}
@Benchmark
public String beginArrayList() {
arrayList.add(0, item);
return arrayList.get(0);
}
@Benchmark
public String beginLinkedList() {
linkedList.add(0, item);
return linkedList.get(0);
}
}
执行结果:
Benchmark Mode Cnt Score Error Units
InsertBeginBenchmark.beginArrayList thrpt 10 3317.829 ± 488.438 ops/s
InsertBeginBenchmark.beginLinkedList thrpt 10 252265.897 ± 12625.616 ops/s
删除的行为也类似。LinkedList的删除是廉价的,最多只修改两个引用。而ArrayList,需要删除的节点的右侧的全部元素都要向左移动一个位置。
如果列表主要是随机访问,那么ArrayList是最佳选择,因为任何元素只需要O(1)的时间,而LinkedList需要从开始遍历节点。访问成本的代码见下:
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
public class AccessingListBenchmark {
private static final String item = "*";
private static final int N = 1_000_000;
private static final List<String> arrayList = new ArrayList<>();
private static final List<String> linkedList = new LinkedList<>();
@Setup
public static final void setup() {
for (int i = 0; i < N; i++) {
arrayList.add(item);
linkedList.add(item);
}
}
@Benchmark
public String accessArrayList() {
return arrayList.get(500_000);
}
@Benchmark
public String accessLinkedList() {
return linkedList.get(500_000);
}
}
执行结果:
Benchmark Mode Cnt Score Error Units
AccessingListBenchmark.accessArrayList thrpt 10 302101236.806 ± 11760857.021 ops/s
AccessingListBenchmark.accessLinkedList thrpt 10 1145.040 ± 38.987 ops/s
除非你需要LinkedList的特定行为,一般都推荐使用ArrayList,特别是你使用的算法需要随机访问的时候。
如果有可能,就设置ArrayList的大小,以避免resize。
Maps
java.util.Map<K, V>描述key和value之间的相关性。key和value都是引用类型。
HashMap
HashMap可以被看作经典的hash表,不过添加了些现代装饰。
HashMap的简化版本(删除泛型和关键属性)应该是这样的:
public Object get(Object key) {
// 简化:不支持null key
if (key == null) return null;
int hash = key.hashCode();
int i = indexFor(hash, table.length);
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
private int indexFor(int h, int length) {
return h & (length-1);
}
// 链表节点
static class Node implements Map.Entry {
final int hash;
final Object key;
Object value;
Node next;
Node(int h, Object k, Object v, Entry n) {
hash = h;
key = k;
value = v;
next = n;
}
}
HashMap.Node类只能被java.util包访问,这是静态类的经典用例。
开始,bucket的条目保存在一个列表内。当它查找一个值的时候,计算key的hash然后使用equals()方法找到列表中的key。所以,一个HashMap内不能有重复的key。插入相同的key会取代当前的值。
在现代Java版本里,一个提升是indexFor()已经被key的hashCode()的code取代,并使用掩码向下传播hash中的高位。这样,当计算一个key应该被hash到哪个桶时,要使用高位。这样做的一个理由是,输入的小变化应该引起hash函数输出的大变化。
HashMap中,和性能相关的两个重要变量是initialCapacity和loadFactor,他们都可以通过构造器设置。HashMap的容量代表当前已经增加的桶的数量,默认值是16。loadFactor代表hash表有多满的时候自动增长。增加 容量和重新计算hash的过程叫rehashing,它会把桶的数量加倍。
准确的initialCapacity会避免表增长,也就避免了自动rehash的需要。也可以调整loadFactor,
不过默认的0.75提供了空间和访问时间的一个平衡。loadFactor大于0.75会简单rehash的需要,但是因为桶比较满,访问会变慢。
HashMap提供了常数级的get()和put()支持,但是遍历比较费时。
另一个影响性能的是treeifying-该创新是HashMap的内部实现细节,对性能工程师比较有用。考虑一下桶被高度填充的情况,如果桶元素是使用LinkedList实现的,列表变大会导致遍历寻找一个元素变得平均更贵。为了抵消这种线性影响,HashMap使用了新机制,一旦桶到达了TREEIFY_THRESHOLD,它就转换成一棵树(行为类似于TreeMap)。
为什么开始的时候不这么做呢?TreeNodes大概是列表节点的两倍大小,因此浪费了空间。一个分布比较好的hash函数很少导致桶转换成TreeNodes。如果i你的程序出现了这种情况,应该回头考虑hash函数、initialCapacity和loadFactor的设置。
LinkedHashMap
LinkedHashMap是HashMap的子类,通过双端链表维护元素的插入顺序。LinkedHashMap默认维护插入顺序,但是也可以切换到访问顺序模式。LinkedHashMap常用于消费者关心顺序的场景,它没TreeMap昂贵。
TreeMap
TreeMap实现了红黑树-它基于二叉树结构,附加了元数据(节点颜色),防止树过度失衡。当需要key的范围的时候,可以使用TreeMap快速访问子map。TreeMap也用于分割数据-从开始到某一点,或者从某一点到结束。
对于get()、put()、containsKey()和remove(),TreeMap提供了log(n)性能。
大多数时候,HashMap能满足Map需求。考虑下面的例子:使用streams或者lambdas处理一个Map的一部分数据。此时,使用TreeMap可能更合适。
缺少MultiMap
Java没提供MultiMap-允许一个key对应多个值。官方认为这个不常用,而且也可以使用Map<K, List>实现。如果需要,可以使用开源实现。
Sets
Java有三种类型的Set,都和Map有类似的性能考虑。事实上,我们研究HashSet,会发现,是使用HashMap实现的。
Set不允许有重复值。add()方法,HashSet简单地把元素E作为key插入HashMap,使用虚拟对象PRESENT作为value。这样做,负担很小,PRESENT对象只需要被增加一次。HashSet有protected的构造器,允许一个LinkedHashMap。用来保持插入顺序。HashSet有O(1)的插入、删除和包含操作的时间。
TreeSet使用类似的方式实现。使用TreeSet会保持由Comparator定义的key的顺序。这样,范围操作和遍历更适合TreeSet。它的插入、删除和包含操作需要log(n)时间。