源码部分从HashMap提及是由于笔者看了不少遍这个类的源码部分,同时感受网上不少都是粗略的介绍,有些可能还不正确,最后只能本身看源码来验证理解,写下这篇文章一方面是为了促使本身能深刻,另外一方面也是给一些新人一些指导,不求有功,但求无过。有错误的地方请在评论中指出,我会及时验证修改,谢谢。html
接下来就来讲下我眼中的HashMap。java
jdk版本:1.8
在深刻源码以前,了解HashMap的总体结构是很是重要的事情,结构也体现出了源码中一些对HashMap的操做,结构大体以下:node
从上边的结构图你们应该也能看出来HashMap的实现结构:数组+链表+红黑树
。segmentfault
看下类注释,直接看源码部分最好,可能大多数都看不明白,这里能够看下别人的翻译:类注释翻译。本文中笔者不打算对红黑树部分进行讲解说明,插入和删除操做会引起各类状态,须要作对应的调整,以后会单独写一篇红黑树基础,结合TreeNode来作讲解。数组
先总结一些名词概念方便初学者理解:安全
1.桶(bucket):数组中存储元素的位置,参考结构图,其实是数组中的某个索引下的元素,这个元素有多是树的根节点或者链表的首节点,固然,理解上仍是一个链表或红黑树总体当成桶2.bin:桶中的每一个元素,即红黑树中的某个元素或者是链表中的某个元素。多线程
除了上边的名词,最好还能去理解下哈希表,能够参考下。HashMap也是对哈希表的一种实现,简单理解,能够类比数学中的求余操做,对范围进行固定,将大量的数据放入一个有界的范围中,求余放置,这种操做算是哈希表的一种实现方式。并发
下面进行源码部分的说明:app
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
继承AbstractMap
实现Cloneable接口,提供克隆功能
实现Serializable接口,支持序列化,方便序列化传输函数
这里有个有意思的问题:为何HashMap继承了AbstractMap还要实现Map接口?有兴趣的能够去看下stackoverflow上的回答:
https://stackoverflow.com/que...
/** * Node数组的默认长度,16 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * Node数组的最大长度,最大扩容长度 */ /** * 默认负载因子 * 这个是干吗的呢? * 负载因子是哈希表在自动扩容以前能承受容量的一种尺度。 * 当哈希表的数目超出了负载因子与当前容量的乘积时,则要对该哈希表进行rehash操做(扩容操做)。 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 链表转换为树的阈值,超过这个长度的链表会被转换为红黑树, * 固然,不止这一个条件,在下面的源码部分会看到。 */ static final int TREEIFY_THRESHOLD = 8; /** * 当进行resize操做时,小于这个长度的树会被转换为链表 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 链表被转换成树形的最小容量, * 若是没有达到这个容量只会执行resize进行扩容 * 能够理解成一种计算规则 */ static final int MIN_TREEIFY_CAPACITY = 64; /** * * 第一次使用的时候进行初始化,put操做才会初始化对象 * 调用构造函数时不会初始化,后面源码可参考 */ transient Node<K,V>[] table; /** * * entrySet保存key和value 用于迭代 */ transient Set<Map.Entry<K,V>> entrySet; /** * * 存放元素的个数,但不等于数组的长度 */ transient int size; /** * * 计数器,fail-fast机制相关,不详细介绍,有兴趣的本身google下 * 你能够当成一个在高并发读写操做时的判断,举个例子: * 一个线程A迭代遍历a,modCount=expectedModCount值为1,执行过程当中,一个线程B修改了a,modCount=2,线程A在遍历时判断了modCount<>expectedModCount,抛错 * 固然,这个只是简单的检查,并不能获得保证 */ transient int modCount; /** * * 阈值,当实际大小超过阈值(容量*负载因子)的时候,会进行扩容 */ int threshold; /** * * 负载因子 */ final float loadFactor;
在看方法以前先看下Node实现:
/** * Node的实现 * 看出来是最多实现单向链表 仅有一个next引用 * 比较简单明了,应该都能看明白 */ 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; } /** * Map.Entry 判断类型 * 键值对进行比较 判断是否相等 */ 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; } }
在注释中我会添加一些标记帮助理清流程,同时方便我后边总结对照和参考(例如A1,A2是同一级)。
/** * 负载因子设置成默认值 0.75f * A1 */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } /** * 初始数组长度设置,负载因子默认值 * A2 */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** * 初始长度和负载因子设置 * A2 */ 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; // 根据初始容量设置阈值 // 二进制操做,比较绕,须要本身好好理解下 // 这值在resize有用,resize代码能够注意下,主要是为了区分是不是有参构造函数仍是无参构造函数以便以后的操做 // 能够参考文章:https://www.cnblogs.com/liujinhong/p/6576543.html // 是否有更深层次的考虑笔者还未想到,有大神能够在评论区告知我 this.threshold = tableSizeFor(initialCapacity); } /** * 将m存入当前map中 */ public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); } /** * evict参数至关于占位符,是为了扩展性,能够追溯到afterNodeInsertion(evict),方法是空的 * 在LinkedHashMap中有实现,有兴趣能够去看看 */ final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); if (s > 0) { /** * 判断table是否已经被初始化 */ if (table == null) { // pre-size // 未被初始化,判断m中元素的个数放入当前map中是否会超出最大容量的阈值 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 计算获得的t大于阈值 阈值设置 if (t > threshold) threshold = tableSizeFor(t); } else if (s > threshold) /** * 当前map已经初始化,而且添加的元素长度大于阈值,须要进行扩容操做 */ resize(); /** * 上边已经初始化并处理好阈值设置,下面使用entrySet循环putVal保存m中的Node对象的key和value * 这里有个重要的地方, * putVal的第一个参数,hash(key),map的put操做也是一样的调用方式 * 能够参考文章:https://www.cnblogs.com/liujinhong/p/6576543.html * 顺便看下源码上的注释,主要是减小冲突和性能上的考虑 */ for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } } /** * 扩容操做,重点部分 * * 若是第一次带容量参数时,建立时阈值设置为对应容量的最小的2的N次方(大于等于传入容量参数),去看下上边HashMap(int initialCapacity), * 若是添加一个元素,会执行resize将阈值设置为了阈值 * 负载因子, * 好比设置1000 建立时阈值threshold=1024,负载因子默认,其余值都未进行操做, * 添加一个元素 阈值变为1024 * 0.75 = 768,建立的Node数组长度为1024,size=1, * 添加第769个元素时,进行resize操做,threshold=1536,Node数组长度为2048,数组拷贝到新数组中, * 若是有确认的数据长度,不想让HashMap进行扩容操做,那么则须要在构造时填上计算好的数组容量 * 强烈建议本身写代码debug试试 */ final Node<K,V>[] resize() { //oldTab 保存扩容前的Node数组 Node<K,V>[] oldTab = table; // oldCap null的话即为0,不然就是扩容前的Node数组的容量大小 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 扩容前的阈值 int oldThr = threshold; // 扩容后的数组容量(长度),扩容后的阈值 int newCap, newThr = 0; // 1.扩容前的数组不为空 // B1 if (oldCap > 0) { // 扩容前的Node数组容量大于等于设置的最大容量,不会进行扩容,阈值设置为Integer.MAX_VALUE if (oldCap >= MAXIMUM_CAPACITY) { // C1 threshold = Integer.MAX_VALUE; return oldTab; } // 若是扩容前的数组容量扩大为2倍依然没有超过最大容量, // 而且扩容前的Node数组容量大于等于数组的默认容量, // 扩容后的数组容量值为扩容前的map的容量的2倍,而且扩容后的阈值一样设置为扩容前的两倍, // 反之,则只设置扩容后的容量值为扩容前的map的容量的2倍 // 这里newCap已经在条件里赋值了 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // C2 newThr = oldThr << 1; // double threshold } // 2.扩容前的数组未初始化而且使用了有参构造函数构造 // 这里在oldCap = 0时执行,这里oldThr > 0说明初始化时是有参初始化构造的map // 本身能够试下无参构造函数,threshold的值为0 // B2 else if (oldThr > 0) // initial capacity was placed in threshold // 使用有参初始化构造函数而且在第一次put操做时会进入执行(去看下put源码) // 扩容后的容量大小设置为原有阈值 // 例如我上边的注释中的例子,这里第一次添加键值对时容量设置为了1024 newCap = oldThr; // 3.扩容前的数组未初始化而且使用了无参构造函数构造 // B3 else { // zero initial threshold signifies using defaults // 扩容后的容量 = 默认容量,扩容后的阈值 = 默认容量 * 负载因子 // 扩容后的容量为16,阈值为12 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } /** * 上边设置了新容量和新的阈值,执行到这里,你应该发现只有newThr可能没被赋值,因此这里要继续进行一个操做,来对newThr进行赋值 * 新阈值等于0,照上边逻辑: * 两种状况: * 1.扩容前的node数组容量有值且扩容后容量超过最大值或者原node数组容量小于默认初始容量16 * 2.使用有参构造函数,第一次put操做时上边代码里没有设置newThr * D1 */ if (newThr == 0) { // 应该获得的新阈值ft = 新容量 * 负载因子 float ft = (float)newCap * loadFactor; // 假如新容量小于最大容量而且ft小于最大容量则新的阈值设置为ft,不然设置成int最大值 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 执行到这,扩容后的容量和阈值都计算完毕 // 阈值设置为新阈值 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) // 建立扩容后的Node数组 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 切换为扩容后的Node数组,此时还未进行将旧数组拷贝到新数组 table = newTab; // E1 if (oldTab != null) { // 原有数组不为空,将原有数组数据拷贝到新数组中 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; // 非空元素才进行赋值 if ((e = oldTab[j]) != null) { // 原有数值引用置空,方便GC oldTab[j] = null; if (e.next == null) // 桶对应的Node只有当前一个节点,链表长度为1 // 中括号中计算原有数组元素在新数组中存放的位置, // 为何这么计算? // 正常的想,添加了一个键值对,键的hash值(固然,这里在HashMap的hash(key)进行了统一处理) // 那么长度是有限的,在这个有限长度下如何放置,类比整数取余操做, // &操做代表只取e.hash的低n位,n是newCap - 1转换成二进制的有效位数 // 这里记得初始不设长度时默认16,二进制为10000,减一为1111,低4位 // 设置长度时tableSizeFor从新设置了长度和16处理相似 // 经过&操做全部添加的键值对都分配到了数组中,固然,分配到数组中同一个位置时会扩展成链表或红黑树 // 添加详细操做看后边putVal源码,这里先不用纠结 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) // 到此说明e.next不为空,那么须要判断了, // 由于有两种结构,一种是链表,一种为红黑树 // 这里先进行红黑树处理,树的具体处理后边有时间单独作一章进行说明讲解, // 这里先简单了解,扩容以后,须要对原有的树进行处理,使得数据分散比较均匀。 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order /** * 到这里结合HashMap的结构, * 排除上边两个条件,这里就进行链表结构的处理 * 进行链表复制操做 * 复制的时候就有个问题了,举个例子,原来我是16,如今扩容成了32(原数组两倍,我上边分析里有说明) * 那么我复制时怎么办? * 不移动原来的链表? * 这里就要想到了我扩容以后访问的时候不能影响 * 那么就须要看下put操做时是怎样存的,这里先说下,putVal里也能够看到 * (n - 1) & hash 和上边newTab[e.hash & (newCap - 1)] 分析是同样的 * 这里不知道你想到了吗?扩容以后有什么不一样? * 若是还没什么想法,请继续往下看,我等下会说明 * 新扩容部分头尾节点(hi能够理解成高位)设置为hiHead,hiTail * 原有部分头尾节点(lo能够理解成低位)设置为loHead,loTail * 这里什么意思呢? * 往下看就好,我下面的注释详细说明了为何定义了两个链表头尾节点 */ Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 这里循环获取链表元素进行处理 do { next = e.next; /** * e.hash & oldCap = 0 * 位与操做,这里初学者要本身写下多理解下 * 举个例子: * oldCap=32=100000(二进制),newCap=64=1000000(二进制) * 在未扩容以前计算元素所处位置是(oldCap-1) & hash * 全1位与操做,取值范围落在0~oldCap-1 * e.hash & oldCap 只判断了最高位的那个1位置是否相同 * 相同则非0,不一样则为0 * 为何要判断这一位呢? * 咱们想想,扩容以后,计算bucket(桶)位置(即元素落在数组那个索引位置)时 * (newCap-1) & hash和(oldCap-1) & hash二者对比,只有一位不一样 * 好比32和64,最高位是1不一样,其余位相同 * 若是扩容以后最高位为0,则扩容先后获得的bucket位置相同,不须要调整位置 * 若是非0,则是1,则须要将桶位置调整到更高的索引位置 * 并且这里也应该明白,同一个bucket下的链表(非单一元素)在扩容后 * 由于只有一位二进制不一样,不是1就是0 * 最多分到两个bucket中,一个是扩容前的bucket(当前所在的bucket), * 一个是扩容后的bucket(新的bucket), * 这里也说明了上边为何设置了两组头尾节点,一组低位链表,一组高位链表 * 扩容先后两个bucket位置之间差值为原数组容量值 * 上边32和64,差值为63-31=32=oldCap=10000(二进制) * 因此这下面使用的是oldCap */ if ((e.hash & oldCap) == 0) { // 说明当前Node元素位置 = 原数组中的位置 // 放入loHead,loTail这一组中,低位链表 if (loTail == null) // 链表还未放元素,链表头赋值 loHead = e; else // 链表存在元素,新元素放置在链表尾部,next指向新元素 loTail.next = e; // 尾节点指向改变,变成了新添加的节点 loTail = e; } else { // 相似上边 if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 上面已经处理完了,分红了高低位两个链表,下面就是将这两个链表放置扩容后的新数组中 if (loTail != null) { // 低位链表不为空,添加到新数组,尾节点next指向置空,由于原有节点可能还存在next指向 loTail.next = null; // 新数组j处就是原有数组j处,这里直接将低位首节点引用赋值给新数组节点 newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; // 这里和我上边注释分析是一致的,相差的值即为oldCap,即原数组的容量 newTab[j + oldCap] = hiHead; } } } } } return newTab; } /** * put操做方法主体 * hash,key的hash值,上边讲过,HashMap本身处理过的 * onlyIfAbsent,是否覆盖原有值,true,不覆盖原有值 * evict,LinkedHashMap实现afterNodeInsertion方法时调用,这里至关于占位符的做用 */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // F1 if ((tab = table) == null || (n = tab.length) == 0) // table为空或长度为0时,对table进行初始化,上边已经分析过了 // 这里也说明了第一次初始化是在这里,而不是使用构造方法,排除putMapEntries方式 n = (tab = resize()).length; // 判断当前须要存储的键值对存放到数组中的位置是否已经存在值(链表或者红黑树) // 便是否已经有对应key // G1 if ((p = tab[i = (n - 1) & hash]) == null) // 不存在,则建立一个新节点保存 tab[i] = newNode(hash, key, value, null); // G2 else { // 将桶上的值进行匹配,判断是否存在 Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 链表头节点(或红黑树根节点)与当前须要保存的hash值相等 // 而且key值相等,e和p是同一个,说明添加了相同的key // e指向p对应的节点 e = p; else if (p instanceof TreeNode) // 红黑树添加节点处理,本文不详细将红黑树部分,后面有空会单独抽出讲解 // 返回值能够理解成若是有相同key,则返回对应Node,不然返回null(建立了新的Node) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 这里说明非头节点(数组中对应的桶的第一个节点),非红黑树结构, // 说明须要匹配链表,判断链表中对应的key是否已存在 // 设置binCount计算当前桶中bin的数量,即链表长度 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { // next 为空 无下一个元素 再也不继续查找 直接新建立直接赋值next p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 判断是否树化,这里就是链表树化条件,在treeifyBin还有个数组容量判断,方法也可能只进行扩容操做 // 总结下,即桶中bin数量大于等于TREEIFY_THRESHOLD=8,数组容量不能小于MIN_TREEIFY_CAPACITY=64时进行树化转化 // 怎么转成红黑树结构这里也不作深刻,后续会进行说明 treeifyBin(tab, hash); break; } // 不为空 且节点为寻找的节点 终止循环 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 上边已经检查完map中是否存在对应key的Node节点,不存在的新建立节点,这里处理下存在对应key的节点数据 // H1 if (e != null) { // existing mapping for key // 保存下原来的节点值 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) // onlyIfAbsent 是否须要覆盖操做,是则覆盖 e.value = value; // 子类实现方法的话能够进行对应的后置操做 afterNodeAccess(e); // 返回原值 return oldValue; } } ++modCount; // 实际元素长度,不是容量,是每次添加一个新的键值对会加1,覆盖不增长 // 判断是否大于阈值,进行扩容操做 // I1 if (++size > threshold) resize(); // 同afterNodeAccess,子类实现方法的话能够进行对应的后置操做 afterNodeInsertion(evict); return null; }
重点的部分也就是在上面这几个方法,剩下的源码部分就不一一贴出来分析了,能看懂我上面说明的部分,基本上除了红黑树和jdk1.8的新特性相关部分,其他部分应该基本都能看懂,这里再补充一个序列化方面的问题:
为何HashMap中的table变量要设置为transient?在理解这个问题以前,自行去看下序列化代码writeObject和readObject,而后参考如下连接来思考:
https://segmentfault.com/q/10...
HashMap中,因为Entry的存放位置是根据Key的Hash值来计算,而后存放到数组中的,对于同一个Key,在不一样的JVM实现中计算得出的Hash值多是不一样的。这里不一样意思是说我原来在window机器上A是放在Node数组中0的位置,在Mac上多是放在Node数组中5的位置,可是不修改的话,反序列化以后Mac上也是0的位置,这样致使后续增长节点会错乱,不是咱们想要的结果,故在序列化中HashMap对每一个键值对的键和值序列化,而不是总体,反序列化一个一个取出来,不会形成位置错乱。
那么JDK1.8中HashMap在多线程环境下会形成死循环吗?
从上边结构以及处理过程的分析来看,应该是不会的,只不过数据丢失仍是会发生,这一块我就不进行验证了,自行Google,手写代码来验证。同时想多说句,对于通常开发人员知道HashMap是非线程安全的,多线程状况下使用ConcurrentHashMap便可,后边有时间ConcurrentHashMap的分析我也会整理出来。
在重点说明部分我已经详细解释了resize和put操做的过程,可能有些新人仍是不能梳理清楚,我在这里结合下平常使用总结下整个过程,方便各位理解:
1.HashMap建立过程(正常状态):
2.HashMap resize过程(正常状态):
3.HashMap put过程(正常状态):
HashMap首先须要理解清楚其内部的实现结构:数组+链表+红黑树
,在结构的基础之上来对源码进行深刻,resize和put操做是最为重要的两部分,理解了这两块,基本上对HashMap的总体处理过程有了必定的认知,另外,必定要本身动手debug,理清数据的转换,对了解HashMap有很大的帮助。
文章先从基础部分提及,解释了一些名词,说起了哈希表,从实现结构开始来帮助各位更好的理解源码操做部分,对重点的几个部分作出详细的说明,resize和put操做难点部分也作了相应的解释,但愿对各位有所帮助,后边有空我会将红黑树部分的理解分享出来,谢谢。