hashmap源码分析
什么是map
在学习java时,在集合部分我们学习了,列表List,集合Set,这两个接口都是继承自Collection接口,还有一个映射集合Map。
查看map源码注释,我们看源码是怎么介绍Map这个接口的:
An object that maps keys to values. A map cannot contain duplicate keys;
each key can map to at most one value.
- 是一个将key映射到值的对象。一个map不能包含重复的key,每一个key可以映射最多一个值。也就是说key-value是一一对应的。
This interface takes the place of the <tt>Dictionary</tt> class, which
was a totally abstract class rather than an interface.
- 是一个替代dictionary字典类的接口。
什么是hashmap
hashmap是基于hash表的map接口的实现。
hashmap的底层实现:
- 在jdk8前,使用数组+链表实现
- 在jdb8后,使用数组+链表+红黑树实现。
主要以jdb8的源码来学习。
数组+链表+红黑树
在jdb8中,hashmap使用hash桶来存储数据,源码见下:
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
可以看出hash桶就是一个数组,也就是hash存储结构中的数组。这个数组中存的是Node,查看Node源码:
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
Node是hashmap的一个内部类,查看这个类的内容,可以看出这个Node节点有 hash,key,value,next
等属性,重点在next属性,next的类型仍然是Node节点。
那么Node节点不断next下去就形成了链表。
至此我们已经查看到了hashmap的数组和链表的实现,我们前面说jdk8之前就是用这两种来做hashmap的底层存储的。那么jdk8后加入了红黑树在哪儿实现的?
/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
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;
// ...具体内容省略
}
这一部分就是红黑树节点的实现代码。此处了解一下,后面再详细分析红黑树。
了解至此,hashmap的底层数据结构已经了解的很清楚了。那么我们首先思考一个问题:
hashmap为什么要选择这样的存储方式?
首先我们思考一下需求:hashmap需要实现什么功能?hashmap是一个映射集合。集合都有什么功能?
读和写,也就是说要往集合中存数据,还要能取出来,能遍历。
思考数组和链表的特性:
- 数组查询快,增删改慢
- 链表查询慢,增删改快
hashmap使用数组+链表的方式,同时使用了两种方式的优点,降低时间复杂度。
数组和链表在hashmap中是如何组合的?
hashmap在调用构造方法时,会传入一个initialCapacity参数,表示hashmap的初始容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
当没有传这个值时,取默认值。
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
先对hashmap的属性进行一个了解:
- table: 也就是hash表,是Node数组,我们也叫hash桶。存储数据的底层结构。
- entrySet: hashmap中所有的键值对的集合
- loadFactor: 加载因子,用来判断是否需要扩容
- threshold: 阈值,用来判段是否需要扩容
- size:包含的键值对的数量
- modCount: hashmap中元素修改的次数
当我们调用hashmap的构造方法创建对象时,如果调用无参构造,那么就会使用默认的加载因子0.75,并在第一次往hashmap中存数据的时候初始化此hash桶。
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
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))))
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;
}
注意看putval方法中,首先会判断当前table属性是否为空,如果为空的话,调用resize()方法。
final Node<K,V>[] resize() {
...
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
...
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
...
}
return newTab;
}
在resize方法中,初始化一个初始容量为16的Node数组。
如果调用的是有参构造制定了初始大小,那么hashmap会对这个初始大小进行计算:
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
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;
}
重点看这个tableSizeFor方法,这个方法做了什么事呢?
它把传过来的容量进行了位运算,通过高16位和低16位的异或运算,得到大于等于指定的initialCapacity的最小的2的幂。
分析tableSizeFor方法:
如果指定的cap为2的幂:
- cap=32: 经过cap-1,得n=31;经过n|=n>>>1,得n=31;经过n|=n>>>2,得n=31;经过n|=n>>>4,得n=31;经过n|=n>>>8,得n=31;
经过n|=n>>>16,得n=31;最终经过判断是否超过最大值,返回结果为32。
如果指定的cap不为2的幂: - cap=27: 经过cap-1, 得n=26;经过n|=n>>>1,得n=31;后面就跟上面一样了,最终返回结果还是32。
当cap为2的幂时,那么经过cap-1后,转换为2进制,无论怎么|运算值都不变。例如:32-1=31,31的二进制为11111,无论左移多少位进行或运算,最终结果都是31,返回值31+1=32。
当cap不为2的幂时,经过cap-1后,转换为2进制,经过不断的或运算, 因为是左移,因此最高位是不会变的,就是1,后边经过多次位移并或运算后,总能将后面所有的数都变为1,因此最终得到的是
当前数的最小2的幂-1,最终返回的就是大于等于当前数的2的幂。
得出结论:无论指定的初始容量是多少,最终hashmap的容量都是2的幂。
我们回到有参构造的方法,可以看到tableSizeFor方法计算的结果,赋值给了threshold属性。
什么是阈值threshold?
在hashmap中,阈值=容量加载因子。也就是说threshold=容量加载因子。
容量就是Node[]数组的长度。
当hashmap的size大于阈值时就会出发扩容,代码如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在put方法中,数据存入成功后,++size,并用当前size与阈值判断,如果当前size大于阈值的话,就开始扩容。
hashmap容器初始化
了解了阈值之后我们再次回到初始化的方法里。现在的问题是,我们现在知道了阈值=容量*加载因子。
但是在上面的有参构造中,我们将传的初始容量赋值给了阈值。???此时想的肯定是这是什么操作。
在构造方法中,只是对阈值和加载因子设置指定值,并没有初始化map容器。
然后在上面我们说了,初始化容器是在putVal方法中。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 当创建map对象成功后,第一次调用put方法时,此时table肯定是空的,然后就会进入下面的条件
if ((tab = table) == null || (n = tab.length) == 0)
// 初始化容器
n = (tab = resize()).length;
...
...
}
现在我们进入的是初始化容器,也就是说现在整个hashmap容器的table还是空的,需要对Node数组初始化,然后此时要记住
我们目前只对threshold和加载因子赋值了,如果没有指定值就是取默认值,然后此时的threshold的值就是构造方法中指定的容器大小。
然后我们进入resize方法:
final Node<K,V>[] resize() {
// 上面说过,resize方法是扩容,如果容器是空的话,那么就是初始化容器。
// 假设我们调用有参构造时传的initialCapacity是27,那么最终经过tableSizeFor方法位运算后,最终的容量就是32,然后赋值给了threshold。
// 加载因子取默认值0.75。 以上就是前提条件,在这个条件下我们继续往下走
// 第一次初始化容器,因此table是空的。
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 但是因为指定初始化容量的缘故,因此此时的threshold的值是:32,注意这个值赋给了oldThr。
int oldThr = threshold;
int newCap, newThr = 0;
// 因为第一次,所以oldCap在上面给的值是0,进入else if
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 上面我们给oldThr的值32,因此进入此条件。
else if (oldThr > 0) // initial capacity was placed in threshold
// 注意这里将oldThr的值给了newCap,newCap也就是新的容量32。
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 当调用无参构造时,会进入这里,使用默认的容量大小
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr值并没有被修改,因此还是0
if (newThr == 0) {
// 在这里计算了新的容器的阈值ft=24,这个ft在下面又被赋给了newThr,
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 将新的阈值给threshold,也就是说,当运行到这里的时候,我们的阈值就是我们期望的容量*加载因子
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 在这一步对table进行了初始化,初始化容量值为newCap,也就是我们调用构造方法赋值时传的参数32。
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
...
return newTab;
}
上面演示了一个hashmap容器初始化的过程。下面我们继续往下看hashmap的扩容。
hashmap扩容
我们之前说,当容器的size大于阈值的时候就会触发扩容,扩容也是调用resize方法,上面跟踪了初始化的过程,下面继续跟踪扩容的过程。
final Node<K,V>[] resize() {
// 此时调用resize方法作为扩容时,那么首先table是不为空的。也就是说table的size以及大于阈值了。才会触发扩容,到达这里。
// 将table赋值给变量oldTab,并获取oldTab的容量和阈值
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 因为是扩容,所以oldCap肯定大于0,进入条件
if (oldCap > 0) {
// 如果table的容量已经大于等于最大值的话,那么就没办法扩容了,仍然返回旧的table
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 否则的话,可以扩容。新容器的大小newCap=oldCap<<1,旧的table容量右移1位,也就是变成原容量的2倍。阈值也变成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);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 执行到这里,就已经得到新的容器的容量以及阈值了,并且初始化了一个容器,新容器的大小是旧的2倍。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 下面就是将旧的容器中的数据存入新的容器中
if (oldTab != null) {
// 通过for循环,遍历老容器中的数据--链表
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;
}
}
}
}
}
return newTab;
}
上面就是一个hashmap的扩容过程。
为什么要扩容?
为了减少hash碰撞,因为如果不扩容的话,那么指定初始大小后,如果存放的数据非常多的话,就会造成hash碰撞肯定非常多,那么就会出现单链表长度过长的情况,
链表的查询速度是非常低的,就会造成hashmap的查询效率非常低,而扩容之后,所有节点会重新计算下标,原来hash碰撞的节点,扩容后可能就不碰撞了,减少了链表的长度
提高了查询效率。
小结:
经过上面的了解,我们现在关于hashmap知道了什么?
- hash表就是Node[],Node数组的大小就是容量,Node[]也叫hash桶。使用了数组(Node[])+链表(Node)+红黑树(TreeNode)存储数据。
- hashmap的容量永远是2的幂
- 当前数量超过阈值时,就会进行扩容。
- 扩容每次的容量为原容量的2倍。扩容是数组的大小扩大为2倍。
- 创建map对象后,在第一次调用put方法时,才会初始化容量
然后我们来针对上面的总结思考问题:
为什么hashmap的容量必须是2的幂?
在讲这个问题前,首先来学习一下,hashmap的工作原理是什么?
我们使用hashmap常用的方法是什么?get和put,就是读数据和写数据,那么hashmap是如何将数据存入hash表中,又是如何取出来的呢?
查看put方法的源码:
public V put(K key, V value) {
// 当调用put方法,并传入参数:key-"name",value-"zhangsan"时
// 调用putVal方法时传的参数注意 hash(key),也就是对name计算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)
n = (tab = resize()).length;
// i=(n-1)&hash 计算name这个key要存放到hash表中时,存放的下标,
// 并获取这个下标的头节点,如果为空,则直接创建新节点并放入当前下标
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 根据key的hash计算下标已经存放节点,发生hahs碰撞。
// 当代码执行到这里时,我们看一下当前变量的值, p:hash桶中碰撞节点的头节点,key:name;value:zhangsan
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 通过equals对第一个节点的key进行判断,如果key已存在的话,就取出来这个节点
e = p;
else if (p instanceof TreeNode)
// 向红黑树节点中插入数据,遍历获取红黑树中此key的节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果p的key与要put的key不一致,则遍历其他的节点,获取此key的节点
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 如果下一个节点是空,那么说明遍历完此链表中还没有存入过此key,那么创建一个新的节点添加到此链表
p.next = newNode(hash, key, value, null);
// 这个条件是如果当前这个bitCount超过7的话,那么就将此链表转为红黑树。
// 但是此时因为在上面新增了一个节点,因此此时的链表长度其实为8,也就是说虽然bitCount是7,但是实例链表长度
// 是8,所以说当链表长度超过8时,转为红黑树
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))))
break;
p = e;
}
}
// 如果e不为空,说明此hash表中已经存在此节点,那么只需要替换值就行了,因为是替换,所以长度不会改变,不需要考虑链表转红黑树的事
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;
}
}
// 将链表转为数
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 注意:当hash桶的容量小于64时,只会触发扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
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);
}
}
总结一下hashmap调用put方法时的工作原理:
调用put方法时,一定会传入key,value:
- 对key取hash值
- 根据第一步求的的hash值与hash桶的长度进行位运算,得到这个key最终要存放在hash桶的下标。
也就是说,我hash桶是一个Node数组,里面存放了好多的Node节点,这些Node每一个都是一个链表,也就是说我数组中,存放了好多的链表,我要计算新来的key到底该 放在哪儿个链表里。怎么计算呢? 我们上面说了,hash桶的长度永远都是2的幂,因此假设长度是n的话,那么就使用 hash&(n-1) 计算下标,为什么要n-1? 1. n-1正好是数组的下标最大值 2. 因为长度是2的幂,那么如果减1就能保证最高位往后都是1。比如(8-1)的二进制为 0111,(16-1)的二进制为 1111,(32-1)的二进制为 0011 1111。 这样的话,通过 hash&(n-1),最终无论hash值有多大,最终的结果都在(n-1)的范围内。假设我现在的hash是79,hash表容量是8,那么最终的运算就是 0100 1111&0000 0111 = 0111,那么这个key就是放在7这个下标,再比如hash值是65,转为二进制就是 01000001&0111=0001,因此此key的下标就是1。
- 计算出下标以后,就拿这个下标当前链表的头节点:
- 如果这个下标还没有节点,是null的,那么就创建一个新的节点放入这个下标。
- 如果这个下标已经有节点了,说明发生了hash碰撞。那么获取这个下标的头节点,也就拿到了整条链表,如果此节点是红黑树节点,那么也就拿到了根节点。
然后在通过 next 获取子节点,通过.equals()方法,将当前节点的key与要put的key进行对比,如果key相等的话,那么说明此key已经存在,更新key的值,并返回旧的值。
put方法至此结束。
如果遍历到最后一个节点,仍然没有找到key值一样的(.equals()匹配),那么则创建一个新节点,并插入当前链表的末尾。如果当前,链表长度超过8的话,那么就将链表转为
红黑树。
如果桶的容量小于64,则只会发生扩容,桶容量大于64时,如果链表超过8才会转为红黑树
- 数据插入成功,判断当前是否需要扩容,如果需要则扩容。
在来看下get方法的源码:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 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; 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))))
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;
}
前面看了put方法的源码后,get方法就比较简单了。与put方法类似,调用get方法一定后传一个key值。只需要对这个key取hash。然后通过位运算计算下标。
获取下标节点,通过 next 方法遍历链表或通过红黑树遍历节点,通过.equals()方法判断key是否相等,如果相等则返回此节点的值。如果查不到相等的节点则返回null。
再来看remove方法源码:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
/**
* Implements Map.remove and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to match if matchValue, else ignored
* @param matchValue if true only remove if value is equal
* @param movable if false do not move other nodes while removing
* @return the node, or null if none
*/
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;
// 根据key的hash值找到在hash桶中哪儿个节点下
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;
// 判断链表的头节点是否匹配key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 如果是树节点,则从树节点中获取此key的节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 遍历链表的子节点,获取匹配的key的节点
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)))) {
// 是树节点就从树节点中删除,是头节点的话,就移除头节点,是子节点的话,就将上一个节点的next指针指向下一个节点。
// 比如说链表 a->b->c,如果要移除b,那么只需要将a节点的下一个节点指向c,也就是a.next=b.next,就移除了b。
if (node instanceof TreeNode)
// 需要注意移除红黑树节点的话,如果树的节点树小于6,那么就将树降为链表
((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;
}
其实上面学了put方法后,下面的会简单很多。remove方法仍是根据key的hash以及.equals()定位节点,如果没有找到节点,则返回null。
如果找到此节点的话,并移除此节点,如果是红黑树数的话,在移除数据后,如果数据量小于6,则将红黑树恢复成链表。
hash
我们注意到,无论是put,get,remove,还是hashmap这个名字,都有一个重要的单词--hash。 再回过头看一下hash这个方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到,hash的取值是通过 (h=key.hashCode())^(h>>>16) 计算出来的。
为什么hash要使用异或?
主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。
hash: Object类的hashCode方法,类重写此方法,调用时会将类对象计算出一个hash值。
hash值相等,两个对象不一定相等。 a.hash == b.hash, a.equals(b)不一定为true。
两个对象相等,那么hash值一定相等。 a.equals(b) == true, a.hash == b.hash.
为什么要设计阈值这个东西?
设计阈值就是为了提高效率,如果没有阈值的话,也就是说阈值是1,那么空间利用率高了,那么扩容的触发条件也就变高了, 如果hash桶足够打,那么触发一次扩容需要的数据
也就非常多了,这样就会造成每个节点下的链表长度都会很长,链表长的话,查询效率就降低了。有了阈值,就可以控制什么时候该扩容,提高查询效率。
那么为什么阈值要为0.75呢?
因为阈值如果高于0.75就会出现上面的情况,如果阈值低的话,那么空间利用率就会降低,频繁的触发扩容,扩容是消耗性能的。因为hash桶是用数组,数组是定长的,
那就意味着,扩容就需要创建一个新的数组,并将原数组中的所有Node,重新计算下标,放入新的数组中。
为什么创建hashmap推荐指定初始大小
因为如果创建hashmap后,需要存入的数据非常多的话,不指定初始大小,就会使用默认的16,16大小肯定不够,就会频繁的触发扩容,前面也说了,频繁扩容影响性能。
如果创建时就指定合适的初始大小,那么在容器初始化时,就会初始化比较大的容量,避免了很多次扩容。提高效率。
为什么要设计红黑树
为了提高查询性能。如果只使用链表存储的话,那么如果某一条链表非常长的话,就会造成hashmap的查询速度非常慢。那么为了提高查询效率,自然就想到使用二叉树,
但是使用平衡二叉树的话,在某种特殊情况下,还是变成了链表。那么就想到了平衡二叉树,平衡二叉树要求比较严格,为了维护平衡二叉树所付出的代价太大。所以hashmap使用
了平衡二叉树。
红黑树
上面在很多地方都出现了红黑树,那么红黑树是什么?
是一种特殊的平衡二叉树。
红黑树的特点
- 每个节点非红即黑
- 根节点为黑色
- 所有叶子节点都为黑色的空节点
- 如果节点是红色的,那么他的子节点一定是黑色的(从每个叶子到根的所有路径上不能有两个连续的红色节点,反之不一定)
- 从根节点到叶节点或空自节点的每条路径,必须包含相同数目的黑色节点(即相同的黑节点高度)。
红黑树在线生成网站
在jdk8前,我们使用链表存储数据,那么上面也说了,链表的缺点就是查询速度慢,因为链表查询一条数据需要从头节点开始,遍历整条链表。我们看这样一条链表:
1->2->3->4->5->6->7->8->9->10
如果要查找10的节点,需要把前面的所有节点遍历一遍,当这个链表非常长的时候,就会特别慢,为了提高查询效率,我们使用二叉树。
二叉树及红黑树讲解请参考:红黑树详解