Map——广义集合的子集
HashTable是早期Java类库提供的一个哈希表实现,扩展了Dictionary类,类结构上与HashMap明显不同,本身是同步的,不支持null键和值,由于同步导致的性能开销,已经很少被推荐使用。
HashMap是应用广泛的哈希表实现,扩展了AbstractMap类,行为上大致上与HashTable一致,主要区别在于HashMap不是同步的,支持null键和值等。HashMap进行put或者get操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选。在Java 8里,HashMap本身发生了非常大的变化。
LinkedHashMap提供的是遍历顺序符合插入顺序,它的实现是通过为键值对维护一个双向链表。通过特定构造函数可以创建反映访问(put、get、compute)顺序的实例。这种行为适用于一些特定场景,比如构建一个空间占用敏感的资源池,希望可以自动将最不常被访问的对象释放掉,就可以利用LinkedHashMap提供的机制来实现。
TreeMap则是基于红黑树的一种提供顺序访问的Map,扩展了AbstractMap类,和HashMap不同,它的get、put、remove操作都是O(log(n))的时间复杂度,具体顺序可以由指定的Comparator来决定,或者根据键的自然顺序Comparable来判断。为了避免模棱两可的情况,自然顺序同样需要符合一个约定,就是compareTo的返回值需要和equals一致。当不遵守约定时,两个不符合唯一性(equals)要求的对象被当作是同一个(compareTo返回了0),这会导致歧义的行为表现。
// 用TreeMap的put方法实现举例
public V put(K key, V value) {
Entry<K,V> t = …
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
// ...
}
注:
1. HashTable本身比较低效,它的实现就是将put、get、size等方法加上synchronized,导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率。
2. 大部分使用Map的场景,通常是放入、访问、删除,对顺序没有特别要求,HashMap在这种情况下基本是最好的选择。HashMap的性能表现非常依赖于哈希码的有效性,hashCode和equals的一些基本约定如下,
- equals相等,hashCode一定要相等。
- 重写了hashCode也要重写equals。
- hashCode需要保持一致性,状态改变返回的哈希值仍然要一致。
- equals的对称、反射、传递等特性。
HashMap剖析
HashMap内部结构可以看作是数组(Node<K,V>[] table)和链表结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的寻址;哈希值相同的键值对以链表形式存储。如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),链表就会被改造为树形结构。
从非拷贝构造函数的实现来看,这个数组似乎并没有在最初就初始化好,仅仅设置了一些初始值。HashMap是按照 lazy-load 原则在首次使用时被初始化(拷贝构造函数除外)。
public HashMap(int initialCapacity, float loadFactor){
// ...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
put方法里只有一个 putVal 调用,putVal方法本身逻辑非常集中,从初始化、扩容到树化,全都和它有关,
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbent,
boolean evit) {
Node<K,V>[] tab; Node<K,V> p; int , i;
if ((tab = table) == null || (n = tab.length) = 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == ull)
tab[i] = newNode(hash, key, value, nll);
else {
// ...
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first
treeifyBin(tab, hash);
// ...
}
}
- 如果数组是null,resize方法会负责初始化它,这从tab = resize()可以看出。
- resize方法有两个职责,创建初始存储数组,或者在容量不满足需求的时候,进行扩容(resize)。
- 在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。
-
if (++size > threshold) resize();
- 具体键值对在哈希表中的位置(数组index)取决于下面的位运算。哈希值的源头并不是key本身的hashCode,而是HashMap内部的另外一个hash方法。为什么这里需要将高位数据移位到低位进行异或运算呢?因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址是忽略容量以上的高位的,这种处理可以有效避免类似情况下的哈希碰撞。
-
i = (n - 1) & hash // 上面说的hash方法 static final int hash(Object kye) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>>16; }
- 前面的链表结构(这里叫bin)会在达到一定阈值时发生树化,为什么HashMap需要对bin进行处理?本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,就会形成一个链表,链表查询是线性的,严重影响存取的性能。在现实世界构造哈希冲突的数据并不复杂,恶意代码可以用这些数据大量与服务器端交互,导致服务器端CPU大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。
resize方法,不考虑极端情况(容量理论最大极限由MAXIMUM_CAPACITY指定,数值为 1<<30,2的30次方),可以归纳如下,
- 阈值等于(负载因子)x(容量),如果构建HashMap时没指定它们,那就用相应的默认常量值。
- 阈值通常是以倍数进行调整 (newThr = oldThr << 1),根据putVal中的逻辑,当元素个数超过阈值时就调整Map大小。
- 扩容后,需要将老数组中的元素重新放置到新数组,这是扩容的一个主要开销来源。
final Node<K,V>[] resize() {
// ...
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY &&
oldCap >= DEFAULT_INITIAL_CAPAITY)
newThr = oldThr << 1; // double there
// ...
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// zero initial threshold signifies using defaultsfults
newCap = DEFAULT_INITIAL_CAPAITY;
newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY;
}
if (newThr ==0) {
float ft = (float)newCap * loadFator;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);
}
threshold = neThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];
table = n;
// 移动到新的数组结构e数组结构
}
容量和负载系数决定了可用桶的数量,空桶太多会浪费空间,如果用的太满会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。
关于容量选择,如果能知道HashMap要存取的键值对数量,可以预先设置合适的容量大小。具体数值可以根据扩容发生的条件来简单预估,根据前面的分析知道它要符合计算条件,所以预先设置的容量要满足大于“预估元素数量/负载因子”,同时它是2的幂数。
负载因子 * 容量 > 元素数量
关于负载因子,如果没有特别需求,不要轻易进行更改,JDK自身的默认负载因子是非常符合通用场景需求的。如果确实需要调整,建议不要设置超过0.75的数值,因为会显著增加冲突,降低HashMap的性能。如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响。
树化逻辑主要在 treeifyBin 方法中,当 bin 的数量大于 TREEIFY_THRESHOLD 时,
- 如果容量小于 MIN_TREEIFY_CAPACITY,只会进行简单地扩容。
- 如果容量大于 MIN_TREEIFY_CAPACITY ,则会进行树化改造。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//树化改造逻辑
}
}
同步的包装容器(Collections.synchronizedMap)
HashMap不是线程安全的,并发情况会导致类似CPU占用100%等一些问题,Collections提供的同步包装器利用输入Map构造了另一个同步版本,所有操作虽然不再声明为synchronized方法,但是还是利用了this作为互斥的mutex,没有真正意义上的改进。HashTable或者同步包装版本都用的是粗粒度的同步方式,只适合非高并发场景。
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
// …
public int size() {
synchronized (mutex) {return m.size();}
}
// …
}
思考个问题,高并发下Map场景该怎么处理呢?
Have Fun