HashMap原理与理解
本文以 Java 1.8 为基础进行展开。
一、HashMap的基本结构
HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造,比如String、ArrayList等,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组、链表、红黑树的结合体(Java 1.8引入了红黑树结构)。
数组的特点:查询效率高,插入,删除效率低。
链表的特点:查询效率低,插入删除效率高。
在HashMap底层使用数组加(链表或红黑树)的结构完美的解决了数组和链表的问题,使得查询和插入,删除的效率都很高。
其结构模型可参考下图(借用baidu的两张图):


二、HashMap的基本属性、方法
1. HashMap的成员变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认的初始容量为16 static final int MAXIMUM_CAPACITY = 1 << 30; //最大的容量为 2 ^ 30 static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认的加载因子为 0.75f static final int TREEIFY_THRESHOLD = 8; //链表长度大于等于8时转化为红黑树 static final int UNTREEIFY_THRESHOLD = 6; //红黑树长度小于等于6时转化为链表 static final int MIN_TREEIFY_CAPACITY = 64; //链表转为红黑树时要求的table capacity最小容量 transient Node<K,V>[] table; //Node类型的数组(实现了Entry接口),HashMap的基本组成单元,用来存储key-value映射 transient Set<Map.Entry<K,V>> entrySet; // transient int size; //HashMap包含key-value映射的数量 final float loadFactor; //加载因子 int threshold; //HashMap的扩容临界点(容量和加载因子的乘积) transient int modCount; // 每次扩容和更改map结构的计数器
计算数组index的时候,为什么要用位运算&呢?
主要是效率问题,位运算(&)效率要比取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。在jdk1.8之前的index计算就是用的取模运算%。
MAXIMUM_CAPACITY 为什么设置成 1 << 30
i. HashMap在确定数组下标Index的时候,采用的是( length-1) & hash的方式。只有当length为2的指数幂,2的n次-1的二进制表示刚好全为1,这样&运算确定的index才能分布均匀,不然如果有一位是0,那
么与运算结果对应的这一位也永远是0,那对应的数组index处就为空,index分布不均匀了。所以HashMap规定了其容量必须是2的n次方,这样才能较均匀的分布元素,hash%length = hash&(length-1)才能成
立。
ii. 另外,HashMap内部由Node[](Entry[])数组构成,Java的数组下标是由Integer表示的。所以对于HashMap来说其最大的容量应该是不超过Integer最大值的一个2的指数幂,而最接近Integer最大值的2的
指数幂就是 1 << 30。此时,HashMap也就无法再继续扩容。
DEFAULT_LOAD_FACTOR = 0.75f 加载因子
loadFactor加载因子,是用来衡量 HashMap 满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率。计算HashMap的实时加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity 是桶的数量,也就是 table 的长度length。当Size>=threshold的时候,那么就要考虑对数组的resize(扩容)。当链表的值超过8则会转红黑树(jdk 1.8新增)
loadFactor太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。
当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。所以开发中尽量减少扩容的次数,可
以通过创建HashMap集合对象时指定初始容量来尽量避免。
同时在HashMap的构造器中可以指定loadFactor:
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); }
TREEIFY_THRESHOLD = 8; UNTREEIFY_THRESHOLD = 6;
为什么Map桶中节点个数大于等于8转为红黑树?桶中链表元素个数小于等于6时,树结构还原成链表?
解释1. 参考自:https://www.cnblogs.com/xc-chejj/p/10825676.html
因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必
要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元
素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
解释2. 参考自:https://www.cnblogs.com/coding-996/p/12468618.html
根据官方源码的注释可以看到:
threshold of 0.75, although with a large variance because of * resizing granularity. Ignoring variance, the expected * occurrences of list size k are (exp(-0.5) * pow(0.5, k) / * factorial(k)). The first values are: * * 0: 0.60653066 * 1: 0.30326533 * 2: 0.07581633 * 3: 0.01263606 * 4: 0.00157952 * 5: 0.00015795 * 6: 0.00001316 * 7: 0.00000094 * 8: 0.00000006 * more: less than 1 in ten million
大概意思就是说,在理想情况下,使用随机哈希码,节点出现在hash桶中的频率遵循泊松分布,同时给出了桶中元素个数和概率的对照表。从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非
常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个的概率小于一千万分之一。即加载因子为0.75,同一个桶中出现8个元素然后转化为红黑树的概率小于1000万分之一。
MIN_TREEIFY_CAPACITY = 64
当HashMap桶的容量超过这个值时才能进行树形化 ,否则会先选择扩容,而不是树形化。为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD。
Node<K,V>[] table
在jdk1.7中,Entry数组是HashMap的基本组成单元:
View Codejdk1.8中,HashMap基本单元由Node数组组成,Node数组实现了Entry接口:
View Code当然,还有jdk1.8 引入的红TreeNode静态内部类:
View CodeSet<Map.Entry<K,V>> entrySet
HashMap中所有key-value映射的集合,保存了所有的key-value键值对。可通过entrySet() 方法获取:
public Set<Map.Entry<K,V>> entrySet() { Set<Map.Entry<K,V>> es; return (es = entrySet) == null ? (entrySet = new EntrySet()) : es; }
粗略一看,发现HashMap中并没有主动地去维护entrySet,比如put的时候去存值或者调用entrySet()去维护值,那entryset的值从哪而来呢?具体在后面的entrySet()方法中说明。
2. HashMap的常用方法
(1)HashMap的主要构造器
i. HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap
View Code
ii. HashMap(int initialCapacity):设置了初始容量initialCapacity,但方法内部会基于initialCapacity重新计算,得到一个不小于initialCapacity的最小的2的指数幂,并将其作为threshold(具体计算逻辑见下面的tableSizeFor() 方法和 put() 方法),同时设置负载因子为 0.75
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); } 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; }
iii. HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。同样,方法内部也基于initialCapacity重新计算了threshold
源码见ii 中所述方法。
(2)tableSizeFor() 方法
源码见上述ii 中代码。该方法对给定初始容量initialCapacity进行初始化,将其修改为不小于initialCapacity的、最小的2的n次幂,以此来保证HashMap的容量只会是2的幂。也就是说如果传入的参数为x,那么调用该方法应该返回一个大于等于x的最小的2的幂。
// 10000000 00000000 00000000 00000000 n |= n >>> 1; // 11000000 00000000 00000000 00000000 n |= n >>> 2; // 11110000 00000000 00000000 00000000 n |= n >>> 4; // 11111111 00000000 00000000 00000000 n |= n >>> 8; // 11111111 11111111 00000000 00000000 n |= n >>> 16; // 11111111 11111111 11111111 11111111
其实,我们只需要关心从左边数第一个不为零的位,其他的位是1是0都不重要。n |= n >>> 1之后可以确保,从左边数第一个不为零的位开始前两位都是1,n |= n >>> 2之后可以确保前四位都是1。以此类推,最后得到的就是从第一个不为零的位开始全为1的数,再将这个数+1就可以得到大于等于cap这个变量的最小的2的幂了。
移位5次就停止是因为HashMap最大容量是1<<30 (2的30次方),是一个int型数据。在java中int型是4个字节也就是32位,除去符号位就是31位,进行5次移位之后已经足以保证31位全为1了。
参考自:https://blog.csdn.net/qq_41046325/article/details/88626353
值得注意的是,上面的源码中,是将tableForSize的值赋值给了threshold, 那为何说是我们初始化容量(capacity)的大小为该值呢?可以下面的put()方法。
(3)put() 方法
先解释一下上面刚刚提出的问题。在第一次向map添加数据时,调用:
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 { ... } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
注意上面,putVal会判断table是否为null
if ((tab = table) == null || (n = tab.length) == 0)
如果为null,则调用resize方法:
n = (tab = resize()).length;
而resize()方法(源码见下文)实际上就是将之前设置的threshold作为了初始化的容量大小。
参考自:https://www.cnblogs.com/shuhe-nd/p/12011269.html
为了逻辑清晰、简洁易懂,在此参考另一篇博客中的一张图,put() 方法的逻辑可以用下图来表示(图中对链表转化为红黑树等条件判断进行了简化):

上图参考自:https://www.cnblogs.com/one-apple-pie/p/10473682.html
(4)resize()方法
当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
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; }
resize的大致逻辑:
i. 校验和扩容:校验capacity 和 threshold,重新计算这两个参数,并新建一个Entry空数组,长度是原来的两倍。
ii. reHash :遍历原来的Entry数组,把所有的数据重新Hash到新的数组。
那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。
上面参考自:https://www.cnblogs.com/yuanblog/p/4441017.html
为什么要重新Hash呢,直接复制过去不是更快捷方便吗?
-
因为扩容以后的数组长度变了,
index = HashCode(key)&(length - 1),扩容后的length和之前不一样了,变为原来的2倍,重新Hash算出来的index值肯定也不一样,而且重新计算后,会使元素更加均匀的分布在HashMap表中,如果直接复制的话,那么数据肯定都堆在一起了,扩容的意义就削弱了。
(5)entrySet()方法
i. 先看一段常用的遍历代码:
View Code上面的遍历方式可以遍历HashMap中所有的key-value键值对,为什么entrySet()会返回值呢?继续看源码:
View Code
View Code
final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> { public final Map.Entry<K,V> next() { return nextNode(); } }
abstract class HashIterator { Node<K,V> next; // next entry to return Node<K,V> current; // current entry int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; if (t != null && size > 0) { // advance to first entry do {} while (index < t.length && (next = t[index++]) == null); } } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } public final void remove() { Node<K,V> p = current; if (p == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; K key = p.key; removeNode(hash(key), key, null, false, false); expectedModCount = modCount; } }
可以看到,上面entrySet() 方法中在entrySet为空时,会初始化一个EntrySet类。而该类中重写了一个方法iterator(),并且在方法内部调用了EntryIterator 的构造方法。EntryIterator 类又继承了HashIterator类,所以会默认调用该类的构造方法。在HashIterator 的构造方法中,会初始化一个next变量,构造初期会从0开始找有值(不为空)的索引位置,找到后将这个Node赋值给next;然后要遍历的时候调用了EntryIterator的 next() 方法,即调用了HashIterator的 nextNode() 方法,这个方法会一直遍历找到下一个有值(不为空)的索引位置。如此反复。
所以,HashMap在put 的时候维护了Node<K,V>[] table,然后在entrySet() 的时候会遍历这个table,从而获得所有的key-value键值对。
参考自:https://www.cnblogs.com/javammc/p/7631597.html
另外,关于entrySet 值初始化的问题,可能跟默认调用toString()方法有关,可以参考下面的讨论:
https://www.cnblogs.com/allmignt/p/12353732.html,https://segmentfault.com/q/1010000012304833
ii. 遍历方法对比
(i) 通过HashMap.entrySet()获得键值对的Set集合,如上面 i 中所述方式。
(ii) 通过HashMap.keySet()获得键的Set集合(iterator或foreach)
Map<String, String> map = new HashMap<String, String>(); map.put("1", "11"); map.put("2", "22"); map.put("3", "33"); // 键和值 String key = null; String value = null; // 获取键集合的迭代器 Iterator it = map.keySet().iterator(); while (it.hasNext()) { key = (String) it.next(); value = (String) map.get(key); System.out.println("key:" + key + "---" + "value:" + value); }
(iii) 通过HashMap.values()得到“值”的集合
Map<String, String> map = new HashMap<String, String>(); map.put("1", "11"); map.put("2", "22"); map.put("3", "33"); // 值 String value = null; // 获取值集合的迭代器 Iterator it = map.values().iterator(); while (it.hasNext()) { value = (String) it.next(); System.out.println("value:" + value); }
上述(i)与(ii)的不同之处在于获取到相应集合之后,在遍历的时候时间复杂度不同:(i)在遍历时通过iterator获取下一个键值对,时间复杂度为O(1),而(ii)调用get()方法则又会进行一次遍历。因此方式(i)的性能要更优于方式(ii),尤其是在map容量比较大的时候。
(iiii) Lambda表达式
(iiiii) stream API
链接的文中对HashMap的遍历方式列举比较全面(包括Lambda表达式、stream API),可以进行学习参考:https://blog.csdn.net/sufu1065/article/details/105852634?utm_medium=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.channel_param&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.channel_param。
三、线程安全
1. 链表头插法、尾插法
举个例子,现在采用多线程往一个容量大小为2的HashMap中put 值:A = 1 ,B = 2,C = 3 ,负载因子是0.75。
正常单线程的情况下,在put第二个值的时候就会进行resize。对于多线程而言,假如各自都安全地完成了数据的插入(但还没有触发扩容),此时A->B->C,状态如下所示:

现在进行扩容。如果使用单链表的头插法,同一index位置上的新元素总会被放到链表的头部,假如某一线程这时刚好把 B 重新hash到了A原来的位置,如图:

由于是多线程同时操作,当所有线程都执行完毕以后,就可能会出现这样的情况:

此时居然出现了环状的链表结构,如果这个时候去取值,就会出错——InfiniteLoop(死循环)。
Java7在多线程操作HashMap时,采用了头插法,在转移过程中修改了原链表中节点的引用关系(互相颠倒),很有可能引起死循环;Java8采用尾插法,就不会引起死循环,原因是扩容前后(如果)仍然位于同一链表上的元素,他们的相对引用顺序不会颠倒。
总之,Java7如果多个线程同时触发扩容,在移动节点时可能会导致一个链表中的2个节点相互引用,从而生成环链表。
四、与Jdk1.7对比
1. HashMap基本构成
参考上面的:Node<K,V>[] table部分
2. 数据插入链表时,JDK8以前是头插法,JDK8是尾插法
参考三、线程安全
3. JDK8引入了红黑树(相对平衡的二叉搜索树),提高了查询效率。
红黑树可以参考:https://www.cnblogs.com/LiaHon/p/11203229.html
五、小结:
1. 扩容是一个特别耗性能的操作,所以在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
2. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
3. HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。

浙公网安备 33010602011771号