Hash 算法
简单介绍一下 Hash 算法以及解决 Hash 冲突的方案。
优秀 Hash 算法的特点:
从 Hash 值不可以反向推导出原始的数据
输入数据的微小变化会得到完全不同的 Hash 值,相同的数据会得到相同的值
哈希算法的执行效率要高效,长的文本也能快速地计算出哈希值
Hash 算法的冲突概率要小
Hash 冲突解决方案
链地址法
链表地址法是使用一个链表数组,来存储相应数据,当 Hash 遇到冲突的时候依次添加到链表的后面进行处理;这也是 HashMap 在 1.8 之后做的优化之一。
开放地址法
线性探测法,就是比较常用的一种开放地址哈希表的一种实现方式。
线性探测法的核心思想是当冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。简单来说就是:一旦发生冲突,就去寻找下 一个空的散列表地址,只要散列表足够大,空的散列地址总能找到。
HashMap 数据结构
数组:通过 Hash 后得到的值可以直接找到对应的数组下标,时间复杂度为 O(1),就算是遍历素组,复杂度也是 O(n)
链表:1.7 版本采用首插法,在 resize 时,会重新计算每个元素的 Hash 值,从新放到不同的位置,首插可能会出现链表变环出现死循环;1.8 版本后采用尾插法,避免了该问题的出现。
红黑树:1.8 版本后添加,使查询性能由链表的 O(n),提升到 O(logn),效率更高
关于 1.7 到 1.8 的升级,后面说明。
HashMap Java 源码
先确定几个预定义常量
默认初始容量
1 2 3 4 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4 ;
扩容阈值
1 2 3 4 static final float DEFAULT_LOAD_FACTOR = 0.75f ;
树化与链化阈值
1 2 3 4 5 6 7 8 9 static final int TREEIFY_THRESHOLD = 8 ;static final int UNTREEIFY_THRESHOLD = 6 ;
最小树化容量
1 2 3 4 static final int MIN_TREEIFY_CAPACITY = 64 ;
HashMap 中的变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 transient Node<K,V>[] table;transient Set<Map.Entry<K,V>> entrySet;transient int size;transient int modCount;int threshold;final float loadFactor;
存储数据的最小单位 Node
Node 是整个 HashMap 的最小单位,实际的值都存储在这个 Node 中;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 static class Node <K,V> implements Map .Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this .hash = hash; this .key = key; this .value = value; this .next = next; } public final K getKey () { return key; } public final V getValue () { return value; } public final String toString () { return key + "=" + value; } public final int hashCode () { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue (V newValue) { V oldValue = value; value = newValue; return oldValue; } 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 ; } }
构造方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public HashMap (int initialCapacity) { this (initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap (int initialCapacity, float loadFactor) { if (initialCapacity < 0 ) throw new IllegalArgumentException ("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException ("Illegal load factor: " + loadFactor); this .loadFactor = loadFactor; this .threshold = tableSizeFor(initialCapacity); }
tableSizeFor() 方法
1 2 3 4 5 6 7 8 9 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 ; }
这里的算法主要目的是计算出离给定数值向上最近的 2 的整数幂的值,简单点来说就是:给 1 就返回 2;给 5 返回 8;给 15 返回 16;给 16 也返回16。
可能会有疑问,给了16 按道理来说不应该是返回 32 吗?这就是为什么一开始会有一个 int n = cap - 1
的操作。
从使用者的视角来看,我需要创建的是一个容量为 16 的 HashMap,那就应该只创建一个容量为 16 的 HashMap,而不是一个 32 的 HashMap,虽然在使用上感受不到,但是在 JVM 中,每块内存都是寸土寸金,能不浪费就不浪费,减 1 操作就是为了得到一个与设置值更加接近的 2 的整数幂的值作为 HashMap 实际的容量,但如果传入的值为 -1,那初始容量就会为 1,会在后面的 resize 中立即扩容;
putVal() 方法
在除了构造方法歪,HashMap 仍然还有几个比较重要的方法,先来介绍一个 putVal()
方法,然后在引入其他的方法。先上源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); } static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); } final V putVal (int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> node; int newLen, index; if ((tab = table) == null || (newLen = tab.length) == 0 ) { newLen = (tab = resize()).length; } if ((node = tab[index = (newLen - 1 ) & hash]) == null ) { tab[index] = newNode(hash, key, value, null ); } else { Node<K,V> nodeBuffer; K k; if (node.hash == hash && ((k = node.key) == key || (key != null && key.equals(k)))) { nodeBuffer = node; } else if (node instanceof TreeNode) { nodeBuffer = ((TreeNode<K,V>)node).putTreeVal(this , tab, hash, key, value); } else { for (int binCount = 0 ; ; ++binCount) { if ((nodeBuffer = node.next) == null ) { node.next = newNode(hash, key, value, null ); if (binCount >= TREEIFY_THRESHOLD - 1 ) { treeifyBin(tab, hash); } break ; } if (nodeBuffer.hash == hash && ((k = nodeBuffer.key) == key || (key != null && key.equals(k)))) { break ; } node = nodeBuffer; } } if (nodeBuffer != null ) { V oldValue = nodeBuffer.value; if (!onlyIfAbsent || oldValue == null ) { nodeBuffer.value = value; } afterNodeAccess(nodeBuffer); return oldValue; } } ++modCount; if (++size > threshold) { resize(); } afterNodeInsertion(evict); return null ; }
resize() 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 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 ; } } else if (oldThr > 0 ) { newCap = oldThr; } else { 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 { 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; }
链表拆分算法
主要算法就是上面源码中出现的:(e.hash & oldCap) == 0
,大白话就不说了,直接上图:
(PS:还是多一句嘴,2 的整数幂都是只有一个 1,比如 8 => 01000,图中的最上一行是 hash 结果,01000 作为 oldCap,最下一行是运算结果)
由上图可知,至于是分到 lo 链表还是 hi 链表,就看最高位是 0 还是 1。
treeifyBin() 方法
进入这个方法的前提是在该桶中的链表长度 >= 8,但不代表进入就一定会树化,源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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); } } }
这里解释一下为什么 8 是树化的阈值(摘抄自网络):
根据泊松分布,在负载因子默认为 0.75 的时候,单个 hash 桶内元素个数为 8 的概率小于百万分之一,所以将 7 作为一个分水岭,等于 7 的时候不转换,大于等于 8 的时候才进行转换,小于等于 6 的时候就化为链表。
所以不要面试的时候就说到 8 就树化,这是错误的!
1.7 到 1.8 的升级
主要升级点有两个:
从 1.7 的链表头插法改成了 1.8 的尾插;
添加了红黑树弥补当链表长度过大,查询效率变低的问题
对于头插改尾插的升级主要是考虑到当多个线程操作 HashMap 时,一旦出现 resize,就会将一个链表拆分成两个链表,但是 HashMap 是线程不安全的,就会导致出现链表变成环,当要查找该桶中的元素时就有可能会出现死循环的情况。
而红黑树可以对查询性能提升数倍,从 O(n) 提升到 O(logn),但是红黑树也会有问题,就是它占用的空间会比链表大很多,所以就出现了负载因子,而且要同时满足两个要求才会树化操作。