源码分析HashMap

it2024-12-23  12

二:源码说明HashMap(通俗易懂)

大家可以看一下: 图解说明HashMap有助理解。 文章有点长,希望读者可以耐心读下去,一定会有收获!

一、构造函数

让我们先从构造函数说起,HashMap有四个构造方法

1.1、HashMap()

// 1.无参构造方法、 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 构造一个空的HashMap,初始容量为16,负载因子为0.75 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }

1.2、HashMap(int initialCapacity)

// 2.构造一个初始容量为initialCapacity,负载因子为0.75的空的HashMap public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }

1.3、HashMap(int initialCapacity, float loadFactor)

// 3.构造一个空的初始容量为initialCapacity,负载因子为loadFactor的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; this.threshold = tableSizeFor(initialCapacity); } //容器最大容量 static final int MAXIMUM_CAPACITY = 1 << 30;

当指定的初始容量< 0时抛出IllegalArgumentException异常,当指定的初始容量> MAXIMUM_CAPACITY时,就让初始容量 = MAXIMUM_CAPACITY。当负载因子小于0或者不是数字时,抛出IllegalArgumentException异常。

设定threshold。 这个threshold = capacity * load factor 。当HashMap的size到了threshold时,就要进行resize,也就是扩容。

tableSizeFor()的主要功能是返回一个比给定整数大且最接近的2的幂次方整数,如给定10,返回2的4次方16.

1.4、HashMap(Map<? extends K, ? extends V> m)

// 4、 构造一个和指定Map有相同mappings的HashMap, // 初始容量能充足的容下指定的Map,负载因子为0.75 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }

对于putMapEntries函数,我们来分析一下它的源码

/** * 将m的所有元素存入本HashMap实例中 */ final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); if (s > 0) { // 判断table是否已经初始化,如果未初始化,则先初始化一些变量。(table初始化是在put时) if (table == null) { // pre-size // 根据待插入的map 的 size 计算要创建的 HashMap 的容量。 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 把要创建的 HashMap 的容量存在 threshold 中 if (t > threshold) threshold = tableSizeFor(t); } // 如果table初始化过,因为别的函数也会调用它,所以有可能HashMap已经被初始化过了。 // 判断待插入的 map 的 size,若 size 大于 threshold,则先进行 resize(),进行扩容 else if (s > threshold) resize(); //然后就开始遍历 带插入的 map ,将每一个 <Key ,Value> 插入到本HashMap实例。 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); // put(K,V)也是调用 putVal 函数进行元素的插入 putVal(hash(key), key, value, false, evict); } } }

二:成员变量/类变量

//实际存储key,value的数组,只不过key,value被封装成Node了 transient Node<K,V>[] table; //默认初始容器大小 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //容器最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; //默认的负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //桶的树化阈值:即 链表转成红黑树的阈值,在存储数据时,当链表长度 > 该值时,则将链表转换成红黑树 static final int TREEIFY_THRESHOLD = 8; // 桶的链表还原阈值:即 红黑树转为链表的阈值,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表 static final int UNTREEIFY_THRESHOLD = 6; //最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树) static final int MIN_TREEIFY_CAPACITY = 64; //容器的阈值 int threshold; //负载因子 final float loadFactor; //记录容器更改次数 transient int modCount;

三、HashMap采用数组+链表+红黑树实现。

在Jdk1.8中HashMap的实现方式做了一些改变,但是基本思想还是没有变得,只是在一些地方做了优化,下面来看一下这些改变的地方,数据结构的存储由数组+链表的方式,变化为数组+链表+红黑树的存储方式,当链表长度超过阈值(8)时,将链表转换为红黑树。在性能上进一步得到提升。接下来我们就聊一下Map的几个常用方法

put:

HashMap的put方法算是HashMap中比较核心的功能了,复杂程度高但是算法巧妙,同时在上一版本的基础之上优化了存储结构,从链表逐步进化成了红黑树,以满足存取性能上的需要。本文逐行分析了put方法的执行流程,重点放在了对整个流程的把握,对于红黑树的执行逻辑只是点到为止,其中HashMap中还有很多细节算法值得分析和学习,本文没有涉及,有兴趣的大家可以研究一下。

public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }

通过源码分析可得,我们在使用map中的put方法时候,调用了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; //如果table为空或者长度为0,则进行扩容,初始化 if ((tab = table) == null || (n = tab.length) == 0) //此时的n就是创建新数组的长度 n = (tab = resize()).length; //确定插入table的位置,如果数组的(n - 1) & hash位置没有元素就插入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { //产生冲突,该位置上存在元素 // 有两种情况,1、key值是一样的,替换value值 //2、key值不一样的有两种处理方式:2.1、存储在i位置的链表;2.2、存储在红黑树中 Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //此时的key是一样的 替换value // 如果当前节点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); // 从0开始的,如果到了7则说明满8了,这个时候就需要转 // 重新确定是否是扩容还是转用红黑树了 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; } } // 此时的e是保存的被碰撞的那个节点,即老节点 if (e != null) { // existing mapping for key // onlyIfAbsent是方法的调用参数,表示是否替换已存在的值, // 在默认的put方法中这个值是false,所以这里会用新值替换旧值 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } // map变更性操作计数器 // 比如map结构化的变更像内容增减或者rehash,这将直接导致外部map的并发 // 迭代引起fail-fast问题,该值就是比较的基础 ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }

此时我们再来看一下put函数中调用的resize()函数,扩充数组长度方法resize,会将整个map中的k-v对重新散列存储,会消耗性能,构造函数中并不会初始化table 变量, table 变量是在 resize过程中初始化的. resize用于以下两种情况之一

初始化table 在table大小超过threshold之后进行扩容

下面分析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) { // MAXIMUM_CAPACITY = 1 << 30 = 1073741824 // Integer.MAX_VALUE = (1 << 31) - 1 = 2147483647 // 如果已经到了最大容量了,那么就调整扩容的threshold阈值 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // DEFAULT_INITIAL_CAPACITY = 1 << 4 // 否则的话,如果将目前的容量扩充2倍还在允许范围之内,则将容量 // 扩充为原来的两倍,并且阈值也为原来的两倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } // 在构造函数中我们知道 // 如果没有指定initialCapacity, 则不会给threshold赋值, 该值被初始化为0 // 如果指定了initialCapacity, 该值被初始化成大于initialCapacity的最小的2的次幂 // 这里是指, 如果构造时指定了initialCapacity, 则用threshold作为table的实际大小 else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; // 如果构造时没有指定initialCapacity, 则用默认值 else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 计算指定了initialCapacity情况下的新的 threshold if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } //设置阈值 threshold = newThr; //从以上操作我们知道, 初始化HashMap时, //如果构造函数没有指定initialCapacity, 则table大小为16 //如果构造函数指定了initialCapacity, 则table大小为threshold, //即大于指定initialCapacity的最小的2的整数次幂 // 从下面开始, 初始化table或者扩容, 实际上都是通过新建一个table来完成的 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // 下面这段就是把原来table里面的值全部搬到新的table里面 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { //这里注意, table中存放的只是Node的引用, 这里将oldTab[j]=null //只是清除旧表的引用, 但是真正的node节点还在, 只是现在由e指向它 oldTab[j] = null; if (e.next == null) // 如果该存储桶里面只有一个bin, 就直接将它放到新表的目标位置 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); // 如果lo链表非空, 我们就把整个lo链表放到新table的j位置上 //如果hi链表非空, 我们就把整个hi链表放到新table的j+oldCap位置上 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }

我们来仔细分析一下resize时的链表拆分

第一段 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null;

上面这段定义了四个Node的引用, 从变量命名上,我们初步猜测, 这里定义了两个链表, 我们把它称为 lo链表 和 hi链表, loHead 和 loTail 分别指向 lo链表的头节点和尾节点, hiHead 和 hiTail以此类推.

第二段 do { next = e.next; if ((e.hash & oldCap) == 0) { // 插入lo链表 if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { // 插入hi链表 if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null);

我们首先准备了两个链表 lo 和 hi, 然后我们顺序遍历该存储桶上的链表的每个节点, 如果 (e.hash & oldCap) == 0, 我们就将节点放入lo链表, 否则, 放入hi链表.

第三段 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }

如果lo链表非空, 我们就把整个lo链表放到新table的j位置上 如果hi链表非空, 我们就把整个hi链表放到新table的j+oldCap位置上

综上我们知道, 这段代码的意义就是将原来的链表拆分成两个链表, 并将这两个链表分别放到新的table的 j 位置和 j+oldCap 上, j位置就是原链表在原table中的位置, 拆分的标准就是:

(e.hash & oldCap) == 0

从网上找了一个很不错的图,帮助大家理解 关于 (e.hash & oldCap) == 0j 以及 j+oldCap

首先我们要明确三点:

oldCap一定是2的整数次幂, 这里假设是2^m newCap是oldCap的两倍, 则会是2^(m+1) hash对数组大小取模(n - 1) & hash 其实就是取hash的低m位

例如: 我们假设 oldCap = 16, 即 2^4, 16 - 1 = 15, 二进制表示为 0000 0000 0000 0000 0000 0000 0000 1111 可见除了低4位, 其他位置都是0(简洁起见,高位的0后面就不写了), 则 (16-1) & hash 自然就是取hash值的低4位,我们假设它为 abcd.

以此类推, 当我们将oldCap扩大两倍后, 新的index的位置就变成了 (32-1) & hash, 其实就是取 hash值的低5位. 那么对于同一个Node, 低5位的值无外乎下面两种情况:

0abcd 1abcd

其中, 0abcd与原来的index值一致, 而1abcd = 0abcd + 10000 = 0abcd + oldCap

故虽然数组大小扩大了一倍,但是同一个key在新旧table中对应的index却存在一定联系: 要么一致,要么相差一个 oldCap。

而新旧index是否一致就体现在hash值的第4位(我们把最低为称作第0位), 怎么拿到这一位的值呢, 只要:

hash & 0000 0000 0000 0000 0000 0000 0001 0000

上式就等效于

hash & oldCap

故得出结论:

如果 (e.hash & oldCap) == 0 则该节点在新表的下标位置与旧表一致都为 j 如果 (e.hash & oldCap) == 1 则该节点在新表的下标位置 j + oldCap

根据这个条件, 我们将原位置的链表拆分成两个链表, 然后一次性将整个链表放到新的Table对应的位置上.

hash()函数 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }

1. h >>> 16 是什么,有什么用?

h是hashcode。h >>> 16是用来取出h的高16,(>>>是无符号右移) 如下展示:

0000 0100 1011 0011 1101 1111 1110 0001 >>> 16 0000 0000 0000 0000 0000 0100 1011 0011

2. 为什么 h = key.hashCode()) 与 (h >>> 16) 异或 讲到这里还要看一个方法indexFor,在jdk1.7中有indexFor(int h, int length)方法。jdk1.8里没有,但原理没变。下面看下1.7源码

1.8中用tab[(n - 1) & hash]代替但原理一样。

static int indexFor(int h, int length) { return h & (length-1); }

这个方法返回值就是数组下标。我们平时用map大多数情况下map里面的数据不是很多。这里与(length-1)相&,

但由于绝大多数情况下length一般都小于2^16即小于65536。所以return h & (length-1);结果始终是h的低16位与(length-1)进行&运算。如下例子(hashcode为四字节)

例如1:为了方便验证,假设length为8。HashMap的默认初始容量为16

length = 8; (length-1) = 7;转换二进制为111;

假设一个key的 hashcode = 78897121 转换二进制:100101100111101111111100001,与(length-1)& 运算如下

0000 0100 1011 0011 1101 1111 1110 0001 &运算 0000 0000 0000 0000 0000 0000 0000 0111 = 0000 0000 0000 0000 0000 0000 0000 0001 (就是十进制1,所以下标为1)

上述运算实质是:001 与 111 & 运算。也就是哈希值的低三位与length与运算。如果让哈希值的低三位更加随机,那么&结果就更加随机,如何让哈希值的低三位更加随机,那么就是让其与高位异或。

补充知识:

当length=8时 下标运算结果取决于哈希值的低三位

当length=16时 下标运算结果取决于哈希值的低四位

当length=32时 下标运算结果取决于哈希值的低五位

当length=2的N次方, 下标运算结果取决于哈希值的低N位。

3. 原因总结

由于和(length-1)运算,length 绝大多数情况小于2的16次方。所以始终是hashcode 的低16位(甚至更低)参与运算。要是高16位也参与运算,会让得到的下标更加散列。

所以这样高16位是用不到的,如何让高16也参与运算呢。所以才有hash(Object key)方法。让他的hashCode()和自己的高16位^运算。所以(h >>> 16)得到他的高16位与hashCode()进行^运算。

4. 为什么用^而不用&和|

因为&和|都会使得结果偏向0或者1 ,并不是均匀的概念,所以用^。

这就是为什么有hash(Object key)的原因。

上述Hash的描述是来自博客: HashMap中hash(Object key)原理,为什么(hashcode >>> 16)

get() 方法

相对于put方法和resize方法来说,get方法比较简单,但是我们还是从源码来看.

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数组是否为空,并且根据hash值找到对应节点 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() 方法

remove方法和get方法基本上是类似的,只是多了一个改变指针的指向!源码如下

public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } //传入两个值 第一个是hash 第二个是key final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { 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) { //判断是不是第一个remove 同时通过hash找到数组种对应的位置 Node<K,V> node = null, e; K k; V v; //现在的p是第一个节点 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; //找到要删除的了 把他赋值给node 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; } writeObject()序列化

HashMap的序列化主要是调用了writeObject方法

private void writeObject(java.io.ObjectOutputStream s) throws IOException { //返回的是Table.length int buckets = capacity(); // Write out the threshold, loadfactor, and any hidden stuff s.defaultWriteObject(); //写入Table数组大小 s.writeInt(buckets); //写入实际数据大小(HashMap中节点的数量) s.writeInt(size); internalWriteEntries(s); }

其中的 internalWriteEntries 函数就是一个遍历,将HashMap中节点的key,value写入

void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException { Node<K,V>[] tab; if (size > 0 && (tab = table) != null) { for (int i = 0; i < tab.length; ++i) { for (Node<K,V> e = tab[i]; e != null; e = e.next) { s.writeObject(e.key); s.writeObject(e.value); } } } } 反序列化 readObject private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold (ignored), loadfactor, and any hidden stuff s.defaultReadObject(); //初始化函数 reinitialize(); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new InvalidObjectException("Illegal load factor: " + loadFactor); //读入buckets,Table.length s.readInt(); // 读入节点的数量 // Read and ignore number of buckets int mappings = s.readInt(); // Read number of mappings (size) if (mappings < 0) throw new InvalidObjectException("Illegal mappings count: " + mappings); else if (mappings > 0) { // (if zero, use defaults) // Size the table using given load factor only if within // range of 0.25...4.0 float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f); float fc = (float)mappings / lf + 1.0f; int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ? DEFAULT_INITIAL_CAPACITY : (fc >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)fc)); float ft = (float)cap * lf; threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE); // Check Map.Entry[].class since it's the nearest public type to // what we're actually creating. SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap); @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] tab = (Node<K,V>[])new Node[cap]; table = tab; // Read the keys and values, and put the mappings in the HashMap for (int i = 0; i < mappings; i++) { @SuppressWarnings("unchecked") K key = (K) s.readObject(); @SuppressWarnings("unchecked") V value = (V) s.readObject(); putVal(hash(key), key, value, false, false); } } } //初始化 void reinitialize() { table = null; entrySet = null; keySet = null; values = null; modCount = 0; threshold = 0; size = 0; }
最新回复(0)