关于HashMap的一些粗鄙理解
学习Java时常常会使用到集合, 在一些算法文献上对集合有另外一个称呼, 叫抽象数据类型(ADT Abstract Data Type),
将一些常用的数据操作抽象出来, 比如新增、查找、删除、包含操作。
在Java里将常见的ADT抽象成接口, 比如Collection、Map等。它们的实现类都对常用的抽象方法进行实现。
HashMap是Map的实现类, 这里就对HashMap的常用数据操作方法简单介绍一下, 一些有意思的处理逻辑稍微详细一点地探讨。
散列表长度必须是2的幂, tableSizeFor(cap)方法保证散列表长度是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;
}
根据cap转换成该数字的当前的或者最近的下一个的2的幂,
比如cap = 16, 当前cap是16则返回16 = 2⁴,
cap = 15, 15不是2的幂, 返回15下一个2的幂是16, 返回16 = 2⁴,
cap = 17, 17不是2的幂, 返回17下一个2的幂是32, 返回32 = 2⁵
计算逻辑, 把n的二进制位转成全是x个1,最后加1, 二进制位最左边是1, 后面跟着x个0, 就是2的幂,
n |= n >>> 1; n跟 n的无符号右移1位或运算, 其实际是将右移一位对应的1取出来;
n |= n >>> 2; n跟 n的无符号右移2位或运算, 是上一步移位的两倍, 刚好是跟上一步错位对上;
n |= n >>> 4; 右移是上一步的两倍;
n |= n >>> 8; 右移是上一步的两倍;
n |= n >>> 16; 右移是上一步的两倍, 到这就刚好共移了31位, 除了第一位其他都或运算过了, 即32位整型的二进位都或运算过了。
比如cap = 16
n = cap - 1 = 15, 二进制表示 1111
n |= n >>> 1, 二进制表示 1111 | 111 = 1111
n |= n >>> 2, 二进制表示 1111 | 11 = 1111
n |= n >>> 4; 二进制表示 1111 | 0 = 1111
n |= n >>> 8; 二进制表示 1111 | 0 = 1111
n |= n >>> 16; 二进制表示 1111 | 0 = 1111
n转成二进制全是1, 1111, 最后加1, 二进制就是10000 = 2⁴ = 16
cap = 15
n = cap - 1 = 14, 二进制表示 1110
n |= n >>> 1, 二进制表示 1111 | 111 = 1111
n |= n >>> 2, 二进制表示 1111 | 11 = 1111
n |= n >>> 4; 二进制表示 1111 | 0 = 1111
n |= n >>> 8; 二进制表示 1111 | 0 = 1111
n |= n >>> 16; 二进制表示 1111 | 0 = 1111
n转成二进制全是1, 1111, 最后加1, 二进制就是10000 = 2⁴ = 16
cap = 17
n = cap - 1 = 16, 二进制表示 10000
n |= n >>> 1, 二进制表示 10000 | 1000 = 11000
n |= n >>> 2, 二进制表示 11000 | 110 = 11110
n |= n >>> 4; 二进制表示 11110 | 1 = 11111
n |= n >>> 8; 二进制表示 11111 | 0 = 11111
n |= n >>> 16; 二进制表示 11111 | 0 = 11111
n转成二进制全是1, 11111, 最后加1, 二进制就是100000 = 2⁵ = 32
第2行代码, cap - 1 就是计算当前cap是2的幂的情况, 当cap = 16时, 返回16, 而不会返回32, 如果这里的cap没有减一, cap = 16时, 返回32。
HashMap的散列函数
源码
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
参考资料Donald E.Knuth 6.4
好的散列函数满足两个条件
a)它的计算应该是非常快的
b)它应该使冲突极小化
a和机器有关,b跟数据有关。
如果key是真正随机的(可以理解唯一key), 则从key的二进制位中简单抽取一些二进位进行散列函数, 但是在实践中, 总是需要用到key的全部二进位进行散列函数, 以此满足b。
这个HashMap的散列函数hash(key), 也可以叫扰乱函数,
它的实质就是将key的全部二进位和一半二进位进行异或运算(XOR), 这个异或运算也可以理解为加法运算。
这里用异或运算的好处是可以避免算术溢出, 还有就是位运算更快, 即符合b。
全部二进位和一半二进位进行异或运算, 刚好是上面说的全部二进位和简单抽取一些二进位的两种散列函数的合并, 也是符合b。
HashMap源码对hash(key)的说明, 可以概括为
取key二进制高位异或运算到低位(key.hashCode() ^ (key.hashCode() >>> 16)), 高位应该是不变的, 因为是无符号右移, 高位补0,
假设高位1010...跟0000...异或运算后还是1010...不变。实现散列函数最常见的错误也许就是忽略了key的高位————《Sedgewick 算法 3.4.1.9》
因为HashMap的表(散列表)长度是2的幂, 要是单纯使用key.hashCode(), 极大概率会出现冲突。
因此使用向下扩展高位方案来改变hash值, 这也可以理解为扰乱key原来的hash值。
位的延申(扩展)在运算的速度、算法的实用、结果的质量都兼顾到了。
因为散列表的bucket(箱)用了树结构处理大型冲突集, 所以用些简单"便宜"的运算, 位移些bit进行异或运算, 减少系统性能开销,
并避免高位的影响, 上面说高位是不变的, 所以不会有影响, 另外由于散列表的边界限制, 高位基本不会用到计算散列表下标,
原因可能是高位的值太大了。
扩容后旧的散列表如何处理
源码
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
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
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
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;
}
}
}
}
}
return newTab;
}
源码30 ~ 71, 是处理扩容后的旧散列表处理逻辑。
假设j是散列表下标, j的计算方式是key的hash mod cap, cap是散列表长度。
所以j = hash mod cap = hash & (cap - 1), cap = 2的n次方, (cap - 1) 的二进制表示n个1, cap二进制表示, 最高位1, 然后接上n个0。
假设n = 4, cap: 10000, (cap - 1): 1111, hash mod cap其实就是取hash的二进位低位, 如按假设值, 低4位即j。
j + hash / cap = cap。
cap扩容2倍, 假设n = 5, cap: 100000, (cap - 1): 11111, 对旧表扩容后它的bucket的处理逻辑,
按高低位来处理, 这个高低位是怎么定义的呢? 就是扩容后hash用于寻址计算的二进位的最高位是1, 则是高位, 否则低位
比如上面n = 5的扩容, 从右至左第5位为最高位, 因为(cap - 1)用于寻址计算是5位二进位,
这里有趣的是, 因为只要确定第5位二进位是否为1, 所以 hash & 10000 == 0 即第5位位0, 否则为1, 而10000刚好是旧表cap,
所以源码 hash & oldCap 判断高低位。== 0 则低位bucket, 否则高位bucket。
高位bucket会移动到新表的递增旧表cap后的位置, 比如高位bucket下标3, 旧表cap:16, 该高位bucket被移到新表19下标的位置。
旧表bucket下标是根据旧表cap计算得来的, 如上面n = 4, bucket下标是hash最低4二进位, hash & (16 - 1) = hash & 1111
扩容后新表则高位bucket下标是hash最低5二进位, 前4二进位是旧表bucket下标, 新表下标要加上右至左最高位, 即第5二进位, 也就是16,
所以当高位bucket旧表下标已知, 则新表下标就是旧表下标加旧表cap。
以上对高位bucket的处理逻辑, 可以看出低位bucket, 因为低位bucket的hash右至左第5二进位是0, 就算新表要用最低5二进位计算下标,
也只有最低4二进位有效, 所以低位bucket在新表位置保持跟旧表一样。
cap 必须是2的幂, 假设旧表cap = 16 = 2⁴ = 二进位:10000, 旧表下标
以下 i 是新表下标, 新表下标推理:
i = hash mod 2⁵ = hash & (2⁵ - 1) = hash & 11111 = (hash & 1111) + (hash & 10000) = 旧表下标
i = (hash mod oldCap) + (hash & oldCap) = hash & (oldCap - 1) + (hash & oldCap).
刚好源码66就是新表下标就是 j + oldCap, 即高位bucket在新表的位置。
新增
源码
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 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;
}
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) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
HashMap采用拉链 + 红黑树方案解决冲突, 且可动态调整散列表大小。
默认散列表容量16, 默认加载因子0.75, 默认扩容阀值: 散列表容量 * 加载因子 = 16 * 0.75 = 12。
put(key, value)函数是新增键值对到散列表, 实现ADT的新增操作。
该函数基本思想跟Donald E.Knuth 6.4 算法C逻辑一样。
如果散列表没有初始化则resize()初始化散列表(源码8~9),
如果已经存在散列表, 则通过扰乱函数计算key的散列值(hash)来定位该key在散列表的位置(源码6, (n - 1) & hash, n是散列表长度),
如果该位置没有被占用, 则根据(key, value)创建键值对节点, 并插入键值对(源码10~11)。
新位置被占用, 则散列表链表数量加一(++size), 当散列表链表数量大于扩容阀值(默认12), 则散列表进行扩容(源码42~43)。
key定位的散列表位置被占用了, 判断占用的当前节点跟插入节点是否同一个节点(源码14~16),
[替换逻辑]同一节点则根据参数onlyIfAbsent==false替换节点value, 否则不替换(源码33~39)。
不是同一个节点, 则判断该位置是否树结构, 是, 则按红黑树结构去插入节点(源码17~18)。
该位置不是树结构, 则按照拉链法插入节点, 这里是后插法, 找到链表最后一个再插入,
如果在此过程中找到相同节点的话结束链表插入逻辑, 并做[替换逻辑]。
当拉链表节点数量大于8时(这时链表节点数应该是9), 如果散列表长度小于64, 则对散列表扩容操作, 否则链表树化操作(源码20~31, 48~67)。
查找
源码
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get(key)根据key查找value, 实现ADT的查找操作
首先根据key的扰乱函数运算后的hash定位散列表位置((n - 1) & hash, n是散列表长度)
位置存在节点, 第一个节点如果是树结构则按照红黑树查找节点并返回,
如果是链表结构则查找链表节点并返回value。
删除
源码
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,
boolean movable) {
int n;
if (tab == null || (n = tab.length) == 0)
return;
int index = (n - 1) & hash;
TreeNode<K,V> first = (TreeNode<K,V>)tab[index], root = first, rl;
TreeNode<K,V> succ = (TreeNode<K,V>)next, pred = prev;
if (pred == null)
tab[index] = first = succ;
else
pred.next = succ;
if (succ != null)
succ.prev = pred;
if (first == null)
return;
if (root.parent != null)
root = root.root();
if (root == null || root.right == null ||
(rl = root.left) == null || rl.left == null) {
tab[index] = first.untreeify(map); // too small
return;
}
TreeNode<K,V> p = this, pl = left, pr = right, replacement;
if (pl != null && pr != null) {
TreeNode<K,V> s = pr, sl;
while ((sl = s.left) != null) // find successor
s = sl;
boolean c = s.red; s.red = p.red; p.red = c; // swap colors
TreeNode<K,V> sr = s.right;
TreeNode<K,V> pp = p.parent;
if (s == pr) { // p was s's direct parent
p.parent = s;
s.right = p;
}
else {
TreeNode<K,V> sp = s.parent;
if ((p.parent = sp) != null) {
if (s == sp.left)
sp.left = p;
else
sp.right = p;
}
if ((s.right = pr) != null)
pr.parent = s;
}
p.left = null;
if ((p.right = sr) != null)
sr.parent = p;
if ((s.left = pl) != null)
pl.parent = s;
if ((s.parent = pp) == null)
root = s;
else if (p == pp.left)
pp.left = s;
else
pp.right = s;
if (sr != null)
replacement = sr;
else
replacement = p;
}
else if (pl != null)
replacement = pl;
else if (pr != null)
replacement = pr;
else
replacement = p;
if (replacement != p) {
TreeNode<K,V> pp = replacement.parent = p.parent;
if (pp == null)
root = replacement;
else if (p == pp.left)
pp.left = replacement;
else
pp.right = replacement;
p.left = p.right = p.parent = null;
}
TreeNode<K,V> r = p.red ? root : balanceDeletion(root, replacement);
if (replacement == p) { // detach
TreeNode<K,V> pp = p.parent;
p.parent = null;
if (pp != null) {
if (p == pp.left)
pp.left = null;
else if (p == pp.right)
pp.right = null;
}
}
if (movable)
moveRootToFront(tab, r);
}
remove(key)根据key删除节点, 实现ADT的删除操作
根据key定位到的散列表位置被占用删除操作才有意义
被占用的位置是链表结构, 则找到链表中的对应key节点, 然后执行链表删除操作(源码38), 源码(35~36)删除链表第一个节点。
树结构删除会复杂一点, 有意思的地方是, 树结构删除节点后如果剩余节点太少了会将树结构转换成链表结构(源码66~70)。
树结构剩余2 ~ 6节点会触发转换为链表结构。
讨论默认参数下, 且不树化的散列表节点数量情况
根据上面提到的散列表节点转换树结构, 需要满足两个条件, 一是同一散列位置链表结构的节点数大于8,
二是散列表长度大于64才会树化, 默认参数扩容两次才是64, 而散列表被占位置数量大于扩容阀值会扩容,
还有链表节点大于8, 同时散列表长度小于等于64, 也会扩容。
第一种大于扩容阀值而扩容, 散列表3/4位置都刚好占用一个节点, 就是48个节点, 最多时每个占用位置链表48 * 8 = 384。
这里散列位链表节点数最多是8, 因为9会扩容, 不适合这里的情况。
第二种链表节点大于8而扩容, 假设有两个散列位的链表节点是9(扩容两次), 最少节点是其他散列位空(46个散列位置), 那么就是18个节点,
最多节点18 + (46 * 8) = 386。其他散列位链表节点数最多只能是8, 因为大于8会扩容不适合这里的情况。
散列表默认参数下没有树化前节点数量最少是18, 也是散列表利用率最低, 散列函数散列效果最差的。
没树化前节点数量最多是386, 散列表利用率最高, 散列函数效果最好的。不过以上两种情况在实际应用中是基本不可能出现的。
包含
源码
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
if ((tab = table) != null && size > 0) {
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next) {
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
return false;
}
containsKey(key)包含key, containsValue(value)包含value, 这两个方法实现ADT的包含操作
包含key是调用了getNode(hash(key), key)方法, 类似get(key), 这里的包含只是判断get到的节点是否为空, 算法复杂度lgN或者N
包含value是使用了双层for循环来查找value, 找到则true, 否则false, 注意这里只是查找链表结构的节点, 算法复杂度N²
当知道key或者(key, value), 优先考虑用containsKey(key)判断包含。
只知道value, 且节点数量不大的情况下使用containsValue(value)。