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个参数
常量
常量
值
Node<K,V> next;
2.只有有参构造
3.key被final修饰-不能修改-只能创建的时候赋值
4.重写equals方法:key和value都相等(地址值相等)返回true
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; 最大容量
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);
}
调用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
(就是十进制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方法
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
e;lotail指向e2
e = next指向e3,其它两个不变
e = next指向e4,loTail.next =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,所有说还是由于数据覆盖又导致了线程不安全。