1、初始化过程(1.7和1.8的区别)
HashMap的容量是有上限的,必须小于1<<30
1、HashMap的初始化过程(1.7和1.8的区别)
HashMap的初始容量为16
在JDK 1.7和JDK 1.8中,HashMap初始化这个容量的时机不同。JDK 1.8中,在调用HashMap的构造函数定义HashMap的时候,就会进行容量的设定。而在JDK 1.7中,要等到第一次put操作时才进行这一操作。
1.1 初始化
/**
* 根据初始化容量和负载因子构建一个空的HashMap.
*/
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;
//注意此处的tableSizeFor方法
this.threshold = tableSizeFor(initialCapacity);
}
/**
* 使用初始化容量和默认加载因子(0.75).
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 使用默认初始化大小(16)和默认加载因子(0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/**
* 用已有的Map构造一个新的HashMap.
*/
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
**通过重载方法HashMap传入两个参数:1. 初始化容量;2. 装载因子。介绍下几个名词:
capacity:表示的是hashmap中桶的数量,初始化容量initCapacity为16,第一次扩容会扩到64,之后每次扩容都是之前容量的2倍,所以容量每次都是2的次幂。
loadFactor:装载因子,衡量hashmap一个满的程度,初始化为0.75
threshold:hashmap扩容的一个阈值标准,每当size大于这个阈值时就会进行扩容操作,threeshold等于capacity*loadfactor
tableSizeFor()方法
这个方法被调用的地方在上面构造函数中,当传入一个初始容量时,会调用this.threshold = tableSizeFor(initialCapacity);
计算扩容阈值。那它是究竟干了什么的呢?
tableSizeFor的功能(不考虑大于最大容量的情况)是返回大于输入参数且最近的2的整数次幂的数。比如10,则返回16
当在实例化HashMap实例时,如果给定了initialCapacity,由于HashMap的容量capacity都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)。**
1.2 扩容机制
**JDK7中的扩容机制
JDK7的扩容机制相对简单,有以下特性:
空参数的构造函数:以默认容量、默认负载因子、默认阈值初始化数组。内部数组是空数组。
有参构造函数:根据参数确定容量、负载因子、阈值等。
第一次put时会初始化数组,其容量变为不小于指定容量的2的幂数。然后根据负载因子确定阈值。
如果不是第一次扩容,则 [公式] , [公式] 。
JDK8的扩容机制
JDK8的扩容做了许多调整。
HashMap的容量变化通常存在以下几种情况:
空参数的构造函数:实例化的HashMap默认内部数组是null,即没有实例化。第一次调用put方法时,则会开始第一次初始化扩容,长度为16。
有参构造函数:用于指定容量。会根据指定的正整数找到不小于指定容量的2的幂数,将这个数设置赋值给阈值(threshold)。第一次调用put方法时,会将阈值赋值给容量,然后让 [公式] 。
(因此并不是我们手动指定了容量就一定不会触发扩容,超过阈值后一样会扩容!!)
如果不是第一次扩容,则容量变为原来的2倍,阈值也变为原来的2倍。(容量和阈值都变为原来的2倍时,负载因子还是不变)
此外还有几个细节需要注意:
首次put时,先会触发扩容(算是初始化),然后存入数据,然后判断是否需要扩容;
不是首次put,则不再初始化,直接存入数据,然后判断是否需要扩容;**
2、put的过程(1.7和1.8的区别)
HashMap put的过程(1.7和1.8的区别)
1.7-put
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return =;
int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);
int i = indexFor(hash, table.length);
for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
**1、表的初始化:我们刚在构造方法中,并没有对table进行初始化,所以inflateTable方法会被执行;**
private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);
float thresholdFloat = capacity * loadFactor;
if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
thresholdFloat = MAXIMUM_CAPACITY + 1;
}
threshold = (int) thresholdFloat;
table = new HashMapEntry[capacity];
}
private static int roundUpToPowerOf2(int number) {
int rounded = number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (rounded = Integer.highestOneBit(number)) != 0
? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
: 1;
return rounded;
}
**roundUpToPowerOf2方法的作用是用来返回大于等于最接近number的2的冪数,最后对table进行初始化。**
**2、根据key存放数据:这里分 “key为null” 和 “key不为null” 两种情况处理。
2.1、key为null,此种情况将会调用putForNullKey方法**
private V putForNullKey(V value) {
for (HashMapEntry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
**首先对数组table从头到尾遍历,当找到有key为null的地方,就将旧值替换为新值,并返回旧值。
否则,计数器modCount加1,调用addEntry方法,并返回null。**
**2.2、key不为null
根据 key 计算出 hashcode,根据计算出的 hashcode 定位数据存放的位置,
bucketIndex是由 key和表的长度共同决定的,在addEntry方法中计算得到
bucketIndex = indexFor(hash, table.length);
首先会根据indexFor(hash, table.length)生成的bucketIndex去table中查找是否存在相同bucketIndex的value,
如果有,说明有哈希碰撞,如果桶是一个链表则需要遍历判断里面的hashcode、key是否和传入key相等,如果相等则进行覆盖,并返回原来的值。
否则,计数器modCount加1,调用addEntry方法,并返回null。
两种情况最终都指向了addEntry方法**
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? sun.misc.Hashing.singleWordWangJenkinsHash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
**首先判断table是否需要扩容。如果需要扩容,则执行resize方法,传入的参数为现有table长度的两倍**
void resize(int newCapacity) {
HashMapEntry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
HashMapEntry[] newTable = new HashMapEntry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
**resize方法中,如果表容量已经达到最大值,则直接返回Integer.MAX_VALUE。否则根据新的容量值创建新表,并执行数据迁移方法transfer**
void transfer(HashMapEntry[] newTable) {
int newCapacity = newTable.length;
for (HashMapEntry<K,V> e : table) {
while(null != e) {
HashMapEntry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
**transfer方法的作用就是将老表的数据全部迁移到新表中。
transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和Jdk1.8有区别**
void createEntry(int hash, K key, V value, int bucketIndex) {
HashMapEntry<K,V> e = table[bucketIndex];
table[bucketIndex] = new HashMapEntry<>(hash, key, value, e);
size++;
}
**最后将数据添加到table的bucketIndex位置,并将size加1,桶是空的,说明当前位置没有数据存入;
新增一个entry对象写入当前位置**
1.8-put
**①. 判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容,初始容量是16;
②. 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,
如果table[i]不为空,转向③;
③. 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,
这里的相同指的是hashCode以及equals;
④. 判断table[i] 是否为TreeNode,即table[i] 是否是红黑树,如果是红黑树,遍历发现该key不存在
则直接在树中插入键值对;遍历发现key已经存在直接覆盖value即可;
⑤. 如果table[i] 不是TreeNode则是链表节点,遍历发现该key不存在,则先添加在链表结尾,
判断链表长度是否大于8,大于8的话把链表转换为红黑树;遍历发现key已经存在直接覆盖value即可;
⑥. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容**
默认容量 - DEFAULT_INITIAL_CAPACITY :默认初始化的容量为16,必须是2的幂。
最大容量 - MAXIMUM_CAPACITY:最大容量是2^30
装载因子 - DEFAULT_LOAD_FACTOR:默认的装载因子是0.75,用于判断是否需要扩容
链表转换成树的阈值 - TREEIFY_THRESHOLD:一个桶中Entry(或称为Node)的存储方式由链表转换成树的阈值。即当桶中Entry的数量超过此值时使用红黑树来代替链表。默认值是8
树转还原成链表的阈值 - UNTREEIFY_THRESHOLD:当执行resize操作时,当桶中Entry的数量少于此值时使用链表来代替树。默认值是6
最小树形化容量 - MIN_TREEIFY_CAPACITY:当哈希表中的容量大于这个值时,表中的桶才能进行树形化。否则桶内元素太多时会扩容,而不是树形化。为了避免进行扩容、树形化选择的冲突,这个值不能小于4 * TREEIFY_THRESHOLD
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为空,初始化一个Table**
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
**//步骤②:如果该bucket位置没值,则直接存储到该bucket位置**
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e;
K k;
**//步骤③:如果节点key存在,直接覆盖value**
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
**//步骤④:如果该bucket位置数据是TreeNode类型,则将新数据添加到红黑树中。**
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);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash); **//如果链表个数达到8个时,将链表修改为红黑树结构**
break;
}
**// key已经存在直接覆盖value**
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;
**//步骤⑥:存储的数目超过最大容量阈值,就扩容**
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
**①. 判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容,初始容量是16;
②. 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③. 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④. 判断table[i] 是否为TreeNode,即table[i] 是否是红黑树,如果是红黑树,遍历发现该key不存在 则直接在树中插入键值对;遍历发现key已经存在直接覆盖value即可;
⑤. 如果table[i] 不是TreeNode则是链表节点,遍历发现该key不存在,则先添加在链表结尾, 判断链表长度是否大于8,大于8的话把链表转换为红黑树;遍历发现key已经存在直接覆盖value即可;
⑥. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。**
**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;
}
**// 没超过最大值,就扩充为原来的2倍**
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);
}
**// 设置新的resize上限**
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) {
**// 把每个bucket都移动到新的buckets中**
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) **//如果该桶只有一个数据,则散列到当前位置或者(原位置+oldCap)位置**
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //红黑树重构
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { **// 链表优化重hash的代码块**
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 { **// 原索引+oldCap**
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
**// 原索引放到bucket里**
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
**// 原索引+oldCap放到bucket里**
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
**下面举个例子说明下扩容过程。
假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当Entry的实际数量size 大于桶table的实际数量时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
在JDK1.8中我们可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:**
**树形化方法treeifyBin()
在Java 8 中,如果一个桶中的链表元素个数超过 TREEIFY_THRESHOLD(默认是 8 ),就使用红黑树来替换链表,从而提高速度。
这个替换的方法叫 treeifyBin() 即树形化。**
/将桶内所有的 链表节点 替换成 红黑树节点
final void treeifyBin(Node[] tab, int hash) {
int n, index; Node e;
//如果当前哈希表为空,或者哈希表中Entry元素总数量小于进行树形化的阈值(默认为 64),就去新建/扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//如果哈希表中的元素个数超过了树形化阈值,进行树形化
// e 是哈希表中指定位置桶里的链表节点,从第一个开始
TreeNode hd = null, tl = null; //红黑树的头、尾节点
do {
//新建一个树形节点,内容和当前链表节点 e 一致
TreeNode 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);
}
}
TreeNode replacementTreeNode(Node p, Node next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
**根据哈希表中元素个数确定是扩容还是树形化
如果是树形化历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系,然后让桶第一个元素指向新建的树头结点,替换桶的链表内容为树形内容
但是我们发现,之前的操作并没有设置红黑树的颜色值,现在得到的只能算是个二叉树。在 最后调用树形root节点 hd.treeify(tab)方法进行塑造红黑树**
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) { //第一次进入循环,确定root根结点,为黑色
x.parent = null;
x.red = false;
root = x;
}
else { //非第一次进入循环,x指向树中的某个节点
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//从根节点开始,遍历所有节点跟当前节点 x 比较,调整位置
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h) //当比较节点p的哈希值比 x 大时, dir为-1
dir = -1;
else if (ph < h) //哈希值比 x 小时,dir为1
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
//把当前节点p变成 x 的父亲
TreeNode<K,V> xp = p;
//如果当前比较节点p的哈希值比 x 大,x 就是左孩子,否则 x 是右孩子
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
//平衡操作
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
**可以看到,将二叉树变为红黑树时,需要保证有序。这里有个双重循环,拿树中的所有节点和当前节点的哈希值进行对比(如果哈希值相等,就对比键,这里不用完全有序),然后根据比较结果确定在树中的位置。
HashMap 在 JDK 1.8 中新增的操作: 红黑树中添加元素 putTreeVal()
在添加时,如果一个桶中已经是红黑树结构,就要调用红黑树的添加元素方法 putTreeVal()。**
final TreeNode putTreeVal(HashMap map, Node[] tab,
int h, K k, V v) {
Class kc = null;
boolean searched = false;
TreeNode root = (parent != null) ? root() : this;
//每次添加元素时,从根节点遍历,对比哈希值
for (TreeNode p = root;;) {
int dir, ph; K pk;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
//如果当前节点的哈希值、键和要添加的都一致,就返回当前节点(奇怪,不对比值吗?)
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
//如果当前节点和要添加的节点哈希值相等,但是两个节点的键不是一个类,只好去挨个对比左右孩子
if (!searched) {
TreeNode q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
//如果从 ch 所在子树中可以找到要添加的节点,就直接返回
return q;
}
//哈希值相等,但键无法比较,只好通过特殊的方法给个结果
dir = tieBreakOrder(k, pk);
}
//经过前面的计算,得到了当前节点和要插入节点的一个大小关系
//要插入的节点比当前节点小就插到左子树,大就插到右子树
TreeNode xp = p;
//这里有个判断,如果当前节点还没有左孩子或者右孩子时才能插入,否则就进入下一轮循环
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node xpn = xp.next;
TreeNode x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode)xpn).prev = x;
//红黑树中,插入元素后必要的平衡调整操作
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
//这个方法用于 a 和 b 哈希值相同但是无法比较时,直接根据两个引用的地址进行比较
//这里源码注释也说了,这个树里不要求完全有序,只要插入时使用相同的规则保持平衡即可
static int tieBreakOrder(Object a, Object b) {
int d;
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
**通过上面的代码可以知道,HashMap 中往红黑树中添加一个新节点 n 时,有以下操作:
从根节点开始遍历当前红黑树中的元素 p,对比 n 和 p 的哈希值;
如果哈希值相等并且键也相等,就判断为已经有这个元素(这里不清楚为什么不对比值);
如果哈希值就通过其他信息,比如引用地址来给个大概比较结果,这里可以看到红黑树的比较并不是很准确,注释里也说了,只是保证个相对平衡即可;
最后得到哈希值比较结果后,如果当前节点 p 还没有左孩子或者右孩子时才能插入,否则就进入下一轮循环;
插入元素后还需要进行红黑树例行的平衡调整,还有确保根节点的领先地位。**
JDK7的元素迁移
JDK7中,HashMap的内部数据保存的都是链表。因此逻辑相对简单:在准备好新的数组后,map会遍历数组的每个“桶”,然后遍历桶中的每个Entity,重新计算其hash值(也有可能不计算),找到新数组中的对应位置,以头插法插入新的链表。
这里有几个注意点:
是否要重新计算hash值的条件这里不深入讨论,读者可自行查阅源码。
因为是头插法,因此新旧链表的元素位置会发生转置现象。
元素迁移的过程中在多线程情境下有可能会触发死循环(无限进行链表反转)。
JDK8的元素迁移
JDK8则因为巧妙的设计,性能有了大大的提升:由于数组的容量是以2的幂次方扩容的,那么一个Entity在扩容时,新的位置要么在原位置,要么在原长度+原位置的位置。原因如下图:
数组长度变为原来的2倍,表现在二进制上就是多了一个高位参与数组下标确定。此时,一个元素通过hash转换坐标的方法计算后,恰好出现一个现象:最高位是0则坐标不变,最高位是1则坐标变为“10000+原坐标”,即“原长度+原坐标”。如下图:
因此,在扩容时,不需要重新计算元素的hash了,只需要判断最高位是1还是0就好了。
JDK8的HashMap还有以下细节:
JDK8在迁移元素时是正序的,不会出现链表转置的发生。
如果桶大于64,并且桶内的元素超过8个,则会将链表转化成红黑树,如果加快数据查询效率。
hashmap1.8扩容会在当前数据元素个数大于阈值时进行扩容,或者当有链表长度大于等于8要变换为红黑树但哈希表长度小于64时进行扩容,扩容也是将新的哈希表长度设置为原来的2倍,再进行转移
3、get的过程(1.7和1.8的区别)
HashMap get的过程(1.7和1.8的区别)
1.7-get
**1、如果key是null,调用getForNullKey方法,遍历table,是否有null的key,如果有返回value,否则返回null。
2、getEntry方法,首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
3、判断该位置是否为链表,不是链表就根据 key、key 的 hashcode 是否相等来返回值。
为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。啥都没取到就直接返回 null 。**
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K, V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
for(AntiCollisionHashMap.Entry e = this.table[0]; e != null; e = e.next) {
if (e.key == null) {
return e.value;
}
}
return null;
}
final Entry<K, V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
**1、如果key是null,调用getForNullKey方法,遍历table,是否有null的key,如果有返回value,否则返回null。
2、getEntry方法,首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
3、判断该位置是否为链表,不是链表就根据 key、key 的 hashcode 是否相等来返回值。
为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。啥都没取到就直接返回 null 。**
1.8-get
1、首先将 key hash 之后取得所定位的桶。
2、如果桶为空则直接返回 null 。
3、否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
4、如果第一个不匹配,则判断它的下一个是红黑树还是链表。
5、红黑树就按照树的查找方式返回值。
6、不然就按照链表的方式遍历匹配返回值
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
这个 getNode() 方法就是根据哈希表元素个数与哈希值求模(使用的公式是 (n - 1) &hash)得到 key 所在的桶的头结点,
如果头节点恰好是红黑树节点,就调用红黑树节点的 getTreeNode() 方法,否则就遍历链表节点。
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; //Table桶
Node<K,V> first, e;
int n;
K k;
//table数组不为空且length大于0,并且key的hash对应的桶第一个元素不为空时,才去get
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//首先判断是不是key的hash对应的桶中的第一个元素
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;
}
1、首先将 key hash 之后取得所定位的桶。
2、如果桶为空则直接返回 null 。
3、否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
4、如果第一个不匹配,则判断它的下一个是红黑树还是链表。
5、红黑树就按照树的查找方式返回值。
6、不然就按照链表的方式遍历匹配返回值
**getTreeNode 方法使通过调用树形节点的 find() 方法进行查找:**
//从根节点根据 哈希值和 key 进行查找
final TreeNode find(int h, Object k, Class kc) {
TreeNode p = this;
do {
int ph, dir; K pk;
TreeNode pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
**由于之前添加时已经保证这个树是有序的,因此查找时基本就是折半查找,效率很高。
这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断 key 是否相等,相等就直接返回(也没有判断值哎);
不相等就从子树中递归查找**
HashMap 在 JDK 1.8 中新增的操作: 树形结构修剪 split()
HashMap 中, resize() 方法的作用就是初始化或者扩容哈希表。当扩容时,如果当前桶中元素结构是红黑树,并且元素个数小于链表还原阈值 UNTREEIFY_THRESHOLD (默认为 6),就会把桶中的树形结构缩小或者直接还原(切分)为链表结构,调用的就是 split():
//参数介绍
//tab 表示保存桶头结点的哈希表
//index 表示从哪个位置开始修剪
//bit 要修剪的位数(哈希值)
final void split(HashMap map, Node[] tab, int index, int bit) {
TreeNode b = this;
// Relink into lo and hi lists, preserving order
TreeNode loHead = null, loTail = null;
TreeNode hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode e = b, next; e != null; e = next) {
next = (TreeNode)e.next;
e.next = null;
//如果当前节点哈希值的最后一位等于要修剪的 bit 值
if ((e.hash & bit) == 0) {
//就把当前节点放到 lXXX 树中
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
//然后 loTail 记录 e
loTail = e;
//记录 lXXX 树的节点数量
++lc;
}
else { //如果当前节点哈希值最后一位不是要修剪的
//就把当前节点放到 hXXX 树中
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
//记录 hXXX 树的节点数量
++hc;
}
}
if (loHead != null) {
//如果 lXXX 树的数量小于 6,就把 lXXX 树的枝枝叶叶都置为空,变成一个单节点
//然后让这个桶中,要还原索引位置开始往后的结点都变成还原成链表的 lXXX 节点
//这一段元素以后就是一个链表结构
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
//否则让索引位置的结点指向 lXXX 树,这个树被修剪过,元素少了
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
//同理,让 指定位置 index + bit 之后的元素
//指向 hXXX 还原成链表或者修剪过的树
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
从上述代码可以看到,HashMap 扩容时对红黑树节点的修剪主要分两部分,先分类、再根据元素个数决定是还原成链表还是精简一下元素仍保留红黑树结构。
1.分类
指定位置、指定范围,让指定位置中的元素 (hash & bit) == 0 的,放到 lXXX 树中,不相等的放到 hXXX 树中。
2.根据元素个数决定处理情况
符合要求的元素(即 lXXX 树),在元素个数小于 6 时还原成链表,最后让哈希表中修剪的痛 tab[index] 指向 lXXX 树;在元素个数大于 6 时,还是用红黑树,只不过是修剪了下枝叶;
不符合要求的元素(即 hXXX 树)也是一样的操作,只不过最后它是放在了修剪范围外 tab[index + bit]。
4、1.8-hash的过程
1.8-hash()方法
//java 8中的散列值优化函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//java 7中的散列函数
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
这段代码叫“扰动函数”。大家都知道上面代码里的**key.hashCode()**函数调用的是key键值类型自带的哈希函数,返回int型散列值。
理论上散列值是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围从**-2147483648到2147483648**。前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。JDK1.8源码中模运算是这么完成的:i = (length - 1) & hash,而在JDK1.7中是在**indexFor( )**函数里完成的。
bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) {
return h & (length-1);
}
indexFor()的代码也很简单,就是把散列值和数组长度做一个“与”操作,就定位出了Key对应的桶,这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是位运算&比取模运算%具有更高的效率。
这也正好解释了为什么HashMap的数组长度要取2的整次幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
5、1.8-remove
1.8-remove
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; //所有的桶
Node<K,V> p; //对应桶的第一个元素
int n, index; //桶数量,对应桶的次序
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e;
K k;
V v;
//要删除的元素如果刚好匹配该桶中的第一个元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//如果不是桶中的第一个元素,往下遍历
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//找到要删除的元素之后,删除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode) //红黑树中删除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) //是该桶中链表首节点删除
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
//清空所有元素
public void clear() {
Node<K,V>[] tab;
modCount++;
if ((tab = table) != null && size > 0) {
size = 0;
//仅清空桶数组的引用
for (int i = 0; i < tab.length; ++i)
tab[i] = null; // 把哈希数组中所有位置都赋为null
}
}
6、1.7头插法多线程put产生死循环
1.7头插法多线程put产生死循环
并发下的Rehash会造成元素之间会形成一个循环链表
头插过程代码
1、next=e.next
2、计算在新表的位置
3、e.next=new table[]
newTable[i]=e
4、e.next准备下一次循环
关键:
1、线程暂停的位置
2、内存可见性
3、注意两个新表
T1线程开始插入,next=e.next执行后暂停,T2线程正常执行完,k2->k1,T1线程继续执行,
由于内存可见性,此时,k1.next=k2,e=k2,T1继续执行k2.next=k1,e=k1,导致k1->k2->k1
这时候调用get()方法会获取值,产生死循环
手写的笔记,将就着看看,哈哈哈
7、为什么是红黑树
可以避免像二叉树的那样最差情况下是一个列表,深度很深,又可以避免像平衡二叉树那样每次插入进来树都会做很大的调整
链表→红黑树条件
1、链表size大于等于8
2、桶 length大于等于64
转化过程
第一个node作为根节点,第二个node的hash值与root之间作比较,小于等于放左边,大于放右边,如果不符合红黑树旋转。
那为什么阀值是8呢?
看出bug没?交点是6.64?交点分明是4,好么。
log4=2,4/2=2。
jdk作者选择8,一定经过了严格的运算,觉得在长度为8的时候,与其保证链表结构的查找开销,不如转换为红黑树,改为维持其平衡开销。
当链表转为红黑树后,什么时候退化为链表? 为6的时候退转为链表。中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
为什么链表的长度为8是变成红黑树?为什么为6时又变成链表? 随机hashCode算法下会遵循泊松分布,泊松分布中离散到链表长度为8的时候概率已经是千万分之一 中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表, 如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
8、为什么扩容是2的次幂
2.为什么扩容是2的次幂?
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;
这个算法实际就是取模,hash%length。
但是,大家都知道这种运算不如位移运算快。
因此,源码中做了优化hash&(length-1)。 也就是说hash%length==hash&(length-1)
那为什么是2的n次方呢?
因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。 例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。 而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了。 所以,保证容积是2的n次方,是为了保证在做(length-1)的时候,每一位都能&1 ,也就是和1111……1111111进行与运算。
9、元素迁移
JDK7的元素迁移
JDK7中,HashMap的内部数据保存的都是链表。因此逻辑相对简单:在准备好新的数组后,map会遍历数组的每个“桶”,然后遍历桶中的每个Entity,重新计算其hash值(也有可能不计算),找到新数组中的对应位置,以头插法插入新的链表。
这里有几个注意点:
是否要重新计算hash值的条件这里不深入讨论,读者可自行查阅源码。 因为是头插法,因此新旧链表的元素位置会发生转置现象。 元素迁移的过程中在多线程情境下有可能会触发死循环(无限进行链表反转)。
JDK8的元素迁移
JDK8则因为巧妙的设计,性能有了大大的提升:由于数组的容量是以2的幂次方扩容的,那么一个Entity在扩容时,新的位置要么在原位置,要么在原长度+原位置的位置。原因如下图:
数组长度变为原来的2倍,表现在二进制上就是多了一个高位参与数组下标确定。此时,一个元素通过hash转换坐标的方法计算后,恰好出现一个现象:最高位是0则坐标不变,最高位是1则坐标变为“10000+原坐标”,即“原长度+原坐标”。如下图:
因此,在扩容时,不需要重新计算元素的hash了,只需要判断最高位是1还是0就好了。
JDK8的HashMap还有以下细节:
JDK8在迁移元素时是正序的,不会出现链表转置的发生。 如果桶大于64,并且桶内的元素超过8个,则会将链表转化成红黑树,如果加快数据查询效率。 hashmap1.8扩容会在当前数据元素个数大于阈值时进行扩容,或者当有链表长度大于等于8要变换为红黑树但哈希表长度小于64时进行扩容,扩容也是将新的哈希表长度设置为原来的2倍,再进行转移
10、HashMap中初始化大小为什么是16?
HashMap中初始化大小为什么是16? 1、能保证计算后的index既可以是奇数也可以是偶数,减少hash碰撞(hahmap每次扩容都是以 2的整数次幂进行扩容) 2、提高map查询效率 3、分配过小防止频繁扩容 4、分配过大浪费资源
影响查找效率的因素主要有这几种:
- 散列函数是否可以将哈希表中的数据均匀地散列?
- 怎么处理冲突?
- 哈希表的加载因子怎么选择?
11、为什么加载因子一定是0.75?
从上文我们知道,HashMap的底层其实也是哈希表(散列表),而解决冲突的方式是链地址法。HashMap的初始容量大小默认是16,为了减少冲突发生的概率,当HashMap的数组长度到达一个临界值的时候,就会触发扩容,把所有元素rehash之后再放在扩容后的容器中,这是一个相当耗时的操作。
而这个临界值就是由加载因子和当前容器的容量大小来确定的:
临界值 = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR
即默认情况下是16x0.75=12时,就会触发扩容操作。
那么为什么选择了0.75作为HashMap的加载因子呢?这个跟一个统计学里很重要的原理——泊松分布有关。
泊松分布是统计学和概率学常见的离散概率分布,适用于描述单位时间内随机事件发生的次数的概率分布。

等号的左边,P 表示概率,N表示某种函数关系,t 表示时间,n 表示数量。等号的右边,λ 表示事件的频率。
在HashMap的源码中有这么一段注释:
- **
** 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
在理想情况下,使用随机哈希码,在扩容阈值(加载因子)为0.75的情况下,节点出现在频率在Hash桶(表)中遵循参数平均为0.5的泊松分布。忽略方差,即X = λt,P(λt = k),其中λt = 0.5的情况,按公式:

计算结果如上述的列表所示,当一个bin中的链表长度达到8个元素的时候,概率为0.00000006,几乎是一个不可能事件。
所以我们可以知道,其实常数0.5是作为参数代入泊松分布来计算的,而加载因子0.75是作为一个条件,当HashMap长度为length/size ≥ 0.75时就扩容,在这个条件下,冲突后的拉链长度和概率结果为:
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
为什么不可以是0.8或者0.6呢?
HashMap中除了哈希算法之外,有两个参数影响了性能:初始容量和加载因子。初始容量是哈希表在创建时的容量,加载因子是哈希表在其容量自动扩容之前可以达到多满的一种度量。
在维基百科来描述加载因子:
对于开放定址法,加载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了加载因子为0.75,超过此值将resize散列表。
在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少扩容rehash操作次数,所以,一般在使用HashMap时建议根据预估值设置初始容量,以便减少扩容操作。
选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择。
12、为什么要先高16位异或低16位再取模运算?
hashmap这么做,只是为了降低hash冲突的几率。
打个比方,当我们的length为16的时候,哈希码(字符串“abcabcabcabcabc”的key对应的哈希码)对(16-1)与操作,对于多个key生成的hashCode,只要哈希码的后4位为0,不论不论高位怎么变化,最终的结果均为0。
而加上高16位异或低16位的“扰动函数”后,结果如下 可以看到: 扰动函数优化前:1954974080 % 16 = 1954974080 & (16 - 1) = 0 扰动函数优化后:1955003654 % 16 = 1955003654 & (16 - 1) = 6 很显然,减少了碰撞的几率。