一、简介

HashMap源码看过无数遍了,但是总是忘,好记性不如烂笔头。

本文HashMap源码基于JDK8。

文章将全面介绍HashMap的源码及HashMap存在的诸多问题。

开局一张图,先来看看hashmap的结构。

二、历史版本

再次声明一下本文HashMap源码基于JDK8。不同版本HashMap的变化还是比较大的,在1.8之前,HashMap没有引入红黑树,也就是说HashMap的桶(桶即hashmap数组的一个索引位置)单纯的采取链表存储。这种结构虽然简单,但是当Hash冲突达到一定程度,链表长度过长,会导致时间复杂度无限向O(n)靠近。比如向HashMap中插入如下元素,你会神奇的发现,在HashMap的下表为1的桶中形成了一个链表。

 map.put(1, 1);
map.put(17,17);
map.put(33,33);
map.put(49,49);
map.put(65,65);
map.put(81,81);
map.put(97,97);
...
16^n + 1

为了解决这种简单的底层存储结构带来的性能问题,引入了红黑树。在一定程度上缓解了链表存储带来的性能问题。引入红黑树之后当桶中链表长度超过8将会树化即转为红黑树(put触发)。当红黑树元素少于6会转为链表(remove触发)。

在这里还有一个很重要的知识点,树化和链表化的阈值不一样?想一个极端情况,假设阈值都是8,一个桶中链表长度为8时,此时继续向该桶中put会进行树化,然后remove又会链表化。如果反复put和remove。每次都会进行极其耗时的数据结构转换。如果是两个阈值,将会形成一个缓冲带,减少这种极端情况发生的概率。

上面这种极端情况也被称之为复杂度震荡

类似的复杂度震荡问题ArrayList也存在。

三、基础知识

3.1,常量和构造方法

 // 16 默认初始容量(这个容量不是说map能装多少个元素,而是桶的个数)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量值
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//树化阈值 一个桶链表长度超过 8 进行树化
static final int TREEIFY_THRESHOLD = 8;
//链表化阈值 一个桶中红黑树元素少于 6 从红黑树变成链表
static final int UNTREEIFY_THRESHOLD = 6;
//最小树化容量,当容量未达到64,即使链表长度>8,也不会树化,而是进行扩容。
static final int MIN_TREEIFY_CAPACITY = 64;
//桶数组,bucket. 这个也就是hashmap的底层结构。
transient Node<K,V>[] table;
//数量,即hashmap中的元素数量
transient int size;
//hashmap进行扩容的阈值。 (这个表示的元素多少,可不是桶被用了多少哦,比如阈值是16,当有16个元素就进行扩容,而不是说当桶被用了16个)
int threshold;
//当前负载因子,默认是 DEFAULT_LOAD_FACTOR=0.75
final float loadFactor;
/************************************三个构造方法***************************************/
public HashMap(int initialCapacity, float loadFactor) {//1,初始化容量2,负载因子
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);//总要保持 初始容量为 2的整数次幂
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

3.2、桶的两种数据结构

前面说了,JDK8 HashMap采用的是链表+红黑树。

链表结构

 static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}

红黑树结构

 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}

3.3、hash算法实现

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

计算桶下标方法

(n - 1) & hash//n表示HashMap的容量。 相当于取模运算。等同于 hash % n。

n其实说白了就是HashMap底层数组的长度。(n-1) & hash这个与运算,等同于hash % n。

hash()方法,只是key的hashCode的再散列,使key更加散列。而元素究竟存在哪个桶中。还是 (n - 1) & hash 结果决定的。

综合一下如下,在hashmap中计算桶索引的方法如下所示。

public static int index(Object key, Integer length) {
int h;
h = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
return (length - 1) & h;
}

假设当前hashmap桶个数即数组长度为16,现在插入一个元素key。

计算过程如上图所示。得到了桶的索引位置。

在上面计算过程中,只有一步是比较难以理解的。也就是为什么不直接拿 key.hashcode() & (n - 1) ,为什么要用 key.hashcode() ^ (key.hashcode() >>> 16) 为什么要多一步呢?后面问题总结会详细介绍。

四、HashMap 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;
//put1,懒加载,第一次put的时候初始化table(node数组)
if ((tab = table) == null || (n = tab.length) == 0)
//resize中会进行table的初始化即hashmap数组初始化。
n = (tab = resize()).length;
//put2,(n - 1) & hash:计算下标。// put3,判空,为空即没hash碰撞。直接放入桶中
if ((p = tab[i = (n - 1) & hash]) == null)
//将数据放入桶中
tab[i] = newNode(hash, key, value, null);
else {//put4,有hash碰撞
Node<K,V> e; K k;
//如果key已经存在,覆盖旧值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//put4-3:如果是红黑树直接插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//如果桶是链表,存在两种情况,超过阈值转换成红黑树,否则直接在链表后面追加
for (int binCount = 0; ; ++binCount) {
//put4-1:在链表尾部追加
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//put4-2:链表长度超过8,树化(转化成红黑树)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
//如果key已经存在,覆盖旧值
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//put5:当key已经存在,执行覆盖旧值逻辑。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)//put6,当size > threshold,进行扩容。
resize();
afterNodeInsertion(evict);
return null;
}

其实上面put的逻辑还算是比较清晰的。(吐槽一下JDK源码,可读性真的不好,可读性真的不如Spring。尤其是JDK中总是在if或者for中对变量进行赋值。可读性真的差。但是逻辑是真的经典)

总结一下put的过程大致分为以下8步。

1,懒汉式,第一次put才初始化table桶数组。(节省内存,时间换空间)
2,计算hash及桶下标。
3,未发生hash碰撞,直接放入桶中。
4,发生碰撞
4.1、如果是链表,迭代插入到链表尾部。
4.2、如果链表长度超过8,树化即转换为红黑树。(当数组长度小于64时,进行扩容而不是树化)
4.3、如果是红黑树,插入到红黑树中。
5,如果在以上过程中发现key已经存在,覆盖旧值。
6,如果size > threshold。进行扩容。

以上过程中,当链表长度超过8进行树化,只是执行树化方法 treeifyBin(tab, hash); 。但是在该方法中还有一步判断,也就是当桶数组长度<64。并不会进行树化,而是进行扩容。你想想,假如容量为16,你就插入了9个元素,巧了,都在同一个桶里面,如果这时进行树化,树化本身就是一个耗时的过程。时间复杂度会增加,性能下降,不如直接进行扩容,空间换时间。

看看这个方法

 final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)//如果容量 < 64则直接进行扩容;不转红黑树。(你想想,假如容量为16,你就插入了9个元素,巧了,都在同一个桶里面,如果这时进行树化,时间复杂度会增加,性能下降,不如直接进行扩容,空间换时间)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}

在put逻辑中还有最重要的一个过程也就是扩容。

五、扩容

5.1、扩容

 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; //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) {//一共oldCap个桶
Node<K,V> e;
if ((e = oldTab[j]) != null) {//如果第j个桶没元素就不管了
oldTab[j] = null;
//只有一个元素,直接移到新的桶中(为什么不先判断是不是TreeNode?
//很简单,因为TreeNode没有next指针,在此一定为null,也能证明是一个元素。
//对于大多数没有hash冲突的桶,减少了判断,处处充满着智慧)
if (e.next == null)
//计算桶下标,e.hash & (newCap - 1)是newCap哦
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // rehash 源码很经典
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 {//判断不成立,说明该元素要移位到 (j + oldCap) 位置
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//j 即oldIndex
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
}
}
}
}
}
return newTab;
}

从以上源码总计一下扩容的过程:

1,创建一个两倍于原来(oldTab)容量的数组(newTab)。
2,遍历oldTab
2.1,如果当前桶没有元素直接跳过。
2.2,如果当前桶只有一个元素,直接移动到newTab中的索引位。(e.hash & (newCap - 1))
2.3,如果当前桶为红黑树,在split()方法中进行元素的移动。
2.4,如果当前桶为链表,执行链表的元素移动逻辑。

在以上过程中,我们着重介绍链表的元素移动。也就是上述代码中的39-68行。

首先,我们看其中

 Node<K,V> loHead = null, loTail = null;//下标保持不变的桶
Node<K,V> hiHead = null, hiTail = null;//下标扩容两倍后的桶

loHead和loTail分别对应经过rehash后下标保持不变的元素形成的链表头和尾。

hiHead和hiTail分别对应经过rehash后下标变为原来(n + oldIndex)后的链表头和尾。

经过上面变量,我们不难发现,桶中的数据只有两个去向。(oldIndex和 n + oldIndex)

接下来我们思考一个问题。为什么经过rehash,一个桶中的元素只有两个去向?

以下过程很烧脑,但是看懂了保证会收获很多。 更会体会到源码之美

大致画一下图,如下所示。

HashMap的容量总是2的n次方(n <= 32)。

假设扩容前桶个数为16。

看扩容前后的结果。观察扩容前后可以发现,唯一影响索引位的是hash的低第5位。

所以分为两种情况hash低第5位为0或者1。

 当低第5位为0:newIndex = oldIndex
当低第5位为1:newIndex = oldIndex + oldCap

以上过程也就说明了为啥rehash后一个桶中的元素只有两个去向。这个过程我看没有博客介绍过。为什么在这里详细介绍这个呢?因为这个很重要,不懂这个就看不懂以上rehash代码,也很难体会到JDK源码的经典之处。给ConcurrentHashMap rehash时的锁打一个基础。

回到源码52行。

if ((e.hash & oldCap) == 0)

这个判断成立,则说明该元素在rehash后下标不变,还在原来的索引位置的桶中。为什么?

我们先看一下 (e.hash & oldCap)

看结果,如果判断 if ((e.hash & oldCap) == 0) 成立,也就是说hash的低第5位为0。

在上个问题我们推导桶中元素的两个去向的时候,发现低第5位的两种情况决定了该元素的去向。再观察上面问题推导中的hash的第一种情况当*为0;

惊不惊喜,意不意外,神奇的发现,当hash低5位为0时,其新索引为依然为oldIndex。OK,你不得不佩服作者的脑子为何如此聪明。当然了这一切巧妙的设计都是建立在hashmap桶的数量总是2的n次方。

回到源码,如下。很简单了,将新的两个链表分别放到newTab的oldIndex位置和newIndex位置。正如我们上面推导的那样

 if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//j 即oldIndex
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
}

以上resize过程就说完了。

留一个问题,以上resize过程性能还能不能进一步优化呢?有兴趣的可以对比ConcurrentHashMap的这个rehash源码。你会神奇的发现JDK8的作者为了性能究竟有多拼。

当然resize过程在并发环境下还是存在一定问题的。接下来继续往下看。

5.2、JDK7并发环境扩容问题——循环链表

先看源码

  //将当前所有的哈希表数据复制到新的哈希表
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍历旧的哈希表
for (Entry<K,V> e : table) {
while(null != e) {
//保存旧的哈希表对应的链表头的下一个结点
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//因为哈希表的长度变了,需要重新计算索引
int i = indexFor(e.hash, newCapacity);
//第一次循环的newTable[i]为空,赋值给当前结点的下一个元素,
e.next = newTable[i];
//将结点赋值到新的哈希表
newTable[i] = e;
e = next;
}
}
}

JDK7 hashmap采用的是头插法,也就是每put一个元素,总是插入到链表的头部。相对于JDK8尾插法,插入操作时间复杂度更低。

看上面transfer方法。假设扩容前数组长度为2,扩容后即长度为4。过程如下。(以下几张图片来自慕课网课程)

第一步:处理节点5,resize后还在原来位置。

第二步:处理节点9,resize后还在原来位置。头插,node(9).next = node(5);

第三步:处理节点11,resize后在索引位置3处。移动到新桶中。

并发环境下的问题

假设此时有两个线程同时put并同时触发resize操作。

线程1执行到,只改变了旧的链表的链表头,使其指向下一个元素9。此时线程1因为分配的时间片已经用完了。

紧接着线程2完成了整个resize过程。

线程1再次获得时间片,继续执行。解释下图,因为节点本身是在堆区。两个线程栈只是调整链表指针的指向问题。

当线程2执行结束后,table这个变量将不是我们关注的重点,因为table是两个线程的共享变量,线程2已经将table中的变量搬运完了。但是由于线程1停止的时间如上,线程1的工作内存中依然有一个变量next是指向9节点的。明确了这一点继续往下看。

当线程2执行结束。线程1继续执行,newTable[1]位置是指向节点5的。如下图。

如上图线程1的第一次while循环结束后,注意 e = next 这行代码。经过第一次循环后,e指向9。如下图所示。

按理来说此时如果线程1也结束了也没啥事了,但是经过线程2的resize,9节点时指向5节点的,如上图。所以线程1按照代码逻辑来说,依然没有处理完。然后再将5节点插入到newTable中,5节点继续指向9节点,这层循环因为5.next==null,所以循环结束(自己看代码逻辑哦,e是在while之外的,所以这里不会死循环)。如下图所示,循环链表形成。

然后在你下一次进行get的时候,会进入死循环。

最后想一下JDK7会出现死循环的根源在哪里?很重要哦这个问题,根源就在于JDK7用的是头插法,而resize又是从头开始rehash,也就是在老的table中本来是头的,到新table中便成为了尾,改变了节点的指向。

5.3、JDK8的数据丢失问题

上面介绍了JDK7中循环链表的形成,然后想想JDK8中的resize代码,JDK8中的策略是将oldTab中的链表拆分成两个链表然后再将两个链表分别放到newTab中即新的数组中。在JDK8会出现丢失数据的现象(很好理解,在这里就不画图了,感兴

趣的自己画一下),但是不会出现循环链表。丢数据总比形成死循环好吧。。。另外一点JDK8的这种策略也间接的保证了节点间的相对顺序。

好吧,还是说说JDK8的丢数据问题吧。

 do {
next = e.next;
if ((e.hash & oldCap) == 0) {//判断成立,说明该元素不用移动
if (loTail == null)//尾空,头插
loHead = e;
else//尾不空,尾插
loTail.next = e;
loTail = e;
}
else {//判断不成立,说明该元素要移位到 (j + oldCap) 位置
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//j 即oldIndex
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
}

假设两个线程,根据代码逻辑,线程1执行了4次循环让出时间片,如下图所示。

此时链表table索引1位置的桶如下所示

如果此时线程2也进行resize。此时线程2看到的oldTab是如上图所示的。很明显,接下来线程1执行完成,并顺利将两个链表放到了newTab中。

此时线程2又获取时间片并继续执行以下操作相当于之前线程1的resize结果被线程2覆盖了。此时就发生了数据的丢失。

终于介绍完了扩容过程,不容易啊。

六、HashMap get过程源码

 public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;//get1,计算hash
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {// get2,(n - 1) & hash 计算下标
if (first.hash == hash && // always check first node //get3-1,首先检查第一个元素(头元素),如果是目标元素,直接返回
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)//get3-2,红黑树
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//get3-3,链表
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

看完了put的源码,会发现get过程是何其简单,大致过程如下

1,计算hash
2,计算下标
3,获取桶的头节点,如果头结点key等于目标key直接返回。
3.1,如果是链表,执行链表迭代逻辑,找到目标节点返回。
3.2,如果是红黑树,执行红黑树迭代逻辑,找到目标节点返回。

关于remove方法,不介绍了,无非就是就是get过程+红黑树到链表的转化过程。不介绍了。

七、问题总结

7.1、为什么hashmap的容量必须是2的n次方。

回顾一下计算下标的方法。即计算key在数组中的索引位。

hash&(n - 1)

其中n就是hashmap的容量也就是数组的长度。

假设n是奇数。则n-1就是偶数。偶数二进制中最后一位一定是0。所以如上图所示, hash&(n - 1) 最终结果二进制中最后一位一定是0,也就意味着结果一定是偶数。这会导致数组中只有偶数位被用了,而奇数位就白白浪费了。无形中浪费了内存,同样也增加了hash碰撞的概率。

其中n是2的n次方保证了(两个n不一样哦,别较真)hash更加散列,节省了内存。

难道不能是偶数吗?为啥偏偏是2的n次方?

2的n次方能保证(n - 1)低位都是1,能使hash低位的特征得以更好的保留,也就是说当hash低位相同时两个元素才能产生hash碰撞。换句话说就是使hash更散列。

呃。。。个人觉得2在程序中是个特殊的数字,通过上文resize中的关于二进制的一堆分析也是建立在容量是2的n次方的基础上的。虽然这个解释有点牵强。如果大家有更好的解释可以在下方留言。

两层含义:

1,从奇偶数来解释。

2,从hash低位的1能使得hash本身的特性更容易得到保护方面来说。(很类似源码中hash方法中 <<< 16的做法)

7.2、解决hash冲突的方法

hashmap中解决hash冲突采用的是链地址法,其实就是有冲突了,在数组中将冲突的元素放到链表中。

一般有以下四种解决方案。详情度娘。

 链地址法
开放地址法
再哈希法
建立公共溢出区

7.3、HashMap、HashTable、ConcurrentHashMap区别

HashMap是不具备线程安全性的。

HashTable是通过Synchronized关键字修饰每一个方法达到线程安全的。性能很低,不建议使用。

ConcurrentHashMap很经典,Java程序员必精通。下篇文章就介绍ConcurrentHashMap。该类位于J.U.C并发包中,为并发而生。

7.4、如何保证HashMap的同步

Map map = Collections.synchronizedMap(new HashMap());其实其就是给HashMap的每一个方法加Synchronized关键字。

性能远不如ConcurrentHashMap。不建议使用。

7.5、为什么引入红黑树

这个问题很简单,因为红黑树的时间复杂度表现更好为O(logN),而链表为O(N)。

为什么红黑树这么好还要用链表?

因为大多数情况下hash碰撞导致的单个桶中的元素不会太多,太多也扩容了。只是极端情况下,当链表太长会大大降低HashMap的性能。所以为了应付这种极端情况才引入的红黑树。当桶中元素很少比如小于8,维护一个红黑树是比较耗时的,因为红黑树需要左旋右旋等,也很耗时。在元素很少的情况下的表现不如链表。

一般的HashMap的时间复杂度用平均时间复杂度来分析。除了极端情况链表对HashMap整体时间复杂度的表现影响比较小。

7.6、为什么树转链表和链表转树阈值不同

其实上文中已经介绍了,因为复杂度震荡。详情请参考上文。

7.7、Capacity的计算

变相问一下这个问题就是当初始化hashMap时initialCapacity参数传的是18,HashMap的容量是什么?是32。

     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;
}

该方法大意:如果cap不是2的n次方则取大于cap的最小的2的n次方的值。当然这个值不能超过MAXIMUM_CAPACITY 。

(这里对这个方法没怎么看懂,明白的大神们回应在留言区指教。)

7.8、为什么默认的负载因子loadFactor = 0.75

      * Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million

源码中有这么一段注释,重点就是  Poisson distribution 泊松分布。

以上是桶中元素个数和出现的概率对照表。

意思就是说当负载因子为0.75的时候,桶中元素个数为8的概率几乎为零。

通过泊松分布来看,0.75是"空间利用率"和"时间复杂度"之间的折衷。关于这个请参考《为什么默认的负载因子是0.75》

7.9、HashMap中为什么用位运算而不是取模运算

主要是位运算在底层计算速度更快。

简单证明一下

 long s1 = System.nanoTime();
System.out.println(2147483640 % 16);//
long e1 = System.nanoTime();
long s2 = System.nanoTime();
System.out.println(2147483640 & 15);//
long e2 = System.nanoTime();
System.out.println("取模时间:" + (e1 - s1));//取模时间:134200
System.out.println("与运算时间:" + (e2 - s2));//与运算时间:15800

题外话:还有一个刷leetcode题,二分法计算中心点。总结的经验,用除法会导致部分算法题超时。

 long s1 = System.nanoTime();
System.out.println(1 + (2147483640 - 1) / 2);//
long e1 = System.nanoTime();
long s2 = System.nanoTime();
System.out.println(1 + (2147483640 - 1) >> 1);//
long e2 = System.nanoTime();
System.out.println("除法时间:" + (e1 - s1));//除法时间:20100
System.out.println("位运算时间:" + (e2 - s2));//位运算时间:15700

注意:一般二分法用left + (right - left)/2;因为如果用(right+left)/2;right + left容易>Integer.MAX_VALUE;

  如有错误的地方还请留言指正。
  原创不易,转载请注明原文地址: https://www.cnblogs.com/hello-shf/p/12168181.html

  参考文献:

  https://www.jianshu.com/p/003256ce41ce

  https://www.cnblogs.com/morethink/p/7762168.html

HashMap 源码赏析 JDK8的更多相关文章

  1. 源码分析(一) HashMap 源码分析|JDK8

    HashMap是一个普遍应用于各大JAVA平台的最最最常用的数据结构.<K,V>的存储形式使HashMap备受广大java程序员的喜欢.JDK8中HashMap发生了很大的变化,例如:之前 ...

  2. java HashMap源码分析(JDK8)

    这两天在复习JAVA的知识点,想更深层次的了解一下JAVA,所以就看了看JAVA的源码,把自己的分析写在这里,也当做是笔记吧,方便记忆.写的不对的地方也请大家多多指教. JDK1.6中HashMap采 ...

  3. HashMap源码解析JDK8

    一.HashMap基础 1.1 HashMap的定义 我们先看一下HashMap的定义: public class HashMap<K,V> extends AbstractMap< ...

  4. Java HashMap源码分析(含散列表、红黑树、扰动函数等重点问题分析)

    写在最前面 这个项目是从20年末就立好的 flag,经过几年的学习,回过头再去看很多知识点又有新的理解.所以趁着找实习的准备,结合以前的学习储备,创建一个主要针对应届生和初学者的 Java 开源知识项 ...

  5. JDK8中的HashMap源码

    背景 很久以前看过源码,但是猛一看总感觉挺难的,很少看下去.当时总感觉是水平不到.工作中也遇到一些想看源码的地方,但是遇到写的复杂些的心里就打退堂鼓了. 最近在接手同事的代码时,有一些很长的pytho ...

  6. 【JAVA集合】HashMap源码分析(转载)

    原文出处:http://www.cnblogs.com/chenpi/p/5280304.html 以下内容基于jdk1.7.0_79源码: 什么是HashMap 基于哈希表的一个Map接口实现,存储 ...

  7. HashMap源码解读(转)

    http://www.360doc.com/content/10/1214/22/573136_78188909.shtml 最近朋友推荐的一个很好的工作,又是面了2轮没通过,已经是好几次朋友内推没过 ...

  8. 自学Java HashMap源码

    自学Java HashMap源码 参考:http://zhangshixi.iteye.com/blog/672697 HashMap概述 HashMap是基于哈希表的Map接口的非同步实现.此实现提 ...

  9. 【Java深入研究】9、HashMap源码解析(jdk 1.8)

    一.HashMap概述 HashMap是常用的Java集合之一,是基于哈希表的Map接口的实现.与HashTable主要区别为不支持同步和允许null作为key和value.由于HashMap不是线程 ...

随机推荐

  1. uva 562 Dividing coins(01背包)

      Dividing coins  It's commonly known that the Dutch have invented copper-wire. Two Dutch men were f ...

  2. 阿里巴巴Java编程规范考试

    阿里巴巴Java编程规范考试 今天在阿里云官网把阿里巴巴Java编程规范认证考试考过了, 写下这篇文章总结一下考试中需要注意的知识点, 主体内容还是要直接看规范: 编程规约 异常日志 单元测试 安全规 ...

  3. UVa 10603 Fill [暴力枚举、路径搜索]

    10603 Fill There are three jugs with a volume of a, b and c liters. (a, b, and c are positive intege ...

  4. H3C端口状态迁移

  5. H3C在设备上使用TFTP服务

  6. 开包即食的教程带你浅尝最新开源的C# Web引擎 Blazor

    在今年年初, 恰逢新春佳节临近的时候. 微软给全球的C#开发者们, 着实的送上了一分惊喜. 微软正式开源Blazor ,将.NET带回到浏览器. 这个小惊喜, 迅速的在dotnet开发者中间传开了. ...

  7. Cmder安装与使用

    越来越多人使用Cmder代替Windows的cmd(毕竟其界面太Lower了),但是每次用Cmder都要回到安装目录查找之后才能使用,真的很麻烦,有木有可以像Git一样右键就是可以用的方法呢?答案当然 ...

  8. FCKeditor使用

    fckeditor - (1)资料介绍与安装 fckeditor介绍  FCKeditor是一个专门使用在网页上属于开放源代码的所见即所得文字编辑器.  1.fckeditor官网:http://ww ...

  9. python知识点总结01(不定时更新)

    手写一个完整的装饰器模版 # 用于修复被装饰对象的名称空间 from functools import wrape def wrapper(func): @wraps(func) def inner( ...

  10. 一次面试题,将 字符串 保存在 Byte 数组中

    最近在面试,遇到一个面试题 字符串 String str = "AD428C93DE" 编程实现把 str 的内容放到 Byte[6] b 的数组中,存入后并能恢复原来的字符串. ...