HashMap
源码深度分析,手把手带你剖析每一行代码!
在后面的两篇文章哈希表的原理和200行代码带你写本人的HashMap
(如果你浏览这篇文章感觉有点艰难,能够先浏览这两篇文章)当中咱们认真谈到了哈希表的原理并且本人入手应用线性探测法实现了咱们本人的哈希表MyHashMap
。在本篇文章当中咱们将仔细分析JDK
当中HashMap
的源代码。
首先咱们须要理解的是一个容器最重要的四个性能 增删改查
,而咱们也是次要依据这四个性能进行开展一步一步的分析HashMap
的源代码。在正式进行源码剖析之前,先提一下:在JDK
当中实现的HashMap
解决哈希抵触的方法是应用链地址法
,而咱们本人之前在文章200行代码带你写本人的HashMap
当中实现的MyHashMap
解决哈希抵触的方法是线性探测法,大家留神一下这两种办法的不同。
HashMap
源码类中关键字段剖析
- 上面字段示意默认的哈希表的长度,也就是
HashMap
底层应用数组的默认长度,在HashMap
当中底层所应用的的数组的长度必须是2
的整数次幂,这一点咱们在文章200行代码带你写本人的HashMap
曾经认真做出了阐明。
/** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
- 这个字段示意哈希表当中数组的最大长度,
HashMap
底层应用的数组长度不能超过这个值。
/** * The maximum capacity, used if a higher value is implicitly specified * by either of the constructors with arguments. * MUST be a power of two <= 1<<30. */ static final int MAXIMUM_CAPACITY = 1 << 30;
- 字段
DEFAULT_LOAD_FACTOR
的作用示意在HashMap
当中默认的负载因子的值。
/** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f;
在理论状况当中咱们并不是当HashMap
当中的数组齐全被应用完之后才进行扩容,因为如果数组快被应用完之后,再退出数据产生哈希抵触的可能性就会很大,因而咱们通常会设置一个负载因子(load factor)
,当数组的使用率超过这个值的时候就进行扩容,即当(数组长度为L
,数组当中数据个数为S
,负载因子为F
):
$$S \ge L \times F$$
TREEIFY_THRESHOLD
这个字段次要示意将链表(在JDK
当中是采纳链地址法去解决哈希抵触的问题)变成一个红黑树(如果你不理解红黑树,能够将其认为是一种均衡二叉树)的条件,在JDK1.8
之后JDK
中实现HashMap
不仅采纳链地址法去解决哈希抵触,而且链表满足肯定条件之后会将链表变成一颗红黑树。而将链表变成一颗红黑树的必要条件
是链表当中数据的个数要大于等于TREEIFY_THRESHOLD
,请大家留神是必要条件
不是充分条件
,也就是说满足这个条件还不行,它还须要满足另外一个条件,就是哈希表中数组的长度要大于等于MIN_TREEIFY_CAPACITY
,MIN_TREEIFY_CAPACITY
在JDK
当中的默认值是64。
/** * The bin count threshold for using a tree rather than list for a * bin. Bins are converted to trees when adding an element to a * bin with at least this many nodes. The value must be greater * than 2 and should be at least 8 to mesh with assumptions in * tree removal about conversion back to plain bins upon * shrinkage. */ static final int TREEIFY_THRESHOLD = 8; /** * The smallest table capacity for which bins may be treeified. * (Otherwise the table is resized if too many nodes in a bin.) * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts * between resizing and treeification thresholds. */ static final int MIN_TREEIFY_CAPACITY = 64;
UNTREEIFY_THRESHOLD
示意当在进行resize
操作的过程当中,红黑树当中的节点个数小于UNTREEIFY_THRESHOLD
时,就须要将一颗红黑树从新复原成链表。
/** * The bin count threshold for untreeifying a (split) bin during a * resize operation. Should be less than TREEIFY_THRESHOLD, and at * most 6 to mesh with shrinkage detection under removal. */ static final int UNTREEIFY_THRESHOLD = 6;
- 下列代码当中的
table
数组对象就是HashMap
底层当中真正用于存储数据的数组。
/** * The table, initialized on first use, and resized as * necessary. When allocated, length is always a power of two. * (We also tolerate length zero in some operations to allow * bootstrapping mechanics that are currently not needed.) */ transient Node<K,V>[] table;
size
示意哈希表中存储的key-value
对象的个数,也就是放入了多少个键值对象。
/** * The number of key-value mappings contained in this map. */ transient int size;
threshold
示意容器当中可能存储的数据个数的阈值,当HashMap
当中存储的数据的个数超过这个值的时候,HashMap
底层应用的数组就须要进行扩容。下列公式中Capacity
示意底层数组的长度(2
的整数次幂,留神与size
进行辨别)。
$$threshold = loadFactor * Capacity$$
int threshold; /** * The load factor for the hash table. * * @serial */ final float loadFactor;
HashMap
底层数组当中的节点类
在上篇哈希表的设计原理当中咱们曾经认真阐明,在HashMap
当中咱们是应用数组去存储具体的数据的,那么在咱们的数组当中应该存储什么样的数据呢?假如在HashMap
的数组当中存储的数据类型为Node
,那么这个类须要有哪些字段呢?
- 首先一点咱们必定须要存储
Value
值,因为咱们最终须要通过get
办法从HashMap
当中取出咱们所须要的值。 - 第二点当咱们通过
get
办法去取值的时候是通过Key
(键值)去取的,当哈希值产生抵触的时候,咱们不仅须要通过哈希值确定地位,还须要通过比拟通过函数get
传递的Key
和数组当当中存储的数据的key
是否相等,因而咱们须要存储键值Key
。 - 第三点为了防止反复计算哈希值(因为有的对象的哈希值计算还是比拟费时间),咱们能够应用一个字段去存储计算好的哈希值。
基于以上三点,在JDK
当中的HashMap
外部的节点类次要构造如下。
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<String, Integer> map = new HashMap<>();map.put("一无是处的钻研僧", 888);
在下面的代码当中put
函数的参数"一无是处的钻研僧"
就是下面Node
类当中的key
,888
就是Node
类当中的value
对象,下面的类当中的hash
对象就是字符串"一无是处的钻研僧"
的哈希值,然而事实上他还须要通过一段代码的解决:
/** * 这个 key 是 put 函数传进来的 key * @param key * @return */ static int hash(Object key) { int h; // 调用对象本人实现的 hashCode 办法 // key.hashCode() = "一无是处的钻研僧".hashCode return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
下面的函数之所以要将对象的哈希值右移16
,是因为咱们的数组的长度个别不会超过$2^{16}$,因为$2^{16}$曾经是一个比拟大的值了,因而当哈希值与$2^n - 1$进行&
操作的时候,高位通常没有应用到,这样做的原理是能够充分利用数据哈希值当中的信息。
tableSizeFor
函深刻分析
/** * Returns a power of two size for the given target capacity. *//** * 返回第一个大于或者等于 capacity 且为 2 的整数次幂的那个数 * @param capacity * @return */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; // 如果最终失去的数据小于 0 则初始长度为 1 // 如果长度大于咱们所容许的最大的容量 则将初始长度设置为咱们 // 所容许的最大的容量 // MAXIMUM_CAPACITY = 1 << 30; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;}
因为咱们须要底层应用的数组table
的长度是2
的整数次幂,而咱们之后在初始化函数当中会容许用户输出一个数组长度的大小,然而用户输出的数字可能不是2
的整数次幂,因而咱们须要将用户输出的数据变成2
的整数次幂,咱们能够将用户输出的数据变成大于等于这个数的最小的2
的整数次幂。
比如说如果用户输出的是12
咱们须要将其变成16
,如果输出的是28
咱们须要将其变成32
。咱们能够通过下面的函数做到这一点。
下面的代码还是很难了解的,让咱们一点一点的来剖析。首先咱们应用一个2
的整数次幂的数进行下面移位操作
的操作!
从上图当中咱们会发现,咱们咋一个数的二进制数的32位放一个1
,通过移位之后最终32
位的比特数字全副变成了1
。依据下面数字变动的法则咱们能够发现,任何一个比特通过下面移位的变动,这个比特前面的31
个比特位都会变成1
,像下图那样:
因而上述的移位操作的后果只取决于最高一位的比特值为1
,移位操作后它前面的所有比特位的值全为1
,而在下面函数的最初,如果最终的容量没有大于咱们设置的最大容量MAXIMUM_CAPACITY
,咱们返回的后果就是下面移位之后的后果 +1
。又因为移位之后最高位的1
到最低位的1
之间的比特值全为1
,当咱们+1
之后他会一直的进位,最终只有一个比特地位是1
,因而它是2
的整数倍。
在tableSizeFor
函数当中,给初始容量减了个1
,这样做的起因是让这个函数的返回值大于等于传入的参数capacity
:
tableSizeFor(4) == 4 // 就是当传入的数据曾经是 2 的整数次幂的时候也返回传入的值tableSizeFor(3) == 4tableSizeFor(5) == 8
HashMap
构造函数剖析
首先咱们先看一下几个构造函数的代码:
public HashMap(int initialCapacity) { // 指定初始容量的构造函数 this(initialCapacity, DEFAULT_LOAD_FACTOR);}public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted}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; // 这里原本应该将 threshold 的值设置为数组长度的 * load factor, // 然而在 HashMap 的源代码当中 // 并没有一个变量存储数组的长度,因为数组的长度间接 array.length // 就能够失去,因而也没必要,而在 HashMap 当中,应用懒加载 // 只有在应用 put 函数的时候才申请数组 因而须要一个变量存储数组的长度 // 而此时 threshold 并没有应用,因而能够长期用于存储 数组的长度 // 在前面申请数组是,将 threshold 更新为 数组长度 * load factor this.threshold = tableSizeFor(initialCapacity);}
HashMap
的构造函数整体来说比较简单,然而下面代码当中最初一行很容易让人蛊惑,具体起因在下面的正文当中曾经阐明了,大家能够浏览一下。
HashMap
的增删改查函数剖析
put
函数剖析——“增改”
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);}
在put
函数当中首先计算参数key
的哈希值,而后调用putVal
函数真正的将输出插入到数据当中,为了不便大家于都代码,代码解释在代码当中对应的地位。
在正式浏览这个代码之前咱们先剖析这个函数的流程:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { // 咱们先只管后面三个参数,后的参数能够先不论 Node<K,V>[] tab; Node<K,V> p; int n, i; // 这里是首次调用函数 putVal 的时候这个 if 条件会通过 // 因为第一次调用这个函数的时候还没有申请数组 所以 table == null if ((tab = table) == null || (n = tab.length) == 0) // 进行扩容 n = (tab = resize()).length; // 如果计算出的下标对应数据还没有村数据间接将数据退出到数组 // 当中即可 // 这行代码不仅会将tab[i = (n - 1) & hash] 的后果赋值给 p // (p = tab[i = (n - 1) & hash]) 这行代码的返回值也是 tab[i = (n - 1) & hash] if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 如果对应地位当中曾经存在数据了 // 即产生了哈希抵触,要采纳链地址法进行解决 Node<K,V> e; K k; // 如果传入的哈希值和对应下标的数据的哈希值相等 // 而且两个 key 相等,这个 if 语句的条件就满足了 // 而后将对应下标的数据赋值给 e 而后在后续的代码当中 // 更新 e 当中的 value 为 putVal 函数传入的 value // 即 e.value = value; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) // 如果 p 是一个红黑树节点,就在红黑树当中放入数据 // 在本篇文章当中咱们不认真去探讨这个函数,因为红黑树 // 的操作比较复杂,咱们之后再专门写一篇对于红黑树的文章来解说这个问题 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 这里就是链表的操作了 for (int binCount = 0; ; ++binCount) { // 如果 e.next == null 阐明曾经遍历到最初一个节点了 // 须要将新退出的 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 如果节点数超过 TREEIFY_THRESHOLD 就须要进行后续的操作 // 在 treeifyBin 函数当中会有一个判断,如果数组的长度大于 // MIN_TREEIFY_CAPACITY 就将链表变成红黑树,否则间接进行扩容 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } // 如果找到雷同的 key 就跳出去 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 当存在一个对象的 key 和传进这个函数的 key 雷同的话 // 就须要进行 value 的更新,相当于将新的 value 替换掉旧的 // value 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); // 这个函数在 HashMap 没啥用,他的函数体为空 return null;}
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); } // 下面的代码次要是计算失去新的阈值 newThr 和数组长度 newCap 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; // e.next == null 示意只有一个数据,并没有造成 2 个 // 数据以上的链表,因而能够间接退出到心得数组 当中 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;}
扩容时链表数据的下标剖析
为了解释下面的链表的从旧数组挪动到新数组的过程,咱们先通过上面的例子来剖析一下:
当初有一个哈希表在工作时候的状况,在进行扩容之前他的构造如下图所示:
在扩容之前数组的长度等于8,那么乘以2倍扩容之后,数组的长度应该变成16,而且链表当中的数据须要进行从新&
的操作,再将其放在新的数组当中,扩容从新进行&
操作之后数组的状况如下图所示:
从下面的两张图咱们能够发现,与元素的哈希值进行&
运算的数组长度减1
的二进制数示意会多出一个1
,即:
$$2^3 - 1 = 7 = 0111_2$$
$$2^4 - 1 = 7 = 1111_2$$
如果数据的哈希值对应的地位也是1
比方上图当中数据2、4、6
的状况,那么咱们在确定数据在新数组当中的地位的时候不须要从新进行&
运算,只须要在旧数组的地位加上原数组的长度就是数据在新数组当中的地位。为什么?
从上图咱们能够发现扩容前后与key
的哈希值进行&
操作的数据的二进制数只是在高位减少了一个1
,因而咱们间接将原数组的下标加上这个高位1
对应的10进制数(这个十进制数对应就是原数组的长度)就失去的数据在新数组的下标。而如果哈希值的二进制示意当中相应的高位的比特值为0
,那么扩容前后他在数组当中的地位是没有发生变化的。
而能进行下面谈到的操作的数据须要满足一个特点就是数据的哈希值对应的高位也是1
,能力进行这个操作。这也是上面代码的if
判断的内容:
// 和数组的长度进行&操作看看高位是不是0 if ((e.hash & oldCap) == 0) { // 如果对应的高位为0 if (loTail == null) loHead = e; else loTail.next = e; loTail = e;}else { // 如果对应的高位为 1 if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e;}
链表扩容代码变量剖析
下面的代码波及四个节点loTail和loHead
,hiTail和hiHead
的相干操作,首先咱们先弄清楚这四个变量的含意是什么。
从下面扩容前后链表当中的数据下标剖析咱们能够晓得,一个链表在扩容之后会放在新数组的两个地位,如果链表数据在旧数组下标为x
的地位,旧数组的长度为L
,那么扩容之后数据在新数组的地位别离为x
和x + L
的地位,整个的扩容过程和loTail和loHead
,hiTail和hiHead
的指向如下图所示:
loTail和loHead
新数组当中下标为x
的链表的表尾和表头,hiTail和hiHead
示意下标为x + L
的链表的表尾和表头。
看到当初置信你曾经能看懂上面的代码了:
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;}
get
函数剖析——“查”
如果你曾经看懂了put
和resize
函数,这个函数就很简略了。
- 首先计算数据在数组当中的下标值
(n - 1) & hash
。 - 如果下标中第一个节点的
key
就等于参数传入的key
,就间接返回数据。 - 如果节点是红黑树当中的节点就通过红黑树进行查找,否则就是链表节点,而后通过链表的形式查找。
- 找到雷同的
key
数据,将后果返回。
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) { 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;}
remove
函数剖析——“删”
整个函数分成一下两个步骤:
- 先找到要删除的节点。
- 删除找到的节点。
大家在了解下面“增改查”三个操作之后,上面的代码很容易了解了,上面代码有正文帮忙大家了解。
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;}final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { // matchValue 这个参数如果为 true 示意传入的参数 value // 和查找到的数据的 value 相等才进行删除 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;}
总结
本篇文章次要跟大家一起剖析了HashMap
当中次要的源代码,次要波及四个操作增删改查
,然而没有仔细分析关系红黑树的局部,因为红黑树波及的局部比拟多,本篇文章曾经比拟长了,当前专门写一篇文章仔细分析红黑树的局部。
在HashMap
当中有很多写的很奇妙的代码,比如说tableSizeFor
函数,扩容的时候两条链表的操作,这些设计都十分奇妙,心愿大家有所播种。我是LeHung,咱们下期再见!!!
更多精彩内容合集可拜访:https://github.com/Chang-LeHu...
关注微信公众号:一无是处的钻研僧,理解更多计算机常识~~~~