HashMap简单讲解

JDK1.7 HashMap

JDK1.8 HashMap

基本信息描述

JDK1.7采用 数组+链表 的数据结构
JDK1.8的HashMap采用 数组+链表+红黑树 的数据结构。新增加红黑树的操作,是为了解决某些情况下,链表过长导致的查询效率问题。链表查询数据的时间复杂度为O(n),红黑树的时间复杂度为Olog(n).当数据量多的时候,红黑树的查询效率明显高于链表
数据结构如下:
JDK1.8HashMap数据结构

基本字段属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75

public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {

private static final long serialVersionUID = 362498820763181265L;

/**
* 集合默认的容量是16,大小必须是2的幂次方
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
* 集合的最大容量。初始化或者扩容时,防止溢出,必须是2次幂 <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
* 构造函数中未指定负载因子时,使用该值,默认是0.75,不建议修改,该值经过大量计算得出.遵循泊松分布
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
* 桶的树化阈值 当链表长度>=8 时,将链表转换成红黑树
*/
static final int TREEIFY_THRESHOLD = 8;

/**
* 红黑树还原回链表的阈值。当红黑树节点数量<=6时,将红黑树转换为链表结构
* 基于时间和空间的考虑
*/
static final int UNTREEIFY_THRESHOLD = 6;

/**
* 桶树化的最小表容量: 当桶中的元素大于64时,且链表的容量>=TREEIFY_THRESHOLD 时,才可以把链表转换为红黑树
* 设置成64,是为了减少扩容的次数。当桶比较小的时候,桶中的元素达到一定的个数时,会频繁的扩容操作。浪费性能
* 也是时间和空间的一种考虑
*/
static final int MIN_TREEIFY_CAPACITY = 64;

/* ---------------- Fields -------------- */

/**
* 第一次使用时进行初始化操作。长度是2的幂
*/
transient Node<K,V>[] table;

/**
* 缓存字段
*/
transient Set<Map.Entry<K,V>> entrySet;

/**
* table中元素的个数
*/
transient int size;

/**
* hashmap改变的次数
*/
transient int modCount;

/**
* 库容的阈值,大小等于 = (capacity * load factor).
*
* @serial
*/
int threshold;

/**
* 负载因子,默认是0.75
*
* @serial
*/
final float loadFactor;

注意: TREEIFY_THRESHOLD=8UNTREEIFY_THRESHOLD=6 是链表转红黑树和红黑树退化成链表的阈值。因为桶中链表的数量在计算hash值的时候,遵循泊松分布。当链表的长度为8时的概率为 8: 0.00000006 而当链表的长度为6时的概率为 6: 0.00001316 概率差了1000多倍。为了避免频繁的树化和解除树化的操作

桶中元素结构体

1
2
3
4
5
6
7
8
9
10
11
12
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // hash值
final K key; // key值
V value; // 桶中元素的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;
}

链表树化结构体

1
2
3
4
5
6
7
8
9
10
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);
}
}

构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
*
* @param initialCapacity 初始容量
* @param loadFactor 负载因子
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 如果初始容量大于最大容量2^30,赋值为最大容量。防止溢出
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// threshold hashmap扩容阈值,注意这个值会发生变化,如果initialCapacity是2的幂,返回原值。如果不是2的幂,返回大于该值的最小的2的幂次方
this.threshold = tableSizeFor(initialCapacity);
}

/**
* 指定集合容量大小,默认负载因子是0.75
*/
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的时候就应该指定它的容量,防止HashMap自动扩容,影响使用效率。 initialCapacity = (需要存储的元素个数 / 负载因子) + 1

常用方法

确定桶的位置

1
tab[i = (n - 1) & hash

hash方法

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

计算 key.hashCode() 并将散列的较高位(异或)传播到较低位。这个怎么理解呢?
这个哈希方法首先计算出key的hashCode,然后赋值给h,然后与h无符号右移16位后的二进制进行按位异或得到最后的hash值(hashCode()计算方法,得出的是一个int类型数,转换为二进制也就是32位)。hash值计算完成后,需要用来计算元素在桶中的位置,计算公式:tab[i = (n - 1) & hash 如果当桶很小时,假设是默认的16的话,这样的值和hashCode()直接做按位与操作,实际上只使用了哈希值的后4位。如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了,所以这里把高低位都利用起来,从而解决了这个问题。也是一种为了降低hash冲突的优化手段。举个例子如下:

  • & (按位与运算) : 运算规则:相同的二进制数位上,都是1的时候,结果为1,否则为零。
  • ^ (按位异或运算) :运算规则:相同的二进制数位上,数字相同,结果为0,不同为1.

代码中通过这个hash & (n-1) 得到存储元素的位置,假设这里的h = key.hashCode()得到的值为,分别进行桶中元素位置计算,看看新老hash算法差异
key1 = 0000 0000 0001 0000 1111 1111 0000 0000
key2 = 0000 1111 1111 1111 1111 1111 0000 0000 高位变化较大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
使用h = key.hashCode()) ^ (h >>> 16 获取hash值
key1的计算如下:
0000 0000 0001 0000 1111 1111 0000 0000 h
0000 0000 0000 0000 0000 0000 0001 0000 h >>> 16
----------------------------------------------------------
0000 0000 0001 0000 1111 1111 0001 0000 按位异或运算后得到的hash值 h = key.hashCode()) ^ (h >>> 16)

此时数据长度n假设为默认的16,那么这个key存放在table中位置为i = (n - 1) & hash
0000 0000 0001 0000 1111 1111 0001 0000 hash
0000 0000 0000 0000 0000 0000 0000 1111 n-1 15 &运算
----------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0000 索引位置为0
key2的计算:
0000 1111 1111 1111 1111 1111 0000 0000 h
0000 0000 0000 0000 0000 1111 1111 1111 h >>> 16
-----------------------------------------------------------
0000 1111 1111 1111 1111 0000 1111 1111 按位异或运算后得到的hash值 h = key.hashCode()) ^ (h >>> 16)

0000 1111 1111 1111 1111 0000 1111 1111
0000 0000 0000 0000 0000 0000 0000 1111 n-1 15 &运算
------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 1111 索引位置是15

反例: (key.hashCode()) & (n-1)
key1:
0000 0000 0001 0000 1111 1111 0000 0000 hash值(重新获取一个hash值,直接进行按位与操作)
0000 0000 0000 0000 0000 0000 0000 1111 n-1 15 &运算
-------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0000 索引位置为0

key2:
0000 1111 1111 1111 1111 1111 0000 0000 hash值 高位变化较大的hash值,低位保持不变
0000 0000 0000 0000 0000 0000 0000 1111 n-1 15 &运算
---------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0000 索引位置为0

由此可见,同样的两个hashcode,经过移位和异或操作后,能够使hashcode更加复杂,同时也把哈希值的高位移动到了低位,降低了hash冲突的概率。实际上hashMap的hash算法做的非常好,进过我的测试,默认负载因子为0.75的时候如果把hashmap加入4000w个数据的时候。依然没有链表转红黑树的操作,都是不断扩容的操作.有兴趣的朋友,可以自己测试一下hash算法的性能。

扩容resize()

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
* 初始化或者扩容表的长度为2倍
* @return the table
*/
final Node<K,V>[] resize() {
// 旧表,第一次执行时,oldTabl为空
Node<K,V>[] oldTab = table;
// 旧表的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
// oldCap大于0说明是对数组进行扩容操作
if (oldCap > 0) {
// 如果扩容前旧表的容量大于阈值,就设置为Integer的最大值
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
// HashMap的构造方法中会对threshold进行初始化操作
newCap = oldThr;
else {
// zero initial threshold signifies using defaults
// 初始化新容器的大小,必须是2的幂,默认是16。表刚创建的时候,会执行到这里
newCap = DEFAULT_INITIAL_CAPACITY;
// 默认的阈值是12 负载因子0.75* 默认的初始化容量16
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);
}
// 更新table表扩容时的的阈值
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操作
if (e.next == null)
// 如果数组中只有一个元素,直接移动即可
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果是红黑树,需要对红黑树进行拆分
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order 维持相对顺序
// 链表节点的处理 head是头节点 tail是尾部节点
// loHead是低位链表 hiHead是高位链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 获取当前节点的下一个节点,每一次循环e值会更新
next = e.next;
// 遍历链表,将链表按照计算后的位置进行分组
// 没有再次hash的操作,而是按照扩容后新增加的那个bit是0还是1进行分组
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
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. 判断是初始化或者扩容操作,计算相应的新数组容量newCap 和 新的阈值大小newThr
2. 初始化新的数组容量newCap
3. 如果是扩容操作,还需要进行一个元素移动的操作。在移动的过程中,分为三种操作:
3.1 如果原数组中只有一个元素,并且next指针为空,直接移动即可 newTab[e.hash & (newCap - 1)] = e;
3.2 如果原数组中的元素是树节点。需要将元素进行拆分,然后映射存储位置
3.3 对于链表元素,需要将元素进行拆分。拆分为2个链表。可以理解为低位链表和高位链表,低位链表存储在扩容后的数组原来的位置,高位链表存储在扩容后数组原来的位置+旧数组容量的位置

get方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 根据key获取其所对应的value值
*/
public V get(Object key) {
Node<K,V> e;
// 计算key值的hashcode。目的是为了通过(n - 1) & hash 计算出当前key值在hash表中的位置
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

/**
* HashMap根据key值和和key的HashCode值查找元素
*
* @param hash 当前key的hash值
* @param key 当前key元素
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果数组中有元素同时根据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;
}

get方法整体上比较简单,就是根据key的hash值,查找元素在数组的位置,找到之后判断是链表还是红黑树。如果是红黑树,就从红黑树的节点中去寻找。如果是链表节点,就遍历链表去查找相应的key所对应的value的值

put方法

put方法相对复杂一些,其实也还好,如果熟悉红黑树这种数据结构的话,看起来也不是很复杂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
*
* @param key
* @param value
* @return
*/
public V put(K key, V value) {
// 通过高16位和低16位的异或运算的到一个hash值
return putVal(hash(key), key, value, false, true);
}

/**
* 真正的添加元素方法。尾插法
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value 是否进行元素的替换,用于插入相同key的时候,是否替换值,默认值为false,替换相应的值
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// hashmap的table表的初始化操作,是在这里进行的。第一次执行的时候,会先在这里进行初始化操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

// 通过hash值和数组长度进行按位与运算,得到元素的存储位置,如果table表的位置为空,就直接新建一个Node节点进行存储操作
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// key映射的数组桶位置有元素,也就是产生了hash碰撞(hashmap采用链地址法解决hash冲突。),走下述代码
Node<K,V> e; K k;

// 在该位置的第一个数据的key值和插入的元素的key值相等。需要进行下面的if (!onlyIfAbsent || oldValue == null) 的替换操作
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) {
// 遍历链表时,取下一个位置存放新增的元素,这里采用的是尾插法(链表中不包含要插入键值对节点)
// a.横竖都要遍历链表的长度是否大于树化的阈值,所以遍历的过程中,就直接插入元素了
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);

//如果链表的长度大于8个时,就进行链表转红黑树的操作
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}

// 如果在链表中找到了相同的key值。直接break操作。那么e节点此时就是与链表要插入的新值key相同的Node节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// key值存在,就替换value值。新插入的元素的value值,替换原来这个key的value值,注意onlyIfAbsent 这个值
// 这个值表示是否仅在oldValue==null 的时候,更新键值对的值。key相同会进行值覆盖
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}

// 增加修改次数
++modCount;

// 如果hashmap中元素的值超过了阈值,就会进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

put方法小结:
1.当桶中的元素为空值,通过扩容的方式进行初始化table
2.对key进行hash运算。得到需要存储在桶中的位置。如果桶中位置为空,直接在桶中放入元素即可。
3.如果桶中的元素不为空。需要先查找插入的key值,是否已经存在,如果已经存在,则需要使用新值替换旧值,
4.如果不存在,则将新插入的key值插入在链表的尾部或者红黑树中。其中插入到链表尾部的过程中还会判断链表的长度是否大于树化的阈值。如果大于阈值,就会进行链表转红黑树的操作
5.最后判断数组中的数量是否大于扩容阈值,如果大于,会进行数组的扩容操作

转换后的形态,可能如下图所示,我主要为了展示大体的数据结构,不要纠结于元素的值

链表树化

JDK1.8 对hashmap进行了改进。采用的数组+链表+红黑树的存储结构。链表转红黑树的操作,主要是为了防止由于hashcode算法性能不佳等原因,造成链表长度过长,查询缓慢的问题,我们元素多的情况下,红黑树是指数级的时间复杂度,在性能上远高于链表的时间复杂度。虽然会有一些额外的空间的消耗,但是时间上能大幅度提升。下面我将介绍 链表树化红黑树自平衡红黑树的基本性质

Node转红黑树节点TreeifyBin()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
替换指定哈希表的索引桶中所有的连接节点,除非表太小,否则将修改大小
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;

/*
* 如果元素数组为空 或者 数组长度小于 树结构化的最小阈值(MIN_TREEIFY_CAPACITY=64) ,就进行扩容操作.对于这个值可以理解为:如果元素数组长度小于这个值,没有必要去进行结构转换.目的是
* 如果数组很小,那么转红黑树,遍历效率要低一些,这时进行扩容操作,重新计算哈希值,链表的长度有可能就变短了。数据会放入到数组中,这样相对来说效率会高一些
*/
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果元素数组长度已经大于等于了 MIN_TREEIFY_CAPACITY,那么就有必要进行结构转换了

// 取出数组对应位置的头节点
else if ((e = tab[index = (n - 1) & hash]) != null) {
//定义几个变量,hd(head)代表头节点,tl代表尾节点(tail) 算法中常用变量
TreeNode<K,V> hd = null, tl = null;
do {
//先把普通Node节点转成TreeNode类型,并赋值给p
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);

//用新生成的双向链表替代旧的单向链表,其实就是把这个数组对应的位置重新赋值成新双向链表的首节点 hd是index对应的桶的首节点
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}

// For treeifyBin
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}

treeifyBin()方法总结

  • 链表转红黑树的条件:
    • 链表长度大于等于树化阈值 TREEIFY_THRESHOLD = 8
    • 数组长度大于等于MIN_TREEIFY_CAPACITY=64
  • 方法执行步骤:
    • 如果元素数组长度已经大于等于了 MIN_TREEIFY_CAPACITY,就进行结构转换,具体为Node转换为TreeNode节点。否则进行扩容操作
    • TreeNode节点间接继承自Node节点,所以TreeNode节点包含next引用。原链表顺序最终通过next引用被保存下来
    • 最后一行,调用treeify 将链表转换为红黑树
      转换后的元素结构,可能是这么一种形态

TreeNode转红黑树treeify()

下面就是链表转红黑树的核心逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
* 将树形链表转换为红黑树
*/
final void treeify(Node<K,V>[] tab) {
// 定义树的根节点
TreeNode<K,V> root = null;

// 遍历链表,x指向当前节点、next指向下一个节点
for (TreeNode<K,V> x = this, next; x != null; x = next) {
// 记录x的下一个节点
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;

// 如果根节点为空,则把当前节点当做根节点。根据红黑树性质,根节点一定为黑色。
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}

// 根节点已经存在的情况
else {
// 取得当前遍历的树形节点的 key 和 hash
K k = x.key;
int h = x.hash;
Class<?> kc = null;

// TODO : 循环开始位置
// 从根节点遍历。这一步主要就是为了判断当前节点应该在树的左侧还是右侧,为节点x找到空位置并插入元素
for (TreeNode<K,V> p = root;;) {
// dir代表方向(左边或者右边) ph表示树节点的hash值。
int dir, ph;
K pk = p.key;

// 比较hash值大小,判断当前节点插入到左边还是右边,并记录dir的值
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;

// 根据比较器判断大小 Comparable 接⼝判断
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
// 通过key的类名比较
dir = tieBreakOrder(k, pk);


// 保存当前遍历的树节点,就是x节点要插入的位置的父节点
TreeNode<K,V> xp = p;
// 判断如果dir<=0 说明当前节点的hash值小于当前树节点的hash值。需要把当前节点放置在当前树节点的左侧
// 判断如果dir>0 说明当前节点的hash值大于当前树节点的hash值。 需要把当前节点放置在当前树节点的右侧
// p的左右节点存在不为空的情况,p节点就是当前遍历的树节点,说明该节点还有子节点。继续循环查找当前节点x的应该在哪个爸爸节点下面插入元素
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// x 节点找到了父节点,并将x节点放入到父节点下面
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 元素插入之后,需要进行红黑树的自平衡操作,重新确定根节点的值
root = balanceInsertion(root, x);
break;
}
}
}
}

// 确保根节点作为第一个节点
moveRootToFront(tab, root);
}

treeify() 方法执行小结:

  • 遍历第⼀个节点时,此时红⿊树不存在,以第⼀个节点作为红⿊树根节点,并转换为黑节点
  • 有了红⿊树后,此后遍历双链表的每个节点时,都要根据红黑树性质从根节点开始寻找要插⼊当前节点的位置,也就是找到⼀个⽗节点,将当前节点作为其左节点或右节点。
  • 插⼊节点后,可能会导致红⿊树特性被破坏,因此每次插⼊节点后要尝试重新调整红⿊树
  • 红黑树调整完成后,要确保根节点就是table桶中的第一个节点

红黑树的自平衡

下面我将介绍红黑树的自平衡操作 这部分代码纯粹就是红黑树的操作了,根hashmap没有多个的关系,有一定数据结构基础的同学,看起来应该没什么难度,对于不了解红黑树的同学,我将在下面代码的下面讲解红黑树的基本操作,帮助你理解如下代码。或者你可以看看我的另一篇文章《红黑树的简单介绍》

 * 红黑树插入节点后,需要进行平衡操作
 *       情景1: 红黑树为空树时,将根节点染成黑色
 *       情景2: 插入的节点在红黑树已经存在,不需要处理
 *       情景3: 插入节点的父节点为黑色,因为所插入的路径,黑色节点没有发生变化,所以红黑树依然平衡,所以不需要处理
 *       情景4: 插入节点的父节点为红色
 *              情景4.1  叔叔节点存在,并且为红色(父和叔  都是红色节点) 根据红黑树性质4.红色节点不能直接相连,由此可知必然存在爷爷节点,且爷爷节点必定为黑节点
 *                          由此可以 a.将爸爸和叔叔节点变成黑色  b.将爷爷节点变成红色  c.并且将爷爷节点当成当前节点,进行下一轮的处理
 *              情景4.2  叔叔节点不存在或者为黑色节点,父节点为爷爷节点的左子树
 *                      情景4.2.1 插入节点为其父节点的左子节点(LL情况) a.将爸爸节点染成黑色 b.将爷爷染成红色 c.然后以爷爷节点进行右旋操作
 *                      情景4.2.2 插入节点为其父节点的右子节点(LR情况) a.已爸爸为节点进行一次左旋操作,得到(LL双红的情况 4.2.1) 然后指定爸爸节点为当前节点,执行下一轮的操作
 *              情景4.3  叔叔节点不存在,或者为黑色节点,父节点为爷爷节点的右子树
 *                      情景4.3.1 插入节点为其父节点的右子节点(RR情况) a.将爸爸染成黑色节点  b.将爷爷染成红色 c.然后以爷爷节点进行左旋操作
 *                      情景4.3.2 插入节点为其父节点的左子节点(RL情况) a.以爸爸节点进行一次右旋,得到RR双红的场景( RR情况 4.3.1).然后指定爸爸节点为当前节点,进行下一轮的操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
// 默认插入节点为红色
x.red = true;

// xp为父节点 xpp:为爷爷节点 xxpl是爷爷的左节点(左叔叔节点) xppr:是爷爷节点的右节点(右叔叔节点)
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {

// 情景1:当前节点的父节点不存在,当前节点就是根节点,根据红黑色性质,根节点为黑色,直接变色就可以了
if ((xp = x.parent) == null) {
x.red = false;
return x;
}

// 情景2 和情景3:插入节点的父节点为黑色,因为所插入的路径,黑色节点没有发生变化,所以红黑树依然平衡,所以不需要处理。 爷节为空,说明x节点的父节点为根节点,可以直接插入节点
else if (!xp.red || (xpp = xp.parent) == null)
return root;

// 情景4:如果父节点为红色,同时是父节点是爷爷节点的左节点,这样就遇到了两个红色节点相连的情况,需要进行处理。根据上述公式分为两种情况
if (xp == (xppl = xpp.left)) {
// 如果爷爷节点的右节点不为空,同时是红节点(也就是右叔叔节点不为空,且为红色.父叔双红)
// 情景4.1 叔叔节点存在,并且为红色(父和叔 都是红色节点) a.将爸爸和叔叔节点变成黑色 b.将爷爷节点变成红色 c.并且将爷爷节点当成当前节点,进行下一轮的处理
// 因为有小伙伴有疑问:这个下一轮处理就是指(xpp节点变成红色后,可能会和xpp节点的父节点发生冲突,也就是两个连续的红色节点,所以需要继续处理)
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}

// 情景4.2 叔叔节点不存在或者为黑色节点,父节点为爷爷节点的左子树。分为两种情况
else {
// 这些变化操作,你画画图就明白了,单纯的看比较抽象
// 情景4.2.2 插入节点为其父节点的右子节点(LR双红情况) a.以爸爸为节点进行一次左旋操作,得到(LL双红的情况 4.2.1) 然后指定爸爸节点为当前节点,执行下一轮的操作
if (x == xp.right) {
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
// 情景4.2.1 插入节点为其父节点的左子节点(LL情况) a.将爸爸节点染成黑色 b.将爷爷染成红色 c.然后以爷爷节点进行右旋操作
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);
}
}
}
}
else {
// 情景4.1 叔叔节点存在,并且为红色(父和叔 都是红色节点) 此时爸爸节点为爷爷节点的右节点和上述情况相反
// a.将爸爸和叔叔节点变成黑色 b.将爷爷节点变成红色 c.并且将爷爷节点当成当前节点,进行下一轮的处理(xpp节点变成红色后,可能会和xpp节点的父节点发生冲突,也就是两个连续的红色节点,所以需要继续处理)
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}

// 如果爷爷节点的左节点是黑色或者为空(左叔叔节点) 。那么可能有两种情况:
else {
// 情景4.3.2 插入节点为其父节点的左子节点(RL情况) a.以爸爸节点进行一次右旋,得到RR双红的场景( RR情况 4.3.1).然后指定爸爸节点为当前节点,进行下一轮的操作
if (x == xp.left) {
root = rotateRight(root, x = xp);
// 平衡过后,重新定义爷爷节点的变量值
xpp = (xp = x.parent) == null ? null : xp.parent;
}

// 情景4.3.1 插入节点为其父节点的右子节点(RR情况) a.将爸爸染成黑色节点 b.将爷爷染成红色 c.然后以爷爷节点进行左旋操作
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}

纯粹就是红黑树的操作了,这没什么可说的。红黑树需要考虑的场景,我已经在方法上面标注过了,并在代码中给了提示,相信各位同学能很清楚的看明白

红黑树的左旋

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* ------------------------------------------------------------ */
// Red-black tree methods, all adapted from CLR
// 红黑树的左旋的过程
// 1 将节点 p 旋转为其右节点的左节点,即将节点 p 挂到其右节点的左边
// 2 其右节点的左节点成为节点p 的右节点
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
// r -> 节点 p 的右节点
// rl -> 节点 p 的右节点的左节点
// pp -> 节点 p 的⽗节点
TreeNode<K,V> r, pp, rl;

// p 不为空且右节点不为空
if (p != null && (r = p.right) != null) {


// 1 将 p 的右节点的左节点挂到 p 的右节点,这有两个信息
// a. 断开 p 与其右节点 r 的连接
// b. 因为 p 要挂到其右节点 r 的左边,因此要把节点 r 原来的左节点挂到 p 的右边
if ((rl = p.right = r.left) != null)
// r 节点的左节点的⽗节点重置为 p
rl.parent = p;

// 2 将 p 的⽗节点设置为 p 的右节点的⽗节点
if ((pp = r.parent = p.parent) == null)
// 如果 p 为 root 节点,那么直接将其右节点设置为 root
(root = r).red = false;

// 3 确定 r 节点应该挂在 p 的⽗节点的左边还是右边。这个根据 p 的位置决定。原来在左边,现在就还在左边
else if (pp.left == p)
pp.left = r;
else
pp.right = r;

// 4 将 p 设置为其右节点的左边

r.left = p;
// 5 将 p 的右节点指为其⽗节点
p.parent = r;
}
// 返回根节点
return root;
}

单独看上面的左旋方法,可能很抽象,根据节点名称,我画了一个草图帮助你们理解,这个图我已经画的很详细了

红黑树的右旋

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 红黑树右旋:
* 1 将节点 p 旋转为其左节点的右节点,即将节点 p 挂到其左节点的右边
* 2 其左节点的右节点成为节点 p 的左节点
*/
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
TreeNode<K,V> p) {
// l -> 节点 P 的左节点
// pp -> 节点 p 的⽗节点
// lr -> 节点 p 的左节点的右孩⼦
TreeNode<K,V> l, pp, lr;

// 节点 p 和 其左节点不为空
if (p != null && (l = p.left) != null) {

// 1 将 p 的左节点的右孩⼦挂到 p 的左边
if ((lr = p.left = l.right) != null)

// 将 p 指定为 lr 的⽗节点
lr.parent = p;

// 2 将 p 的⽗节点指定为其右节点的⽗节点
if ((pp = l.parent = p.parent) == null)
// 将l节点设置为root节点,并调整为黑色.
(root = l).red = false;

// 2.1 确定 p 的右节点应该挂在 p的⽗节点左边还是右边.这个根据 p 的位置决定。原来在左边,现在就还在左边
else if (pp.right == p)
pp.right = l;
else
pp.left = l;

// 将 p 设置为其左节点的右孩⼦
l.right = p;
// 将 p 的⽗节点指定为其左节点
p.parent = l;
}
// 返回根节点
return root;
}

为了帮助理解,同样的,下面增加了红黑树的右旋代码示意图

红黑树的性质

  • 红黑树性质
    • 性质1: 每个节点要么是黑色,要么是红色。
    • 性质2: 根节点是黑色。
    • 性质3: 每个叶子节点(NIL) 是黑色。
    • 性质4: 每个红色节点的两个子节点一定都是黑色。
    • 性质5: 任意节点到每个叶子节 点的路径都包含数量相同的黑结点。从性质5又可以推出:
    • 性质5.1:如果一个节点存在黑子节点, 那么该结点肯定有两个子节点

红黑树的场景

插入场景:

情景1:插入的节点为空树

直接把插入的节点作为根节点就可以了,并且把根节点变成黑色,如下图所示:

情景2:插入节点的Key已存在

处理:更新当前节点的值,为插入节点的值

情景3 插入结点的父结点为黑结点

由于插入的结点是红色的,并不会影响红黑树的平衡,直接插入即可,无需做自平衡。如图所示:

情景4:插入节点的父节点为红色

红黑树的性质2: 根结点是黑色,如果插入节点的父结点为红结点,那么该父结点不可能为根结点,所以插入结点总是存在祖父结点。

分为两种情况,如下图所示

  • 一种是爸爸节点为红色,叔叔节点也是红色
  • 一种是爸爸节点为红色,叔叔节点为黑色或者不存在

情景4.1

叔叔结点存在并且为红结点

依据红黑树性质4 可知,红色节点不能相连==>祖父结点肯定为黑结点。因为不可以同时存在两个相连的红结点。那么此时该插入子树的红黑层数的情况是黑红红。显然最简单的处理方式是把其改为红黑红
处理:

  • 1 将爸爸节点(P)和叔叔节点(U)节点改为黑色
  • 2 将爷爷PP改为红色
  • 3 将爷爷PP设置为当前节点,进行后续处理.注意但需要注意的是 PP 变为红⾊后,可能会和它的⽗节点形成连续的红⾊节点,此时需要递归向上调整,也就将 PP 看作新插⼊节点继续尝试调整。

如下图所示:

可以看到,我们把PP结点设为红色了,如果PP的父结点是黑色,那么无需再做任何处理;但如果PP的父结点是红色,则违反了红黑树的性质.所以需要将PP设置为当前节点,继续做插入操作自平衡处理,真到平衡为为止.

插入情景4.2

叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的左子结点

注意:单纯从插入前来看,叔叔节点非红即空(NIL节点) ,否则的话破坏了红黑树性质5,此路径会比其它路径多一个黑色节点。

新插入节点,可能为P节点的左子节点,也可能是P节点的右子节点,所以分为两种情况分别处理

LL红色情况

4.2.1 新插入节点,为其父节点的左子节点
处理:

  1. 将P设置 为黑色,将PP设置为红色 ,然后以爷爷节点为当前节点
  2. 对PP节点进行右旋

处理结果如下图所示:

LR红色情况

4.2.2 新插入节点,为其父节点的右子节点(LR红色情况)
处理:

  • 对P进行左旋
  • 将P设置为当前节点,得到LL红色情况
  • 按照LL红色情况处理(1.变颜色2.右旋PP)

操作过程如下图所示:

第一步

第二步

情况4.3

叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的右子结点 和上述操作4.2 相反图,如下所示:

RR红色情况

4.3.1新插入节点,为其父节点的右子节点(RR红色情况)

处理操作:

  1. 将P设置为黑色,将PP设置为红色
  2. 对PP节点进行左旋

旋转过程如下图所示:

RL红色情况

4.3.2 新插入节点,为其父节点的左子节点(RL红色情况)

处理:
1.对P进行右旋
2.将P设置为当前节点,得到RR红色情况
3.按照RR红色情况处理(1.变颜色2.左旋PP)

第一步对P节点进行右旋操作 如下图所示:

第二步:变色+旋转,如下图所示

红黑树链化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    /**
* Returns a list of non-TreeNodes replacing those linked from
* this node.
* 红⿊树中仍然保留了原链表节点顺序。有个这个特点,再将红⿊树转成链表就简单多了,仅需将TreeNode 链表转成 Node 类型的链表即可。
*/
final Node<K,V> untreeify(HashMap<K,V> map) {
// ⽤于组织链表的头、尾指针
Node<K,V> hd = null, tl = null;

// 遍历 TreeNode 链表,并⽤ Node 替换
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}

// For conversion from TreeNodes to plain nodes
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
return new Node<>(p.hash, p.key, p.value, next);
}

这段代码链画的过程,相当简单,没什么可说的

红黑树的拆分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* Splits nodes in a tree bin into lower and upper tree bins,
* or untreeifies if now too small. Called only from resize;
* see above discussion about split bits and indices.
*
* @param map the map
* @param tab the table for recording bin heads
* @param index the index of the table being split
* @param bit the bit of hash to split on
*/
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// 重新链接到 lo 和 hi 列表,保持顺序
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;

/**
* 红⿊树节点仍然保留了 next 引⽤,因此仍可以按链表⽅式遍历红⿊树。下⾯的循环是对红⿊树节点进⾏分组,与普通链表操作类似
* 下面这个循环进行的事链表的分组曹组
*/
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}


if (loHead != null) {
// 如果 loHead 不为空,且链表⻓度⼩于等于 6,则将红⿊树转成链表
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
// hiHead == null 时,表明扩容后,所有节点仍在原位置,树结构不变,⽆需重新树化,否则,将 TreeNode 链表重新树化
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}

这段逻辑发生在数据扩容的时候,对于红黑树节点的处理。
红黑树的扩容逻辑和链表的扩容逻辑整体上类似。唯一不同的是,除了这段逻辑将红黑树分组后,会判断链表的长度,如果小于UNTREEIFY_THRESHOLD ,会进行红黑树转链表的操作。否则根据条件,将链表树化为红黑树

问题答疑:

问题1:

问题:为什么集合的初始容量必须是2的n次幂?如果输入值不是2的幂,比如17会怎么样?

这样做是为了减少hash碰撞的次数, 2的n次方实际就是1后面n个0,2的n次方减1二进制表示时实际就是n个1

按位与运算 : 相同的二进制数位上,都是1的时候,结果为1,否则为零。

按位或运算 : 相同的二进制数位上,都是0的时候,结果为0,否则为1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
hash计算存放位置的时候,是通过 hash & (length-1)
例如1:
hash值如果为3,hashmap默认容量为16 ,即 3 & (16-1)
0000 0011 3
0000 1111 15
------------------
0000 0011 3 索引值为3

例如2:
hash值如果为5,hashmap默认容量为16 ,即 5 & (16-1)
0000 0101 5
0000 1111 15
------------------
0000 0101 5 索引值为5

------------------------------------------------
如果不是2的n次幂
hash值如果为3,hashmap容量设置为17 ,即 3 & (17-1)
0000 0011 3
0001 0000 16
------------------
0000 0000 0 索引位置为0

hash值如果为7,hashmap容量设置为17 ,即 7 & (17-1)
0000 0111 7
0001 0000 16
------------------
0000 0000 0 索引位置为0

hash值如果为9,hashmap容量设置为17 ,即 7 & (17-1)
0000 1001 9
0001 0000 16
------------------
0000 0000 0 索引位置为0

由上面可以看出,当我们根据key的hash确定其在数组的位置时, 如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。因此,HashMap 容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大, 代表数组中一个链的长度越大,这样的话会降低hashmap的性能

问题2

如果初始hashmap的容量不是2的n次幂,会做哪些操作
如果创建HashMap对象时,输入的数组长度是17,不是2的幂,HashMap通过移位运算或运算得到比那个数大且最近的二次幂数字。例如如果容量是17,初始化容量就是返回32

jdk1.8 表改变大小的源码操作如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 返回给定目标容量的2次幂大小。
* Returns a power of two size for the given target capacity.
*/
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;
}

再次强调一下:

按位与运算 : 相同的二进制数位上,都是1的时候,结果为1,否则为零。

按位或运算 : 相同的二进制数位上,都是0的时候,结果为0,否则为1。
1是负数 0正数

下面看看这几个无符号右移操作是干什么的

第一种情况,容量传递的是0

如果n这时为0了(经过了cap-1之后) , 则经过后面的几次无符号右移依然是0,最后返回的capacity是1

第二种情况,n不等于0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
HashMap <String,Object> hashmap = new HashMap<>(17);
cap = 17
int n = cap - 1 = 16;
第一次无符号右移一位
0000 0000 0000 0000 0000 0000 0001 0000 n=16
0000 0000 0000 0000 0000 0000 0000 1000 n >>> 1
------------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 1000 24 ===>n

第二次无符号右移2
0000 0000 0000 0000 0000 0000 0001 1000 n=24
0000 0000 0000 0000 0000 0000 0000 0110 n >>> 2
------------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 1110 n = 30

第三次无符号右移4
0000 0000 0000 0000 0000 0000 0001 1110 n = 30
0000 0000 0000 0000 0000 0000 0000 0001 n >>> 4
------------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 1111 n = 31

第四次无符号右移8
0000 0000 0000 0000 0000 0000 0001 1111 n=31
0000 0000 0000 0000 0000 0000 0000 0000 n >>> 8
-------------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 1111 n=31

第五次无符号右移16
0000 0000 0000 0000 0000 0000 0001 1111 n=31
0000 0000 0000 0000 0000 0000 0000 0000 n >>> 16
-------------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 1111 n=31
执行最后一行代码操作,n>0且小于最大容量,返回31+1 = 32
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

第三种情况Hashmap最大容量的移动位置操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
0010 0000 0000 0000 0000 0000 0000 0000 2^30
int n = cap-1;
0001 1111 1111 1111 1111 1111 1111 1111 n=2^30 -1
0000 1111 1111 1111 1111 1111 1111 1111 n >>> 1
-------------------------------------------
0001 1111 1111 1111 1111 1111 1111 1111 2^29


0001 1111 1111 1111 1111 1111 1111 1111 2^29
0000 0111 1111 1111 1111 1111 1111 1111 n>>>2
------------------------------------------------
0001 1111 1111 1111 1111 1111 1111 1111 2^29

.....

最后得到的值一定为
0001 1111 1111 1111 1111 1111 1111 1111 2^29
加一操作后为
0010 0000 0000 0000 0000 0000 0000 0000 2^30

总结 :如果容量大于MAXIMUM_CAPACITY 则取最大容量。不到2^30 的容量,通过移位操作后,会得到大于值的最小2的幂。如果当前值就是2的幂次方,返回当前值

问题3

为什么Map桶中的节点个数要超过8才转红黑树
因为树节点的大小大约是普通节点的两倍,所以我们只在链表中包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树箱。理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布,默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)*pow(0.5,k)/factorial(k)).
*
* 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
1.TreeNodes占用空间是普通Nodes的两倍,所以只有当链表中包含足够多的节点时才会转成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD(8)的值决定的。
当链表中节点数变少时, 红黑树又会转成普通的链表。并且我们查看源码的时候发现,链表长度达到8(桶的数量要大于64)就转成红黑树,当长度降到6就转成普通链表
这样就解释了为什么不是一开始就将其转换为TreeNodes, 而是需要一定节点数才转为TreeNodes, 说白了就是权衡,空间和时间的权衡。

2.当hashCode离散性很好的时候,树型节点用到的概率非常小,因为数据均匀分布在每个桶中,几乎不会有桶中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有桶中节点的分布频率会遵循泊松分布,我们可以看到,一个桶中链表长度达到8个元素的概率为0.00000006. 几乎是不可能事件。所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。

下面是我找的一个泊松分布 示意图

图片原来链接:http://www.ruanyifeng.com/blog/2015/06/poisson-distribution.html
可以参考看下

问题4:

基于JDK1.8,hashmap引入了红黑树,为什么一开始不按照红黑树存储。非要等到链表长度大于8才转换

1.JDK 1.8以前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表, 这个时候HashMap就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势。针对这种情况,JDK 1.8中引入了红黑树(查找时间复杂度为O(logn))来优化这个问题。当链表长度很小的时候, 即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响, 所以才需要转成树。

2.TreeNodes占用空间是普通Nodes的两倍,当元素较少时,增加多余的开销

问题5

问题:为什么不使用AVL树而使用红黑树
红黑树和AVL树都是最常用的平衡二叉搜索树,它们的查找、删除、修改都是O(lgn) time

AVL树和红黑树有几点比较和区别:
(1)AVL树是更加严格的平衡,因此可以提供更快的查找速度,一般读取查找密集型任务,适用AVL树。
(2)红黑树更适合于插入修改密集型任务。
(3)通常,AVL树的旋转比红黑树的旋转更加难以平衡和调试。
原文链接:https://blog.csdn.net/21aspnet/article/details/88939297

小结

最后提供一个红黑树的学习地址,里面包含的各种数据结构,便于学习和理解数据结构
https://www.cs.usfca.edu/~galles/visualization/RedBlack.html