Java|HashMap源码解读 川长思鸟来 2022-01-14 03:29 253阅读 0赞 ### HashMap源码解读 ### * 1 相关知识 * * 1.1 Hash * 1.2 位运算 * 2 HashMap * * 2.1 属性 * 2.2 结构 * 2.3 构造方法 * 2.4 put()方法 * 2.5 get()方法 * 2.6 扩容机制 resize() * 相关面试题 # 1 相关知识 # ## 1.1 Hash ## 把**任意长度**的输入通过一种算法(Hash),变成**固定长度**的输出,这个输出值就是散列值,属于**压缩映射**,容易产生**Hash冲突**。 解决冲突的方法有: **1.** 开放寻址 **2.** 再散列 **3.** 链地址法 md4,md5,sha其实都是Hash算法/摘要算法/指纹算法,而并非加密算法。 ## 1.2 位运算 ## << 有符号左移 >> 有符号右移 <<< 无符号左移 >>> 无符号右移 a << n 等价于 a \* 2 ^ n a % (2 ^ n) 等价于 a & (2 ^ n-1) 位运算在**权限控制**和**设置属性**(eg.商品属性非常多时)有广泛的应用。 一个int可以保存32个属性。 # 2 HashMap # ## 2.1 属性 ## // 缺省初始化容量,必须是2的幂 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 即 16 // 最大容量,为什么是1 << 30后面说 static final int MAXIMUM_CAPACITY = 1 << 30; // 缺省装载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 由链表转为红黑树的阈值 static final int TREEIFY_THRESHOLD = 8; // 由红黑树转为链表的阈值 static final int UNTREEIFY_THRESHOLD = 6; // 结构转化为红黑树对应的数组的最小大小,如果当前容量小于它,就不会将链表转化为红黑树,而是用resize()代替 // 树的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32 然后为了避免(resizing 和 treeification thresholds) 设置成64 static final int MIN_TREEIFY_CAPACITY = 64; // 存储数据的数组, 大小为2的幂 // 第一次使用时被初始化 transient Node<K,V>[] table; // 存储数据的数组, 大小为2的幂 transient Set<Map.Entry<K,V>> entrySet; // 键值对的数量(注意这个不等于数组长度) transient int size; // HashMap 发生结构化改变的次数。 // 结构化改变包括改变键值对数量或rehash // 用于fail-fast机制 transient int modCount; // 临界值 当实际大小(容量*装填因子)超过临界值时,会进行扩容 int threshold; // 哈希表的装填因子 final float loadFactor; **装填因子** * 表示Hash表中元素的填满的程度。a=n/m 其中 n 为关键字个数,m 为表长。 * 装填因子越大,填满的元素越多,空间利用率高,但冲突的机会加大;反之,装填因子越小,填满的元素越少,冲突的机会减小,但空间浪费多。冲突的机会越大,则查找的成本越高;反之,查找的成本越小,因而查找时间就越少。 在设置其初始容量时,**应考虑Map的预期条目数及其装填因子**,以便最小化扩容成本。 如果初始容量大于最大条目数除以负载因子,则不会发生重新排列操作。 **最大容量为什么是1 << 30** 1. HashMap内部由Node\[\]数组构成 2. 规定了数组的长度必须为2的幂 3. Java的数组下标是由int(32位,-2^31 —— 2^31-1)表示 所以对于HashMap来说其最大的容量应该是不超过int最大值的一个2的指数幂,而最接近int最大值的2的指数幂用位运算符表示就是 1 << 30。 **为什么不是1 << 31?** int 的最高位是符号位,1 << 31 就变成负数了。 **为什么转红黑树的阈值是8?** ## 2.2 结构 ## HashMap主要是通过hashCode类似计算hash值的。只要hashCode相同,计算出的hash值就一样。当出现hash冲突时,HashMap通过**拉链法**解决hash冲突。 HashMap的底层是一个存储Node<K,V>的**数组**,数组中的元素是**单链表**。 ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2Rhcmxpbmd3b29kMjAxMw_size_16_color_FFFFFF_t_70] JDK1.8考虑到链表过长时,查找性能很差(O(n)),所以在链表结点数量不小于8时,把该链会被调整为一颗**红黑树**。 ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2Rhcmxpbmd3b29kMjAxMw_size_16_color_FFFFFF_t_70 1] Map.Entry的实现类Node作为Map的嵌套内部类。其是一个**单链表**(具有next项)。 可以看到重写了hashCode()和equals(Object)方法,其中equals(Object)方法判断两者的key和value如果都相同,则为true。hashCode()方法对key和value做异或。能够保证equals(Object)相等则两者hashCode()相等。 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; } } 树结点,还没仔细看,里面的相关方法太多了。 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } } ## 2.3 构造方法 ## // 指定初始容量和装填因子,构造一个空的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; // tableSizeFor方法计算临界值,put数据的时候如果超出该值就会扩容 // 指定的初始容量没有保存下来,只用来生成了一个临界值 // tableSizeFor()方法保证总是返回大于cap并且是2的幂的值,比如传入999 返回1024 this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** * Constructs an empty <tt>HashMap</tt> with the default initial capacity * (16) and the default load factor (0.75). */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 使用与指定Map相同的映射构造一个新的HashMap。 使用默认加载因子(0.75)创建HashMap,初始容量足以保存指定Map中的映射。 // @param是要将映射放在此映射中的映射 // @throws NullPointerException如果指定的映射为null public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); } > **Float.isNaN()** > Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常(这里所讲的是java语言中的异常,请勿与IEEE 754规范中的浮点异常相互混淆,IEEE 754的浮点异常是一种运算信号),当一个操作产生溢出时,将会使用有符号的无穷大来表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示,所有使用NaN值作为操作数的算术操作,结果都返回NaN。Java中的Double和Float中都有isNaN函数,判断一个数是不是NaN,其实现都是通过上述 v != v 的方式,因为NaN是唯一与自己不相等的值,NaN与任何值都不相等。 在构造方法`HashMap(int initialCapacity, float loadFactor)`中: * 用户指定的装填因子 loadFactor 被保存到 HashMap 的成员变量loadFactor 中 * 而用户指定的初始化容量 initialCapacity 则没有被保存下来。 this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); **tableSizeFor()** initialCapacity被传入函数 tableSizeFor()中,返回一个大于输入参数且最近的2的整数次幂的数。比如10,则返回16。该算法源码如下: // 返回大于cap的且离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; } > 先来分析有关n位操作部分:先来假设n的二进制为01xxx…xxx。接着 > 对n右移1位:001xx…xxx,再位或:011xx…xxx > 对n右移2为:00011…xxx,再位或:01111…xxx > 此时前面已经有四个1了,再右移4位且位或可得8个1 > 同理,有8个1,右移8位肯定会让后八位也为1。 > 综上可得,该算法让最高位的1后面的位全变为1。 > 最后再让结果n+1,即得到了2的整数次幂的值了。 现在回来看看第一条语句: int n = cap - 1; 让cap-1再赋值给n的目的是另找到的目标值**大于或等于原值**。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。 **知道了tableSizeFor()到底做了什么事情,我们看看为什么这个返回值赋给了threshold。** 根据前面的属性介绍,threshold它是一个临界值,当实际值超过这个临界值就会扩容。 这一点在扩容部分讲。 **总的来说:** * `HashMap(int initialCapacity, float loadFactor)`是核心的构造方法,除了`HashMap(Map<? extends K, ? extends V> m)`,其他构造方法都调用这个构造方法。 * HashMap 的构造方法,提供给用户可调整的参数是 负载因子loadFactor 和 初始化容量initialCapacity。 * 提供负载因子使得用户可以根据需要控制HashMap数组的满的情况。 * 而提供初始化容量是为了方便用户在已知元素大致数量的时候,减少扩容。 关于这一点,可参考文章:[https://www.hollischuang.com/archives/3545][https_www.hollischuang.com_archives_3545] 本文后面也会介绍扩容的相关内容。 ## 2.4 put()方法 ## 1. 如果table还没初始化,先初始化它 2. 根据`(n - 1) & hash`计算key应存放的位置 3. 如果该位置没有元素就直接放进去 4. 如果该位置有元素,如果他们的hash和key都相等,说明是修改操作 5. 否则根据是此链是树还是链表进行不同的处理 6. 判断是否需要扩容 public V put(K key, V value) { // 取关键字key的哈希值 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; //如果 table 还未被初始化,则初始化它 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //根据(n - 1) & hash找到该键对应到数组中存储的索引 //如果为 null,那么说明此索引位置并没有被占用 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 如果哈希表当前位置上已经有节点的话,说明有hash冲突 Node<K,V> e; K k; //当前结点和将要插入的结点的 hash 和 key 相同,说明这是一次修改操作 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); //如果插入后链表长度大于等于 8 ,将链表裂变成红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //遍历的过程中,如果发现与某个结点的 hash和key,这依然是一次修改操作 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; } put()方法比较复杂,因为其包含的内容比较多: * **table的初始化**在HashMap的构造函数里没有做,而是放在了**第一次插入元素时做**。 * 有两种链需要处理:链表和红黑树结构。 * 需要确定是修改操作还是插入操作。 * 需要确定是否扩容。 **为什么JDK1.8中插入新结点用的尾插法,JDK1.8以前用的头插法?** 头插法是操作速度最快的,找到数组位置就直接找到插入位置了,但是jdk8之前hashmap这种插入方法在并发场景下如果多个线程同时扩容会出现循环列表。jdk8开始hashmap链表在节点长度达到8之后会变成红黑树,这样一来在数组后节点长度不断增加时,遍历一次的次数就会少很多很多(否则每次要遍历所有),相比头插法而言,尾插法操作额外的遍历消耗已经小很多了,也可以避免之前的循环列表问题。(同时如果变成红黑树,也不可能做头插法了) ## 2.5 get()方法 ## 1. 对key的hashCode()做hash,然后再计算index; 2. 如果该 index 下没有值,则直接返回 null; 3. index 下有值,判断 table 数组中该下标处第一个节点是否命中,命中则直接返回; 4. 未命中,则判断去树结构中查找,或者去链表结构中查找,找到则返回,,否则返回null。 // 找不到则返回null // 否则返回对应值 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; // table 不为空 && table长度大于0 && key对应的桶不为空 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; } ## 2.6 扩容机制 resize() ## HashMap 外层是一个数组,数组容量是一定的,当需要装入更多元素时,就要对其进行扩容了。所谓扩容就是申请一个更大的空间,把原来空间的元素移入进去。可以看到,这个操作是非常**耗时**的。 所以我们在使用这样需要动态扩容的集合时,应该注意,在能够预知容量的情况下,尽量使用该容量进行初始化集合。 跑偏了,哈哈~根据上面介绍的扩容,自然而然地我们会思考如下几个问题: 1. 什么时候需要扩容?有没有一个具体的值限定呢? 2. 如果能够装的元素数量已经达到极限了怎么办? 3. 具体怎么扩容?扩到多大? 4. 万一后面把里面的元素给删掉了,会需要缩减容量吗? 好滴,带着这些问题我们来一探究竟。 先不写了。歇会。 threshold /** * Initializes or doubles table size. If null, allocates in * accord with initial capacity target held in field threshold. * Otherwise, because we are using power-of-two expansion, the * elements from each bin must either stay at same index, or move * with a power of two offset in the new table. * * @return the table */ 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; } # 相关面试题 # **HashMap在多线程情况下为什么不安全?** HashMap在多线程环境下put操作会引起死循环,因为使得Entry链表产生**环形**的数据结构。 **HashMap的实现原理?** 数组+链表+红黑树。 **HashMap什么时候会进行rehash?** HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor \* capacity。 那HashMap的初始容量设置成多少比较合适呢? **结合源码说说HashMap在高并发场景中为什么会出现死循环?** JDK1.8中对HashMap做了哪些性能优化? **HashMap和HashTable有何不同?** * Hashtable 是一个线程安全的 Map 实现,HashMap 是线程不安全的实现,HashMap 比 Hashtable 性能高 * Hashtable 不允许使用 null 作为 key 和 value,如果试图把 null 值放进 Hashtable 中,将会引发 NullPointerException 异常;但 HashMap 可以使用 null 作为 key 或 value。 Hashtable 是从JDK1.0就开始有的古老的类,一般不推荐使用,即使需要线程安全的场景,也有其他方式可以使用,而不使用 Hashtable。 参考文章: Float.isNaN(): [https://www.cnblogs.com/big-xuyue/p/4106130.html][https_www.cnblogs.com_big-xuyue_p_4106130.html] [https://www.jb51.net/article/80443.htm][https_www.jb51.net_article_80443.htm] tableSizeFor():[https://www.cnblogs.com/loading4/p/6239441.html][https_www.cnblogs.com_loading4_p_6239441.html] [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2Rhcmxpbmd3b29kMjAxMw_size_16_color_FFFFFF_t_70]: /images/20220113/24b5e5c615334dbcbb2c29eb15a319b9.png [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2Rhcmxpbmd3b29kMjAxMw_size_16_color_FFFFFF_t_70 1]: /images/20220113/4faee8c5e60b4c1f8cbbc16dcc305f64.png [https_www.hollischuang.com_archives_3545]: https://www.hollischuang.com/archives/3545 [https_www.cnblogs.com_big-xuyue_p_4106130.html]: https://www.cnblogs.com/big-xuyue/p/4106130.html [https_www.jb51.net_article_80443.htm]: https://www.jb51.net/article/80443.htm [https_www.cnblogs.com_loading4_p_6239441.html]: https://www.cnblogs.com/loading4/p/6239441.html
还没有评论,来说两句吧...