原文地址java
HashMap
是 Map
的一个实现类,它表明的是一种键值对的数据存储形式。node
大多数状况下能够直接定位到它的值,于是具备很快的访问速度,但遍历顺序倒是不肯定的。算法
HashMap
最多只容许一条记录的键为null
,容许多条记录的值为null
。不保证有序(好比插入的顺序)、也不保证序不随时间变化。数组
jdk 8
以前,其内部是由数组+链表来实现的,而 jdk 8
对于链表长度超过 8 的链表将转储为红黑树。安全
HashMap
非线程安全,即任一时刻能够有多个线程同时写HashMap
,可能会致使数据的不一致。若是须要知足线程安全,能够用 Collections
的synchronizedMap
方法使HashMap
具备线程安全的能力,或者使用ConcurrentHashMap
。app
下面咱们先来看一下HashMap内部所用到的存储结构函数
HashMap
是数组+链表+红黑树(JDK1.8增长了红黑树部分)实现的性能
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; } }
Node
是HashMap
的一个内部类,实现了Map.Entry接口,本质上就是一个映射(键值对)。优化
有时两个key
会定位到相同的位置,表示发生了Hash碰撞。固然Hash
算法计算结果越分散均匀,Hash
碰撞的几率就越小,map
的存取效率就会越高。this
HashMap
类中有一个很是重要的字段,就是 Node[] table
,即哈希桶数组。
若是哈希桶数组很大,即便较差的Hash
算法也会比较分散,若是哈希桶数组数组很小,即便好的Hash
算法也会出现较多碰撞。
因此就须要在空间成本和时间成本之间权衡,其实就是在根据实际状况肯定哈希桶数组的大小,并在此基础上设计好的hash
算法减小Hash碰撞。那么经过什么方式来控制map使得Hash碰撞的几率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。
下面咱们就来看一下hashmap
中通过jdk1.8优化过的Hash算法和扩容机制。
不过在这以前咱们先了解下hashmap中的变量
//初始化容量16 hashMap的容量必须是2的指数倍,Hashtable是11 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //最大容量2的30次方 static final int MAXIMUM_CAPACITY = 1 << 30; //默认加载因子默认的平衡因子为0.75,这是权衡了时间复杂度与空间复杂度以后的最好取值 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 若是链表的长度超过这个阈值就改用红黑树存储 static final int TREEIFY_THRESHOLD = 8; static final int UNTREEIFY_THRESHOLD = 6; static final int MIN_TREEIFY_CAPACITY = 64; transient Node<K,V>[] table; transient Set<Map.Entry<K,V>> entrySet; transient int size; //实际存储的键值对个数 transient int modCount; //阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold通常为 capacity*loadFactory。 int threshold; final float loadFactor; //负载因子,表明了table的填充度有多少,默认是0.75
在HashMap中有两个很重要的参数,容量(Capacity)和负载因子(Load factor)
Capacity
就是buckets
的数目,Load factor
就是buckets
填满程度的最大比例。若是对迭代性能要求很高的话不要把capacity
设置过大,也不要把load factor
设置太小。当bucket
填充的数目(即hashmap中元素的个数)大于capacity*load factor
时就须要调整buckets的数目为当前的2倍。
static final int hash(Object key) { int h; // h = key.hashCode() 为第一步 取hashCode值 // h ^ (h >>> 16) 为第二步 高位参与运算 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } static int indexFor(int h, int length) { return h & (length-1); //第三步 取模运算 }
indexFor是jdk1.7的源码,jdk1.8没有这个方法可是jdk1.8也是经过取模运算来计算的
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
对于任意给定的对象,只要它的hashCode()
返回值相同,那么程序调用方法一所计算获得的Hash
码值老是相同的。咱们首先想到的就是把hash
值对数组长度取模运算,这样一来,元素的分布相对来讲是比较均匀的。可是,模运算的消耗仍是比较大的,这里咱们用&位运算来优化效率。
这个方法很是巧妙,它经过h & (table.length -1)
来获得该对象的保存位,而HashMap
底层数组的长度老是2的n次方,这是HashMap
在速度上的优化。当length老是2的n次方时,h& (length-1)
运算等价于对length取模,也就是h%length
,可是&比%具备更高的效率。
在JDK1.8
的实现中,优化了高位运算的算法,经过hashCode()
的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么作能够Node数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
扩容(resize)就是从新计算容量,向HashMap
对象里不停的添加元素,而HashMap
对象内部的数组没法装载更多的元素时,对象就须要扩大数组的长度,以便能装入更多的元素。
固然Java
里的数组是没法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像咱们用一个小桶装水,若是想装更多的水,就得换大水桶。
当put
时,若是发现目前的bucket
占用程度已经超过了Load Factor
所但愿的比例,那么就会发生resize
。在resize
的过程,简单的说就是把bucket
扩充为2倍,以后从新计算index
,把节点再放到新的bucket
中。
由于咱们使用的是2次幂的扩展(指长度扩为原来2倍),因此,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
例如咱们从16扩展为32时,具体的变化以下所示:
所以元素在从新计算hash
以后,由于n变为2倍,那么n-1的mask范围在高位多1bit(红色),所以新的index就会发生这样的变化:
所以,咱们在扩充HashMap
的时候,不须要从新计算hash
,只须要看看原来的hash
值新增的那个bit是1仍是0就行了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。
这个设计确实很是的巧妙,既省去了从新计算hash值的时间,并且同时,因为新增的1bit是0仍是1能够认为是随机的,所以resize
的过程,均匀的把以前的冲突的节点分散到新的bucket
了。
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) { 若是容量超过Hash Map限定的最大值,将再也不扩容 threshold = Integer.MAX_VALUE; return oldTab; } // 没超过最大值,就扩充为原来的2倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // 2倍 } //数组未初始化,但阈值不为 0,为何不为 0 ? //构造函数根据传入的容量打造了一个合适的数组容量暂存在阈值中,这里直接使用 else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { //数组未初始化而且阈值也为0,说明一切都以默认值进行构造 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // newCap = oldThr 以后并无计算阈值,因此 newThr = 0 // 从新计算下一次进行扩容的上限 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) { // 把每一个bucket都移动到新的buckets中 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) //若是 e 是红黑树结点,红黑树分裂,转移至新表 ((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 { // 原索引+oldCap if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { // 原索引放到bucket里 loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { // 原索引+oldCap放到bucket里 hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
下面咱们再来看看hashmap中的其余方法
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); }
这是一个最基本的构造函数,须要调用方传入两个参数,initialCapacity 和 loadFactor。
程序的大部分代码在判断传入参数的合法性,initialCapacity 小于零将抛出异常,大于 MAXIMUM_CAPACITY 将被限定为 MAXIMUM_CAPACITY。loadFactor 若是小于等于零或者非数字类型也会抛出异常。
整个构造函数的核心在对 threshold 的初始化操做:
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; }
由以上代码能够看出,当在实例化HashMap
实例时,若是给定了initialCapacity
,因为HashMap
的capacity
都是2的幂次方,所以这个方法用于找到大于等于initialCapacity
的最小的2的幂(initialCapacity若是就是2的幂,则返回的仍是这个数)。
下面分析这个算法:
首先,咱们想一下为何要对cap作减1操做?
int n = cap - 1
这是为了防止,cap已是2的幂。若是cap已是2的幂,又没有执行这个减1操做,则执行完后面的几条无符号右移操做以后,返回的capacity将是这个cap的2倍。若是不懂,要看完后面的几个无符号右移以后再回来看看。
下面看看这几个无符号右移操做:
若是n这时为0了(通过了cap-1以后),则通过后面的几回无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操做)。
这里咱们只讨论n不等于0的状况。
n |= n >>> 1;
因为n不等于0,则n的二进制表示中总会有一bit为1,这时考虑最高位的1。经过无符号右移1位,则将最高位的1右移了1位,再作或操做,使得n的二进制表示中与最高位的1紧邻的右边一位也为1,如000011xxxxxx。
n |= n >>> 2;
注意,这个n已经进行过 n |= n >>> 1; 操做。假设此时n为000011xxxxxx ,则n无符号右移两位,会将最高位两个连续的1右移两位,而后再与原来的n作或操做,这样n的二进制表示的高位中会有4个连续的1。如00001111xxxxxx 。
n |= n >>> 4;
此次把已经有的高位中的连续的4个1,右移4位,再作或操做,这样n的二进制表示的高位中会有8个连续的1。如00001111 1111xxxxxx 。
以此类推 。。。
注意,容量最大也就是32bit的正数,所以最后 n |= n >>> 16; 最多也就32个1,可是这时已经大于了MAXIMUM_CAPACITY ,因此取值到MAXIMUM_CAPACITY 。
下面咱们经过一个图片来看一下整个过程:
HashMap 中还有不少的重载构造函数,但几乎都是基于上述的构造函数的。
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
以上这些构造函数都没有直接的建立一个切实存在的数组,他们都是在为建立数组须要的一些参数作初始化,
因此有些在构造函数中并无被初始化的属性都会在实际初始化数组的时候用默认值替换。
实际对数组进行初始化是在添加元素的时候进行的(即put方法)
public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
put
方法也是HashMap
中比较重要的方法,由于经过该方法咱们能够窥探到 HashMap
在内部是如何进行数据存储的,所谓的数组+链表+红黑树的存储结构是如何造成的,又是在何种状况下将链表转换成红黑树来优化性能的。
put方法的大体实现过程以下:
public V put(K key, V value) { // 对key的hashCode()作hash 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) // tab为空则建立(初次添加元素) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) //根据键值key计算hash值获得插入的数组索引i,若是table[i]==null,直接新建节点添加 tab[i] = newNode(hash, key, value, null); else { //若是对应的节点存在元素 Node<K,V> e; K k; if (p.hash == hash && //判断table[i]的首个元素是否和key同样,若是相同直接覆盖value ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) //判断table[i] 是否为treeNode,即table[i] 是不是红黑树,若是是红黑树,则直接在树中插入键值对 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 遍历table[i],判断链表长度是否大于TREEIFY_THRESHOLD(默认值为8),大于8的话把链表转换为红黑树,在红黑树中执行插入操做,不然进行链表的插入操做; // 遍历过程当中若发现key已经存在直接覆盖value便可; 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; } } //e 不是 null,说明当前的 put 操做是一次修改操做而且e指向的就是须要被修改的结点 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,若是超过,进行扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
在理解了put以后,get就很简单了。大体思路以下:
bucket里的第一个节点,直接命中;
若是有冲突,则经过key.equals(k)去查找对应的entry
若为树,则在树中经过key.equals(k)查找,O(logn);
若为链表,则在链表中经过key.equals(k)查找,O(n)。
具体代码的实现以下:
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) { // 在树中get if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); // 在链表中get do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
删除操做就是一个查找+删除的过程,相对于添加操做其实容易一些
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; }
根据键值删除指定节点,这是一个最多见的操做了。显然,removeNode 方法是核心。
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; }
删除操做须要保证在表不为空的状况下进行,而且 p 节点根据键的 hash 值对应到数组的索引,在该索引处一定有节点,若是为 null ,那么间接说明此键所对应的结点并不存在于整个 HashMap 中,这是不合法的,因此首先要在这两个大前提下才能进行删除结点的操做。
第一步
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))) node = p;
须要删除的结点就是这个头节点,让 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); } }
若是头节点是红黑树结点,那么调用红黑树本身的遍历方法去获得这个待删结点。不然就是普通链表,咱们使用 do while 循环去遍历找到待删结点。找到节点以后,接下来就是删除操做了。
第三步
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; }
删除操做也很简单,若是是红黑树结点的删除,直接调用红黑树的删除方法进行删除便可,若是是待删结点就是一个头节点,那么用它的 next 结点顶替它做为头节点存放在 table[index] 中,若是删除的是普通链表中的一个节点,用该结点的前一个节点直接跳过该待删结点指向它的 next 结点便可。
最后,若是 removeNode 方法删除成功将返回被删结点,不然返回 null。
public void clear() { Node<K,V>[] tab; modCount++; if ((tab = table) != null && size > 0) { size = 0; for (int i = 0; i < tab.length; ++i) tab[i] = null; } }
该方法调用结束后将清除 HashMap 中存储的全部元素。
//实例属性 keySet transient volatile Set<K> keySet; public Set<K> keySet() { Set<K> ks; return (ks = keySet) == null ? (keySet = new KeySet()) : ks; } final class KeySet extends AbstractSet<K> { public final int size() { return size; } public final void clear() { HashMap.this.clear(); } public final Iterator<K> iterator() { return new KeyIterator(); } public final boolean contains(Object o) { return containsKey(o); } public final boolean remove(Object key) { return removeNode(hash(key), key, null, false, true) != null; } public final Spliterator<K> spliterator() { return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0); } }
HashMap 中定义了一个 keySet 的实例属性,它保存的是整个 HashMap 中全部键的集合。上述所列出的 KeySet 类是 Set 的一个实现类,它负责为咱们提供有关 HashMap 中全部对键的操做。
能够看到,KeySet 中的全部的实例方法都依赖当前的 HashMap 实例,也就是说,咱们对返回的 keySet 集中的任意一个操做都会直接映射到当前 HashMap 实例中,例如你执行删除一个键的操做,那么 HashMap 中将会少一个节点。
public Collection<V> values() { Collection<V> vs; return (vs = values) == null ? (values = new Values()) : vs; }
values 方法其实和 keySet 方法相似,它返回了全部节点的 value 属性所构成的 Collection 集合,此处再也不赘述。
public Set<Map.Entry<K,V>> entrySet() { Set<Map.Entry<K,V>> es; return (es = entrySet) == null ? (entrySet = new EntrySet()) : es; }
它返回的是全部节点的集合,或者说是全部的键值对集合。