前言
HashMap 可以说是 Java 项目里最常用的集合类了,作为一种典型的 K-V 存储的数据结构,日常开发最常用的 Java 集合类就是它了。可能有些人只会用,并不知道原理,也可能有些人知道底层数据结构,并不知道为什么要这样设计,下面我们从以下几点来分析一下:
- hash 算法原理,为什么这样做?
- 使用了什么数据结构?为什么要转化为红黑树?
- 树化标准为什么是 8 ?树退化为链表的阈值为什么是 6 ?
- 主要成员变量和构造方法
- put 方法
- get方法
- remove 方法
- 扩容机制
一、hash 算法原理,为什么这样做?
我们都知道在 HashMap 中计算数组下标的 hash 算法是 HashMap 的核心算法,看了源码中的说明也只是说这样写是为了让元素分布更均匀,但是为什么这样设计呢?先来看代码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这个方法的返回值还是一个 key 的哈希值,那为什么不直接返回 key.hashCode() 呢?还要与 (h >>> 16) 异或。这里先来了解以下知识点: ^ 运算、 >>> 运算 、& 运算
1. h >>> 16 是什么,有什么用? h 是 hashcode , h >>> 16 是用来取出 h 的高 16 位(>>> 是无符号右移) , 如下展示:
0000 0100 1111 1101 0111 1100 0110 0101
>>> 16
0000 0000 0000 0000 0000 0100 1111 1101
实际上就是把二进制数像右移了 16 位
2. 为什么 h = key.hashCode()) 与 (h >>> 16) 异或 讲到这里还要看一个方法 indexFor,在 jdk 1.7 中有 indexFor(int h, int length) 方法(jdk1.8 里没有,使用 tab[(n - 1) & hash] 代替但原理没变),下面看下 1.7 源码
static int indexFor(int h, int length) {
return h & (length-1);
}
这个方法返回值就是数组下标,我们平时用 map 数据不是很多,绝大多数情况下 length 一般都小于 2^16 即小于 65536。所以 return h & (length-1) 结果始终是 h 的低 16 位与(length-1)进行 & 运算。这里通过一个例子来看下:
HashMap 的默认初始容量为 16,所以:
length = 16,length-1 = 15,转换二进制为1111;
假设一个 key 的 hashcode = 25488422 转换二进制:0001 1000 0100 1110 1100 0010 0110;
与(length-1)& 运算如下
0000 0001 1000 0100 1110 1100 0010 0110
& length-1(15) 运算
0000 0000 0000 0000 0000 0000 0000 1111
= 0000 0000 0000 0000 0000 0000 0000 0110 (就是十进制 6 ,所以下标为 6)
上述运算实质是:0110 与 1111 进行 & 运算,也就是哈希值的低 4 位与 length-1 进行 & 运算,如果让哈希值的低 4 位更加随机,那么 & 结果就更加随。
由于 length 绝大多数情况小于 2 的 16 次方,所以始终是 hashcode 的低 16 位(甚至更低)参与运算,这样高 16 位是用不到的。如何让高 16 也参与运算呢?要是高 16 位也参与运算,会让得到的下标更加散列,所以 hash 方法中通过 (h >>> 16) 得到他的高 16 位与 hashCode() 进行 ^ 运算。
异或运算规则:0^0=0; 0^1=1; 1^0=1; 1^1=0,我们来看下运算过程:
//hashCode 二进制值
0000 0001 1000 0100 1110 1100 0010 0110
>>> 16
0000 0000 0000 0000 0000 0001 1000 0100
^
0000 0001 1000 0100 1110 1100 0010 0110
=
0000 0001 1000 0100 1110 1101 1010 0010
& length-1(15) 运算
0000 0000 0000 0000 0000 0000 0000 1111
=
0000 0000 0000 0000 0000 0000 0000 0010 (就是十进制 2 ,所以下标为 2)
补充知识:
-
当 length = 8 时 ,下标运算结果取决于哈希值的低三位
-
当 length = 16 时,下标运算结果取决于哈希值的低四位
-
当 length = 32 时,下标运算结果取决于哈希值的低五位
-
当 length = 2 的 N 次方,下标运算结果取决于哈希值的低 N 位
3. 为什么用 ^ 而不用 & 和 |
因为 & 和 | 都会使得结果偏向 0 或者 1 ,并不是均匀的概念 , 所以用 ^,这样运算是为了减少碰撞冲突,达到较好的分布离散效果。
二、使用了什么数据结构?为什么要转化为红黑树?
在 JDK1.8 之前 HashMap 的哈希表数据结构是 “链表散列” 的数据结构,即数组和链表的结合体,如下:
为什么采用这种结构来存储元素呢?
数组的特点:查询效率高,插入,删除效率低。
链表的特点:查询效率低,插入删除效率高。
综合了数组和链表的特点,HashMap 采用了数组和链表的结合的方式,完美的解决了数组和链表的问题,使得查询和插入,删除的效率都很高。
为什么要转化为红黑树?
首先说明一下,在 HashMap 中,决定某个对象落在哪一个 “桶“,是由该对象的 hashCode 决定的,JDK 无法阻止用户实现自己的哈希算法,如果用户重写了 hashCode,并且算法实现比较差的话,就很可能会使 HashMap 的链表变得很长,就比如这样:
public class HashMapTest {
public static void main(String[] args) {
Map<User, Integer> map = new HashMap<>();
for (int i = 0; i < 1000; i++) {
map.put(new User("张三 " + i), i);
}
}
static class User{
private String name;
public User(String name) {
this.name = name;
}
@Override
public int hashCode() {
return 1;
}
}
}
我们设计了一个 hashCode 永远为 1 的类 User,这样一来存储到 HashMap 的所有 User 对象都会存放到同一个“桶”里,这样一来查询效率无疑会非常的低下。
所以为了有效防止用户自己实现了不好的哈希算法时导致链表过长的情况,在 JDK1.8 之后 HashMap 引入了红黑树的结构,先来看下红黑树的特点。
定义:R-B Tree,全称是 Red-Black Tree,又称为 “红黑树”,是一种特殊的二叉查找树。红黑树的每个结点上都有存储位表示结点的颜色,可以是红 (Red) 或黑 (Black) 。它或者是一颗空树,或者是一颗具有如下性质的二叉树:
-
节点非红即黑
-
根节点是黑色
-
所有 NULL 节点称为叶子节点,且默认为黑色
-
所有红节点的子节点都为黑色
-
从任一节点到其叶子节点的所有路径上都包含相同的黑节点
红黑树的优点:
-
首先红黑树是不符合平衡二叉(AVL )树的平衡条件的,即每个节点的左子树和右子树的高度最多差 1 的二叉查找树。但是提出了为节点增加颜色,红黑树是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在 3 次旋转之内解决,而 AVL 是严格平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多,所以红黑树的插入效率更高。
-
红黑树能够以 O(log2 (n)) 的时间复杂度进行搜索、插入、删除操作 ,即 1000 个数据查找最坏情况下只需要 log2 (1000) = 10 次。
-
简单来说红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。
既然红黑树的效率高,那怎么不一开始就用红黑树存储呢?
这其实是基于空间和时间平衡的考虑,JDK 的源码里已经对这个问题做了解释:
* 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.
看注释里的前面四行就不难理解,单个 TreeNode 需要占用的空间大约是普通 Node 的两倍,所以只有当包含足够多的 Nodes 时才会转成 TreeNodes,这个足够多的标准就是由 TREEIFY_THRESHOLD 的值(默认值 8)决定的。而当桶中节点数由于移除或者 resize (扩容) 变少后,红黑树会转变为普通的链表,这个阈值是 UNTREEIFY_THRESHOLD(默认值 6)。
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;
看到这里就不难明白了,红黑树虽然查询效率比链表高,但是结点占用的空间大,只有达到一定的数目才有树化的意义,这是基于时间和空间的平衡考虑。
总结:
链表取元素是从头结点一直遍历到对应的结点,这个过程的复杂度是 O(N) ,而红黑树基于二叉树的结构,查找元素的复杂度为 O(logN) ,所以,当元素个数过多时,用红黑树存储可以提高搜索的效率。所以 JDK 1.8 后 HashMap 底层选用 数组 + 链表 + 红黑树 进行优化。
|JDK 版本 |实现方式 |节点数 >= 8 | 节点数 <= 6| |--|--|--|--|--| | 1.8 以前 | 数组+单向链表 | 数组+单向链表| 数组+单向链表| |1.8 以后 |数组+单向链表+红黑树 |数组+红黑树 | 数组+单向链表|
链表节点:
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;
}
}
红黑树节点:
static final class TreeNode<K,V> extends LinkedHashMap.LinkedHashMapEntry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; //左子树
TreeNode<K,V> right; //右子树
TreeNode<K,V> prev; // 上一个节点
boolean red; //是否是红树
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
/**
* 获取根节点
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
...
}
数据结构:
三、树化标准为什么是 8 ?树退化为链表的阈值为什么是 6 ?
你平时会不会在奇怪,为什么链表转为红黑树树化的标准是 8 ,而不是其他的 5,6,7,9,10 。为什么树退化为链表的阈值为什么是 6 ,而不是 7 呢?接下来我们就来了解一下。
1. 为什么树化标准是 8 个?
至于为什么树化标准的数量是 8 个,在源码中,上面那段笔记后面还有一段较长的注释,我们可以从那一段注释中找到答案,原文是这样:
* 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
大概意思就是:如果 hashCode 的分布离散良好的话,那么红黑树是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合 泊松分布,各个长度的命中概率依次递减,注释中给我们展示了 1-8 长度的具体命中概率,当长度为 8 的时候,概率概率仅为 0.00000006,这么小的概率,HashMap 的红黑树转换几乎不会发生,因为我们日常使用不会存储那么多的数据,你会存上千万个数据到 HashMap 中吗?
当然,这是理想的算法,但不妨某些用户使用 HashMap 过程导致 hashCode 分布离散很差的场景,这个时候再转换为红黑树就是一种很好的退让策略。
2. 为什么退化为链表的阈值是 6 ?
上面说到,当链表长度达到阈值 8 的时候会转为红黑树,但是红黑树退化为链表的阈值却是 6,为什么不是小于 8 就退化呢?比如说 7 的时候就退化,偏偏要小于或等于 6?
主要是一个过渡,避免链表和红黑树之间频繁的转换。如果阈值是 7 的话,删除一个元素红黑树就必须退化为链表,增加一个元素就必须树化,来回不断的转换结构无疑会降低性能,所以阈值才不设置的那么临界。
四、主要成员变量和构造方法
1. 主要成员变量
/**
* 默认的table初始容量
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 链表长度大于该参数转红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 当树的节点数小于等于该参数退化为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 存储 Node 元素的数组(也有称作Hash桶),总是 2 的倍数
*/
transient Node<K,V>[] table;
/**
* 表示当前HashMap包含的键值对数量
*/
transient int size;
/**
* 表示当前HashMap修改次数
*/
transient int modCount;
/**
* 数组 table 中元素填充的数量,一旦超过这个数量 HashMap 就会进行扩容
*/
int threshold;
/**
* 负载因子,用于扩容
*/
final float loadFactor;
为什么 HashMap 初始化长度要是 2 的幂次?
如果数组进行扩容,数组长度发生变化,而存储位置 index = h & (length-1) , index 也可能会发生变化,需要重新计算 index。目的为了是数组离散均匀,同时减少哈希冲突和不必要的数据空间浪费。
2. 构造方法
构造方法有四种:
源码如下:
public HashMap(int initialCapacity, float loadFactor) {
//初始容量不能小于 0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//初始容量不能超过最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//装载因子需大于 0
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);
}
前面三个构造器的区别都是在于指定初始容量以及负载因子,如果你选择默认的构造器那么在创建的时候不会指定 threshold 的值,而第二个以及第三个构造器在一开始的时候就会根据下面的这个方法来确认 threshold 值,可以看到下面用到了移位算法,最后一个构造器很显然就是把另一个 Map 的值映射到当前新的 Map 中这边不再赘述。
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;
}
这边先提下负载因子(loadFactor),源码中有个公式为 threshold = loadFactor * 容量,表示当负载情况达到负载因子水平的时候,容器会自动扩容,HashMap 默认使用的负载因子值为 0.75f(当容量达到四分之三进行再散列(扩容))。当负载因子越大的时候能够容纳的键值对就越多但是查找的代价也会越高。所以如果你知道将要在 HashMap 中存储多少数据,那么你可以创建一个具有恰当大小的初始容量这可以减少扩容时候的开销。但是大多数情况下 0.75 在时间跟空间代价上达到了平衡所以不建议修改。
三、put 方法
先来看 put 方法的代码:
public V put(K key, V value) {
//key 值进行 hash 计算
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) // 如果 table 为空或长度为 0 时
n = (tab = resize()).length; //对 HashMap 进行扩容
if ((p = tab[i = (n - 1) & hash]) == null) //索引数组对应位置元素为空
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))))
// table 中的键值对 key 和要存入的键值对 key 一致
e = p;
else if (p instanceof TreeNode)
//table 中的节点为红黑树
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//table 中为链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//遍历到表尾也没有找到 key 值相同节点,插入新节点
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//长度超过树化阈值转化为红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 链表中包含相同 key 的元素
break;
p = e;
}
}
if (e != null) { // existing mapping for key
// map 中存在相同 key 元素
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//替换元素的 value 值
e.value = value;
afterNodeAccess(e);
//返回旧的 value
return oldValue;
}
}
++modCount;
if (++size > threshold)
//数组元素长度超过临界值,进行扩容
resize();
afterNodeInsertion(evict);
return null;
}
代码中注释已经很清楚,总结来说就是几步:
- 判断数组是否初始化,未初始化则进行扩容操作。
- hash 算法计算节点在数组中的索引位,若数组元素为空则新建节点插入元素到数组中。
- 判断数组元素和插入元素 key 值是否相同,相同则替换 value 值。
- 数组中的节点是否为红黑树结构,若为红黑树则进行红黑树插入操作。
- 遍历节点链表,查找到相同 key 值节点则替换 value 值,若不存在则新建节点插入链表。
- 链表长度是否大于树化阈值,大于则转换链表为红黑树;数组元素长度是否超过临界值,超过则进行扩容。
接着继续看红黑树的插入操作:
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
//查找根节点
TreeNode<K,V> root = (parent != null) ? root() : this;
for (TreeNode<K,V> p = root;;) {//遍历红黑树
int dir, ph; K pk;
if ((ph = p.hash) > h) //当前节点 hash 值大于要插入的节点 hash 值
dir = -1; //往左孩子查找
else if (ph < h) //当前节点 hash 值小于要插入的节点 hash 值
dir = 1;//往右孩子查找
else if ((pk = p.key) == k || (k != null && k.equals(pk))) //查找到相同 key 值得节点
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) { //节点元素未实现 compare 方法
if (!searched) {
TreeNode<K,V> 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)) //遍历右孩子
//查询到相同 key 值节点
return q;
}
//节点元素实现了 compare 方法,计算遍历方向
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) { // 遍历到末尾节点还未查找到相同元素
Node<K,V> xpn = xp.next;
//创建新节点
TreeNode<K,V> 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<K,V>)xpn).prev = x;
//balanceInsertion 插入操作平衡红黑树,并把根节点和原来数组中的元素进行交换位置
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
//移动根节点到前面数组中
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
int n;
if (root != null && tab != null && (n = tab.length) > 0) {
int index = (n - 1) & root.hash; //计算根节点位置
TreeNode<K,V> first = (TreeNode<K,V>)tab[index]; //取出数组的第一个元素
if (root != first) { //根节点和数组的元素不相同
Node<K,V> rn;
tab[index] = root; //根节点放到数组中
TreeNode<K,V> rp = root.prev; //取出根节点的上一个元素
if ((rn = root.next) != null) //根节点下一个元素不为空
((TreeNode<K,V>)rn).prev = rp; //根节点下一个元素前指针指向根节点上一个元素
if (rp != null) //根节点上一个元素不为空
rp.next = rn;//根节点上一个元素后指针指向根节点下一个元素
if (first != null) //数组元素存在
first.prev = root; //数组元素的前指针指向根节点
root.next = first;//根节点的后指针指向取出的数组元素
root.prev = null; //端口根节点的前指针
}
assert checkInvariants(root);
}
}
上面就是红黑树插入的代码,逻辑并不难,总结一下就是通过遍历红黑树,用节点的 hash 值和树中的节点进行比较,计算出要插入节点在树中的方向,是左边还是右边。因为红黑树也是一颗二叉排序树,具有二叉排序树的特点,值小的放左边,大的放右边,找到合适的位置后把节点指针连上就把数据插入到树中了。
接下来最麻烦的就是插入数据后进行二叉树的平衡操作,因为数据插入当前的树可能就不满足红黑树的特点了,为了让插入数据的树也是一颗红黑树,就要进行平衡操作。我们先来看看插入数据平衡时要经过哪些步骤:
接着上代码,通过上面的步骤分析一下:
//插入平衡操作
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
x.red = true;//把节点染红
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
if ((xp = x.parent) == null) { //插入的节点就是根节点
x.red = false;//节点涂黑
return x;//返回当前节点
}
else if (!xp.red || //父节点是黑节点
(xpp = xp.parent) == null) //祖父节点为空,即是根节点的孩子
return root; //根节点无变化
if (xp == (xppl = xpp.left)) { //父节点是祖父节点的左孩子
if ((xppr = xpp.right) != null && xppr.red) { //祖父节点的右孩子(叔叔节点)是红色
xppr.red = false;//将当前节点的叔叔节点涂黑
xp.red = false;//父节点涂黑
xpp.red = true; //祖父节点涂红
x = xpp;//把当前节点指向祖父节点,从新的当前节点开始算法
}
else {//祖父节点的右孩子(叔叔节点)是黑色
if (x == xp.right) { //当前节点是其父节点的右孩子
//当前节点的父节点做为新的当前节点,以新当前节点为支点左旋。
root = rotateLeft(root, x = xp);
//当前节点的父节点不为空时祖父节点指向父节点,否则祖父节点赋空
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {//当前节点是其父节点的左孩子
xp.red = false; //父节点涂黑
if (xpp != null) { //祖父节点不为空
xpp.red = true; //祖父节点变红色
root = rotateRight(root, xpp);//以祖父节点为支点进行右旋
}
}
}
}
else {//父节点是祖父节点的右孩子;和上面一样,左变右
if (xppl != null && xppl.red) {//祖父节点的左孩子(叔叔节点)是红色
xppl.red = false;//将当前节点的叔叔节点涂黑
xp.red = false;//父节点涂黑
xpp.red = true;//祖父节点涂红
x = xpp;//把当前节点指向祖父节点,从新的当前节点开始算法
}
else {//祖父节点的左孩子(叔叔节点)是黑色
if (x == xp.left) {//当前节点是其父节点的左孩子
//当前节点的父节点做为新的当前节点,以新当前节点为支点右旋。
root = rotateRight(root, x = xp);
//当前节点的父节点不为空时祖父节点指向父节点,否则祖父节点赋空
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {//当前节点是其父节点的右孩子
xp.red = false;//父节点涂黑
if (xpp != null) {//祖父节点不为空
xpp.red = true;//祖父节点变红色
root = rotateLeft(root, xpp);//以祖父节点为支点进行左旋
}
}
}
}
}
}
就是通过 "旋转和重新着色" 等一系列操作来修正该树,使之重新成为一棵红黑树。 而旋转是使红黑树的树高控制在合理范围内的必要步骤,接着来看看旋转的原理:
- 左旋 左旋是将 X 的右子树绕 X 逆时针旋转,使得X的右子树成为 X 的父亲,同时修改相关结点的引用,旋转之后,要求二叉查找树的属性依然满足。
2. 右旋
右旋是将 X 的左子树绕X顺时针旋转,使得 X 的左子树成为X的父亲,同时注意修改相关结点的引用,旋转之后要求仍然满足搜索树的属性。
了解原理后,我们接着来看下 HashMap 中的旋转代码:
//左旋转操作
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> r, pp, rl;
if (p != null && (r = p.right) != null) { //当前节点不为空并且右孩子不为空
if ((rl = p.right = r.left) != null) //当前节点右孩子的左孩子不为空
//p.right = r.left 是通过左旋把右孩子的左孩子变为当前节点的右孩子
rl.parent = p;//将右孩子的左孩子父亲指向当前节点
if ((pp = r.parent = p.parent) == null)//当前节点没有父节点
// r.parent = p.parent 通过左旋把右孩子作为根节点,父指针和当前节点断开
(root = r).red = false; //将右孩子作为根节点并涂黑
else if (pp.left == p) //当前节点是父亲的左孩子节点
pp.left = r; //左旋把父节点的左孩子指向当前节点的右孩子
else //当前节点是父亲的右孩子节点
pp.right = r;//左旋把父节点的右孩子指向当前节点的右孩子
r.left = p;//左旋后把当前节点作为右孩子的左孩子
p.parent = r;//左旋后把当前节点父指针指向右孩子
}
return root;
}
//右旋转操作
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> l, pp, lr;
if (p != null && (l = p.left) != null) {//当前节点不为空并且左孩子不为空
if ((lr = p.left = l.right) != null)//当前节点左孩子的右孩子不为空
//p.left = l.right 是通过右旋把左孩子的右孩子变为当前节点的左孩子
lr.parent = p;//将左孩子的右孩子父亲指向当前节点
if ((pp = l.parent = p.parent) == null)//当前节点没有父节点
// l.parent = p.parent 通过右旋把左孩子作为根节点,父指针和当前节点断开
(root = l).red = false;//将左孩子作为根节点并涂黑
else if (pp.right == p)//当前节点是父亲的右孩子节点
pp.right = l;//右旋把父节点的右孩子指向当前节点的左孩子
else//当前节点是父亲的左孩子节点
pp.left = l;//右旋把父节点的左孩子指向当前节点的左孩子
l.right = p;//右旋后把当前节点作为左孩子的右孩子
p.parent = l;//右旋后把当前节点父指针指向左孩子
}
return root;
}
五、get 方法
get 取值方法就比较简单,就是通过 key 值查询 map 中对应的 value 值,还是通过代码来分析:
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;
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))))
//数组中的元素接口 key 值和查询的 key 值一致
return first;
if ((e = first.next) != null) { // 节点的 next 指针不为空
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))))
//查询到 key 值一致的元素节点
return e;
} while ((e = e.next) != null); //遍历链表
}
}
return null;
}
//取出红黑树中的节点
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
//遍历查找红黑树中的元素
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h) //当前节点 hash 值大于 key 的hash 值
p = pl; //查找左孩子
else if (ph < h) //当前节点 hash 值小于 key 的hash 值
p = pr; //查找右孩子
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
//当前节点 key 值和查询的 key 值一致,返回当前节点
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) // 元素实现了 comparable 类
//通过 comparable 比对结果获取查找的方向
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 的 hash 值计算出数组索引位。
- 取出索引位节点判断是否命中,若 key 值相同则直接返回。
- 若未命中并且索引位上存在 hash 冲突的链表,则从头到尾遍历链表查找相同 key 值的 value。
- 如果索引位上的是红黑树,则通过遍历树查找相同 key 值的 value。
六、remove 方法
remove 就是移除当前 key 的数据,首先通过 key 的 hash 索引从表中查到对应的 value 值,移动数据的指针断开移除数据和哈希表中的数据引用,就可以把数据从 map 中移除。
public V remove(Object key) {
Node<K,V> e;
//计算 hash 值并移除节点
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))))
//查找到 key 值相同的节点
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode) //节点是红黑树节点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key); //从红黑树中取出对应 key 的节点
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {//链表中查到 key 值相同的节点
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;
}
链表和数组中移除的代码比较简单,通过上面的注释都能看懂,比较复杂的就是从红黑树中移除数据,先看代码:
//移除树节点
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,
boolean movable) {
int n;
if (tab == null || (n = tab.length) == 0)
return;
int index = (n - 1) & hash; //计算数组中索引位
TreeNode<K,V> first = (TreeNode<K,V>)tab[index], root = first, rl; //取出数组中索引位对应的节点元素
TreeNode<K,V> succ = (TreeNode<K,V>)next, pred = prev;
if (pred == null)//移除的节点为数组中的节点
tab[index] = first = succ; //把移除节点的下一节点移动到数组中
else //移除的节点存在上一个节点
pred.next = succ; //把上一个节点的指针指向下一节点
if (succ != null) //如果移除节点的下一节点不为空
succ.prev = pred; //把下一节点的指针指向上一节点
if (first == null) //数组索引位上没有元素
return;
if (root.parent != null) // 数组位上节点的父节点不为空
root = root.root(); //取出根节点
if (root == null || root.right == null || //根节点或根节点的右孩子为空
(rl = root.left) == null || rl.left == null) { //根节点左孩子或根节点左孩子的左孩子为空
tab[index] = first.untreeify(map); // too small
return;
}
TreeNode<K,V> p = this, pl = left, pr = right, replacement;
if (pl != null && pr != null) { //当前节点有左孩子和右孩子
TreeNode<K,V> s = pr, sl;
while ((sl = s.left) != null) // find successor
s = sl; //移动指针到右孩子最左边孩子
// p 节点和 s 节点交换颜色
boolean c = s.red; s.red = p.red; p.red = c; // swap colors
TreeNode<K,V> sr = s.right;
TreeNode<K,V> pp = p.parent;
if (s == pr) { // p was s's direct parent
//右孩子没有左孩子
p.parent = s;// 当前节点和右孩子交换位置
s.right = p;
}
else { //右孩子有左孩子
TreeNode<K,V> sp = s.parent; // 取出右孩子最左边孩子的父节点
if ((p.parent = sp) != null) { //sp 节点不为空
//把当前节点 p 和 s 节点交换位置
if (s == sp.left) // s 是左孩子
sp.left = p; //p 和左孩子交换位置
else // s 是右孩子
sp.right = p; // p 和右孩子交换位置
}
if ((s.right = pr) != null) // s 节点的右孩子和 p 节点右孩子不为空
pr.parent = s; //p 节点的右孩子 pr 作为 s 节点的右孩子
}
p.left = null; // p 节点的左孩子赋空
if ((p.right = sr) != null) //s 节点的右孩子不为空
sr.parent = p; //把 s 节点的右孩子作为 p 节点的左孩子
if ((s.left = pl) != null) //p 节点的左孩子不为空
pl.parent = s; // 把 p 节点的左孩子作为 s 节点的左孩子
if ((s.parent = pp) == null)//当前节点的父节点为空
root = s; //把 s 节点作为根节点
else if (p == pp.left) // 当前节点是父节点的左孩子
pp.left = s; //把 s 节点作为父节点的左孩子
else // 当前节点是父节点的右孩子
pp.right = s;//把 s 节点作为父节点的右孩子
if (sr != null) //s 节点的右孩子不为空
replacement = sr; // 替换 s 节点的右孩子
else //s 节点的右孩子为空
replacement = p;// 替换当前节点 p
}
else if (pl != null) //当前节点只有左孩子
replacement = pl; // 替换左孩子
else if (pr != null) //当前节点只有右孩子
replacement = pr; //替换右孩子
else // 当前节点是叶子节点
replacement = p; //替换当前节点
if (replacement != p) { //如果替换的不是当前节点
//取得当前节点的父节点,并把替换节点是父指针指向当前节点的父节点
TreeNode<K,V> pp = replacement.parent = p.parent;
if (pp == null) //如果父节点为空
root = replacement; //替换的节点作为根节点
else if (p == pp.left) //当前节点是左孩子
pp.left = replacement; //替换节点作为父亲节点的左孩子
else //当前节点是右孩子
pp.right = replacement; //替换节点作为父亲节点的右孩子
p.left = p.right = p.parent = null; //断开当前节点的指针
}
//如果删除的是黑节点,则进行平衡操作
TreeNode<K,V> r = p.red ? root : balanceDeletion(root, replacement);
//如果替换的是当前节点,即是叶子节点
if (replacement == p) { // detach
TreeNode<K,V> pp = p.parent; //取到当前节点的父亲节点
p.parent = null; //断开当前节点的父指针
if (pp != null) { //如果父亲不为空
if (p == pp.left) //如果移除的节点是左孩子
pp.left = null; //断开父节点的左指针
else if (p == pp.right) //如果移除节点是右孩子
pp.right = null; //断开父节点的右指针
}
}
if (movable) //移动根节点位置
moveRootToFront(tab, r);
}
代码很长,但是理解原理就不是很难了,如果删除的是叶子节点,则断开叶子节点和父节点之前的指针连接就可以把数据从数组删除了,如果不是叶子节点则把要删除的节点移动到叶子节点再进行删除。注意一点的是,如果删除节点后元素个数达到退化为链表的阈值,就要把树转化为链表。而且删除之后的树可能又不是一颗红黑树了,所以还要进行平衡操作,还是先来看下删除的平衡操作要经过哪些步骤:
接着看代码,通过上面步骤进行分析:
//删除节点的平衡操作
static <K,V> TreeNode<K,V> balanceDeletion(TreeNode<K,V> root,
TreeNode<K,V> x) {
for (TreeNode<K,V> xp, xpl, xpr;;) { //遍历红黑树
if (x == null || x == root) // 替换节点为空或者是根节点
return root; //不用平衡操作
else if ((xp = x.parent) == null) { // 替换当前节点没父亲节点
x.red = false; //把节点染成黑色
return x;
}
else if (x.red) {//如果当前节点是红色
x.red = false; //直接染黑,结束
return root;
}
else if ((xpl = xp.left) == x) { //如果当前节点是左孩子
if ((xpr = xp.right) != null && xpr.red) {//如果存在兄弟节点并且为红色
xpr.red = false; //兄弟节点染黑
xp.red = true; //父节点涂红
root = rotateLeft(root, xp); //以父节点作为支点进行左旋转操作
//父节点不为空则取出兄弟节点
xpr = (xp = x.parent) == null ? null : xp.right;
}
if (xpr == null) //不存在兄弟节点
x = xp; //设置x的父节点为新的x节点
else { //存在兄弟节点
TreeNode<K,V> sl = xpr.left, sr = xpr.right;
if ((sr == null || !sr.red) &&
(sl == null || !sl.red)) {//兄弟节点的两个孩子都为黑色
xpr.red = true; //将x的兄弟节点设为红色
x = xp; //设置x的父节点为新的x节点
}
else {
if (sr == null || !sr.red) {//兄弟节点的右孩子不存在或右孩子为黑节点
if (sl != null) //兄弟节点有左孩子
sl.red = false; //把兄弟节点的左孩子染黑
xpr.red = true; //把兄弟节点涂红
root = rotateRight(root, xpr); //以兄弟节点作为支点进行右旋转操作
xpr = (xp = x.parent) == null ?
null : xp.right;//存在父亲则取出兄弟节点
}
if (xpr != null) { //存在兄弟节点
//把兄弟节点染成当前节点父节点颜色
xpr.red = (xp == null) ? false : xp.red;
if ((sr = xpr.right) != null) //兄弟节点有右孩子
sr.red = false; //把兄弟节点的右孩子染黑
}
if (xp != null) { // 存在父亲节点
xp.red = false; // 把当前节点父节点染成黑色
root = rotateLeft(root, xp); //以父亲节点作为支点进行左旋操作
}
x = root;
}
}
}
else { // symmetric 删除的是右孩子
if (xpl != null && xpl.red) { //如果存在兄弟节点并且为红色
xpl.red = false; //左兄弟节点染黑
xp.red = true;//父节点涂红
root = rotateRight(root, xp);//以父节点作为支点进行右旋转操作
//父节点不为空则取出左兄弟节点
xpl = (xp = x.parent) == null ? null : xp.left;
}
if (xpl == null)//不存在兄弟节点
x = xp;//和父节点交换
else {//存在左兄弟节点
TreeNode<K,V> sl = xpl.left, sr = xpl.right;
if ((sl == null || !sl.red) &&
(sr == null || !sr.red)) { //兄弟节点的两个孩子都为黑色
xpl.red = true;//将x的兄弟节点设为红色
x = xp;//设置x的父节点为新的x节点
}
else {
if (sl == null || !sl.red) { //兄弟的左孩子是黑色
if (sr != null) //兄弟节点存在右孩子
sr.red = false;//把兄弟节点的右孩子染黑
xpl.red = true;//把兄弟节点涂红
root = rotateLeft(root, xpl);//以兄弟节点作为支点进行左旋转操作
//父节点不为空则取出兄弟节点
xpl = (xp = x.parent) == null ?
null : xp.left;
}
if (xpl != null) {//存在兄弟节点
//把兄弟节点染成当前节点父节点颜色
xpl.red = (xp == null) ? false : xp.red;
if ((sl = xpl.left) != null)//兄弟节点有左孩子
sl.red = false; //兄弟节点左孩子染成黑色
}
if (xp != null) {
xp.red = false;//把当前节点父节点染成黑色
root = rotateRight(root, xp);//以当前节点的父节点为支点进行右旋,算法
}
x = root;
}
}
}
}
}
和插入操作一样,也是通过染色旋转操作进行平衡转换,使得当前的数据满足红黑树的特性,重新变为一颗红黑树。
七、扩容机制
当 Hashmap 中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对 Hashmap 的数组进行扩容,数组扩容这个操作也会出现在 ArrayList 中,所以这是一个通用的操作,而在 Hashmap 数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是 resize。
那么 Hashmap 什么时候进行扩容呢?当 Hashmap 中的元素个数超过数组大小的 loadFactor 倍时,就会进行数组扩容,loadFactor 的默认值为 0.75,也就是说,默认情况下,数组大小为 16,那么当 Hashmap 中元素个数超过 16 * 0.75 = 12 的时候,就把数组的大小扩展为 2 * 16 = 32,即扩大一倍,然后重新计算每个元素在数组中的位置,这是一个非常消耗性能的操作,所以如果我们已经预知 HashMap 中元素的个数,那么预设元素的个数能够有效的提高 HashMap 的性能。
//扩容
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) { //原数组长度大于 0
if (oldCap >= MAXIMUM_CAPACITY) { // 原数组已是最大容量
threshold = Integer.MAX_VALUE;// 阈值设置为最大容量
return oldTab; //返回原来长度,不用扩容
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //新的长度在原来基础上增大 2 倍
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) { // 新阈值为 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; //回收老数组的指向
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 存在哈希冲突的链表结构
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 {//放到新索引位
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;//把新的链表存到新数组的 index + 老长度位置
}
}
}
}
}
return newTab;
}
总结:
HashMap 的知识点还有很多,这里我也强烈大家去多看几遍源码,是对自己能如何更好的使用 HashMap 能有更清晰的认知,毕竟它实在是太常见了,用的不好很容易就产生 bug 。而且,我觉得 JDK 的源码真的有很多值得我们开发者深入研究的地方,就比如这个 HashMap,它的真实代码量不算多,但非常的高效,最重要的是,它每个版本都在不停的优化,每一行代码都是精雕细琢。