HashMap<String, Integer> map = new HashMap<>();
这个语句执行起来,在 jdk1.8 之前,会创建一个长度是 16 的 Entry[]
数组,叫 table
,用来存储键值对。
在 jdk 1.8 后,不在这里创建数组了,而是在第一次 put
的时候才会创建数组叫 Node[] table
,用来存储键值对。
声明部分:
HashMap 实现了 Map 接口,又继承了 AbstractMap
,但是 AbstractMap
也是实现了 Map
接口的,而且很多集合类都是这种实现,这是一个官方失误造成的冗余,不过一直流传了下来。
AbstractMap
,这个父类作为抽象类,实现了 Map
的很多方法,为了减少直接实现类的工作;Cloneable
接口和 Serializable
接口,这个问题在 原型模式 里面说过,就是深拷贝的问题,但是值得注意的是,HashMap 实现这两个接口,重写的方法仍然不是深拷贝,而是浅拷贝。属性部分:
序列化默认版本号,不重要。
集合默认初始化容量,注释里写了必须是 2 的幂次方数
,默认是 16。
问题 1 : 为什么非要是 2 的次方数呢?
答:第一方面为了均匀分布,第二方面为了扩容的时候重新计算下标值的方便。
这个涉及到了插入元素的时候对每一个 node 的应该在的桶位置的计算:
核心在这个方法里,会根据 (n - 1) & hash
这个公式计算出 i
,hash
是提前算出的 key
的哈希值,n
则是整个 map
的数组的长度。
那么这个节点应该放在哪个桶,这就是散列的过程,我们当然希望散列的过程是尽量均匀的,而不会出现都算出来进入了 table[]
的同一个位置。那么,可以选择的方法有取余啊、之类的,这里采用的方法是位运算来实现取余。
就是(n - 1) & hash 这个位运算,2 的幂 -1 都是11111结尾的:
2 进制,所以 2 的几次方都是 1 00000(很多个 0 的情况),然后 -1, 就会变成 000 11111(很多个1)那么和 本来计算的具有唯一性的 hash 值相与,
- 用高位的 0 把hash 值的高位都置为了 0 ,所以限制在了 table 的下标范围内。
- 保证了 hash 值的尽量散开。
对于第 2 点,如果不是 2 的幂次方,那么 -1 就不会得到 1111 结尾,甚至如果是个基数,-1 后就会变成形如 0000 1110
这样的偶数,那么相与的结果岂不是永远都是偶数了?这样 table 数组就会有一半的位置永远利用不上的。所以 2 的幂次方以及 -1 的操作,才能保证得到和取模一样的效果。
因此得出结论,如果 n 是 2 的幂次方,计算出的位置会很均匀,相反则会干扰这个运算,导致计算出的位置不均匀。
第二个方面的原因就是扩容的时候,重新要计算下标值 hash
,2 的幂次方
带给了好处,下面的扩容部分有详细说明。
注意到我们初始化 HashMap 的时候可以指定容量。
问题 2 那么如果传入的容量并不是 2 的次方,怎么办呢?
从构造方法可以看到,调用指定加载因子和 容量的方法,如果大于最大容量,就会改为最大容量,接着对于容量,调用 tableSizeFor
方法,此时传入的参数已经肯定是 <=
最大容量的数字了。
tableSizeFor
这个方法会产生一个大于传入数字的、最小的 2
的幂次方数。
最大 hashMap 的容量就是 1 左移 30 位,也就是 2 的 30 次方
。
默认加载因子为 0.75
,也就是说,如果键值对超过了当前的容量 * 0.75
,就会触发扩容。
问题 为什么是 0.75
而不是别的数呢?
答:如果加载因子越大,对空间的利用更充分,但是查找效率会降低(链表长度会越来越长);如果加载因子太小,那么表中的数据将过于稀疏(很多空间还没用,就开始扩容了),对空间造成严重浪费。
其实 0.75
是一个统计的结果,比较理想的值,根据旧版源码里面的注释,和概率的泊松分布有关系,当负载因子是 0.75
的情况下,哈希碰撞的概率遵循参数约为 0.5
的泊松分布,因此选择它是一个折衷的办法来满足时间和空间。
默认为 8
,也就是说一个桶内的链表节点数多于 8
的时候,结合数组当前长度会把链表转换为红黑树。
问题 为什么是超过 8
就转为红黑树?
答:首先,红黑树的节点在内存中是普通链表节点方式存储的 2 倍
,成本是比较高的,那么对于太少的节点数目就没必要转化,继续扩容就行了。
结合负载因子 0.75
的泊松分布结果,每个链表有 8
个节点的概率已经到达可以忽略的程度,所以将这个值设置为 8
。为了避免出现恶意的频繁插入,除此之外还会判断数组长度是否达到了 64。
所以到这里我个人的理解是:
-> 最开始hashmap的思想就是数组加链表;
-> 因为数组里的各个链表长度要均匀,所以就有了哈希值的算法,以及适当的扩容,扩容的加载因子定成了 0.75 ;
-> 而扩容只能根据总共的节点数来计算,可能没来得及扩容的时候还是出现了在同一个链表里元素变得很多,所以要转红黑树,而这个数量就根据加载因子结合泊松分布的结果,决定了是8.
默认为 6
, 也就死说如果操作过程发现链表的长度小于 6
,又会把树退回链表。
不仅仅是说有链表的节点多于 8
就转换,还要看 table
数组的长度是不是大于 64
,只有大于 64
了才转换。为了避免开始的时候,正好一些键值对都装进了一个链表里,那只有一个链表,还转了树,其实没必要。
还有属性的第二部分:
第一个是容器 table
存放键值对的数组,就是保存链表或者树的数组,可以看到 Node
类型也是实现了 Entry
接口的,在 1.8
之前这个节点是不叫 Node
的,就叫的 Entry
,因为就是一个键值对,现在换成了 Node
,是因为除了普通的键值对类型,还可能换成红黑树的树节点TreeNode
类型,所以不是 Entry
了。
第二个是保存所有键值对的一个 set
集合,是一个存放缓存的;
第三个 size
是整个hashmap
里的键值对的数目;
第四个是 modCount
是记录集合被修改的次数,有助于在多个线程操作的时候报根据一致性保证安全;
第五个 threshold 是扩容的阈值,也就是说大于阈值的时候就开始扩容,也就是 threshold = 当前的 capacity * loadfactor
;
第六个 loadFactor
也是对应前面的加载因子。
事实上,在 jdk1.8 之后,并不会直接初始化 hashmap
,只是进行加载因子、容量参数的相关设定,真正开始将 table
数组空间开辟出来,是在 put
的时候才开始的。
第一个:
public HashMap()
是我们平时最常用的,只是设置了默认加载因子,容量没有设定,那显然就是 16
。
第二个:
public HashMap(int initialCapacity)
为了尽量少扩容,这个构造方法是推荐的,也就是指定 initialCapacity
,在这个方法里面直接调用的是
第三个构造方法:
public HashMap(int initialCapacity, float loadFactor)
用指定的初始容量和加载因子,确保在最大范围内,也调整了 threshold 容量是 2 的幂次方数
。
这里就是一个问题,把 capcity
调整成 2 的幂次方
数,计算 threshold
的时候不应该要乘以 loadfactor
吗,怎么能直接赋给 threshold
呢?
原因是这里没有用到 threshold
,还是在 put
的时候才进行 table
数组的初始化的,所以这里就没有操作。
最后一个构造方法是,将本来的一个 hashmap 放到一个新的 map 里。
put
方法是直接调用了计算 hash
值的方法计算哈希值,然后交给 putVal
方法去做的。
hash
方法就是调用本地的 hashCode
方法再做一个位移操作计算出哈希值。
为什么采用这种右移 16 位
再异或的方式计算 hash
值呢?
因为 hashCode
值一般是一个很大的值,如果直接用它的话,实际上在运算的时候碰撞的概率会很高,所以要充分利用这个二进制串的性质:int
类型的数值是 4
个字节的,右移 16
位,再异或可以同时保留高 16 位
和低 16 位
的特征,进行了混合得到的新的数值中,高位与低位的信息都被保留了 。
另外,因为,异或运算能更好的保留各部分的特征,如果采用 &
运算计算出来的值会向 1
靠拢,采用 |
运算计算出来的值会向 0
靠拢, ^
正好。
最后的目的还是一样,为了减少哈希冲突。
算出 hash 值后,调用的是 putVal 方法:
传入哈希值;要插入的 key 和 value;然后两个布尔变量,onlyIfAbsent 代表当前要插入的 value 是否存在了如果是 true,就不修改;evict 代表这个 hashmap 是否处于创建模式,如果是 false,就是创建模式。
下面是源码及具体注释:
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;//调用resize方法初始化tab,验证了我们说的,构造方法不会创建数组,而是插入的时候创建。
//这个算法前面也已经讲过,就是计算索引,如果p的位置是 null,就在这里放入一个newNode;
//如果p的位置不是 null,说明这个桶里已经有链表或者树了,就不能直接 new ,而是要遍历链表插入,并同时判断是不是需要转树
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)
//已经不是链表是红黑树了,调用putTreeVal
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//是链表,用 for 循环遍历
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;//如果已经有值,覆盖,这里用到了onlyIfAbsent
afterNodeAccess(e);
return oldValue;
}
}
//增加修改hashMap的次数
++modCount;
//如果已经达到了阈值,就要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
这里面涉及到的步骤主要如下:
调用 resize
方法初始化 table
数组,jdk1.8 后确实是到 put
的时候才会初始化数组;
用 hash
值计算出在数组里应该在的索引;
如果索引位置是 null
,就直接放入一个新节点,也就是 Node
对象;
如果不是 null
,则要在这个桶里插入:
hash
值、key值和传入的这个新的一样,赋值给 e
这个节点;instanceof
判断是否为 TreeNode
类型,也就是说如果这个桶里已经不是链表而是红黑树了,就调用 putTreeVal
方法;hash
值、key
值和传入的一样,赋值给 e
这个节点,否则遍历到最后,把一个 Node
对象插到链表末尾,插完后链表长度已经大于阈值,就要转树。结束插入的动作后,前面的 e
一旦被赋值过了,说明是有一样的 key
出现,那么就说明不用插入新节点,而是替代旧的 val
。
这里面涉及到的 resize 、putTreeVal 和 treeifyBin 也是比较复杂的方法,下来进行介绍。
转换为树的方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果数组的长度还没有达到 64 ,就不转树,只是扩容。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果 e 不为空,那么遍历整个链表,把每个节点都换成具有prev和next两个指针的树节点
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
//结束后要开始把一个普通的树(此时其实严格上说是一个双链表的形态)转化成红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
treeify 里面调用了各种左旋啊、右旋啊,平衡
啊,各种很复杂的红黑树操作方法,这里不再深入。
问题:什么时候会扩容?
从前面成员变量的解释和插入元素,已经能总结出两种扩容的情况:
数组长度*负载因子(0.75)
的时候,扩容;8
个,并且数组长度没有达到 64
,则扩容而不转红黑树。扩容每次都会把数组的长度扩到 2
倍,并且之后还要把每个元素的下标重新计算,这样的开销是很大的。
值得注意的是,重新计算下标值的方法 和第一次的计算方法一样,这样很简便且巧妙:
(n - 1) & hash
这个式子计算索引,但是显然有重新计算的时候,变化的是 n-1
,有些就不会在原位置了;n
的变化入手,因为是 2
倍扩容,而数组长度本身也设置是 2
的幂次,在二进制位上来说,新算出来的 n-1
只是相比旧的 n-1
左移了一位;比如 16-1 = 15,就是 1 0000 - 1 = 0 1111;
新的 32-1 = 31,就是 10 0000 - 1 = 01 1111;
hash
相与运算,节点要么在原来位置,要么在原位置+旧的容量的位置
,也就是在最高位加上了一个原来的容量;上面讲过的为什么长度设置 2 的幂次,这里也能作为一个优势的解释。
源码如下:
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //这里把新的阈值和新的边界值都*2
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//创建新数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//for循环就开始把所有旧的节点都放到新数组里
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
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 {
//是链表,保持顺序,用do-while循环进行新的位置安排
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) {//用hash和oldCap的与结果,拆分链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}else {//用hash和oldCap的与结果,拆分链表
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//还放在原来索引位置
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;//放在新索引位置,就是加上 oldCap
}
}
}
}
}
return newTab;
}
remove 直接调用的 removeNode 方法,类似于前面的 put 调用 putVal 。
注意 remove
根据 key
的时候肯定默认那个对应的 value
也是要删除的,所以 matchValue
置为 false
,意思就是不用看 value
。
removeNode
的整体思路比较常规,就是我们能想到的:
如果本身 hashmap
不为空,且 hash
值对应的索引位置不为空,才去某一个桶里找并删除;
key
来比较的;matchValue
判断要不要删除,删除的过程就是用之前找到的那个位置,然后指针操作就可。否则,直接返回 null
。
get
也只直接调用了 getNode
方法:
这里面的代码就和 remove
方法的前半部分几乎一样,也就是找到指定的 key
的位置,并返回对应的 value
。
HashMap 本身维护了一个 keySet 的 Set,拿到所有的 key 。(显然维护 value 是没办法的,因为 key 都是唯一的),但这种方法不推荐,因为拿到 key 后再去找 value又是对 map 的遍历。
Set<String> keys = map.keySet();
for (String key: keys){
System.out.println(key + map.get(key));//根据key得到value
}
也可以拿到所有的 value 需要用 Collection 来接收:
Collection<Integer> values = map.values();
for (Integer v: values){
System.out.println(v);
}
也可以获取到所有的键值对Entry 的 Set 集合,然后拿到对应的迭代器进行遍历:
Set<Map.Entry<String,Integer>> entries = map.entrySet();
Iterator<Map.Entry<String,Integer>> iterator = entries.iterator();
while (iterator.hasNext()){
Map.Entry<String,Integer> entry = iterator.next();
System.out.println(entry.getKey()+entry.getValue());//得到key和value
}
jdk 1.8 之后,还增加了一个 forEach 方法,可以接口里的这个方法本身也是通过第二种方法实现的,在HashMap 里重写了这个方法,变成了对 table 数组的遍历,使用的时候,用 lambda 表达式传入泛型就可以。
map.forEach((key,value)->{
System.out.println(key + value);
});
这种方法其实用到的也属于设计模式的代理模式
关于数组本身,1.7 是一个 Entry 类型的数组,1.8是一个 Node 类型。
1.7 扩容时机
数组长度 * 加载因子
。加载因子默认情况是 0.75
,等键值对个数 size
达到了数组长度 * 加载因子
;扩容后,添加元素。
1.8 的扩容时机
先添加元素,再看是否需要扩容。
数组长度 * 加载因子。
加载因子默认情况是 0.75
,等键值对个数 size
达到了数组长度 * 加载因子
(这点判断是一样的)
当其中某一个链表的元素个数达到 8
个,走到转树节点的方法里,但是又发现数组长度没有达到 64
,则扩容而不转红黑树。
1.7 扩容的实现
数组长度 * 2
操作;transfer
里,对单向链表进行一个一个 hash
重新计算并且安排,采用头插法来安排单向链表,把节点都安排好。但是如果多线程的情况下,有别的线程先完成了扩容操作,这个时候链表的重新挪动已经导致节点位置的变化,切换回这个线程的时候,继续改变链表指针就可能会产生环,然后这个线程死循环。
具体就是 7 的扩容方法在迁移的时候采用的是头插法,那么比如两个元素 ab一个链表,线程1和2都发现要扩容,就会去调用transfer方法:
- 1 先读取了 e 是 a,next 是 b,但是没来得及继续操作就挂起了;
- 2 开始读取,并采用头插法就是遍历ab,先把a移到新数组的位置,此时a.next = null;继续遍历到 b,b移到新位置,b.next = a;(形成了 b->a)
- 这时候切换到了线程 1 执行,本来已经再循环里面记录了 e 和 e.next 了,然而这时本来数组都变新的了,所以修改的时候计算位置啥的还是这个新数组里,不会变,因为计算的肯定是一样的, a.next = b,而前面就修改过了b.next = a,这样已经是环了,那么线程 1 继续while,一直next,死循环。
1.8 扩容的实现
因为是先插入,再扩容,所以插入的时候对于链表就是一个尾插法。
然后如果达到了扩容的条件,也就先进行数组长度 * 2
操作,直接在 resize
方法里完成数据迁移,这里因为数据结构已经有链表+红黑树两种情况:
链表
,把单链表进行数据迁移,充分利用与运算,将单链表针对不同情况拆断,放到新数组的不同位置;红黑树
,树节点里维护了相当于双向链表的指针,重新处理,如果处理之后发现树的节点(双向链表)小于等于 6 ,还会再操作把树又转换为单链表。但是如果在多线程的情况下,不会形成环链表,但是可能会丢失数据,因为会覆盖到一样的新位置。