对于答主这样的内存敏感人士,一般不用java.util.HashMap
内存篇
以下举出一个例子
这个例子是以int为key,int为value的map,对比多种实现
说明java.util.HashMap占用内存相比其他数据结构或者hash map实现有时是不可接受的
package tmptest;
import static javax.lang.Sizeof.sizeof; // 我的常用工具,有空补上代码
import static java.lang.System.out;
public class RamCmp {
static void printSize(Object o) {
out.printf("类型:%s,占用内存:%.2f MB\n", o.getClass().getSimpleName(), sizeof(o) / 1024D / 1024D);
}
public static void main(String[] args) throws Throwable {
int size = 30000;
java.util.Map<Object, Object> javaUtilHashMap = new java.util.HashMap<>();
for (int i = 0; i < size; javaUtilHashMap.put(i, i), i++) {
}
net.openhft.koloboke.collect.map.hash.HashIntIntMap openHftHashIntIntMap =
net.openhft.koloboke.collect.map.hash.HashIntIntMaps.newUpdatableMap();
for (int i = 0; i < size; openHftHashIntIntMap.put(i, i), i++) {
}
java.util.ArrayList<Object> javaUtilArrayList = new java.util.ArrayList<>();
for (int i = 0; i < size; javaUtilArrayList.add(i), i++) {
}
Integer[] objectArray = new Integer[size];
for (int i = 0; i < size; objectArray[i] = i, i++) {
}
com.carrotsearch.hppc.IntArrayList hppcArrayList = new com.carrotsearch.hppc.IntArrayList();
for (int i = 0; i < size; hppcArrayList.add(i), i++) {
}
int[] primitiveArray = new int[size];
for (int i = 0; i < size; primitiveArray[i] = i, i++) {
}
out.println("java.vm.name=" + System.getProperty("java.vm.name"));
out.println("java.vm.version=" + System.getProperty("java.vm.version"));
out.println("容器元素总数:" + size);
printSize(javaUtilHashMap);
printSize(openHftHashIntIntMap);
printSize(javaUtilArrayList);
printSize(hppcArrayList);
printSize(primitiveArray);
printSize(objectArray);
}
}
输出结果如下:
java.vm.name=Java HotSpot(TM) 64-Bit Server VM
java.vm.version=25.20-b23
容器元素总数:30000
类型:HashMap,占用内存:2.08 MB
类型:UpdatableLHashParallelKVIntIntMap,占用内存:0.50 MB
类型:ArrayList,占用内存:0.58 MB
类型:IntArrayList,占用内存:0.17 MB
类型:int[],占用内存:0.11 MB
类型:Integer[],占用内存:0.57 MB
如果想验证以上结果,可以使用以下包和我的javax.lang.Sizeof工具类(有空补上)
<dependency>
<groupId>com.carrotsearch</groupId>
<artifactId>hppc</artifactId>
<version>0.5.4</version>
</dependency>
<dependency>
<groupId>net.openhft</groupId>
<artifactId>koloboke-api-jdk8</artifactId>
<version>0.6.5</version>
</dependency>
<dependency>
<groupId>net.openhft</groupId>
<artifactId>koloboke-impl-jdk8</artifactId>
<version>0.6.5</version>
</dependency>
对比结果分析:
元素数量在30000个的时候,java.util.HashMap相比其他几个实现,都已经多浪费1MB内存了
甚至是最节省内存的int[]的20倍
原因是:
1. hash中为避免退化为数组(如openhft的实现可以退化为数组)或者链表(java.util.HashMap可能退化为链表)使用的空槽
2. java.util.HashMap.Entry的额外占用的内存,用于维持链表、内存对齐等
3. 对象内存占用:在HotSpot 64位jdk中,一个java.lang.Integer占用16字节,一个引用占用4字节,总共20字节,而一个int只占用4字节
设想一下,如果一个分布式系统中,java.util.HashMap的对象占用了你的大部分内存,可以想象,集群规模达到一定程度,浪费的内存的价格要比程序员的工资多得多,就有必要优化了(以上作为反驳匿名用户的答案——内存太便宜不用操心,的论据)
CPU篇
2015-04-19更新
这个比较复杂,需要分很多情况,比如:
可以把操作分为:get/put,有的map 的get更快,有的map的put更快
可以把put操作分为:已有key更新value/添加新key
可以把get操作分为:已存在的key/不存在的key
把map分为:只读 / 可添加不可删除 / 可添加可删除
可以把map元素密度分为:密集型/稀疏型
按线程分为:put&get之间可否并发,get&get之间可否并发
import static java.lang.Math.*;
import static java.lang.System.*;
import static java.util.Arrays.*;
import java.util.Collections;
public class CpuCmp {
static void printTime(Class type, Runnable r) {
double time = timeCall(r, 30);
char[] rpad = " ".toCharArray();
type.getSimpleName().getChars(0, type.getSimpleName().length(), rpad, 0);
out.printf("类型:%s \t 耗时:%.2g s\n", new String(rpad), time);
}
public static <T> double timeCall(Runnable call) {
long startA = nanoTime();
long start = nanoTime();
try {
call.run();
} catch (Exception e) {
throw new RuntimeException(e);
}
return 1E-9d * (max(0, nanoTime() - start - (start - startA))); // 减去 nanoTime() 本身的耗时;
}
public static <T> double timeCall(Runnable call, int repeat) {
double[] a = new double[repeat];
setAll(a, i -> timeCall(call));
if (repeat > 7) { // 对排序中间的 60% 计算平均
sort(a);
int i = round(repeat * 0.2f);
return stream(a, i, repeat - i).average().getAsDouble();
}
if (repeat > 3) {
// 去掉一个最高分,一个最低分,剩下的取平均
sort(a);
return stream(a, 1, repeat - 1).average().getAsDouble();
}
return stream(a).average().getAsDouble();
}
public static void main(String[] args) throws Throwable {
int size = 1000000;
out.println("java.vm.name=" + System.getProperty("java.vm.name"));
out.println("java.vm.version=" + System.getProperty("java.vm.version"));
out.println("容器元素总数:" + size);
printTime(java.util.HashMap.class, () -> {
java.util.Map<Object, Object> javaUtilHashMap = new java.util.HashMap<>();
for (int i = 0; i < size; javaUtilHashMap.put(i, i), i++) {
}
});
printTime(java.util.LinkedHashMap.class, () -> {
java.util.Map<Object, Object> javaUtilLinkedHashMap = new java.util.LinkedHashMap<>();
for (int i = 0; i < size; javaUtilLinkedHashMap.put(i, i), i++) {
}
});
printTime(java.util.concurrent.ConcurrentHashMap.class, () -> {
java.util.Map<Object, Object> javaUtilLinkedHashMap = new java.util.concurrent.ConcurrentHashMap<>();
for (int i = 0; i < size; javaUtilLinkedHashMap.put(i, i), i++) {
}
});
printTime(Collections.synchronizedMap(new java.util.concurrent.ConcurrentHashMap()).getClass(), () -> {
java.util.Map<Object, Object> javaUtilLinkedHashMap = Collections.synchronizedMap(new java.util.concurrent.ConcurrentHashMap());
for (int i = 0; i < size; javaUtilLinkedHashMap.put(i, i), i++) {
}
});
printTime(java.util.TreeMap.class, () -> {
java.util.Map<Object, Object> javaUtilTreeMap = new java.util.TreeMap<>();
for (int i = 0; i < size; javaUtilTreeMap.put(i, i), i++) {
}
});
printTime(net.openhft.koloboke.collect.map.hash.HashIntIntMaps.newUpdatableMap().getClass(), () -> {
net.openhft.koloboke.collect.map.hash.HashIntIntMap openHftHashIntIntMap =
net.openhft.koloboke.collect.map.hash.HashIntIntMaps.newUpdatableMap();
for (int i = 0; i < size; openHftHashIntIntMap.put(i, i), i++) {
}
});
printTime(java.util.ArrayList.class, () -> {
java.util.ArrayList<Object> javaUtilArrayList = new java.util.ArrayList<>();
for (int i = 0; i < size; javaUtilArrayList.add(i), i++) {
}
});
printTime(Integer[].class, () -> {
Integer[] objectArray = new Integer[size];
for (int i = 0; i < size; objectArray[i] = i, i++) {
}
});
printTime(com.carrotsearch.hppc.IntArrayList.class, () -> {
com.carrotsearch.hppc.IntArrayList hppcArrayList = new com.carrotsearch.hppc.IntArrayList();
for (int i = 0; i < size; hppcArrayList.add(i), i++) {
}
});
printTime(int[].class, () -> {
int[] primitiveArray = new int[size];
for (int i = 0; i < size; primitiveArray[i] = i, i++) {
}
});
}
}
输出结果:
java.vm.name=Java HotSpot(TM) 64-Bit Server VM
java.vm.version=25.20-b23
容器元素总数:1000000
类型:HashMap 耗时:0.058 s
类型:LinkedHashMap 耗时:0.032 s
类型:ConcurrentHashMap 耗时:0.091 s
类型:SynchronizedMap 耗时:0.090 s
类型:TreeMap 耗时:0.23 s
类型:UpdatableLHashParallelKVIntIntMap 耗时:0.054 s
类型:ArrayList 耗时:0.0073 s
类型:Integer[] 耗时:0.0033 s
类型:IntArrayList 耗时:0.0042 s
类型:int[] 耗时:0.00098 s
惊奇的发现java8中的HashMap并不比openhft的UpdatableLHashParallelKVIntIntMap慢,记忆中java6中的Map是比openhft慢很多的,我们来看下源码找下原因:
/*
* Copyright (c) 1997, 2013, Oracle and/or its affiliates. All rights reserved.
* ORACLE PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
*/
也许这就是根本原因吧,Java在Oracle旗下活得不错,接下来看看直接原因:put方法实现
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
有个Java6版HashMap没有的亮点:
计算槽位采用:tab[i = (n - 1) & hash]
tab[i = hash % n]
如果你学过《计算机组成原理》,你可以看出来%操作需要用到除法器,需要多个时钟周期,这个比&位运算这种只需要1个时钟周期的操作慢得多,在有些CPU上甚至可以慢几十倍
那为什么 hash & (n - 1) 等价于 hash % n呢,回忆一下二进制,当n是2的整数次方的时候,n-1用二进制表示形如:0000000011111111 (0*1*),和这种数字做与运算,就是保留低位,这样就和取模有一样的结果,保证n必须是2的整数次方,在resize方法中可以看到:newCap = oldCap << 1
n一开始是2的整数次方16,每次resize,左移1位,相当于乘以2,保证了n是2的整数次方
这个实现和Java6一样也使用链表结构将相同hash的key-value节点串起来
链表有什么缺点呢?链表中的每一个节点,存储在内存条的不同物理位置上,这意味着对内存的访问是随机性的,而不是连续性的,而对内存条的随机访问的低效的(管内存条叫做Random Access Memory,真的好吗?)
为什么对内存的随机访问的低效的?CPU从内存条中读数据,需要通过L2/L3 cache,如果cache能够命中所需要的数据,就不再从内存载入,否则L2/L3 cache是一次性将一片连续的内存(比如:64字节)载入,再将计算单元用到的部分交给计算单元,而这两种操作速度有10倍的差距,我的i7 CPU,L3 cache的只有区区4MB,只能保存内存中数据的极小一部分,所以如果对内存访问不是连续的,就需要频繁的换入换出内存数据
来把容器元素数量提高10倍,来验证这个缺点:java.vm.name=Java HotSpot(TM) 64-Bit Server VM
java.vm.version=25.20-b23
容器元素总数:10000000
类型:HashMap 耗时:5.1 s
类型:LinkedHashMap 耗时:7.5 s
类型:ConcurrentHashMap 耗时:7.4 s
类型:SynchronizedMap 耗时:5.0 s
类型:TreeMap 耗时:8.9 s
类型:UpdatableLHashParallelKVIntIntMap 耗时:0.80 s
类型:ArrayList 耗时:0.78 s
类型:Integer[] 耗时:0.30 s
类型:IntArrayList 耗时:0.058 s
类型:int[] 耗时:0.013 s
重点是:
容器元素总数:10000000
类型:HashMap 耗时:5.1 s
类型:UpdatableLHashParallelKVIntIntMap 耗时:0.80 s
对比之前的效果:
容器元素总数:1000000
类型:HashMap 耗时:0.058 s
类型:UpdatableLHashParallelKVIntIntMap 耗时:0.054 s
HashMap耗时增加了100倍!!openhft的UpdatableLHashParallelKVIntIntMap只增加了13倍
为什么?来看看openhft的实现: public int put(int key, int value) {
int free;
if (key == (free = freeValue)) {
free = changeFree();
}
long[] tab = table;
int capacityMask, index;
int cur;
long entry;
if ((cur = (int) (entry = tab[index = ParallelKVIntKeyMixing.mix(key) & (capacityMask = tab.length - 1)])) == free) {
// key is absent
incrementModCount();
tab[index] = ((((long) key) & INT_MASK) | (((long) value) << 32));
postInsertHook();
return defaultValue();
} else {
keyPresent:
if (cur != key) {
while (true) {
if ((cur = (int) (entry = tab[(index = (index - 1) & capacityMask)])) == free) {
// key is absent
incrementModCount();
tab[index] = ((((long) key) & INT_MASK) | (((long) value) << 32));
postInsertHook();
return defaultValue();
} else if (cur == key) {
break keyPresent;
}
}
}
// key is present
int prevValue = (int) (entry >>> 32);
U.putInt(tab, LONG_BASE + INT_VALUE_OFFSET + (((long) (index)) << LONG_SCALE_SHIFT), value);
return prevValue;
}
}
有以下亮点是Java8的HashMap不具备的:
1. 用一个long[]数组存储所有节点,每个long表示一个节点,高位表示key,低位表示value,key和value的物理地址连续,而且key和value不是引用类型(指针类型),这意味不需要跳转到其他位置,避免了随机访问,增加了cache命中率long[] tab = table;
...
tab[index] = ((((long) key) & INT_MASK) | (((long) value) << 32));
2.相同hash的不同节点之间,不采用链式结构,而采用开发寻址(OpenAddressing)
while (true) {
... tab[(index = (index - 1) & capacityMask)])) ...
index = (index - 1) & capacityMask,其中capacityMask就是tab.length - 1,index每次挪动一个位置,对tab数组的内存是连续访问
对比Java8版HashMap的链式结构:for (; ;) {
... e = p.next ...
链表的缺点前文说过了,对内存是随机访问
最后,做为数组脑残粉,推广下数组大法有多好吧:
1. 你所知道的高效数据结构,多数需要依赖数组,因为你想连续访问内存只能用数组
2. 数组既是一个完美的hash map,又是一个深度仅为1的多叉树
3. 如果key是int,数组存key不需要任何空间,只需要存value,其它数据结构做不到
4. 数组是内存条的最直接表示,类实例/结构体,可以看做一个特殊的数组