HashMap位于java.util包下,实现Map接口。
键值对,每个键都唯一(插入重复键时,覆盖value值),只允许有一个空键。
结构:
数组+链表/红黑树,初始默认容量为16
基本元素:
size:hashmap中实际存在键值对的数量。
length:数组长度,必须为2的幂次方。
threshold:在此Load factor(负载因子,默认为0.75)和length对应下允许的最大元素数目,即与size相比较,size超过这个值就resize进行扩容。
modCount:用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。(内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。)
构造方法:
索引方法:
计算hash:key的hashCode值的高16位异或低16位
计算索引:hash值对数组长度length取模(&length-1)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K, V>[] tab; // 指向当前哈希数组
Node<K, V> p; // 指向待插入元素应当插入的位置
……
// p指向hash所在的哈希槽(链)上的首个元素
p = tab[i = (n - 1) & hash];
……
}
同一哈希链/树下,key的hash值未必相同(对长度取模),key的hash值相同,hashcode也未必相同。
(图片来自美团技术团队https://tech.meituan.com/2016/06/24/java-hashmap.html)
对于(n - 1) & hash,n是数组长度,n是2的i次方,由上图可知,与n-1,索引结果就是hash值的低i位。
put方法:
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) {
// 初始化哈希数组,或者对哈希数组扩容,返回新的哈希数组
tab = resize();
n = tab.length;
}
// p指向hash所在的哈希链/树上的首个元素
p = tab[i = (n - 1) & hash];
// 如果哈希槽为空,直接放置新节点存储键值对
if(p == null) {
tab[i] = newNode(hash, key, value, null);
// 不为空,在后面链接更多的元素
} else {
Node<K, V> e;
K k;
/*
* 对哈希槽中的首个元素进行判断
*
* 只有哈希值一致(还说明不了key是否一致),且key也相同(先判断key地址,不是同一个key再用equals()判断两个key是否值相同)时,
* 这里才认定是存在同位元素(在HashMap中占据相同位置的元素)
*/
if(p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
e = p; //e指向哈希槽首个元素,覆盖value,具体对value值的覆盖在后面(修改e的value)
// 如果是红黑树结点,调用红黑树的插入方法
} else if(p instanceof TreeNode) {
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
} else {//当前是链表
// 遍历(binCount统计的是插入新元素之前遍历过的元素数量)
for(int binCount = 0; ; ++binCount) {
// 如果没有找到同位元素,则需要插入新元素
if((e = p.next) == null) {
// 插入一个普通结点
p.next = newNode(hash, key, value, null);
// 插入结点后需进行判断,插入后有没有达到阈值(默认为8),若达到,即将链表转换为红黑树
if(binCount >= TREEIFY_THRESHOLD - 1) { // -1 for 1st
treeifyBin(tab, hash);
}
break;
}
/*
* 对哈希槽后面链接的其他元素进行判断
*
* 只有哈希值一致(还说明不了key是否一致),且key也相同(必要时需要用到equals()方法)时,
* 这里才认定是存在同位元素(在HashMap中占据相同位置的元素)
*/
if(e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
break;
}
p = e;
}
}
// 如果存在同位元素(在HashMap中占据相同位置的元素)
if(e != null) { // existing mapping for key
// 获取旧元素的值
V oldValue = e.value;
// 如果不需要维持原状(可以覆盖旧值),或者旧值为null
if(!onlyIfAbsent || oldValue == null) {
// 更新旧值
e.value = value;
}
afterNodeAccess(e);
// 返回覆盖前的旧值
return oldValue;
}
}
//不是覆盖情况,插入了新结点, HashMap的更改次数加一
++modCount;
// 如果哈希数组的容量已超过阈值,则需要对哈希数组扩容
if(++size>threshold) {
// 初始化或扩容,返回新的哈希数组
resize();
}
afterNodeInsertion(evict);
// 插入新元素,返回null
return null;
}
扩容
如果是链表,将链表拆分,以后补充
线程不安全
jdk1.7中,多线程扩容时,由于头插法,会产生循环链表。
jdk1.8改成了尾插法,但在多线程put时会产生数据覆盖问题。
以下为put方法的代码,假设A、B计算出的下标相同,并且都判断了p为空,则会导致数据被覆盖。
关于链表长度大于8转换为红黑树:
- 首先链表长度大于8不一定会转成红黑树。见如下代码
final void treeifyBin(Node<K, V>[] tab, int hash) {
int n;
// 哈希数组的容量还未达到形成一棵红黑树的最低要求
if(tab == null || (n = tab.length)<MIN_TREEIFY_CAPACITY) {
// 初始化哈希数组,或者对哈希数组扩容,返回新的哈希数组
resize();
}
else{//转换成红黑树
...
}
....
}
- 为什么不直接使用红黑树:因为树节点的大小是链表节点大小的两倍,所以只有在容器中包含足够的节点才用红黑树,来提高查询效率,也就是这时查询性能提高的优势大于内存占用较多的劣势(见源码注释)。
- 长度降到6会转换回链表,个人猜测6是内存和转换回链表带来的开销之间的平衡数字
- 为什么是8:
在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,所以通常情况下,也就是hash 计算的结果离散好,因此链表不会过长,(需要存储很多的数据才会发生链表长度大于等于8的情况),因此不会发生从链表向红黑树的转换。
当链表长度超过8,就可以推出hash函数不是很好了,此时不需要存储很多数据,链表也可能会过长,即超过8,此时转换为红黑树,就能提高查找的效率。
参考:
美团技术团队https://tech.meituan.com/2016/06/24/java-hashmap.html