认识HashMap HashMap是平时开发中常用一种数据结构。其底层精妙的设计大大提高了我们开发出来的产品的底层性能。那么它是如何实现的,我们来一探究竟
底层数据结构 JDK1.7及之前 桶数组(bucket) + 链表
JDK1.8之后 当链表达到一定长度时 会转化为红黑树
其实就是哈希散列法中的“拉链法”,只不过当一条链长度过长时,为了增加查询数据的性能,在JDK1.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 26 27 28 public HashMap () { this .loadFactor = DEFAULT_LOAD_FACTOR; } public HashMap (Map<? extends K, ? extends V> m) { this .loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false ); } 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); }
一般常用的就是构建一个空的HashMap,别的构造函数涉及到了一些需要知道的概念,借此机会一次性说掉
门限值 门限值Threshold就是扩容机制触发的一个阈值 计算公式为:
门限值 = 数组容量 X 负载因子
其中负载因子会在下面做出说明
初始化容量 指的就是桶数组(bucket)的初始化大小 默认为16
为什么是16呢 简单的解释就是使用2的次方的容量可以在HASH取模运算后使得散列更加均匀 学过数据结构的朋友应该都有印象 书上建议初始化大小为2的次幂 在这里JDK开发者选择了16作为默认的值
这些说明了为什么扩容时都是乘以2 就是为了保证桶数组的容量为2的次幂
在构造函数中若指定了一个初始的大小 则初始容量会进行计算 找到一个最近的2次幂数
1 this .threshold = tableSizeFor(initialCapacity);
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 ; }
乍看是不是很懵逼 这个算法十分精妙 来理解一下
比如要求17对应的2次幂数 易得出是32
那么这个算法如何实现的呢 就是先将17 -1 得到 16 二进制就是 10000
只要将二进制数第一个为1的位置开始,后面的数字全变为1 最后再加上1 就是我们要的2次幂数了 10000
-> 11111(31)
-> 31 + 1
-> 32
这个算法就是实现将后面数全变为1的操作 那为什么是逻辑右移1,2,4,8,16 就结束了呢
这样思考 一个位置为1 右移1位 就得到了两个1 此时右移2位 就得到了4各个1 以此类推 当右移16位时 刚好一个int类型位数操作完 自然可以得到任何int类型数据的所要求的二次幂数
负载因子 负载因子可以自定义,默认为0.75f
该参数用于调节散列效果 例如当负载因子为1时 数组需要到达容量大小则才开始扩容 不可避免会出现很多HASH冲突 导致某个节点的链条过长 影响查询性能 当负载因子为0.5时 每当数组容量使用了一半时就开始扩容了 虽然会大大降低HASH冲突的概率 但散列效果却会不尽如人意 于是取中间值0.75作为默认的负载因子 开发者可以根据需求 自由调节
扰动函数 HashMap中有这样一个方法专门用于求键值的HASH值
1 2 3 4 static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); }
可见,在key求hashcode
之后又将其与自己逻辑右移16位后的结果做了按位与操作,那么有两个问题
为什么要做按位与
为什么是16位
因为hashcode
表示数据范围是int类型数据范围 众所周知 int由32个bit组成 因此逻辑右移16位(一半距离) 以此结合一个hashcode
高位和低位的特征 以降低在对桶数组容量取模运算后的hash冲突
个人理解:这些参数的存在,就是为了数据有一个较好的散列效果,以增强数据查询插入的效率,以达到高性能的效果
HashMap基本操作和遍历 先循环插入一些数
1 2 3 4 5 6 HashMap<Integer, Integer> map = new HashMap <>(); for (int i = 0 ; i < 14 ; i++) { map.put(i, (int ) (Math.random() * 100 )); } System.out.println("直接输出HashMap" ); System.out.println(map);
1 2 直接输出HashMap {0=43, 1=14, 2=54, 3=22, 4=75, 5=36, 6=73, 7=37, 8=46, 9=77, 10=69, 11=23, 12=63, 13=85}
这里可见HashMap以键值对的形式返回了数据
4种遍历方式 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 System.out.println("Iterate entries using ForEach loop:" ); for (Map.Entry<Integer, Integer> stringStringEntry : map.entrySet()) { System.out.println("Key = " + stringStringEntry.getKey() + ", Value = " + stringStringEntry.getValue()); } System.out.println("Iterate keys / values using ForEach loop:" ); for (Integer key : map.keySet()) { System.out.println("Key=" + key); } for (Integer value : map.values()) { System.out.println("Value=" + value); } System.out.println("Iterate using Iterator with generics=v:" ); Iterator<Map.Entry<Integer, Integer>> iterator = map.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<Integer, Integer> entry = iterator.next(); System.out.println("Key=" + entry.getKey() + " Value=" + entry.getValue()); } System.out.println("Iterate using Java 8 Lambda Expression:" ); map.forEach((K, V) -> System.out.println("key: " + K + " value:" + V));
打印结果太多 就不展示了 可以自己试一下 个人觉得Lambda表达式简直爱了好吗 Elegant And Concise 优雅且简洁
底层数据结构的构建 下面就来看看所谓的桶数组+链表+红黑树到底是怎么构建 让我们一起撕开神秘的面纱
节点类 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 ; } }
这便是HashMap中最基本的一个节点的构成 它实现了Map.Entry<K,V>
Entry是Map的一个静态内部类,用于存放键值对
值得注意的是,这里Node的equals方法,是比较Key和Value的equals方法,在开发中,如果有需求要进行个性化的比较,就需要重写我们自己的Object类的equals方法了
插入节点的putVal()方法 此方法是一个内部方法,仅在内部调用。在我们插入一个节点时,就会调用该方法
1 2 3 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); }
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 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 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; }
头疼的来了 看看怎么拆解 怎么理解
做好预备工作
这里进行一系列的初始化 tab即是桶数组容器 p就是插入的单个节点 若一上来table为null 则说明HashMap仅仅是做了初始化 还没有一个初始化的大小空间 那么就resize()方法分配初始空间 这里只要知道resize方法就是用来分配空间的 具体细节后面会做分析
第一种情况(当前位置为null) HASH分配到的偏移位置还没有插入节点,那么二话不说,直接塞入新节点
1 2 if ((p = tab[i = (n - 1 ) & hash]) == null ) tab[i] = newNode(hash, key, value, null );
值得注意的是 数组偏移是通过 (n - 1) & hash 得到的 其实 这就是将hash对数组容量做了一个取默运算 只不过数组偏移是从0开始的 所以是n-1 非常精妙
第二种情况(hash分配的位置相同且Key相同) 说明这是节点更新,直接将当前节点更新
1 2 3 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
第三种情况(加入红黑树节点) 1 2 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this , tab, hash, key, value);
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 final TreeNode<K,V> putTreeVal (HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v) { Class<?> kc = null ; boolean searched = false ; TreeNode<K,V> root = (parent != null ) ? root() : this ; for (TreeNode<K,V> p = root;;) { int dir, ph; K pk; if ((ph = p.hash) > h) dir = -1 ; else if (ph < h) dir = 1 ; else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; else if ((kc == null && (kc = comparableClassFor(k)) == null ) || (dir = compareComparables(kc, k, pk)) == 0 ) { if (!searched) { TreeNode<K,V> q, ch; searched = true ; if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null ) || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null )) return q; } dir = tieBreakOrder(k, pk); } TreeNode<K,V> xp = p; if ((p = (dir <= 0 ) ? p.left : p.right) == null ) { Node<K,V> xpn = xp.next; TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); if (dir <= 0 ) xp.left = x; else xp.right = x; xp.next = x; x.parent = x.prev = xp; if (xpn != null ) ((TreeNode<K,V>)xpn).prev = x; moveRootToFront(tab, balanceInsertion(root, x)); return null ; } } }
具体怎么加入的,需要先了解红黑树,这里不做多说明
第四种情况(加入链表节点) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 else { for (int binCount = 0 ; ; ++binCount) { if ((e = p.next) == null ) { p.next = newNode(hash, key, value, null ); if (binCount >= TREEIFY_THRESHOLD - 1 ) treeifyBin(tab, hash); break ; } if (e.hash == hash ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } }
遍历链表,若hash值Key值均一样,则直接退出循环
若链表长度即将大于树化的门槛值,则要靠考虑进行树化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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); } }
首先会判断桶数组容量是否达到可以树化的最低标准,没有则先进行桶数组的扩容再HASH,达到了则将链表转化为红黑树
HashMap的resize()方法 在HashMap中,采用二次方扩容法
当HashMap在元素达到负载因子阈值时,便会自动扩容,以保证HASH得到很好的散列
1 2 3 4 Node<K,V>[] oldTab = table; int oldCap = (oldTab == null ) ? 0 : oldTab.length;int oldThr = threshold;int newCap, newThr = 0 ;
resize()
方法的一开始,进行一系列的初始化。下面分种情况来讨论
无参构造的空HashMap 由于是一个空的HashMap,oldTable
oldCap
oldThr
均为空
于是直接初始化容量为16,门限值为0.75*16 = 12
达到门限值需要扩容
如果原来的桶数组大小已经超过最大限定容量(1 << 30)了,那么将门限值设定为Integer数据类型的最大值(1 << 31)
否则,就直接按照2次幂扩展规则,将桶数组容量和门限值都扩大为原先的2倍
接着,不可避免的,需要rehash
大概结构如下:遍历每个节点,进行调整
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 ; ...... } } }
数组的调整 1 2 3 4 5 6 7 if ((e = oldTab[j]) != null ) { oldTab[j] = null ; if (e.next == null ) newTab[e.hash & (newCap - 1 )] = e; ...... } }
链表的调整 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 if ((e = oldTab[j]) != null ) { oldTab[j] = null ; ...... ...... 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; } } }
乍一看很复杂,是的。
首先,要明白高低位链表是什么:
将每个节点HASH值与oldCap按位与,如果为0,则加入低位链表,如果高位为1,则加入高位链表
为什么会这样呢,举个例子,oldCap为16
若7(00111) & 16(10000) => 00000 则加入低位链表
若17(10001) & 16(10000) => 10000 则加入高位链表
下面看看怎么通过循环构建两根链表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 );
将loHead
loTail
分别指向低位链表的头尾节点
将hiHead
hiTail
分别指向高位链表的头尾节点
接着将两条链表加入新的桶数组中
1 2 3 4 5 6 7 8 if (loTail != null ) { loTail.next = null ; newTab[j] = loHead; } if (hiTail != null ) { hiTail.next = null ; newTab[j + oldCap] = hiHead; }
最后,借互联网上的一张图来帮助理解
红黑树的调整 1 2 3 4 5 6 7 8 9 10 if ((e = oldTab[j]) != null ) { oldTab[j] = null ; ...... else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this , newTab, j, oldCap); ...... }
其中split函数细节为:
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 final void split (HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) { TreeNode<K,V> b = this ; TreeNode<K,V> loHead = null , loTail = null ; TreeNode<K,V> hiHead = null , hiTail = null ; int lc = 0 , hc = 0 ; for (TreeNode<K,V> e = b, next; e != null ; e = next) { next = (TreeNode<K,V>)e.next; e.next = null ; if ((e.hash & bit) == 0 ) { if ((e.prev = loTail) == null ) loHead = e; else loTail.next = e; loTail = e; ++lc; } else { if ((e.prev = hiTail) == null ) hiHead = e; else hiTail.next = e; hiTail = e; ++hc; } } if (loHead != null ) { if (lc <= UNTREEIFY_THRESHOLD) tab[index] = loHead.untreeify(map); else { tab[index] = loHead; if (hiHead != null ) loHead.treeify(tab); } } if (hiHead != null ) { if (hc <= UNTREEIFY_THRESHOLD) tab[index + bit] = hiHead.untreeify(map); else { tab[index + bit] = hiHead; if (loHead != null ) hiHead.treeify(tab); } } }
看起来有些复杂,其实和链表一样的。根据HASH将红黑树分出高低位链表来。再将两条链表树化塞入新的桶数组中。其中,有很多细节和特殊情况,暂不做讨论。关于红黑树的理论和实现,这里也不再详细赘述。
这样,对于HashMap是如何进行扩容的,我们有了一个基本的认识