概述
HashMap
的组成部分:数组 + 链表 + 红黑树。HashMap
的主干是一个Node
数组。Node
是HashMap
的基本组成单元,每一个Node
包含一个key-value
键值对。HashMap
的时间复杂读几乎可以接近O(1)
(如果出现了 哈希冲突可能会波动下),并且HashMap
的空间利用率一般就是在40%左右。HashMap
的大致图如下:
PS
HashMap的存取是没有顺序的
KV均允许为NULL
多线程情况下该类不安全,可以考虑用HashTable。
JDk8底层是数组 + 链表或红黑树,JDK7底层是数组 + 链表。
初始容量和装载因子是决定整个类性能的关键点,轻易不要动。
HashMap是懒汉式创建的,只有在你put数据时候才会build
单向链表转换为红黑树的时候会先变化为双向链表最终转换为红黑树,双向链表跟红黑树是
共存
的,切记。对于传入的两个
key
,会强制性的判别出个高低,判别高低主要是为了决定向左还是向右。
源码分析
重要参数
/**
* 默认的初始容量为16,箱子的个数不能太多或太少。如果太少,很容易触发扩容,如果太多,遍历哈希表会比较慢。
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* 最大的容量为2的30次方,当容量达到64时才可以树化。
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的装载因子,因此初始情况下,当存储的所有节点数 > (16 * 0.75 = 12 )时,就会触发扩容。 默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 当一个桶中的元素个数大于等于8时进行树化,是系统根据泊松分布的数据分布图来设定的。
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 当一个桶中的元素个数小于等于6时把树转化为链表。设置为6猜测是因为时间和空间的权衡。
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 链表转变成树之前,还会有一次判断,只有数组长度大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 数组,又叫作桶(bucket),无论我们初始化时候是否传参,它在自扩容时总是2的次幂。
*/
transient Node<K,V>[] table;
/**
* HashMap实例中的Entry的Set集合,作为entrySet()的缓存
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* HashMap表中存储的实例KV元素的数量
*/
transient int size;
/**
* 修改次数,用于在迭代的时候执行快速失败策略,凡是我们做的增删改都会引发modCount值的变化,跟版本控制功能类似,可以理解成version,在特定的操作下需要对version进行检查,适用于Fai-Fast机制。
* 在java的集合类中存在一种Fail-Fast的错误检测机制,当多个线程对同一集合的内容进行操作时,可能就会产生此类异常。
* 比如当A通过iterator去遍历某集合的过程中,其他线程修改了此集合,此时会抛出ConcurrentModificationException异常。
* 此类机制就是通过modCount实现的,在迭代器初始化时,会赋值expectedModCount,在迭代过程中判断modCount和expectedModCount是否一致。
*/
transient int modCount;
/**
* 当桶的使用数量达到多少时进行扩容,threshold = capacity * loadFactor
*/
int threshold;
/**
* 可自定义的负载因子,不过一般都是用系统自带的0.75
*/
final float loadFactor;
构造方法
1、默认构造方法,空参构造方法,全部使用默认值。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; //负载因子0.75
}
2、传入初始容量大小
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
//容量自定义,负载因子0.75
}
3、传入初始容量大小及负载因子
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的作用是返回大于输入参数且最小的2的整数次幂的数。比如10,则返回16。
this.threshold = tableSizeFor(initialCapacity);
}
static final int tableSizeFor(int cap) {
// 扩容门槛为传入的初始容量往上取最近的2的n次方
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;
}
4、map作为参数,map的key和value使用泛型通配符。构造函数传入一个map 使用默认的负载因子,然后根据当前map
的大小来反推需要的threshold
,同时还可能会涉到resize
,然后住个put
到 容器中。
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;//负载因子默认0.75
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();// 获取m的实际大小
if (s > 0) {
// 判断table(哈希桶)是否初始化,没有则初始化
if (table == null) { // pre-size
// 求出需要的最小容量(即扩容阈值threshold),这里s就相当于需要的最小长度
// 根据公式:需要的最小长度=总容量(即table.length)*0.75我们可以求出最小容量
// 为什么加1,是因为加载因子是小数,小数相除基本上不会是整数所以此处加1,后面转换成整数
float ft = ((float)s / loadFactor) + 1.0F;
// 限制容量不能超出最大容量
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 判断最小容量是否大于当前阈值,是的话需要重新计算阈值
if (t > threshold)
threshold = tableSizeFor(t);
}
// 如果table已经初始化,且s>当前阈值,则表示需要扩容
else if (s > threshold)
resize();
// 遍历m,将m中的元素添加到HashMap中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
put方法
HashMap的插入方法和修改方法用的都是同一个方法
remove方法
remove方法执行流程:
根据指定的key计算出其hash值;
根据hash值定位到桶数组中对应的索引位置,然后遍历该索引位置上的链表或树,查找要删除的节点;
如果找到要删除的节点,则进行删除操作,并将被删除的节点返回;否则直接返回null;
如果被删除的节点是树节点,则调用removeTreeNode方法删除树节点;
如果被删除的节点是链表节点,则直接删除该节点,并将其前驱节点指向其后继节点;
调整HashMap的size属性值;
调用afterNodeRemoval方法进行后续处理
/**
* Removes the mapping for the specified key from this map if present.
*
* @param key key whose mapping is to be removed from the map
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V remove(Object key) {
// 定义节点e
Node<K,V> e;
// 调用removeNode方法删除指定节点
// 如果节点存在则返回被删除节点的值,否则返回null
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在桶数组中的索引位置是否有链表头节点
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;
// 如果链表头节点的hash值和key值都匹配,则表示该节点就是要删除的节点
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)))) {
// 如果要删除的节点是树节点,则调用removeTreeNode方法删除树节点
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方法进行后续处理
afterNodeRemoval(node);
// 返回被删除的节点
return node;
}
}
// 如果没有找到要删除的节点,则返回
return null;
}
get方法
get方法相对来说比较简单,流程如下:
获得key的hash然后根据hash和key按照插入时候的思路去查找
get
。如果数组位置为NULL则直接返回 NULL。
如果数组位置不为NULL则先直接看数组位置是否符合。
根据获取的数组下标位置中的第一个元素,若第一个元素的key等于待查找的key,直接返回;
如果数组位置有类型说红黑树类型,则按照红黑树类型查找返回。
如果数组有next,则按照遍历链表的方式查找返回。
/**
* Returns the value to which the specified key is mapped,
* or {@code null} if this map contains no mapping for the key.
*
* <p>More formally, if this map contains a mapping from a key
* {@code k} to a value {@code v} such that {@code (key==null ? k==null :
* key.equals(k))}, then this method returns {@code v}; otherwise
* it returns {@code null}. (There can be at most one such mapping.)
*
* <p>A return value of {@code null} does not <i>necessarily</i>
* indicate that the map contains no mapping for the key; it's also
* possible that the map explicitly maps the key to {@code null}.
* The {@link #containsKey containsKey} operation may be used to
* distinguish these two cases.
*
* @see #put(Object, Object)
*/
public V get(Object key) {
Node<K,V> e;
//如果节点为空,则返回null,否则返回节点的value。
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;
// 如果数组的数量大于0并且待查找的key所在的数组的第一个元素不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 若hash值和key都相等,则说明我们要找的就是第一个元素,如果是则直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果不是的话,就遍历当前链表(或红黑树)
if ((e = first.next) != null) {
//如果是红黑树结构,则找到当前key所在的节点位置
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);
}
}
//否则,说明没有找到,返回null
return null;
}
resize方法(扩容方法)
当HashMap中元素的数量超过了负载因子乘以容量时,就需要扩容
扩容时会将容量翻倍,并重新计算hash值,将每个元素分配到新的桶中
创建一个新的桶数组,并将HashMap中的所有元素重新分配到新桶中
对于每个元素,计算其在新桶中的索引位置,然后将其插入到该索引位置
如果在新桶中的索引位置上已经存在元素,就将该元素插入到链表或红黑树中
如果插入后链表或红黑树的长度超过了TREEIFY_THRESHOLD,就将该链表转化为红黑树
扩容后更新容量和负载因子,并将新的桶数组赋值给HashMa
ps:需要注意的是,在扩容时需要重新计算元素在新桶中的索引位置,而这个索引位置计算方式与当前的容量相关。因此,在扩容时,需要保存当前的容量和桶数组的引用。另外,如果插入元素后链表或红黑树的长度超过了TREEIFY_THRESHOLD,就需要将该链表转化为红黑树。
/**
* 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.
*
* @return the table
*/
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) {
// 如果已经到达HashMap的最大容量,不再扩容
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
// 新的容量为旧的阈值
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;
// 如果计算结果小于HashMap的最大容量,则使用计算结果
// 否则使用最大值
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;// 直接分配到新桶中
//如果当前桶中有多个元素,则使用红黑树的split方法分裂成两个桶
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;// 低位桶的尾结点的next置为null
newTab[j] = loHead;// 将低位桶的头结点放入新桶数组的相应位置
}
if (hiTail != null) {
hiTail.next = null;// 高位桶的尾结点的next置为null
newTab[j + oldCap] = hiHead;// 将高位桶的头结点放入新桶数组的相应位置
}
}
}
}
}
// 返回新的桶数组
return newTab;
}
在上述代码中,当旧桶数组的长度大于等于默认初始容量时,会将其翻倍。如果翻倍后的长度仍小于HashMap的最大容量,则新的负载因子乘以容量为旧的两倍。如果旧桶数组不为空,则将其中的元素遍历,将其分配到新桶中。
如果旧桶数组中的某个桶中有多个元素,则需要将这些元素分配到新桶数组的不同桶中。如果当前桶中只有一个元素,则直接将其放入新桶数组的相应位置即可。如果当前桶中有多个元素,但是不是红黑树结构,则需要将这些元素分成两个桶,一个为低位桶,另一个为高位桶。分配的方法是按照每个元素的hash值的第i位是否为1来进行,如果是,则放入高位桶中,否则放入低位桶中。最后,将低位桶的头结点和尾结点放入新桶数组的相
红黑树
为了解决JDK1.7中的死循环问题, 在jDK1.8中新增加了红黑树,即在数组长度大于64,同时链表长度大于8的情况下,链表将转化为红黑树。同时使用尾插法。当数据的长度退化成6时,红黑树转化为链表。
什么是红黑树
在讲红黑树之前咱们得了解一下什么是二叉树:
1.左子树上所有结点的值均小于或等于它的根结点的值。
2.右子树上所有结点的值均大于或等于它的根结点的值。
3.左、右子树也分别为二叉排序树
如果要查找10。先看根节点9,由于10 > 9,因此查看右孩子13;由于10 < 13,因此查看左孩子11;由于10 < 11,因此查看左孩子10,发现10正是要查找的节点;这种方式查找最大的次数等于二叉查找树的高度。 复杂度为O(log n),但是二叉查找树也有他的缺点,如果二叉树有如下的三个节点:
当插入7,6,5,4这四个节点时:
随着树的深度增加,那么查找的效率就变得非常差了,变成了O(n),就不具有二叉查找树的优点了。
那么红黑树就诞生了,红黑树是一种自平衡的二叉查找树。
如上图就是一颗红黑树。
红黑树的特性
节点是红色或黑色;
根节点是黑色;
每个叶子节点都是黑色的空节点(NIL节点);
每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点);
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点;
每次新插入的节点都必须是红色
红黑树从根节点到叶子节点的最长路径不会超过最短路径的两倍。但是红黑树有时候在插入和删除过程中会破坏自己的规则,比如插入节点26,如下图:
由于父节点27是红色节点,因此这种情况打破了红黑树的规则4(每个红色节点的两个子节点都是黑色),必须进行调整,使之重新符合红黑树的规则。
常用的调整方法有三种:
-
左旋转
-
右旋转
-
染色
感谢阅读~