概述
key-value键值对的集合
常用实现: HashMap, LinkedHashMap
HashMap
特性
无序,线程不安全
查询速度快,时间复杂度为O(1)
存储结构: 数组+链表(数组的元素由链表构成)
容量始终为2的n次方
extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable : 可被克隆,支持序列化
结合源码
问题1:为何、如何保证hashMap的容量始终要为2的n次方
问题2:put,get源码实现
问题3:何时如何扩容
问题4:如何计算元素所在数组索引位置,如何处理hash冲突,扩容后如何保证通过hash计算出的索引位置不会改变。
核心属性
transient Node<K,V>[] table : 存储数据即key-value键值对
The table, initialized on first use, and resized as necessary. When allocated, length is always a power of two.
(We also tolerate length zero in some operations to allow bootstrapping mechanics that are currently not needed.)
该表在首次使用时初始化,并调整为必要。分配时,长度始终是2的幂。
(在某些操作中,我们还允许长度为零,以允许目前不需要的引导机制。)
transient Set<Map.Entry<K,V>> entrySet
Holds cached entrySet(). Note that AbstractMap fields are used for keySet() and values().
保存缓存的entrySet()。请注意,使用AbstractMap字段用于keySet()和values()。
transient int size : 数量
The number of key-value mappings contained in this map.
此映射中包含的键-值映射数。
int threshold: 阈值:容量*负载系数
The next size value at which to resize (capacity * load factor).
要调整大小的下一个大小值(容量*负载系数)
final float loadFactor: 负载系数 默认0.75
The load factor for the hash table.
哈希表的负载因子。
tableSizeFor(int cap)
给定cap,返回>=cap的最小2的n次方的值
例如 cap=3 return 4, cap=8,return 8, cap=9, return 16
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
思路:
对n转化为2进制再进行考虑,如5的二进制为(8位)0000 0101 则>=5且为2的n次方的值为8(0000 1000)
那如何得到上边的结果呢:通过不断无符号右移使得当前最低位到非0的最高位皆为1,
最后+1即得到了我们想要的结果
至于为什么要先减1呢:原因是考虑cap本身为2的n次方的情况,应该返回cap本身,以n=8为例,0000 1000,
无符号右移按位或后得到0000 1111
加1后->0001 0000 = 16 ,不符合预期,而减1后 即n=7
7的8位二进制表示为0000 0111,无符号右移按位或后得到0000 0111,加1后0000 1000 = 8 为预期值
示例(32位)
n=5 -> 0000 0000 0000 0000 0000 0000 0000 0101
>>>1 -> 0000 0000 0000 0000 0000 0000 0000 0010
|= -> 0000 0000 0000 0000 0000 0000 0000 0111
>>>2 -> 0000 0000 0000 0000 0000 0000 0000 0001
|= -> 0000 0000 0000 0000 0000 0000 0000 0111
...
n = 0000 0000 0000 0000 0000 0000 0000 0111
n+1 = 0000 0000 0000 0000 0000 0000 0000 1000
n+1 = 8
putVal
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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;
//通过i=(n-1)&hash计算索引位置
if ((p = tab[i = (n - 1) & hash]) == null)
//若当前索引位置无数据,直接赋值
tab[i] = newNode(hash, key, value, null);
else {
//若当前索引位置已有数据p,即出现了冲突
Node<K,V> e; K k;
//判断当前位置数据p的key是否与k相同
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//判断p位置的数据是否为TreeNode类型(红黑数)
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//遍历p位置链表的各个节点,若当前链表中尚不存在节点的key
与传入的key相等,插入p链表末端;若存在,返回对应节点e
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//插入新节点后,判断当前链表长度是否达到阈值,若达到,
变更数据结构为更方便查找的红黑树 TREEIFY_THRESHOLD=7
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;
}
}
//若传入的key已经存在对应的节点e,重新赋值e的value值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//若长度达到了阈值,进行扩容
值得注意的是此处的threshold并不是table数组的长度,resize方法中对该值进行了重新赋值
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
put操作的整体流程如下:
1、通过(n-1)&hash计算该key在table数组中的位置索引i
2、判断当前table是否为空,若为空,初始化table数组,并重新计算threshold
3、判断i位置处是否已存在数据,即是否存在冲突
4、若i处尚不存在数据,赋值table[i],size+1,判断size值是否超过threshold值,
若超过,resize()扩容
5、若i处已存在数据p(p为链表的根节点),且p的key与传入的key相同(hash值相等且key值相等)
赋值e=p(e不为空,代表传入的key在当前集合中已存在,最后做修改操作)
6、若i处已存在数据p,且p为红黑树(链表长度超过指定阈值,优化为红黑树),
通过putTreeVal赋值
7、若i处已存在数据p,且皆不满足5,6,遍历链表P,若存在key对应的数据,赋值e,
若不存在,p链表尾部插入该节点,插入后,判断链表长度是否超过阈值,若超过,变更为红黑树
8、若i处已存在数据p,且e不为空(即key值已存在),做修改操作e.val=value,并返回e的旧值
通过i=(n-1)&hash计算索引位置(数据在数组tab中的位置)
由此可以解答问题1,为何要保证数组的长度始终要为2的n次方:
数组长度为2的n次方,意味着n-1后的二进制,低位的值全为1,
在与hash进行按位与操作时,hash低位的值来决定索引位置,
降低出现冲突(不同hash值得到了相同的索引位置)的概率。
我们来比较n为8与n为9的情况(hash1: 0100, hash2: 0101):
n=8:
n-1=7 对应的二进制为 0111
7&hash1 -> 0111 & 0100 -> 0100 -> 4
7&hash2 -> 0111 & 0101 -> 0101 -> 5
n=9:
n-1=8 对应的二进制为 1000
8&hash1 -> 1000 & 0100 -> 0000 -> 0
8&hash2 -> 1000 & 0101 -> 0000 -> 0\
再总结一下:按位与操作,对应位皆为1结果为1,否则为0; 而8的二进制1000,低3位皆为0,
导致不管hash值的低三位为啥,按位与后皆变成了0
resize()
扩容&threshold赋值
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//table不为空且长度大于0
if (oldCap > 0) {
//超过最大容量,赋值threshold为int的最大值>table.length
即后续不会再触发扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//若当前table扩容后(*2)未超最大容量限制,且当前长度大于默认容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//oldThr大于0 ,即初始化HashMap时给定了初始值,如new HashMap<>(4)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//初始化时未给定初始化的情况,即new HashMap<>()
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;
//table不为空,即需要扩容,遍历旧数据重新计算各数据在扩容后的数组中的位置
if (oldTab != null) {
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 { // 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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
Get
看完了put方法后,再来看get方法后,清晰了很多
首先还是根据hash值跟table.length-1计算索引值,
然后通过进一步比较key的值来确定具体的值
问题解答
我们再次在对看源码时携带的问题做出解答
问题1:为何、如何保证hashMap的容量始终要为2的n次方
为何:通过容量-1与hash做按位与计算来计算索引值,始终保证容量为2的n次方可以使得
索引位的决定性放在随机性更大的hash值上,减少冲突的出现。
如何:通过tableSizeFor方法,在上边已经有详细的说明
问题2:put,get源码实现
见上方
问题3:何时如何扩容
通过比较size的值与threshold来判断是否需要扩容,threshold一般情况下为容量*loadFactor(默认为0.75)
例如:table.length=16, 则threshold=12
问题4:如何计算元素所在数组索引位置,如何处理hash冲突,扩容后如何保证通过hash计算出的索引位置不会改变。
keyHash&(table.length)=元素所在索引位置i
若存在冲突,使用链表存储冲突的元素,put和get时,
通过确认两者的key的hash值和值是否相等来判断是否为同一个元素。
跨容时,若table已存在数据,重新遍历已存在数据,重新计算索引位置放入扩容后的数组中。
附加问题:HashMap计算hash值的优化
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们知道元素的索引位置通过(table.length-1)&hash得到,
所以hash值的随机性决定了冲突出现的频率,而我们实际应用中table.length一般不会太大,
所以在与hash进行按位与运算时,hash的高位值就失去了干扰作用,
通过hash^(hash>>>16),使得hash的高位对hash的低位形成干扰,
从而使得hash值的从最低到最高皆会对索引的计算产生影响,
从而降低冲突出现的概率。
解密EntrySet
看过Map源码的同学,可能对entrySet都或多或少抱有一些疑问,这里以我最开始看源码的角度展开
疑问1:entrySet何时被初始化
解答:entrySet属性在entrySet()方法中被初始化,需要注意的是entrySet()会在HashMap的toString方法中被调用。
transient Set<Map.Entry<K,V>> entrySet;
以上为entrySet在HashMap中的定义,然而我们断点跟进new HashMap<>()的时候,奇怪的发现,entrySet已经被初始化了
如下图
那么entrySet是何时初始化的呢
实际上我们debug模式下往往会忽略掉一个重点,debug模式下各对象是怎么呈现给我们的呢:
答案就是 toString(),我们debug进入new Hash()方法,idea debug呈现窗会给我们呈现this对象也就是当前的新建的HashMap对象,
即会调用HashMap的toString方法,我们再来看下HashMap toString()的实现:
如上两图所示
第一幅图为toString实现,HashMap未重写toString(),所以使用的为父类AbstractHashMap的实现
toString()中调用了entrySet()方法,即图二的实现,从而完成了HashMap的entrySet的初始化。
接下来,我们再对上述结论做下验证,去掉无关debug断点,在调用entrySet()中加入条件断点entrySet == null,
如下:
结合断点位置以及之前的结论,我们做下预测,在执行entrySet()之前,entrySet未被初始化,entrySet==null,会进入断点,
实际执行,进入断点,如图所示:
总结:
HashMap的entrySet属性,在调用entrySet()方法时才完成初始化的,
而debug模式下,因为HashMap的toString()中调用了entrySet()方法,给了一个最开始就初始化完成的错觉。
疑问2:entrySet数据是何时维护的
解答:HashMap的entrySet中并不维护实际的数据,只是提供了一个迭代器,数据依然取自table[], debug模式下看到的数据其实是entrySet迭代器给table的数据做了一个影射呈现给了我们而已。
从上两图HashMap的两种迭代方式的实现可以看到,迭代时数据取自table
LinkedHashMap
特性
LinkedHashMap<K,V> extends HashMap<K,V> implements Map
通过继承关系可得知,LinkedHashMap继承自HashMap
相比于HashMap,LinkedHashMap我们关注的相比于HashMap的特性为:有序性(遍历HashMap数据的顺序等于插入顺序)
LinkedHashMap在构建节点时,维护顺序
LinkedHashMap如何保证有序性
实现逻辑为:LinkedHashMap中维护一个头尾节点,且在每个节点中维护before、after节点,
put数据时,按插入顺序构建链表。
TreeMap
特性
有序(相比于LinkedHashMap的有序,此处的有序指的不是插入顺序,而是key值遵循规则保持有序,例如数字从小到大)
基于红红黑树实现
可以通过自定义比较器来控制元素顺序\