HashMap原理
1.HashMap存储结构
从结构来讲,HashMap是有数组,链表,红黑树(jdk1.8之后加入)实现的,如下图所示
引入红黑树是因为他查找,插入,删除的平均时间复杂度为O(log(n))。这是因为当产生hash碰撞的时候,数据会挂载(尾插),形成链表,链表空间上不连续,逻辑上连续,增删元素块,只需要采用节点间引用,时间复杂度为O(1),查询慢,需要遍历查找,时间复杂度为O(n);
2.源码分析
2.1构造方法
// initialCapacity 初始容量 默认16
// loadFactor 加载因子 默认 0.75
// threshold 阈值 = initialCapacity*loadFactor
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);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
HashMap构造方法一共重载了四个,其中初始化三个参数
- initialCapacity:初始容量,默认为16,HashMap底层由数组+链表(或红黑树)实现,但是还是从数组开始,所以当储存的数据越来越多时,就必须进行扩容操作,如果在知道需要存储数据大小的情况下,指定初始容量可以避免不必要的扩容操作,提升效率。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
- loadFactor:加载因子(默认0.75),当负载因子较大的时,去给table数组扩容的可能性会少,所以相对占用内存就会较少(空间上较少),但是每条entry链上的元素相对较多,查询的时间就会增长(时间上较多)。反之就是,负载因子较小的时候,给table数组扩容的可能性就会变大,那么占用内存空间就会较大,但是entry链上的元素就会较少,查询的时间也会减少。所以才有了负载因子是时间和空间上一种折中的说法。所以设置负载因子的时候要考虑自己追求的事时间上的少还是空间上的少(一般情况下不需要设置,系统给定的默认值就是比较合适了)。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
-
threshold :阈值,hashMap所能容纳最大键值对的数量,如果超过则需要扩容,计算方式
,构造方法中通过#tableSizeFor(initiaCapacity)方法进行了赋值,主要原因是在构造方法中,数组table并没有进行初始化,而是在put方法中进行初始化的,同时在put方法中也会对threshold进行重新赋值。
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 找到大于或等于cap的最小2的幂
* 不考虑最大容量的情况下,返回cap且最近接2的整数次幂
*/
static final int tableSizeFor(int cap) {
int n = cap - 1; // 防止cap已经是2的整数次幂
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.2put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int TREEIFY_THRESHOLD = 8;
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)
// 如果此时table尚未初始化,则此处进行初始化数组,并赋值初始容量,重新计算阈值,默认长度为16
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存在哪个桶中
if ((p = tab[i = (n - 1) & hash]) == null)
// 通过hash找到下标,如果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))))
// 如果需要插入的key与当前hash值指定下标的key一样,现将第一个元素p赋值给e
e = p;
// 如果节点为红黑树节点
else if (p instanceof TreeNode)
// 放入到树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 其余情况则为链表节点
else {
// 遍历当前链表,在链表的最末端插入节点(jdk1.7采用头插法,容易造成死循环)
for (int binCount = 0; ; ++binCount) {
// p.next 为空则代表链表尾端
if ((e = p.next) == null) {
// 在链表尾部插入节点
p.next = newNode(hash, key, value, null);
// TREEIFY_THRESHOLD 扩容阈值8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 如果达到阈值,则转为红黑树
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中元素的key与插入元素key值是否想的
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
// 如果相等,掉出循环
break;
// 遍历桶中的列表,与前面e = p.next 组合,可以遍历链表
p = e;
}
}
// 如果e有记录,则表示桶中找到与元素key值,hash值与插入元素相等的节点,说明上面的值以及存在于当前的hashmap中,更新指定指定 // 键值对
if (e != null) { // existing mapping for key
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent true 不改变已经存在的值
// onlyIfAbsent false
if (!onlyIfAbsent || oldValue == null)
// onlyIfAbsent 为false,或者新插入的value为空,用新值替换旧值
e.value = value;
// 回调,将元素插入到链表的最后
afterNodeAccess(e);
// 返回旧值
// map.put("haha","hehe");
// map.put("haha","heiheihei");
// return hehe
return oldValue;
}
}
// 记录内部结构发生变化的次数
++modCount;
// 每当put一个元素,当实际大小,小于大于阈值的时候,进行扩容
if (++size > threshold)
// 扩容
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
具体过程如下图
2.3resize方法
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
// 如果旧的容量已经达到最大值2^30,则不再扩容,阈值直接设置为最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果旧数组*2小于最大容量2^30 并且 旧数组的常量大于等于初始容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 阈值扩容的大小为当前的2倍
newThr = oldThr << 1; // double threshold
}
// 旧阈值>0,
// 说明使用的构造方法为HashMap(int initialCapity,int loadFactory),该方法中,
// this.threshold=tableSizeFor(initialCapity),返回的容量为2的n次幂
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 阈值为初始化0,oldCap为空,及创建数组是为无参构造方法,调用resize()初始化默认值,
// 将新的初始化长度设置为16,阈值设置为16*0.75=12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新的阈值为0,根据负载因子设置初始化的值
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"})
// 创建一个长度为newCap的Node数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果就的数组中有数据,则将数组中的值复制到新的数组中
if (oldTab != null) {
// 遍历旧的数组,将元素节点进行复制
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果指定下标有数据,
if ((e = oldTab[j]) != null) {
//1.将指定下标数据置空,
oldTab[j] = null;
// 2.指定下标只有一个元素
if (e.next == null)
// 重新计算hash值,确定元素的位置
newTab[e.hash & (newCap - 1)] = e;
// 红黑树 treenode数据结构
else if (e instanceof TreeNode)
//
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 链表数据结构
else { // preserve order
// 如果是链表,重新计算hash值,根据下标重新分组
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 如果hash值为0,元素在数组中的位置未发生改变
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 不为0,元素在扩容数组中的位置发生改变,新的下标为原索引+oldCap
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;
}
注意
1.先判断当前table是否进行过初始化,如果没有,此处解决了调用无参构造器的时候,threshold和initialCapacity初始值的问题,如果已经初始化了,则扩容为原来的2倍
2.扩容后对新的table,并对所有的数据进行遍历
- 如果新计算的位置为空,则直接插入
- 如果新计算的位置未链表,则通过hash算法重新计算下标,对链表进行分组。
- 如果是红黑树,则需要进行重新拆分操作。
2.4get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 1.根据hash算法找出对应位置的第一个数据,如果key相等,则直接返回
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 如果该节点为树节点,则通过数查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 链表 遍历链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
说明
- 根据hash值找到对应的数据的位置;
- 判断第一个节点的key是否为需要查询的key,如果是直接返回,如果不是继续查找第二个节点;
- 如果数据是TreeNode(红黑树节点),直接红黑树节点查找数据;
- 如果是链表结构,直接遍历所有节点,返回数据;
- 如果没有找到符合要求的,直接返回null。
2.4.1 hash方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
说明:这段代码叫做扰动函数,也是HashMap中的hash运算。
- key.hashcode(),获取key的hashcode值,如果不进行重写返回的就是根据内存地址得到一个int值
- 获取到的hashcode值无符号右移16位然后与原hashcode进行 ^ 运算,这样做的目的就是让高位与低位进行混合,让两者都参与运算,一遍hash值分布更均匀。
2.4.2 取模运算 (n-1) & hash
hash算法中为了使元素分布的更加均匀,很多都会使用取谋运算,在HashMap中并没有hash%n这样的取谋运算,而是使用(n-1) & hash代替,原因是因为在计算机中,&的效率远高于%,需要注意的是,只有容量在2的n次幂的情况下,(n-1)&hash才等效于hash%n,这也是为什么hashmap在初始容量的时候,不管传入什么值,都会执行tableSizeFor(int cap)的方法转换成2的n次幂的原因。
3.总结
- HashMap底层结构在1.7之前是数组+链表组成的,1.8之后加入了红黑树;链表的长度小于8的时候,发生hash冲突的时候会增加链表的长度,当链表长度大于8的时候,会先判断数组的容量,如果容量小于64则先扩容(原因是数组长度越小,越容易发生碰撞,因此当容量小的时候,首先考虑的是扩容),如果容量大于64,则将链表转换为红黑树,提升效率。
- hashmap的容量为2的n次幂,无论在初始化的时候传入的容量的值为多少,都会转换为2的n次幂,这样做的原因是为了在取模运算的时候可以用&而不是用%,可以极大的提升效率,同时也降低了hash冲突。
- HashMap是非线程安全的,在多线程的情况下会存在异常(如形成闭环),1.8的时候已修复闭环问题,但仍是线程非现场安全的。可以使用hashtable和ConcurrentHashMap代替。
4.问题
1.为什么主数组的长度要为2的n次幂,如何保证?
为了让HashMap存储高效,尽量减少碰撞,也就是尽量的把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
首先想到的是hash%n hash=key的hashcode n为数组大小,
- 取余(%)操作中如果除数为2的n次幂,则等价于与其除数-1的(&)操作,也就是(***<u>hash%n== (n-1) & hash ,前提是n为2的n次幂)
- 采用2进制&操作,相对于采用%操作效率要高的多。
这就解释了为什么数组的长度为2的n次幂。
2.为什么桶中节点个数超过8个才会转成红黑树?
红黑树中,删除,插入和遍历的最坏时间复杂度为O(log(n))
TreeNode占用的空间是普通Nodes占用空间的2倍,所以只有当bin(bin就是bucket-桶,即HashMap中hashcode值一样元素保存的地方)包含足够多的节点的时候才会转换为treenode,足够多这个值是由TREEIFY_THRESHOLD的值决定的,当bin中节点数变少是,又会转换为普通node。
当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。