https://www.cnblogs.com/vitasyuan/p/9220773.html
1.HashMap-1.8介绍
HashMap为Map接口的一个实现类,实现了所有Map的操作。HashMap除了允许key和value保存null值和非线程安全外,其他实现几乎和HashTable一致。
HashMap使用散列存储的方式保存kay-value键值对,因此其不支持数据保存的顺序。如果想要使用有序容器可以使用LinkedHashMap。
在性能上当HashMap中保存的key的哈希算法能够均匀的分布在每个bucket中的是时候,HashMap在基本的get和set操作的的时间复杂度都是O(n)。
在遍历HashMap的时候,其遍历节点的个数为bucket的个数+HashMap中保存的节点个数。因此当遍历操作比较频繁的时候需要注意HashMap的初始化容量不应该太大。
这一点其实比较好理解:当保存的节点个数一致的时候,bucket越少,遍历次数越少。
另外HashMap在resize的时候会有很大的性能消耗,因此当需要在保存HashMap中保存大量数据的时候,传入适当的默认容量以避免resize可以很大的提高性能。
具体的resize操作请参考下面对此方法的分析
HashMap是非线程安全的类,当作为共享可变资源使用的时候会出现线程安全问题。需要使用线程安全容器:
Map m = new ConcurrentHashMap();或者Map m = Collections.synchronizedMap(new HashMap());
具体的HashMap会出现的线程安全问题分析请参考9中的分析。
2.数据结构介绍
HashMap使用数组+链表+树形结构的数据结构。其结构图如下所示。
3.HashMap源码分析(基于JDK1.8)
3.1关键属性分析
transient Node<K,V>[] table;
Node类型的数组,记我们常说的bucket数组,其中每个元素为链表或者树形结构
transient Set<Map.Entry<K,V>> entrySet;
保存缓存的entrySet()
transient int size;
HashMap中保存的数据个数
transient int modCount;
此哈希映射在结构上被修改的次数
int threshold;
HashMap需要resize操作的阈值
final float loadFactor;
负载因子,用于计算threshold。计算公式为:threshold = loadFactor * capacity
备注:有默认容量capacity 2^4 = 16,默认扩容负载因子loadFactor=0.75等.用于构造函数没有指定数值情况下的默认值。
3.2Node分析
他是Hashmap的内部类,存储key,value键值对
1.4个参数
Hash - final常量
Key - final常量
value - 值
Node<K,V> next;
2.只有有参构造
3.key被final修饰-不能修改-只能创建的时候赋值
4.重写equals方法:key和value都相等(地址值相等)返回true
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | static class Node<K,V> implements Map.Entry<K,V> { final int hash; //final修饰-常量-不可变 final K key; //final修饰-常量-不可变 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) { //value设值:旧值新值被覆盖且返回旧值 V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { //key和value相等(地址值) 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 ; } } |
3.3关键函数源码分析
3.3.1构造函数
无参构造:默认扩容负载因子0.75
有参构造:最多两个参数 1.cap初始容量 2.loadFactor 扩容因子
有参构造虽然传入cap的值,但是没有创建tab,只是对阀值threshold 进行了赋值,赋值为传入cap最近的大于等于cap的2的整数次幂(这个值resize的时候会赋值给cap,保证cap是2的整数次幂),这个时候不创建tab(cap=tab.length就还没有确定)是为了节省空间
HashMap提供了三个不同的构造函数
static final int MAXIMUM_CAPACITY = 1 << 30; 最大容量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | public HashMap() { this .loadFactor = DEFAULT_LOAD_FACTOR; //无参构造-默认扩容负载因子0.75 //static final float DEFAULT_LOAD_FACTOR = 0.75f; } public HashMap( int initialCapacity) { //有参-设定初始容量大小,默认扩容负载因子0.75 this (initialCapacity, DEFAULT_LOAD_FACTOR); } 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); //tableSizeFor:返回大于输入参数且最近的2的整数次幂的数(当这个值大于最大容量MAXIMUM_CAPACITY时,返回MAXIMUM_CAPACITY)。 //注意:此处的initialCapacity为数组table的大小,即bucket的个数。另外此处赋值为this.threshold,是因为构造函数的时候并不会创建table,//只有实际插入数据的时候才会创建。目的应该是为了节省内存空间。//在第一次插入数据的时候,会将table的capacity设置为threshold,同时将threshold更新为loadFactor * capacity } |
tableSIzeFor()方法
tableSizeFor的功能:返回大于输入参数且最近的2的整数次幂的数(当这个值大于最大容量MAXIMUM_CAPACITY时,返回MAXIMUM_CAPACITY)。比如10,则返回16。该算法源码如下
1 static final int tableSizeFor(int cap) { 2 int n = cap - 1; 3 n |= n >>> 1; 4 n |= n >>> 2; 5 n |= n >>> 4; 6 n |= n >>> 8; 7 n |= n >>> 16; 8 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 9 }
先来分析有关n位操作部分:先来假设n的二进制为01xxx...xxx。接着
对n右移1位:001xx...xxx,再位或:011xx...xxx
对n右移2为:00011...xxx,再位或:01111...xxx
此时前面已经有四个1了,再右移4位且位或可得8个1
同理,有8个1,右移8位肯定会让后八位也为1。
综上可得,该算法让最高位的1后面的位全变为1。
最后再让结果n+1,即得到了2的整数次幂的值了。
现在回来看看第一条语句:
int n = cap - 1;
让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。
hash(Object key)方法
1 static final int hash(Object key) { 2 int h; 3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 4 }
看一个方法indexFor,在jdk1.7中有indexFor(int h, int length)方法。jdk1.8里没有,但原理没变,1.8中用tab[(n - 1) & hash]代替但原理一样。下面看下1.7源码
static int indexFor(int h, int length) { return h & (length-1); }
h:key 调用hash()获取的值,length - node数组的长度,即hashmap的capacity容量大小
indexFor这个方法返回的是这个键值对在数组中存储的位置的下标,也就是说下标的结果与hash值有关
而h & (length-1)这个计算有个规律:
当length=8时 下标运算结果取决于哈希值的低三位
当length=16时 下标运算结果取决于哈希值的低四位
当length=32时 下标运算结果取决于哈希值的低五位
当length=2的N次方, 下标运算结果取决于哈希值的低N位
如果hash()方法中不进行>>> 和 ^运算,在大多数情况下,length的值(hashma的容量)小于2^16次方,根据上面的规律,hash值的高16位是没有参与下标的结果的。那么这样子会导致获取的下标不够分散均匀。所以对key.hashCode()进行>>>和^运算后,再去进行h & (length-1)运算,那么高16位就参与了下标的结果
例如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与运算。如果让哈希值的低三位更加随机,那么&结果就更加随机,如何让哈希值的低三位更加随机,那么就是让其与高位异或。
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)的原因。
3.3.2put方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | public V put(K key, V value) { return putVal(hash(key), key, value, false , true ); } final V putVal( int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0 ) //1.首次添加,直接扩容resize() n = (tab = resize()).length; //table 是参数 - transient Node<K,V>[] table; if ((p = tab[i = (n - 1 ) & hash]) == null ) //2.数组该下表没有元素,直接添加 tab[i] = newNode(hash, key, value, null ); else { //3.数组该下标有元素 Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) //3.1数组该下标元素key与put的key一致(单个元素或者链表的首个元素的key和put的key一致),e取旧的元素,e的value下面回赋值 e = p; else if (p instanceof TreeNode) //3.2数组该下标元素为红黑树-处理 e = ((TreeNode<K,V>)p).putTreeVal( this , tab, hash, key, value); else { //3.2数组该下标元素为链表且第一个元素与put的key不一致/数组该下标只有一个元素且key与put的key不一致 for ( int binCount = 0 ; ; ++binCount) { if ((e = p.next) == null ) { //当链表下一个元素为null,创建新元素加入链表,结束循环 p.next = newNode(hash, key, value, null ); if (binCount >= TREEIFY_THRESHOLD - 1 ) // -1 for 1st treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) //链表的下一个元素的key和put的key一致,结束循环 break ; p = e; } } if (e != null ) { // e-用于put的key和数组原元素相同时,记录旧的元素,这里吧put的value更新到元素中 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; } |
3.3.3resize方法
无参构造:默认扩容负载因子0.75
有参构造:最多两个参数 1.cap初始容量 2.loadFactor 扩容因子
虽然传入cap的值,但是没有创建tab,只是对阀值threshold 进行了赋值,赋值为传入cap最近的大于等于cap的2的整数次幂
(这个值会赋值给cap,保证cap是2的整数次幂),这个时候不创建tab(cap=tab.length就还没有确定)是为了节省空间
resize:
第一次扩容:
无参构造创建: cap:2^4 Thr:cap * 0.75
有参构造创建: 初始容量设为阀值阀值设置为threshold = cap * loadFactor
非第一次扩容:2倍扩容(最大值为MAXIMUM_CAPACITY = 1 << 30)
阀值等于threshold = cap * loadFactor
1 final Node<K,V>[] resize() { 2 3 Node<K,V>[] oldTab = table; //旧的node数组 4 5 int oldCap = (oldTab == null) ? 0 : oldTab.length;//旧的数组容量 6 7 int oldThr = threshold;//旧的扩容阀值 8 9 int newCap, //新的容量 10 11 newThr = 0; //新的扩容阀值 12 13 if (oldCap > 0) { //1.原来容量不为0(不是第一次添加,已经扩过融了) 14 15 if (oldCap >= MAXIMUM_CAPACITY) { 16 17 // static final int MAXIMUM_CAPACITY = 1 << 30; 18 19 //如果旧容量大于等于1^30,扩容机制直接取int最大值2^32 20 21 threshold = Integer.MAX_VALUE; 22 23 return oldTab; //不扩容,直接返回旧的数组 24 25 } 26 27 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 28 29 oldCap >= DEFAULT_INITIAL_CAPACITY) 30 31 newThr = oldThr << 1; // double threshold 32 33 //static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 34 35 //如果旧的数组容量2倍小于最大容量并且旧的容量大于默认容量2^4,新的扩容阀值等于旧的2倍 36 } 37 38 else if (oldThr > 0) 39 //2.有参构造常见的第一次扩容 - 初始容量设置为阀值,初始化的时候有参构造传入了容量大小,但是初始化的时候只设置了threshold 40 //的值而没有设置capacity的值,而threshold 取得是大于等于传入容量大小的离他最近的一个2的整次幂的值, 41 //保证threshold 是2的整次幂,此时将threshold 赋值给capacity,保证capacity是2的整次幂 42 newCap = oldThr; 43 else { 44 // 3.无参构造创建的,第一次添加 45 newCap = DEFAULT_INITIAL_CAPACITY; //默认值 2^4 46 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//默认值 0.75 * 2 ^ 4 = 12 默认扩容阀值 47 } 48 49 if (newThr == 0) { 50 //扩容阀值等于容量乘以扩容因子最大值为最大int 51 float ft = (float)newCap * loadFactor; 52 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 53 (int)ft : Integer.MAX_VALUE); 54 } 55 56 threshold = newThr; 57 58 @SuppressWarnings({"rawtypes","unchecked"}) 59 60 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 61 62 table = newTab; 63 64 if (oldTab != null) { //把旧的数组的Node放到新的数组里面 65 66 for (int j = 0; j < oldCap; ++j) { 67 68 Node<K,V> e; 69 70 if ((e = oldTab[j]) != null) { //旧的Node元素不为null时,赋值给e 71 72 oldTab[j] = null; 73 74 if (e.next == null) //Node对象中的参数 Node<K,V> next为null,说明这里不是链表结构,原数组这里只有一个元素 75 76 newTab[e.hash & (newCap - 1)] = e;//e放入新数组中 77 78 else if (e instanceof TreeNode)//如果是红黑树树形结构,红黑树的重定位; 79 80 81 82 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 83 84 else { //当前是链表 85 86 Node<K,V> loHead = null, //用于接收新数组中下标为j这里的元素 87 88 loTail = null; 89 90 Node<K,V> hiHead = null, //用于结束新数组中下标为j+oldcap的元素 91 92 hiTail = null; 93 94 Node<K,V> next; 95 96 do { 97 98 next = e.next; 99 100 if ((e.hash & oldCap) == 0) { //处理新数组中下表为j的,这里结果是0的话,代表e.hash/oldcap的结果是2的倍数+余数,扩容后e.hash/2oldcap,余数不变,所以下标不变 101 102 if (loTail == null) 103 104 loHead = e; 105 106 else 107 108 loTail.next = e; 109 110 loTail = e; 111 112 } 113 114 else { //用于处理新数组中下表为j+capacity的 115 116 if (hiTail == null) 117 118 hiHead = e; 119 120 else 121 122 hiTail.next = e; 123 124 hiTail = e; 125 126 } 127 128 } while ((e = next) != null); 129 130 if (loTail != null) { //新数组线标j元素赋值 131 132 loTail.next = null; 133 134 newTab[j] = loHead; 135 136 } 137 138 if (hiTail != null) {//新数组线标j+oldcap元素赋值 139 140 hiTail.next = null; 141 142 newTab[j + oldCap] = hiHead; 143 144 } 145 146 } 147 148 } 149 150 } 151 152 } 153 154 return newTab; 155 156 }
resize链表处理关键代码
1 if ((e.hash & oldCap) == 0) { 2 if (loTail == null) 3 loHead = e; 4 else 5 loTail.next = e; 6 loTail = e; 7 } 8 else { 9 if (hiTail == null) 10 hiHead = e; 11 else 12 hiTail.next = e; 13 hiTail = e; 14 } 15 } while ((e = next) != null);
如上图:
链表处理,把链表分为了两块,1.在型数组中与原数组下标相同的,2.在型数组中下标=原数组下标+原数组容量,我们只看第一种:新数组中下标和原数组中相同的(第二种是一样的):
加入现在这个链表有4节 e1-e2-e3-e4,
if ((e.hash & oldCap) == 0)这个判断分别是true true false true,
那么参数e lohead lotail在上图中4次判断的内存情况分别为 黑色 绿色 黄色 红色
第一次true, e指向 e1 lohead 指向e1 lotail指向e1
第二次true, e = next指向e2 ,loTail.next = e---litail原指向e1,相当于e1.next=e=e2;loTail = e;lotail指向e2
第三次false,e = next指向e3,其它两个不变
第四次true:e = next指向e4,loTail.next = e --- lotail原指向e2,相当于e2.next = e = e4,loTail = e;lotail指向e4
那么最终的结果就是红色的线条,我们看lohead的指向lohead = e1 e1.next = e2 e2.next = e4,所以它现在是e1-e2-e4,就是我们想要的结果。
3.4Cloneable和Serializable分析
在HashMap的定义中实现了Cloneable接口,Cloneable是一个标识接口,主要用来标识 Object.clone()的合法性,在没有实现此接口的实例中调用 Object.clone()方法会抛出CloneNotSupportedException异常。可以看到HashMap中重写了clone方法。
HashMap实现Serializable接口主要用于支持序列化。同样的Serializable也是一个标识接口,本身没有定义任何方法和属性。另外HashMap自定义了
private void writeObject(java.io.ObjectOutputStream s) throws IOException
private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException
两个方法实现了自定义序列化操作。
注意:支持序列化的类必须有无参构造函数。这点不难理解,反序列化的过程中需要通过反射创建对象。
4.HashMap线程不安全问题
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素 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); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } 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; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
其中第6行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此之前,还有就是代码的倒数第4行处有个++size
,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap
的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?