和之前的系列一样,我们先上HashMap
的类继承关系图,如下:
一般说到HashMap
,和它关联最大的应该就是ConcurrentHashMap
、HashTable
、TreeMap
等。之前已经介绍了HashTable
,这里通过继承关系图可以看到和HashTable
不一样的是,HashMap
是继承实现的AbstractMap
,而HashTable
则是继承实现自Dictionary
类。
接下来,我们继续分析HashMap
的底层数据存储方式,对此几个主要变量如下:
-
transient Node<K,V>[] table
:底层存储数据用的数组
-
transient int size
:数组中已有的元素个数
-
int threshold
:扩容阀值
-
final float loadFactor
:负载因子
-
static final int TREEIFY_THRESHOLD = 8
:链表转红黑树的阀值
-
static final int UNTREEIFY_THRESHOLD = 6
:红黑树转链表的阀值
-
static final int MIN_TREEIFY_CAPACITY = 64
:需要进行链表转红黑树的table数组阀值
以上变量可以得到的信息是HashMap
底层的存储是一个Node
数组,数组还和红黑树有关联,这里所谓的和红黑树的关联就是在JDK 1.8中HashMap
中每个table
链表数组中的链表在达到对应条件后,为了提高效率会把对应槽位的链表结构转化为一个红黑树的结构,从而提高了查询效率,因为后续分析过程中会发现其实HashMap
中会涉及非常多的查询逻辑,这里指的查询不只是单纯的get
方法的查询。在1.7以前的版本中,如果哈希函数设计的不合理,那么可能会导致非常多的哈希冲突发生,那么可能就会导致在某一个槽位的链表中出现非常多的元素,从而在查询的时候需要遍历一遍效率非常低,这也是引入红黑树的原因。
接下来,我们先走进HashMap
的构造函数,构造函数和HashTable
一样有两大类四个构造函数,一种是没有数据只是进行简单初始化的构造,还有一种是带有map
数据的构造,代码如下:
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;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
以上构造函数需要注意的是,我们常用的是无参构造和指定初始化长度的这两个,对于无参构造而言,在没有调用put
方法之前,构造函数中只是给负载因子赋值0.75,而初始化长度和阀值等都是默认为0的。对于第一个两个参数都指定的构造函数有一点需要注意的是,在初始化的时候,做的事情只是给负载因子以及扩容阀值赋值,并且这里的扩容阀值的赋值还是和我们知道的threshold=size*loadFactor
不一样,这里threshold=tableSizeFor(initialCapacity) = tableSize
。对于tableSizeFor
函数而言,作用就是找到最接近刚好大于等于initialCapacity
的2^n
的值,即假设initialCapacity=13
,那么tableSizeFor(initialCapacity)=16
。一开始看到这里的时候自己也很纳闷为什么这里是这么赋值的,而且没有给table
数组初始化,既然构造函数里都没有做这些事情,那么很显然唯一能做这些事情的只有在put
初次往HashMap
中插入数据的时候了。带着这个疑问,我们跳过另外一个构造函数的解析,因为实现都很简单,无需赘述,直接开始介绍关于HashMap
的增删改查对应的方法实现。
新增&修改put
关于put
方法延伸开来,需要介绍的地方有很多,我们先看源码和备注如下:
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;
// 如果发现table为空,说明这是第一次调用put方法,需要进行数组初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果在tab位置的对应槽位没有数据,说明本次插入运气好,找到了一个空的位置,直接新建一个节点插入到对应槽位即可。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 发现这个槽位已经有数据了,也即产生了哈希冲突了,进一步进行判断如何解决这个问题
else {
// 这里的e是用来标记本次put操作是更新了已有key的数据还是插入了一个新节点
Node<K,V> e; K k;
// 发现刚好当前链表的头结点就是需要插入数据的key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 头结点不是我们所需,且发现这个槽位中存储的其实是一个红黑树,则需要往红黑树中执行put操作
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 到这里就发现头结点不是我们找的节点,且p不是红黑树,那么就只有去遍历链表p来进行put操作了
else {
// binCount是用来记录当前遍历了多少个节点的
for (int binCount = 0; ; ++binCount) {
// 满足这个条件说明已经遍历到了链表p的尾节点还没找到key,那么需要做的事情就是在最后
// 插入新的节点数据,这里需要注意如果是尾节点,那么会导致e=p.next=null,在后续有用。
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 判断当前链表中已有的节点个数是否满足转换为红黑树的阀值,如果达到条件就转化。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 遍历中间过程中,判断当前节点的key是否就是我们寻找的那个她,如果是就掳走!
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// e如果不为空,说明是找到了那个满足条件的她,而不是插入了一个新节点,那么我们需要做的
// 就是把e的值修改为本次put的value,并返回oldValue。
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 执行到这里,说明本次put操作是新建了一个节点插入到链表中。
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
以上putVal
方法中备注部分已经把执行逻辑基本介绍清楚,但是其中涉及到resize
和红黑树相关内容,这里我们暂时忽略红黑树,继续介绍resize
扩容的操作,代码如下:
final Node<K,V>[] resize() {
// 备份原始table,保留犯罪现场。
Node<K,V>[] oldTab = table;
// 初次调用put时,之前说过在构造函数中不会初始化table,所以这里肯定table==null成立,
// 即oldCap=0,当然如果不是初次调用就是oldCap=oldTab.length
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 这里记住之前构造函数中,在构造函数中threshold取值其实是初始化的数组长度!!
int oldThr = threshold;
int newCap, newThr = 0;
// 满足这个条件,说明原始的table中已经初始化过了,说明肯定有数据。
if (oldCap > 0) {
// table长度都达到人生巅峰了,不需要再扩容了!
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 执行到这里说明数组有数据,这种情况下先给newCap赋值为oldCap*2,然后判断条件是否满足,
// 如果条件满足,则给新的阀值赋值为原始阀值的两倍。
// 这里有一种情况就是构造函数加入调用的是 new HashMap<>(8)这种,此时初始化数组长度是8,
// 在没有进行扩容前,执行到这里会发现由于oldCap>=16不成立而跳过,为啥要这样,
// 我们先带着这个疑问继续往下看。
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 执行到这里,说明oldCap==0,如果oldThr>0,说明用的构造函数是带有初始化数组长度的。
else if (oldThr > 0)
// 之前介绍过,对于带有初始化长度的构造函数,阀值就是数组初始化长度,果然这里用到了
newCap = oldThr;
else { // 执行到这块逻辑说明调用的是无参构造创建的,才会有阀值和数组长度都为0。
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 执行满足条件说明是第一个if或者第二个else if中没有设置newThr,在这里统一设置
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 更新扩容阀值,这里对于初次构造函数中设置的"错误"的阀值在此处就回归正轨了
threshold = newThr;
// 创建新的Node数组,来容纳之前的数据
@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; // 由于e中已经保留了备份,这里值为空值便于GC
// 当前槽位只有一个节点,直接挪到新表中即可。
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
// 到这里就意味着,当前槽位是一个没有转化为红黑树的链表,由于数组长度是2^n
// 扩容后变为了2^(n+1),所以对于模运算来说,之前在同一个槽位中的节点,
// 扩容后只会分配到两个位置,一个是新数组中当前下班low处,另一个是新数组中的新下标high处
// 为了提高插入新数组的效率,先遍历一遍链表,然后得到两个独立的链表,分别代表low和high
// 对于这两个链表分别插入到新数组中的两个位置。
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);
// 分别将loTail和hiTail插入新数组中。
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
以上就是关于HashMap
中对于put
方法插入数据时的相关内容,为了提高效率,内部进行的优化有:
-
- 尽量用各种位运算,提高效率
-
- 为了解决1.7以前某个槽位链表过程导致查询耗时过长问题,引入红黑树,当链表长度超过8时,将链表转换为红黑树,提高检索效率。
-
- 在进行扩容时,先遍历链表中数据,分别得到low和high两个新链表,分别插入到新数组中。
-
- 还有就是遍历过程中,用完槽位的数据立马设置为null,便于GC。
-
- 由于扩容是原始长度*2,所以对于获取新数组中的槽位index的时候,只会分别对应两个值,为啥这么说呢,这是因为同一个链表中的所有key,他们对于之前老的数组模除假设为
m
,这里假设哈希值为hash
,数组长度为l
,那么可以知道hash = l*t + m
,这里t
是大于等于0的整数,只有这样才有hash%l=m
。那么,这里当t
为偶数的时候,假设t=2*k
,用扩容后的新数组n=2*l
来模运算,则有nhash = (l*2*k+m)%(2*l)=m
,即如果t
为偶数时,在新数组中当前这个key的槽位下标是不变的,还是在m
处。同理,很显然,当t
为基数的时候,t=2*k+1
可以得到nhash=(l*2*k+l+m)%(2*l)=l+m
,所以对于这类节点,它们新的归属就是新数组中的l+m
位置,这也是为啥resize
中挪动链表时会出现两个loTail
和hiTail
的原因,分为两个链表后,可以保留之前的顺序,并且在新数组中只需要执行两次插入操作即可。
- 由于扩容是原始长度*2,所以对于获取新数组中的槽位index的时候,只会分别对应两个值,为啥这么说呢,这是因为同一个链表中的所有key,他们对于之前老的数组模除假设为
当然了,这其中在putVal
中最后有几个方法是预留给LinkedHashMap
方法实现用的,因为LinkedHashMap
就是升级版本或者说改造版本的HashMap
,这个在之后再进行分析。
删除remove
对于remove
方法,和之前写的系列文章说的一样,在弄明白put
方法是如何插入数据后,其它的删除,修改,查找方法的逻辑都是不难理解的,因为put
方法中基本会涉及大家都会用到的检索逻辑,只要把检索节点的逻辑搞清楚了,那么后续其它的所有方法都是很好理解的了。我们直接上remove
的方法如下:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 下面这部分的逻辑和之前`put`中遍历节点是完全一样的,先找到匹配key的节点,记录在node变量中
// 然后遍历完后,判断node是否为空,如果不为空说明找到了需要删除的节点,进行删除操作就可以了
// 由于这里面涉及的删除操作是链表的删除,也不需要和数组删除一样移动元素,实现十分简单,
// 在此不再进行赘述,只是说如果发现没有匹配到key,返回null则说明删除失败。
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
删除的逻辑和put
检索用到的逻辑完全一致,分析看备注中内容即可。另外,还有其它的删除需要匹配value
的方法,就是在检索的时候如果找到key
满足条件,则进行删除的时候多加一条逻辑判断value
是否匹配即可。
查询get
和remove
一样,先直接上代码:
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))))
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
而言,其实逻辑比remove
更简单,因为对于remove
还需要考虑如果删除成功后,节点个数少于6个则需要把红黑树转换为链表的情况。而get
就只是需要检索,找到匹配的key就可以返回了。
以上就是HashMap
的主要介绍,当然了一般会把HashMap
和HashTable
进行比较,这里简单列一下他们的区别:
-
HashTable
是线程安全的,都是通过synchronized
关键字加锁实现的。
-
HashTable
在构造函数中是会初始化table数组的,初始化默认长度为11,HashMap
则默认是16,并且在构造函数中不会初始化。
-
HashTable
中的value
是不能为null
的,而HashMap
对此不加限制。
-
HashTable
中扩容的时候,由于底层存储的数组长度不一定是2^n
,而且扩容是用的newCap = (oldCap<<1)+1
,新数组长度也不是倍数关系,所以之前HashMap
推导新数组中节点槽位的方法这里不适用,从而在扩容时,必须一一遍历数组中的节点,然后依次插入到新的数组中,效率十分低。
-
HashMap
和HashTable
继承的子类也不一样,HashMap
继承自AbstractMap
,HashTable
继承自Dictionary
。