【Java】集合源码 - HashMap

mac2025-03-27  18

HashMap

概述1. 类2. 属性3. 方法构造方法put(key, value)方法resize()扩容get(key)方法 JDK 1.7 和 JDK 1.8 的区别

概述

基于哈希表实现了 Map 接口, 提供了所有 map 的操作无序, 且不能保证次序保持不变提供时间复杂度为常数的操作性能, 具体性能受初始容量和负载因子两个参数影响当哈希表中的元素数量超过了负载因子和哈希表容量的乘积时, 哈希表将会被rehash, 底层数组容量会被扩容, 每次扩容大小为2倍因为底层数组的大小总是2的整数次幂, 所以使用除留余数法计算数组下标时, 可以使用与运算进行优化.默认的负载因子 0.75 在时间和空间成本之间提供了一个良好的平衡, 若值越高会节省更多空间但会提升查找开销, 值约低则相反。设置map初始容量时,应考虑Map中元素数量的期望值以及其负载因子,且传入的初始容量会被自动转为大于原数的最小的2的次方HashMap 的实现是非同步的,在并发的对一个HashMap对象进行改变结构(增加或删除)的操作时, 必须由程序员在外部实现同步, 或者使用其他同步的map容器。HashMap的迭代器是fail - fast 的,即在并发场景下使用迭代器迭代集合的过程中, 如果集合在结构上发生改变, 迭代器会尽最大努力抛出ConcurrentModificationException异常导致遍历失败。fail - fast 被用于检测bug

1. 类

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { ...... }

继承关系

2. 属性

静态属性

private static final long serialVersionUID = 362498820763181265L; // 默认的容量 必须是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; // 链表转红黑树的计数阈值 static final int TREEIFY_THRESHOLD = 8; // 红黑树转链表的计数阈值 static final int UNTREEIFY_THRESHOLD = 6; // 当数组长度小于该值时, 红黑树会被转成链表 static final int MIN_TREEIFY_CAPACITY = 64;

静态内部类

Node结点

static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; // 构造器 get set equals方法 ..... }

TreeNode结点

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; // 操作树的方法 ...... }

成员属性

// 表, 第一次使用时初始化 transient Node<K,V>[] table; // 保存缓存entrySet() transient Set<Map.Entry<K,V>> entrySet; // 包含的元素个数 transient int size; // fail - fast 机制用到的元素修改次数 transient int modCount; // 下一个要扩容的阈值(容量 * 负载因子) int threshold; // 哈希表的负载因子 final float loadFactor;

3. 方法

构造方法

// 无参构造, 使用默认容量16 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 初始容量为 initialCapacity 会被转换为最小的大于该值的2的次幂 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 负载因子为 loadFactor 初始容量为 initialCapacity, 大小同样会被转换2的次幂。 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; // 将初始容量转为大于该值的最小的2的次幂 this.threshold = tableSizeFor(initialCapacity); } // 将 cap 转换为 最小的大于cap的二的次幂 static final int tableSizeFor(int cap) { int n = cap - 1; // 无符号右移 + 或运算 // 示例 10010000 -> 11011000 -> 11111110 -> 11111111 n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; // 最后判断得出的 n 要大于0 小于最大容量才正确 // 返回 n + 1 = 100000000 即为 大于10010000的最小的2的次幂 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }

put(key, value)方法

public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } // HashMap 允许null键和null值。 h >>> 16 在 与 h 异或, 将高位的影响向下传播。 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } // hash 键的哈希值 // key 键 // value 值 // onlyIfAbsent 是true 则不替换已经存在的值 // evict 用于子类 LinkedHashMap // 返回被替换的值, 新增则返回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; // table 表为空 或 table 的长度为0 则调用resize() 初始化表 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 因为 table 长度 n 为2的整数次幂, 则(n - 1) & hash 就等于hash对n取余, 得到table中的位置 p // 如果 p 为 null 则直接插入。 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 如果第一个节点的哈希和键就相等, 那么直接替换 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; } // 如果到链表末尾前找到了相同哈希和键的节点, 则break跳出 到下一步替换值 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // e != null 说明之前找到了哈希值和键相同的节点, 现在用新的 value 替换旧value , 替换操作没有改变表的结构, 所以直接返回。 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; // LinkedHashMap 的操作 afterNodeAccess(e); return oldValue; } } // 上一个if没有进入, 说明表的结构发生了改变, 需要modCount++记录 以及 resize()判断 ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } 判断 table 是否为空, 空则初始化对hash值进行与运算得到数组下标, 取出首元素p如果首节点 p 为 null 则可直接插入首节点 p 不为 null 则 进行 三个判断 如果 p 的 键和要插入的键相等, 则用 e 记录否则 如果 p 是红黑树节点, 那么调用红黑树查找到键key相等的节点, 用 e 记录.。找不到则直接插入节点, 设置 e = null否则 说明 p 是链表节点, 使用链表查找到和键key相等的节点, 用 e 记录。找不到则直接插入到链表为, 设置 e = null, 并且判断是否需要转成红黑树。 判断 e != null , 表明存在键key相等的节点, 直接替换value值, 返回被替换的旧值oldValue没有返回则表明 e == null, 不存在key相等的节点, 说明新增了节点来插入链表或者红黑树, 表的结构发生了变化, 需要modCount++记录以及判断是否需要resize()扩容。最后返回null

resize()扩容

设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M)。 所以要想提升查找的效率, 需要让 M 即table尽可能的大, 但太大又会浪费太多空间。 所以HashMap采用动态扩容来根据N的值调整M。 使时间和空间维持一个良好的平衡。

与扩容有关的参数

参数含义capacitytable 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。size键值对数量。thresholdsize 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。threshold = newCapacity * loadFactorloadFactor负载因子,size 和 capacity的比例, 默认0.75。 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; // 如果哈希表还没初始化, 则oldcap = 0 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; } // oldCap << 1 扩容为原来的二倍, 且小于最大容量大于默认的容量 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 扩容阈值也提升两倍 newThr = oldThr << 1; // double threshold } // 原来的容量不大于0 且 扩容阈值大于0 则新容量等于oldThr扩容阈值, (调用带参构造器设置初始容量时, 会将调整好的初始化大小赋给threshold扩容阈值) else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; // oldCap <= 0 也 oldThr <= 0 说明数组还未初始化 且没有设置初始容量, 则赋值默认容量 else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 如果newThr还为0 则计算newThr if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 确定了 newThr, 进行赋值 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) // 新建Node数组, 开始赋值rehash 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) { // 释放引用, 可以GC回收 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; // 如果 hash 值的 oldCap的最高位的二进制为0, 则接到lo链表里 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 如果为1 则接到hi链表里 else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 遍历结束 lo链表接在新数组的原位置j if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // hi链表接在新数组的 j + oldCap位置 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }

JDK1.7 在进行resize() 时, 会将旧数组中链表的每个节点的hash依次重新计算并使用头插法插入新数组。 JDK1.8 使用与运算判断, 能够直接用尾插法创建好新数组对应节点上的2个链表,最后将链表在新数组中对应的位置插入。

get(key)方法

// 判空处理 并计算hash值 hash() 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判空 以及取得数组对应位置的首节点first 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) { // 如果是红黑树, 进行红黑树的getTreeNode操作 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; }

在 put(), get() 方法和 resize() 中的部分代码中, 每当要遍历一个数组位置上上的所有元素时, 一般分为三步

判断 table 是否合法, 判断第一个节点是否符合条件。第一个节点条件不符合, 在接着判断该位置存储的是是红黑树还是链表。进行红黑树或链表的相关操作。

JDK 1.7 和 JDK 1.8 的区别

存储:

JDK1.7 只使用单链表进行纵向存储。JDK1.8 单链表长度达到8时会转成红黑树。

插入:

JDK1.7 采用头插法,因为只用单链表存储, 所以头插法可以提升插入的效率,但在并发环境下put()会形成环形链表,出现死循环问题,且resize()扩容后链表会逆序。JDK1.8 采用尾插法,且不会影响效率,因为加入了红黑树避免了单链表过长的情况,尾插法同时也解决了死循环和逆序的问题。

扩容时索引的计算:

JDK1.7 在扩容时直接通过hash再次计算索引插入新数组。JDK1.8 在扩容时通过判断二进制位,可以直接计算出元素在新数组中的位置,并且可以一次性建立好链表,直接插入到新数组对应位置。
最新回复(0)