1、HashMap
HashMap是一种key-value的数据结构,在jdk1.7中,它的底层数据结构是链表+数组,在jdk1.8中,它的数据结构是链表+数组+红黑树。
之所以引进红黑树,是因为在jdk1.7中,当链表的长度过长,会影响链表的查询性能。
加载因子为什么是0.75?
-
当加载因子设置比较大时,扩容的门槛被提高,扩容发生频率低,虽然说占用内存比较少,但是发生Hash冲突的几率会提升。设置比较大时,对元素的操作时间会增加,运行效率低。
-
当加载因子设置比较小时,扩容的门槛比较低,占用内存比较多,存储的元素相对比较稀疏,发生Hash冲突的几率比较小,操作性能比较高。
-
为了在内存和性能间做一个折中,选择0.5和1之间的中位数0.75来作为加载因子。
HashMap中查询方法get(key)源码如下:
public V get(Object key) {
Node<K,V> e;
//对key进行哈希操作
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;
//数组为空或数组长度为0或hash值对应所在链表第一个元素为null时返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//通过key校验,检查第一个元素是不是要查找的元素,是的话返回第一个值
//1.hash 2.key
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//第一个节点判断不是的话,进入下一个节点,并判断first结点是不是treenode类型
if ((e = first.next) != null) {
if (first instanceof TreeNode)
//如果第一个节点是treenode类型,则根据红黑树来判断所在节点是否是要查找的节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//否则根据链表的方法进行判断
do {
//如果该entry是属于该key的,要满足两个条件,1.hash值相等,2.key相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get方法总结:
当查询某个键对应的值时,先把key做一次hash得到一个hash值
- 第一步:先判断数组是否为空或**hash值所对应的数组位置中的第一个元素是否为null,**若这两者都成立,则返回null,表示没有这个key对应的value;
- 第二步:如果第一步不返回null,通过key校验(不仅要key的hash值相等,而且key也要要相等==),检查hash值所对应的数组位置中的第一个元素是不是要查找的元素,如果判断成立,则返回该数组位置的第一个元素;
- 第三步:如果第二步不成立,则判断第一个元素后面是否有后继元素?如果没有,也是返回null,表示没有这个key对应的value;如果有,则先判断第一个节点的类型,如果第一个节点是红黑树的节点类型,则用红黑树的方法getTreeNode来查找该key对应的value值,如果第一个节点是链表的节点类型,则不停的循环判断所在节点的key是否和查询的key一致(hash值相等且key相等==),直至链表的尾端,若不存在,则返回null
HashMap中查询方法put(key,value)源码如下:
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;
//哈希表为空,初始化
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 {
Node<K,V> e; K k;
//如果哈希表中的第一个元素是该key值对应的元素,即hash值相等且key相等
//则覆盖这个key对应的元素
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) {
//没有这个key对应的元素时,加入
p.next = newNode(hash, key, value, null);
//链表长度大于8时,转为红黑树处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//有这个key对应的元素,把它取出
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;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//当哈希表长度>threshold时,扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
put方法总结:
当put某个键值对的时候,先对键进行hash得到hash值
- 第一步:如果当前哈希表为空,则进行初始化;
- 第二步:将键的哈希值和哈希表的长度减1进行与操作(得到要放入键值对的哈希表的位置),如果该位置上没有元素,则直接插入并返回null,且size++,当size>threshold时,即哈希表长度大于扩容阈值,进行数组扩容;如果该位置上的元素(第一个元素)是key值对应的元素,即hash值相等且key相等==,则覆盖这个key对应的value,并返回旧值;
- 第三步:在第二步不成立的基础上,判断第一个元素的节点类型是红黑树节点还是链表节点,如果是红黑树节点,则用红黑树的putTreeVal方法插入对应的键值对,如果是链表节点,则对链表上的节点循环判断 第四步;
- 第四步:如果没有对应的key的节点,则new一个新的节点,并加到链表的尾部,加入后判断链表长度是否大于8,如果是,则转为红黑树;如果有对应的key的节点,取出对应的节点,并更新value,并返回旧的value。
- 注意:整个过程中,只有当直接插入时,即哈希表所在位置没有元素时,直接加入后才需要判断是否对哈希表进行扩容,因为这时候开辟了一个新的位置用来存储元素,size++。
HashMap中查询方法resize()源码如下:
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; // double threshold
}
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 (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//当发现哈希表中的元素不为空
if ((e = oldTab[j]) != null) {
//将原哈希表位置所在元素置空
oldTab[j] = null;
//当哈希表所在位置只有一个元素时,将其重新散列到新的哈希表的其他位置中,还是那个方法:(hash值 & 数组长度-1)
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//当哈希表所在位置不只有一个元素时,如果元素是红黑树型,则根据红黑树进行切分
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//如果是链表型,JDK8优化部分
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;
//如果元素的hash值 和 旧的哈希表的容量 做与操作后,等于0
//把元素放在low所在的链表中,链表头为loHead,尾是loTail
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//如果元素的hash值 和 旧的哈希表的容量 做与操作后,等于1
//把元素放在high所在的链表中,链表头为hiHead,尾是hiTail
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//low链表还是在哈希表原来的位置,high链表移动到原来的哈希表位置+原来的哈希表容量的位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize方法总结:
resize方法主要是对哈希表进行初始化或扩容。
- 当哈希表(数组)为空时,oldCap=0且oldThr=0,至于oldThr为什么等于0,返回到其定义的位置,在javadoc上有这样的描述:
Additionally, if the table array has not been allocated, this field holds the initial array capacity, or zero signifying DEFAULT_INITIAL_CAPACITY.)
译文:此外,如果没有分配表数组,则该字段保存初始数组容量,或者表示DEFAULT_INITIAL_CAPACITY的值为零。 主要看后半句,表示DEFAULT_INITIAL_CAPACITY的值为零。
-
第一步,当数组为空(刚创建HashMap,没指定容量),直接对newCap和newThr赋值:
newCap = DEFAULT_INITIAL_CAPACITY; //初始值为16,见其定义的位置,不再赘述 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //初始值为12,factor为0.75
-
第二步,对数组容量进行初始化,并返回初始化后的数组:
threshold = newThr; Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab;
-
补充:当数组为空时,还存在另一种情况,就是我们早已创建过HashMap,但是我们remove掉了所有的元素,(或者我们在初始化HashMap传入了参数)这时数组虽然为null,但是oldThr>0(即进入了下面的if判断): 这个时候,扩容后的数组容量(newCap)为旧的扩容阈值(oldThr),且如果新数组的容量及它和负载因子的乘积(ft)都小于最大容量时,新的扩容阈值(newThr)为ft,最后因为数组为空,直接返回扩容后的新数组。
else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); }
-
当哈希表不为空时,oldCap和oldThr都>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; // double threshold } -
第一步,先判断oldCap(旧的数组的容量)是否大于MAXIMUM_CAPACITY(规定的最大容量)?如果是,threshold(扩容阈值)赋值为Integer.MAX_VALUE,并返回原来旧的哈希表;如果不是,新的哈希表的容量为oldCap << 1,即旧哈希表容量的两倍,扩容后:若newCap<MAXIMUM_CAPACITYold且Cap>=DEFAULT_INITIAL_CAPACITY,那么newThr = oldThr << 1,即扩容阈值也变为原来的两倍。
-
第二步,数组中元素重排,根据原哈希表的长度遍历,当发现某个位置中的哈希表中的元素不为空,记录该位置的元素,并将其置空,当哈希表所在位置只有一个元素时,将其重新散列到新的哈希表的其他位置中,还是那个方法:(hash值&数组长度-1),如果不是只有一个元素,到第三步中处理。
-
第三步,判断第一个节点是不是红黑树节点,如果是则根据红黑树进行split,
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);如果不是,则用链表处理方式,转至第四步。 -
第四步,如果元素的hash值 和 旧的哈希表的容量 做与操作后,等于0,把元素放在low链表中,链表头为loHead,尾是loTail;如果元素的hash值 和 旧的哈希表的容量 做与操作后,等于1,把元素放在high链表中,链表头为hiHead,尾是hiTail
-
第五步,low链表还是在哈希表原来的位置,high链表移动到原来的哈希表位置+原来的哈希表容量的位置。
newTab[j + oldCap] = hiHead;扩容完成后,返回新的哈希表。常见问题
-
什么时候将链表转为红黑树,以及什么时候进行扩容?
链表长度>=8且数组长度>=64时,将链表转化为红黑树;当数组长度>扩容阈值threshold时,进行扩容。
- 怎么定位到对应的链表(怎么散列到数组中的)
key进行哈希后的hash值 和 数组长度-1做与运算
- JDK1.7 HashMap是如何导致死循环的
hashmap用数组+链表。数组是固定长度,链表太长就需要扩充数组长度进行rehash减少链表长度。如果两个线程同时触发扩容,在移动节点时会导致一个链表中的2个节点相互引用,从而生成环链表。
- jdk1.8的HashMap在多线程的情况下也会出现死循环的问题
链表转换树或者对树进行操作的时候会出现线程安全的问题。两个TreeNode节点的Parent引用都是对方
-
线程安全的定义:
在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。
- HashMap 的长度为什么是 2 的幂次方
在散列的时候,数组下标通过(n - 1) & hash计算而来,其实它的本质是:hash值对数组长度取余,因为当数组长度是2的幂次方时, hash%length等价于hash&(length-1),所以长度是2的幂次方。
- 如果我们在创建一个HashMap时,传入构造方法的长度不是2的幂次方,会怎么样?
在构造函数中,会对initialCapacity进行tableSizeFor,this.threshold = tableSizeFor(initialCapacity);自动把长度resize成2的幂次方。
为什么要把tableSizeFor的值赋给threshold呢?第一次对HashMap进行put操作时,会调用resize方法(见resize中的补充部分)。
- HashMap 有哪几种常见的遍历方式?
1、迭代器(Iterator)方式遍历
//entrySet
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> entry = iterator.next();
System.out.print(entry.getKey());
System.out.print(entry.getValue());
}
//keySet
Iterator<Integer> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
Integer key = iterator.next();
System.out.print(key);
System.out.print(map.get(key));
}
2、For Each方式遍历
//entrySet
for (Map.Entry<Integer, String> entry : map.entrySet()) {
System.out.print(entry.getKey());
System.out.print(entry.getValue());
}
//keySet
for (Integer key : map.keySet()) {
System.out.print(key);
System.out.print(map.get(key));
}
3、lambda表达式
map.forEach((key, value) -> {
System.out.print(key);
System.out.print(value);
});
4、Streams API
//单线程
map.entrySet().stream().forEach((entry) -> {
System.out.print(entry.getKey());
System.out.print(entry.getValue());
});
//多线程
map.entrySet().parallelStream().forEach((entry) -> { System.out.print(entry.getKey()); System.out.print(entry.getValue()); });