深入源码-HashMap
😉 本文共3175字,阅读时间约10min
数据结构
before 1.8
在JDK1.8 之前 HashMap 由 数组+链表 数据结构组成的。
使用拉链法来解决哈希冲突(两个对象的hashcode在调用hash()后一致导致数组索引相同)。
Node<K,V>[] table; // 底层是Node数组,Node<K,V>是泛型类
Set<Map.Entry<K,V>> entrySet;
int threshold; // 扩容阈值
float loadFactor; // 负载因子
int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 初始化容量
since 1.8
在JDK1.8 之后 HashMap 由 数组+链表 +红黑树数据结构组成的。
-
解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。
将链表转换成红黑树前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表变为红黑树。而是选择进行数组扩容。
数组较小时,优先扩容,避免红黑树结构。红黑树要进行左旋、右旋、变色等操作保持平衡,不如扩容效率高。
why 红黑树?
哈希函数取得再好,也很难达到元素百分百均匀分布。
当大量元素到同一个桶时,桶下链表很长,查询效率是O(n)。
链表长度变长时,查询效率太低,红黑树查询效率是O(logn)。
why 大于8才转为红黑树?
- 希望尽量不用红黑树:这个数字要足够大。树节点占用空间是链表节点2倍,只有链表节点太多时,才认为转成红黑树带来的查询优化抵消掉空间劣势。
- 防止不好的hashcode情况:
- 当hashcode离散性很好时,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。
- 这个数字要足够小。如果用户实现了不好的hashcode时,要能优化单链表的查询时间。
- why 8 ?泊松分布
- 随机hashcode方法的节点分布频率贴近泊松分布,源码注释列出了链表长度为0到8的概率,达到8的概率非常小,几乎是不可能事件。(泊松分布适合于描述单位时间内随机事件发生的次数)
- 这样,正常的hashcode函数几乎不会树化,而非正常的也得到了树化的防止机会。
当长度小于6时,则从红黑树转为链表
HashMap的hash
对象的hashcode无符号右移16位 与 hashcode做异或,相当于高16位保留,低16位是高16位和低16位异或后的结果。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
why 这样hash?
- 如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了,所以这里把高低位都利用起来,从而解决了这个问题。
- 如果当n即数组长度很小,假设是16的话,那么n-1即为 ---》1111 ,这样的值和hashCode()直接做按位与操作,实际上只使用了哈希值的后4位。
其他的hash函数
构造散列函数的两个基本原则:计算简单+均匀分布。
平方取中法:将关键字平方之后取中间若干位数字作为散列地址。
除留余数法:f(key) = key mod p ,结合开放定址法。如果冲突发生,就选择另外一个可用/空的位置。只要散列表足够大,空的/可用的散列地址总能找到,并将记录存入。
这两个都不如位运算效率高。
why loadFactor 0.75?
负载因子,衡量hashmap满的程度。
threshold计算公式:capacity(数组长度默认16) * loadFactor(负载因子默认0.75)。size达到该值,hashmap需要扩容。负载因子 = size/capacity,而不是占用桶的数量去除以capacity。capacity 是桶的数量,也就是 table 的长度length。
loadFactor太大容易哈希碰撞,太小数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。
put流程图
说明:
1.size表示 HashMap中K-V的实时数量 , 注意这个不等于数组的长度 。
2.threshold( 临界值) =capacity(容量) * loadFactor( 加载因子 )。这个值是当前已占用数组长度的最大值。size超过这个临界值就重新resize(扩容),扩容后的 HashMap 容量是之前容量的两倍 。
重要方法
构造方法
//创建HashMap集合的对象,指定数组长度是10,不是2的幂
HashMap hashMap = new HashMap(10);
public HashMap(int initialCapacity) {//initialCapacity=10
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {//initialCapacity=10
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);//initialCapacity=10
}
static final int tableSizeFor(int cap) {//int cap = 10
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;
}
- 长度必须是2的幂次:如果指定数组长度是10,不是2的幂,会通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。
- Node数组初始化延迟:构造函数并不分配那么大的Node数组空间,延迟到第一次put进行Node的初始化。
- 初始化指定容量好处:在已知多少数据的情况下,避免频繁扩容
why 容量必须是2的幂次?
计算数据在数组上的索引位置是一次取模操作,如果容量是2的幂次可以利用位运算实现高效的取模。
hash & (length - 1) 等效于 hash % length
put方法
// 内部调用putVal方法
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;
// table长度为空或为null,初始化扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 与操作计算索引位置
if ((p = tab[i = (n - 1) & hash]) == null)
// 索引位置为空,直接放
tab[i] = newNode(hash, key, value, null);
else {
// 执行else说明tab[i]不等于null,表示这个位置已经有值了。
Node<K,V> e; K k;
// 第一个元素是否equal,相等则替换
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);
// 添加完成,若达到树化阈值,则树化,break
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) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 判断实际大小是否大于threshold阈值,如果超过则扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
- 树化方法
treeifyBin
很有意思,实际变成红黑树的方法是调用的另一个内部方法。这个方法做了三件事:- 数组长度小于64,选择扩容而非树化
- 遍历ListNode,创建相同个数的TreeNode,复制内容,建立起联系prev、next
- 让桶中的第一个元素指向新创建的树根节点,替换桶的链表内容为树形化内容
get 方法
- 步骤:
- 通过hash值找索引
- 索引上的Node就是要查找的key,直接返回
- 如果不是,后续节点是链表,就到链表搜。后续节点是红黑树,就到红黑树搜索。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 通过hash值找索引
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 索引上的Node就是要查找的key,直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 判断是否是红黑树,是的话调用红黑树中的getTreeNode方法获取节点
// 红黑树查找跟二叉搜索树一样,递归左右子树的一边
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 不是红黑树的话,那就是链表结构了,通过循环的方法判断链表中是否存在该key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
resize扩容方法
何时扩容?
当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容。
注:第一次put,也会resize,因为此时table为空。
怎么扩容?
- 创建新的Node数组,容量是原来的两倍(如果超出最大值,就按最大值)
- 遍历旧数组的所有Node,将其rehash到新数组。
- 如果是链表,按链表遍历处理
- 如果是红黑树,按红黑树处理
巧妙的rehash方式
rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。
正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。
使用hashmap
如何遍历
-
遍历keySet(),获取key然后get到value
说明:根据阿里开发手册,不建议使用这种方式,因为迭代两次。keySet获取Iterator一次,还有通过get又迭代一次。降低性能。
-
使用Iterator迭代器迭代
-
遍历entrySet()
-
jdk8以后使用Map接口中的默认方法:
public class Demo02 {
public static void main(String[] args) {
HashMap<String,String> m1 = new HashMap();
m1.put("001", "zhangsan");
m1.put("002", "lisi");
m1.forEach((key,value)->{
System.out.println(key+"---"+value);
});
}
}
EntrySet
之所以比KeySet
的性能高是因为,KeySet
在循环时使用了map.get(key)
,而map.get(key)
相当于又遍历了一遍 Map 集合去查询key
所对应的值。为什么要用“又”这个词?那是因为在使用迭代器或者 for 循环时,其实已经遍历了一遍 Map 集合了,因此再使用map.get(key)
查询时,相当于遍历了两遍。而
EntrySet
只遍历了一遍 Map 集合,之后通过代码“Entry<Integer, String> entry = iterator.next()”把对象的key
和value
值都放入到了Entry
对象中,因此再获取key
和value
值时就无需再遍历 Map 集合,只需要从Entry
对象中取值就可以了。所以,
EntrySet
的性能比KeySet
的性能高出了一倍,因为KeySet
相当于循环了两遍 Map 集合,而EntrySet
只循环了一遍。
hashmap的初始化
默认情况下HashMap的容量是16,但是,如果用户通过构造函数指定了一个数字作为容量,那么Hash会选择大于该数字的第一个2的幂作为容量。(3->4、7->8、9->16) 。
《阿里巴巴Java开发手册》中建议我们设置HashMap的初始化容量。
如果我们已知这个Map中即将存放的元素个数,给HashMap设置初始容量可以防止多次扩容
- 初始大小建议为size/ 0.75F + 1.0F。
- 注意,JDK并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个2的幂。
- 为什么加1?比如size = 6,不加1初始大小为8,又会触发一次扩容。加1初始大小为16,不会触发扩容。
为什么1.7头插法不安全?
多线程扩容rehash操作时,容易形成循环链表。jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。
- 线程一和线程二都进行扩容,假设线程一暂时被调度挂起
- 线程一被调度回来执行,继续扩容
- 这下就死循环了。原来 3 - > 7 ,线程2 7 -> 3,线程1 3 -> 7,但此时7的下一个不是null,而是3,这是由线程2引起的。