HashMap和HashTable的区别:
- 不是线程安全的。HashTable中每个方法加上了synchronized来保证线程安全
- key和value允许空值(null值),HashTable不允许
一、类变量
- DEFAULT_INITIAL_CAPACITY = 16,初始容量,可以在构造方法中指定。容量指哈希表中bin的个数,而不是保存元素的个数
- MAXIMUM_CAPACITY = 2 30 2^{30} 230,最大容量
- DEFAULT_LOAD_FACTOR = 0.75,负载因子,可以在构造方法中指定。当有0.75 * capacity个bin中存有元素时,需要扩容
- TREEIFY_THRESHOLD = 8。当一个bin中存的元素数量>=8时,将链表转化为红黑树。根据泊松分布,这种情况出现的概率为亿分之六。
- UNTREEIFY_THRESHOLD = 6。当一个bin中存储元素 <= 6时,从红黑树转为链表
- MIN_TREEIFY_CAPACITY = 64。当整个hashmap中存储的元素数量>=64时,才允许将链表转为红黑树,否则先使用hashmap扩容来减低hash冲突
二、链表和红黑树
HashMap处理hash冲突的方法是链地址法,每个bin存储的是一个链表的首节点。每个节点定义为
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
除链表节点外,还定义了TreeNode作为红黑树的节点,什么时候转红黑树,什么时候转回链表,都有红黑树的插入删除操作控制
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
...
}
与jdk7的区别: jdk7仅使用链表存储hash冲突的元素,jdk8中会在链表长度 >= 8且hash表容量 >= 64时将链表转为红黑树,保证查找的效率。
为什么要求hash表容量 >= 64?
hash表容量较小时有更大的可能进行扩容,扩容后又要重新移动数据,使用红黑树开销更大。
三、hash值、表容量与扩容
key的hashCode的高16位与低16位异或运算,得到hash值。
为什么不直接使用hashCode作为hash值: 因为hash表长度多数时都比较短,导致之后计算这一元素放在哪一个bin中时只会用到hash值的低几位。因此用hashCode高位进行扰动,减少hash冲突
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
bin的位置的计算在putVal()方法中:
if ((p = tab[i = (n - 1) & hash]) == null)
本质上是hash值对hash表长度取模,但由于规定了hash表长度为2的幂,因此可以使用hash值与(hash表长度 - 1)进行与运算。假设hash表长度 l = 00...01 ( 00...00 ) i − 1 l = 00...01(00...00)_{i-1} l=00...01(00...00)i−1。一个数 h h h对 l l l取模,结果为 h h h的第 i i i位及其之前的位全为0,后面的位保持不变。这一结果等于 h h h与 00...0 ( 11..1 ) i − 1 00...0(11..1)_{i-1} 00...0(11..1)i−1进行与运算,而 00...0 ( 11..1 ) i − 1 = l − 1 00...0(11..1)_{i-1} = l-1 00...0(11..1)i−1=l−1
构造方法中可以指定初始容量,不论指定数值为多少,都会调用tableSizeFor()方法将这个数转变为2的整数幂
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
五个或运算是为了将n的最高位后面全都变为1,即 00...011...1 00...011...1 00...011...1的形式,最后再+1得到2的整数幂,当指定的cap已经是2的整数幂时,这么做会将结果变为输入的2倍,因此第一步先将cap - 1。
resize()方法用于扩容,每次扩容会将hash表的容量扩大为原来的2倍,扩大为2倍的操作也使用位运算来完成。至此,可以看出hash表容量一定为2的整数幂的原因:
- 计算元素应放置的位置时可以使用位运算代替取模运算
- 扩容时可以使用位运算代替乘法运算
与jdk7的区别: jdk7中HashMap的容量保证为素数,目的是较少hash冲突
final Node<K,V>[] resize() {
...
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
...
在重新计算hash表容量并分配数组空间之后,需要对hash表中每个元素重新计算位置并插入。由于每次扩容都是原来容量的2倍,因此元素的在新表中的位置只有两种情况:
- 与原来的位置相同
- 变为原来位置+旧表长度
假设原来旧表长度 - 1为 00...0 ( 11...1 ) i 00...0(11...1)_{i} 00...0(11...1)i,则新表长度 - 1为 00...0 ( 11...1 ) i + 1 00...0(11...1)_{i+1} 00...0(11...1)i+1。若元素hash值的第 i i i位为0,则两次与运算的结果相同。若元素hash值的第 i i i位为1,则与运算后最高位多了一个1,相当于加上了旧表长度。
因此,在迁移元素时将旧表中一个bin的链表拆分为两个,分别加入到新表中的对应位置。
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) // 这个位置只有1个元素
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) // 这个位置的元素已经转化为红黑树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 拆分链表
Node<K,V> loHead = null, loTail = null; // 第一个链表的头尾
Node<K,V> hiHead = null, hiTail = null; // 第二个链表
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 第一种情况,在原位置不变
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 第二种情况,原位置+旧表长
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
与jdk7的区别: jdk7中扩容同样是扩大为原来的2倍,但迁移数据只是遍历数据、重新计算哈希值、头插法形成链表。这样的缺点是:1. 重新计算位置效率低。 2. 多线程环境下使用头插法可能形成环,之后元素再插入时找不到链表尾形成死循环
四、增删改查
数据插入实际上调用的是putVal()方法,先插入数据,再判断是否需要扩容。插入时分为三种情况处理:
- 这个位置现在为空,直接插入,作为链表头节点
- 已经有相同key的元素了,只需要更新
- 这个位置上的数据已经转为红黑树,按照红黑树的插入进行操作
- 这个位置是链表,找到链表尾节点,在之后插入新元素。在寻找尾节点的同时,计算链表长度,若当前长度 >= 7(插入新元素后将变为8),则转化为红黑树(按照前面说的,并不是必定转化,而是判断hash表长度是否 >= 64)。
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;
// 情况2,直接修改
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 情况3,红黑树插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// binCount记录链表长度
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;
}
}
}
...
}