Java中的HashMap源码分析
作者:你好世界wxx
这篇文章主要介绍了Java中的HashMap源码分析,散列表是根据关键码值(Key value)而直接进行访问的数据结构,也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度,这个映射函数叫做散列函数,存放记录的数组叫做散列表,需要的朋友可以参考下
1.HashMap源码分析
- 散列表(也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
- 不同语言中都有哈希表的实现,在C++中unordered_map是哈希表的具体实现,在Java中HashMap是哈希表的具体实现。unordered_map和HashMap基本上都能做到O(1)时间内的增删改查操作,时间性能十分优秀。
- 我们都知道这个世界上没有免费的午餐,既然unordered_map和HashMap时间性能如此优秀,那么其他方面一定有所不足。事实正是如此,unordered_map和HashMap在操作的同时无法保证数据的有序性,即牺牲了数据的有序性,换取了优秀的时间性能。
- 那么有没有既可以在O(1)的时间内完成增删改查操作,又可以保证数据的有序性的数据结构呢?据我所知,没有(可能有,但我不知道)。不过C++中的map和Java中的TreeMap可以在操作的同时保证有序性(这两者的内部实现都是红黑树),但其操作的时间复杂度都是O(log(n))的。如下是四者的对比:
操作 | unordered_map (C++) | HashMap (Java) | map (C++) | TreeMap (Java) |
是否可以保证数据的有序性? | × | × | √ | √ |
操作是否可以达到O(1)? | √ | √ | × | × |
操作时间复杂度 | O(1) | O(1) | O(log(n)) | O(log(n)) |
哈希表实现过程中一个很重要的问题是如何解决地址相同产生的冲突,一般来说,有下面四种方式:
(1)开放定址法:线性探测再散列、平方(二次)探测再散列、随机探测再散列
(2)再哈希法
(3)链地址法
(4)建立一个公共溢出区
2 HashMap简介
- HashMap 是Java中对于哈希表的具体实现,本文会比较详细的介绍 Java 8 中对 HashMap 的主要函数的实现。
- Java 8 中对 HashMap 做了全面的升级,其源码也由原来的Java 7中的不足 1200行 变为了现在的 2400 多行,可想而知, HashMap 从Java 7到Java 8结构也发生了很大的变化。在Java 7中 HashMap 的具体实现是:数组+链表,Java 8中是:数组+链表+红黑树。如下是两者的对比图:
Java 8中 HashMap 的定义如下(<K, V>代表使用到了泛型):
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { // 具体实现,省略...... }
继承关系图如下:
HashMap 解决地址冲突采用的方式为:链地址法
本文着重分析 HashMap 中的两个函数:
// 添加键值对 public V put(K key, V value) { /* ... */ } // 空间不足时,进行扩容的函数 final Node<K,V>[] resize() { /* ... */ }
另外,还需要提到的一点是: HashMap 不能保证多线程下的并发安全,如果想要在多线程下使用哈希表,请使用JUC(java.util.concurrent)包下的 ConcurrentdashMap ,这是一个并发安全的哈希表。Java 7中多线程下使用 HashMap 会导致的死循环
3 HashMap分析准备
3.1 HashMap中的常量与变量
在分析上面提到的那两个函数之前,需要明确HashMap中的一些常量定义,方便之后的理解(注释已由英语翻译为汉语,如有错误请指正)
/** * 默认初始容量-必须是2的幂。 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * 最大容量,如果有参数的构造函数隐式指定了更高的值,则使用。必须是2的幂且要小于等于1<<30。 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 构造函数中未指定时使用的负载因子。 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * bin(指table(HashMap中真正存储数据的变量,下面有定义)中的某一项)中使用红黑树而不是链表的阈值。 * 将元素添加到至少有这么多节点的bin时,容器将可能转换为树。 * 该值必须大于2,并且至少应为8,以满足在红黑树中移除元素后转换为链表的要求。 */ static final int TREEIFY_THRESHOLD = 8; /** * 在resize操作时树退化成链表的阈值。应小于TREEIFY_THRESHOLD,且最多6,以去匹配删除元素时的收缩检测。 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 可将bin转化为红黑树的最小表容量(否则(即table.length<该值),如果bin中的节点太多,则会调整table的大小)。 * 应至少为4 * TREEIFY_THRESHOLD,以避免调整大小和树化阈值之间的冲突。 */ static final int MIN_TREEIFY_CAPACITY = 64; /* ---------------- Fields -------------- */ /** * table,在第一次使用时初始化(懒加载),并根据需要调整大小。 * 分配时,长度总是2的幂(在某些操作中,我们也允许长度为零,以允许当前不需要的引导机制) */ transient Node<K,V>[] table; /** * 保留缓存的entrySet()。请注意,AbstractMap字段用于keySet()和values()。 */ transient Set<Map.Entry<K,V>> entrySet; /** * 此映射中包含的键值映射数。 */ transient int size; /** * 此哈希映射在结构上被修改的次数。 * 结构修改是指那些改变HashMap中映射的数量或以其他方式修改其内部结构的修改(例如,rehash)。 * 此字段用于使HashMap的集合视图上的迭代器快速失败。(参见ConcurrentModificationException)。 */ transient int modCount; /** * 要调整大小的下一个大小值(capacity * load factor)。 */ // (序列化后javadoc描述为true。此外,如果table数组尚未被分配, // 则此字段保留初始数组容量,或零表示DEFAULT_INITIAL_CAPACITY) int threshold; /** * 哈希表的加载因子。 */ final float loadFactor;
对常量的进一步理解
/** * 如果我们传入的初始容量(记为a)不是2的指数次幂,会将变量threshold改成大于该数a且最接近这个数a的2的指数次幂。 * 比如:Map<String, Double> t = new HashMap<>(13); * 执行上面这句话后,threshold会被赋值为16(tableSizeFor()函数),因为懒加载,此时不会实例化table; * 之后put数据时会实例化table(长度为16),并将threshold赋值为12。 * * 为什么数组table的长度一定要是2的幂次呢? * (1) 为了让位运算 (n-1)&hash 达到取模(hash%n)的目的,加快运算速度; * (2) 更重要的一点是:要保证定位出来的值是在数组的长度之内的,不能超出数组长度; * 并且减少哈希碰撞,让每个位都可能被取到,例如: * 正例:(16-1) & hash * 二进制的15: 0000 0000 0000 1111 * hash(随机) 1101 0111 1011 0000 * hash(随机) 1101 0111 1011 1111 * 结果 0000 0000 0000 0001 ~ 0000 0000 0000 1111 * 即得出的索引下标只能在0~15之间,保证了所有索引都在数组长度的范围内而不会越界 * 并且由于2的指数次幂-1都是...1111的形式的,即最后一位是1 * 这样,由于hash是随机的,进行与运算后每一位都是能取到的 * ======================================================================== * 反例:(7-1) & hash * 二进制6: 0000 0000 0000 0110 * hash 1011 1001 0101 0000 * hash 1001 0001 0000 1111 * 结果 0000 0000 0000 0000 ~ 0000 0000 0000 0110 * 即得出的索引范围在0~6,虽然不会越界,但最后一位是0 * 即现在无论hash为何值,0001,0011,0101这几个值是不可能取到的 * 这就加剧了hash碰撞,并且浪费了大量数组空间,显然是我们不想看到的 * (3) 让resize()过程中不需要重新调用哈希函数计算哈希值,加快运行速度 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * 最大容量,如果有参数的构造函数隐式指定了更高的值,则使用。必须是2的幂且要小于等于1<<30。 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 默认的加载因子仍是0.75 * * 为什么默认加载因子设为0.75? * (1) 当加载因子比较大的时候:节省空间资源,耗费时间资源(链表查询比较慢) * (2) 当加载因子比较小的时候:节省时间资源,耗费空间资源 * 综合来看,0.75效果最好(可以认为这是JDK源码工程师测试得到最好结果) */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 树阈值为8, 如果链表长度大于或等于8转成红黑树 * * 为什么树阈值定为8? * 当put进来一个元素,通过hash算法,然后最后定位到同一个桶(链表)的概率p会随着链表的长度k的增加而减少。 * 根据jdk源码注释,这里p服从泊松分布:p = (exp(-0.5) * pow(0.5, k) / factorial(k)) * 当这个链表长度为8的时候,这个概率几乎接近于0(为0.00000006),所以我们才会将链表转红黑树的临界值定为8 * 如下图 */ static final int TREEIFY_THRESHOLD = 8; /** * 树退化阈值为6,如果红黑树节点个数小于或等于6转成链表 * * 为什么树退化阈值为6,而不是8? * 目的是为了防止复杂度震荡。 * 例如当前bin中有7个元素,此时不断进行如下操作:添加一个元素后然后删除(假设该元素也进入该bin中) * 则会出现不断将链表变为红黑树,然后红黑树变为链表的操作,复杂度飙升,产生震荡。 * 这是算法中常用的一种技巧,一种Lazy的技巧。同样HashMap在创建的时候也采用了这种技巧,即懒加载。 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 最小树形化容量阈值64:即 当哈希表中的容量 >= 该值时,才允许树形化链表 (即将链表转换成红黑树) * 否则,若桶内元素太多时,则直接扩容,而不是树形化。 * 目的是为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD */ static final int MIN_TREEIFY_CAPACITY = 64;
TREEIFY_tdRESHOLD=8对应源码中的解释:
3.2 HashMap中的节点定义
Node节点,用于存储一个键值对的节点,也就是上面提到的 bin。
/** * 基本哈希bin节点,用于大多数条目。(请参见下面的TreeNode子类,并在LinkedHashMap中查看其Entry子类。) */ 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; } }
TreeNode节点,用于存储红黑树中的节点,这个静态内部类在下面的分析中不重要,因此这里不给出定义,具体定义可以参照源码。
4 put(K key, V value)函数分析
函数定义(注释已由英语翻译为汉语,之后的注释都会被翻译)
/** * 将指定值(value)与此映射中的指定键(key)相关联。如果映射以前包含键(key)的映射,则旧的值(value)被替换 * * @param key 与指定值关联的键 * @param value 要与指定键关联的值 * @return 与键关联的上一个值,如果键没有映射,则为null。(null返回也可以表示映射以前与null键关联。) */ public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
可以看到, put 函数调用了 hash 函数和 putVal 函数,下面是这两个函数的分析(分析在注释中):
/** * 计算关键字.hashCode()并将散列的高位(异或)扩散到低位。 * 由于table使用的掩码是2的幂次,因此仅在当前掩码上方的位上变化的哈希集将始终发生冲突。 * (在已知的例子中有一组在小表格中保持连续整数的浮点键。) * 因此我们应用了一种向下扩展高位影响的变换。在速度、实用性和位扩展质量之间存在一种折衷。 * 因为许多常见的散列集已经被合理地分布(所以不能从传播中受益), * 而且因为我们使用红黑树来处理容器中的大量冲突,所以我们只是以最便宜的方式对一些移位的位进行异或, * 以减少系统损失,以及合并最高位的影响,否则由于table边界,这些最高位将永远不会用于索引计算。 */ static final int hash(Object key) { int h; // 为什么要这么做? 目的:降低hash冲突的几率的同时保证计算哈希值的速度 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
/** * 实现Map.put和相关方法。 * * @param hash 键的哈希值 * @param key 键 * @param value 要放置的值 * @param onlyIfAbsent 如果为true,则不更改现有值(value) * @param evict 如果为false,则table处于创建模式。 * @return 上一个值,如果没有则为null */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 1. 如果当前table为空,新建table // 如果是空参构造器,默认table长度为16; // 如果指定大小,则table的长度为threshold(这个值已经被tableSizeFor()函数变为2的幂) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 2. 获取当前key对应的节点 // n-1 相当于掩码,因为 n 是 2 的次幂,所以 n-1 二进制最低位有 k 个1(其中k=log2(n)) // (n-1)&hash 是待添加元素应该存放的位置(有冲突的话使用链地址法解决) // (n-1)&hash 相当于 hash%n(在n是2的幂次的情况下) // 例如 n=4, hash=10 : (n-1)&hash = 0011&1010 =2, 10%4=2 if ((p = tab[i = (n - 1) & hash]) == null) // 3. 如果位置tab[(n-1)&hash]不存在节点,则新建节点 tab[i] = newNode(hash, key, value, null); else { // 4. 位置tab[(n-1)&hash]存在节点,采用链地址法解决冲突 Node<K,V> e; K k; // 5. key的hash相同 || key的引用相同或者key equals,则覆盖原本的键值对中的值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 6. 如果当前节点(上面提到的bin)是一个红黑树节点(树根),则将节点添加到红黑树中 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 7. bin不是红黑树节点,也不是相同节点,则表示为bin为链表的头结点 else { // 8. 找到链表中最后的那个节点 // 尾插法。不能采用头插法,因为要比较插入的键(key)是否已经存在,存在则替换,否则插入 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 9. 如果链表长度(包含当前还未插入的节点)大于或等于8转成红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } // 10.如果链表中有相同的节点,则覆盖 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; // 是否替换掉value值 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } // 记录修改次数 ++modCount; // 是否超过容量,超过需要扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
5 resize()函数分析
函数定义以及分析:
/** * 初始化 或 加倍table。 * 如果为空,则根据字段threshold中的值初始化table大小。 * 否则,因为我们使用的是倍增,所以每个bin中的元素要么留在同一索引中,要么在新表中以二次幂偏移量移动。 * * @return the table */ final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; // 1.如果创建HashMap时调用的是有参函数,第一次put时调用该函数时threshold不为0 int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { // 2.1 说明空间不足,需要扩增 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 // 2.2 有参函数,第一次put进入这个分支 newCap = oldThr; else { // zero initial threshold signifies using defaults // 2.3 无参函数,第一次put进入这个分支 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 3.有参函数,第一次put这个判断成立;否则不成立 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; // 4.创建新的table用于存放数据,有两种情况 // (1) 懒加载,第一次put会初始化table (2) 倍增 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 5.将新创建的数组newTab的引用赋值给字段table table = newTab; // 6.1 oldTab如果为null,说明是第一次put进入的该函数,该函数作用是给table初始化 if (oldTab != null) { // 6.2 说明需要将oldTab中的数据迁移到newTab中 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) // 7.1 说明这个bin中只有一个数据,直接移入新数组中即可 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) // 7.2 说明这个bin是红黑树的根节点,......(不是重点,略过) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order // 7.3 说明这个bin是链表的头结点,需要将高位和低位断开 // 将一个链表分为两组,缩短了链表,提高了性能 /* * 假设 oldCap=16,链表中存在两个哈希值,hash1=0b0 1010,hash2=0b1 1010 * 此时的j=0b1010,这两个值最关键之处在于最高位值不同,将会被分到低位和高位两个索引处 * hash1 hash2 * hash值: 0 1010 1 1010 * oldCap(16) 1 0000 1 0000 * 0 0000 1 0000 * hash1对应的节点将会被放入newTab[j]中,hash2对应的节点将会被放入newTab[j+oldCap]中 * ‘与'运算的结果,只可能有两种值:0 或者 16 * 也就是说当前节点用当前节点的hash值和旧数组的长度(16)做'与'运算的结果只可能是0或16 */ 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) { // 如果是0,则使用低位的指针 if (loTail == null) loHead = e; else loTail.next = e; // 尾插法 loTail = e; } else { // 如果是16,则使用高位的指针 if (hiTail == null) hiHead = e; else hiTail.next = e; // 尾插法 hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; // 把低位的尾部节点的next值为空(先将高位和低位断开) newTab[j] = loHead; // 将低位的头部赋给新数组的某个值,也就是将高位的所有节点移动过去 } if (hiTail != null) { hiTail.next = null; // 把高位的尾部节点的next值为空 // 再将高位的头部放到新数组的j + oldCap索引处(当前索引+旧数组的长度) // 比如说现在的索引是3,再加上数组长度16,最后就是将高位放到新数组的索引为19的地方去 newTab[j + oldCap] = hiHead; } } } } } return newTab; }
到此这篇关于Java中的HashMap源码分析的文章就介绍到这了,更多相关HashMap源码内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!