目录
简介
HashMap是自jdk1.2引入的,底层使用散列表实现的,存储的是键-值对映射。HashMap继承了抽象类AbstractMap,实现了Map、Cloneable、java.io.Serializable接口,所以它可以克隆,进行序列化传输。HashMap允许存储null键和null值,null键的哈希值是0,它不能保证键-值对顺序,意味着HashMap中键-值对顺序并非是插入元素的顺序。
此外HashMap不是同步的,线程不是安全的,所以在多线程中使用可能会出现问题,可以通过Collections.synchronizedMap来获取安全的HashMap。Hashtable与HashMap具有相同的方法,区别是Hashtable是线程安全的和不允许存储null键和null值。
HashMap使用的是哈希桶数组来实现存储键-值映射,根据键的哈希值取模计算得到索引位置,不同键具有相同的哈希值将会落到同一个索引位置,这就是hash冲突(也称hash碰撞)。解决这种冲突的方法常见的有探测再散列和链地址法(拉链法)。HashMap使用的是链地址法,通过数组加链表的形式实现的,其中链表是单向的。jdk1.8引入了红黑树,存储和查找速度有了一定的提升。如下图:
HashMap在进行增删查等操作时, 会先根据键key的hash值定位到桶,然后遍历桶中的链表找到符合条件的节点。需要说明的一点是HashMap底层是数组+链表(或红黑树),数组每个索引位置是链表,也称为桶。链表中每个节点是Node,它是HashMap的内部类,实现了Map.Entry<K,V>接口,内部变量包含了hash值,key和value,以及后驱指针next。链表是单向的,每个节点Node实则就是存储键-值对映射的类,实际上HashMap的底层就是一个Node类型的数组。
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;
}
}
源码分析
1.构造方法
HashMap的构造方法有四个,包含三个是有参构造方法和一个无参构造方法。构造方法会初始化一些基本变量,HashMap有参构造方法可指定负载因子和初始容量,通过初始容量计算出阈值。无参构造方法将初始化负载因子,其他变量将会在首次插入操作时进行计算。对于几个参数没有特别要求的情况下,常用无参构造方法创建HashMap实例。
//1.有参构造方法,构建的是指定初始容量和指定负载因子的HashMap.
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);
}
//2.有参构造方法,构建的是指定初始容量,默认负载因子是0.75的HashMap.
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//3.无参构造方法,构建的是初始容量是16,默认负载因子是0.75的HashMap.
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//4.有参构造方法,构建映射关系与m相同的HashMap.负载因子是0.75和足够多的容量容纳m的键-值映射.
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
2.内部变量(初始容量,负载因子,阈值等)
//HashMap默认的初始化容量,必定2的幂方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4
//可扩容或者构造方法指定的最大容量
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
//由链表转换成红黑树之前,进行判断
static final int MIN_TREEIFY_CAPACITY = 64
//HashMap底层的节点数组,首次使用会进行初始化,并调整大小.
//分配时大小一定是2的幂。
transient Node<K,V>[] table;
//对应键-值对的映射集
transient Set<Map.Entry<K,V>> entrySet
//HashMap中包含的键-值对映射数量。
transient int size
//modCount用来记录HashMap的结构修改次数。
transient int modCount
//HashMap当前能容纳的键-值对映射的数量,超过此值将会扩容(capacity * load factor)
int threshold
//负载因子
final float loadFactor
HashMap中有几个比较重要的变量,modCount是用来记录修改的次数,任何对HashMap的修改都会导致ModCount++,使用迭代器进行迭代时会将ModCount值赋值给expectedModCount。在迭代过程中会判断modCount是否等于expectedModCount。如果不等,说明在迭代过程中其他线程修改了HashMap,就会抛出ConcurrentModificationException异常。
table是用于存储数据的变量,可以看到table实际一个Node类型数组,称为哈希桶数组。数组每个索引位置存储的元素是一个链表(或者红黑树),table的默认初始容量是16。如果哈希桶数组长度比较小,即使哈希算法比较优化情况下,当数组中元素增加到一定的数量时,会产生碰撞。如果哈希桶数组的长度比较大,这会浪费一部分的空间。需要在空间和时间上进行权衡,使得哈希桶发生碰撞概率较低,并且不会浪费空间。HashMap有两个可供调节的参数,loadFactor和threshold。
loadFactor是负载因子,它表示的是HashMap的容量在自动增长前,可存储键-值对映射充满程度,其值可以大于1。负载因子过高,虽然减小了空间开销,但同时也导致查找效率下降,键与键之间碰撞率高的情况下,可能会变成线性查找;负载因子过低,键与键之间的碰撞率减小,理想情况下,table中每个索引处值存储一个节点Node,查找的效率大大提升,但同时也会浪费了一些的空间。默认负载因子0.75,是时间成本和空间成本上的权衡,一般是不需要修改此参数默认值。
threshold是阈值,是通过负载因子乘以容量得到的,源码中有注释 ,所以实际上决定阈值大小因素除负载因子以外,与容量大小有关,而初始容量可以通过HashMap有参构造方法来指定。当哈希桶数组中元素个数超过阈值,便会进行扩容。比如哈希桶数组初始容量capacity=16,负载因子loadFactor是0.75,阈值threshold= 16*0.75=12,当桶数量超过12时,会进行扩容。HashMap无参构造方法会在put方法时计算threshold,有参构造方法会调用tableSizeFor(int cap)方法来计算初始阈值threshold, 下面来分析方法tableSizeFor()。
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的注释是获取大于给定capacity的最小的2次幂的一个数值。例如:十进制数1593294323,可以表示成+3,经过tableSizeFor方法计算得到的结果是
。算法也比较好理解,可结合下面的例子理解,首先通过位算法,将第一个1后面的所有的二进制位变成1,然后计算n+1,将会得到一个二进制数,其仅包含一个二进制位为1,其他二进制位都是0。一个二进制数只包含一个1,其他二进制位都是0,那么这个二进制数对应十进制数必定是2的幂次方(如1000=8=
),所以经过方法tableSizeFor计算得到结果必定是
,至于为什么是2的幂次方,后面在做解释。
3.内部方法
3.1 get()方法
public V get(Object key) {
Node<K,V> e;
//getNode方法返回的是null,返回null,否则返回对应节点的value.
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) {
//检查对应哈希桶第一个节点,如果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) {
//如果存储的形式是红黑树,遍历红黑树,获取对应的节点
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;
}
//求取哈希值,当键key为null时,返回的哈希值为0
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
get()方法是通过给定的键找到对应的键-值对映射,并返回对应的值。核心方法是getNode()。散列表不为空时,通过(n - 1) & hash位运算定位到键在哈希桶数组中的索引位置,取对应索引的第一个节点,判断情况可以分为三种:
1)检查第一个节点与指定键的hash值与key值,如果都相等情况下,直接返回第一个节点。
2)如果第一个节点的类型是红黑树,调用getTreeNode()方法遍历红黑树获取符合条件的节点。
3)否则节点属于链表类型,遍历整个链表,找到链表中hash值与key值都与指定键相同的节点并返回。
此处不详细介绍红黑树相关的代码。这段代码并不难,无非是通过键的hash值定位哈希桶数组中位置,如果命中指定索引位置中第一个节点,直接返回,否则节点存储形式可能是红黑树或者链表,遍历树或者链表,找到命中的节点并返回。这里两个点需要解释一下,一是定位键在哈希桶数组中位置的计算(n-1)&hash。第二点是求取键的hash值方法,并不是直接使用了键的hashCode值,而是经过位运算获取的哈希值。
对于(n-1)&hash计算,其中n是哈希桶数组的长度length,也就是(length-1)&hash。而哈希桶数组长度必定是2的幂次方,当n=时,(length-1)&hash计算结果与(length-1)%hash的结果是一样,这也解释了哈希桶数组长度一定是2的幂次方的原因,取模通过位运算而不是通过算术运算符“%”来计算,大大提升了计算的速度。实际上计算机内部处理数据都是操作二进制数进行的,位运算处理效率是比较高的。有些运算或者判断逻辑可以转换成位运算,但是生活中用的是十进制,人类处理算法的方式与计算机不一致,可以说位运算的可读性不是很好,并且比较难写(门槛高)。此处的位运算来取模运算是一种优化处理方式,举个例子:比如hash = 3327858,n = 16(容量默认值)。
从上面取模运算结果可以看出,由于哈希桶数组的长度比较小,计算结果只保留了hash值低位信息,缺失了高位信息。HashMap中对于求取键的hash值做了特殊处理,键的hashCode值二进制位是32位(int类型),将hashCode值无符号右移16位后,与本身进行异或位运算(二进制位相同为0,不同则为1),运算结果的低位间接地拥有高位的信息,增加低位信息的随机性,这会影响键在哈希桶数组中的分散性,减小了键的碰撞率。
3.2 put()方法
//将指定的键-值对映射存储到HashMap中
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;
//如果哈希桶数组为空或者长度为0
if ((tab = table) == null || (n = tab.length) == 0)
//扩容后,得到哈希桶数组的长度
n = (tab = resize()).length;
//根据键的hash值计算在table中索引位置,如果对应索引位置没有元素,创建一个节点添加到table中
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//对应索引位置存在元素
Node<K, V> e;
K k;
//先判断对应索引处的第一个节点key和hash与指定键的是否相同
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) {
//p的下一个节点为null,表明p是链表中最后一个节点
if ((e = p.next) == null) {
//新建节点,并将p的后驱指针指向新建节点
p.next = newNode(hash, key, value, null);
//链表的长度超过了阈值 TREEIFY_THRESHOLD,将链表转换成红黑树
//注意:此处 TREEIFY_THRESHOLD-1是由于binCount从0开始的
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果链表中某个节点的键的hash值与键值与指定键的相同,表明已经存在映射,跳出循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
//将下一个节点赋值给当前节点,继续往下遍历链表
p = e;
}
}
//如果e不为空,表明哈希桶数组已经存在指定键的键-值对映射
if (e != null) { // existing mapping for key
//获取键-值对映射的旧值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//用指定的值替换旧值
e.value = value;
//此函数会将链表中最近使用的Node节点放到链表末端
//因为未使用的节点下次使用的概率较低
afterNodeAccess(e);
//返回旧值
return oldValue;
}
}
++modCount;
//添加元素后超过阈值,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
put() 是将指定键-值对映射存储到HashMap中,可以看得出哈希桶数组table在此时才进行了初始化,源码中的步骤也比较清晰,根据键的hash值计算在哈希桶数组中索引位置,根据索引处位置是否存在元素进行了下面的判断:
1)如果索引位置不存在元素,直接创建一个新节点Node存储。
2)索引位置存在元素,进行以下判断:
a.先检查索引位置的第一个节点是否符合条件,即第一个节点的hash值和键值是否与指定键的相同。
b.如果节点的存储形式是红黑树,会遍历红黑树搜索与指定的键具有相同的键值和hash值的树节点,找到情况下,返回命中的树节点,否则会将键-值对映射存储到红黑树中。
c.如果节点的存储形式是链表,搜索链表找到与指定键具有相同的键值和hash值的节点,找到情况下,会直接跳出循环,找不到情况下,会将指定键- 值对映射插入在链表末尾。需要注意的是在插入键-值对映射后,链表中节点超过TREEIFY_THRESHOLD=8时,会将链表转换为红黑树。
3)经过以上的判断如果原先红黑树或者链表中存在与指定键具有相同的键值和键hash值的节点,替换原先键-值对映射中的值,并返回原先的值,当然添加完键-值对映射超过了阈值便会扩容。
链表转红黑树
当指定的索引位置链表中节点的数量超过阈值TREEIFY_THRESHOLD时,会调用方法treeifyBin将链表转成红黑树,源码中treeifyBin()方法做了两种判断:
1.先判断了哈希桶数组是否为null,或者哈希桶数组的长度小于阈值MIN_TREEIFY_CAPACITY,会进行扩容。
2.其次是将链表中的节点对应转换成树节点,然后树节点之间双向关联,实际做的事情是树节点串起来的双向链表。然后将此双向链表转成红黑树。
通过上面的分析,如果哈希通数组不为null的情况下,链表转成红黑树的必须满足两个条件是:
第一是链表的长度必须大于等于阈值TREEIFY_THRESHOLD=8。
第二是哈希桶数组的长度小于MIN_TREEIFY_CAPACITY=64。
关于第一点,HashMap源码中已经给出了明确的解答,理想状态下,哈希桶表数组中每个索引位置的节点数量符合泊松分布,先科普一下泊松分布,泊松分布比较适合描述的是随机事件在单位时间(或者单位面积)内发生的次数,通常是用来估计算一段时间或者空间内发生成功时间的概率。公式如下:
其中是单位时间(或单位面积)随机事件发生的次数 。当负载因子是默认值0.75时,
的值是0.5。所以上面公式变成了P=(
*
)/
,随着节点数量
的变化,概率分布如下:
节点数量 | 概率 |
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 |
可以发现哈希桶数组中某索引位置处链表中节点数量为8的出现概率极小,如果节点数量超过8个,也会导致链表增删成本比较高,将链表转成红黑树也是一种优化。
关于第二点,哈希桶数组的容量比较小时,随着向HashMap添加的键-值对映射越来越多,发生碰撞概率也会变大。源码注释指出指定索引位置的节点数量太多,应该优先调整的是哈希桶数组的容量,而不是进行树化。如果不先进行扩容,而是当节点数量达到树化的阈值,将链表转成红黑树。以后再进行扩容时,需要先将红黑树拆分,存储在新的哈希桶数组中,拆分后如果树节点数量小于等于链化阈值,又会将红黑树链化操作,此过程复杂且耗时,所以哈希桶数组长度达到阈值前,会先进行扩容操作。
//将链表转换成红黑树
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果哈希桶数组为null,或者哈希桶数组长度小于MIN_TREEIFY_CAPACITY
//先进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果指定索引位置的元素不为null
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);
}
//将链表节点转化成树节点返回
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
3.3 resize()方法
即使hash算法再优异,随着哈希桶数组中元素数量增多,发生的碰撞概率也会增大,这可能导致索引位置的链表会越来越长,查询和存储效率随之递减。所以超过阈值(capacity * load factor),需要进行扩容。从下面源码中可以看到扩容其实是一个比较耗时的操作,需要遍历哈希桶数组,将所有索引处的元素移动到新的哈希桶数组中,所以知道存储的元素数量,可以设置HashMap的容量。
final Node<K,V>[] resize() {
//原先哈希桶数组是oldTab
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//分支1
if (oldCap > 0) {
//如果原先的哈希桶数组容量 超过最大值
if (oldCap >= MAXIMUM_CAPACITY) {
//将阈值设置最大值,并直接返回原先的哈希桶数组
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新table容量是原先table容量的两倍,并且原先table容量超过默认容量值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//阈值也变为原先阈值的2倍
newThr = oldThr << 1; // double threshold
}
//分支2:走到此处表明oldCap=0,即table为null,将原先的阈值赋值给容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//分支3:走到此处表明oldCap=0,即table为null,并且原先阈值oldThr=0
else {
// zero initial threshold signifies using defaults
//设置容量和阈值
newCap = DEFAULT_INITIAL_CAPACITY;
//通过负载因子*初始容量计算阈值
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);
}
//将计算后的阈值赋值给thresold
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;
//如果节点e在原先哈希桶数组中的存储形式是红黑树
//将整个红黑树遍历存储到新的哈希桶数组中
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//如果原先哈希桶数组指定索引处存储的是链表
//会将原先里链表中的节点分成两条,以e.hash&oldcap是否为空分割
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//符合e.hash&oldcap==0的节点串在一起
if ((e.hash & oldCap) == 0) {
if (loTail == null)
//loHead是节点的头
loHead = e;
else
loTail.next = e;
//将当前节点e赋给loTail
loTail = e;
}
//符合e.hash&oldcap!=0的节点串在一起
else {
if (hiTail == null)
//hiHead是节点的头
hiHead = e;
else
hiTail.next = e;
//将当前节点e赋值给hiTail
hiTail = e;
}
} while ((e = next) != null);
//当loTail不为null时,将新哈希桶数组中j位置设置为loHead链表
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//当hiTail不为null时,将新哈希桶数组中j+oldCap位置设置为hiHead链表
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新的哈希桶数组
return newTab;
}
扩容方法resize()可以看到,计算新阈值和新容量的情况分为三种情况:
判断条件 | 说明 |
---|---|
oldCap>0 | 表明已经创建过哈希桶数组 |
oldThr>0 && oldCap==0 | 表明原先是没有创建过哈希桶数组,是通过有参构造方法HashMap(int) 和 HashMap(int, float)创建的实例 |
oldThr==0 && oldCap==0 | 表明HashMap无参构造方法创建的实例 |
对于上面的三种情况简单分析
1)oldCap>0:即原先哈希桶数组长度大于0,表示已经创建过哈希桶数组,如果原先容量超过了MAXIMUM_CAPACITY,便会将阈值设置为最大值,直接返回。否则容量和阈值都会扩容为原先的2倍,前提是扩容后的容量不会超过最大值。
2)oldThr>0:走到此分支oldCap==0,表示原先是没有创建过哈希桶数组,属于HashMap有参构造方法创建的实例,第一次进行put()方法操作。此处将新容量设置为原先阈值,并没有进行设置新阈值。
3)走到最后的分支表示oldThr==0&&oldCap==0:属于HashMap无参构造方法创建的实例,第一次进行put()方法操作。会将新容量设置为默认容量,新阈值设置为默认负载因子乘以默认容量。
分支2中并没有计算新阈值,所以通过后面的判断newThr==0的判断计算了新的阈值,存储在原先的哈希桶数组中所有键-值对映射位置是根据键hash值跟哈希通数组长度取模得到,扩容以后由于哈希桶数组长度有变化,所以要重新计算键-值对映射在新哈希桶中的索引位置,可以看到首先创建了一个新的哈希桶数组,并且长度是上面计算过的新容量。
遍历原先的哈希桶数组,重新计算每个索引位置的元素在新哈希桶数组中的位置。分为了几种情况下:
1)如果索引位置只有一个节点Node情况下,通过e.hash & (newCap - 1)直接计算在新哈希桶数组中的位置。
2)如果索引位置的元素是存储形式是红黑树,将红黑树拆分后存储到新哈希桶数组中。
3)如果索引位置的元素是链表,遍历链表,将链表中的元素存储到新的哈希桶数组位置,需要注意的是此处的将链表以e.hash & oldCap是否等于0为依据划分成两个链表。后面代码可以看到将符合e.hash & oldCap==0链表存储到新哈希桶数组的j索引位置,而将e.hash & oldCap!=0的链表存储到了j+oldCap索引位置,这里使用了一个技巧。看一个例子:n=16(默认初始容量)
n-1=15,取模运算通过e.hash&(n-1)来计算,e1和e2的结果都是2。进行扩容后,需要注意容量将扩展为原来的2倍,即n=32,取模运算时e1的结果是没有变化的,依旧是2,但e2的结果变成18,这个值恰好是2(原先索引值)+16(oldCap)。那么代码里面为什么以e.hash & oldCap==0为依据?首先哈希桶数组的容量一定是,表示成二进制方式是
(1+后面n个0的形式),n=16的二进制数是10000,e.hash&n!=0表明e.hash值必定是1xxxx。扩容后容量n=32,二进制就是100000,那么计算e.hash的方法是e.hash & (newCap - 1)就变成了1xxxx&11111,不管后面几位二进制运算结果如何,第一位必定是1,而后面几位结果是xxxx&1111运算结果,也就是e.hash&(oldCap-1)。最终结果等价于10000+(e.hash&(oldCap-1))=oldCap+j(在原先哈希桶数组中的索引)。
红黑树拆分
扩容时如果索引位置的节点存储形式是红黑树,调用spilt方法会将红黑树进行拆分。从源码中可以看出,红黑树拆分后存储到新哈希桶数组,会根据e.hash&oldCap是否为0拆分成两颗红黑树,将两颗树分别存储到在到新哈希桶数组中的index位置和index+oldCap位置,这个思想与与将链表存储到哈希桶数组的思想基本一致。对于这部分源码的分析完全可以参考上面扩容中链表拆分源码分析,这里不在赘述,另外需要注意的是拆分红黑树后,如果红黑树的节点数量小于树化阈值,将会调用untreeify()方法将树转成链表。
//拆分红黑树
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
//遍历红黑树,根据e.hash&bit是否等于0拆分成两个树
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) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
//存储到哈希桶表中index位置
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
//hiHead树做了同样的工作,树节点小于阈值,会转变成链表
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
3.4 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;
//哈希桶数组不为null,并且根据指定键的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和hash值与指定键相同,将节点赋值给node
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
//如果节点p是以红黑树形式存储的.遍历树,找到对应的节点
if (p instanceof TreeNode)
node = ((TreeNode<K, V>) p).getTreeNode(hash, key);
else {
//如果节点p是以链表形式存储的,遍历链表,找到与指定键具有相同的key和hash的节点
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
node = e;
//找到节点跳出循环
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//remove()方法调用时,!matchValue==true,所以条件是true.
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
//链表情况下,将首节点后驱指针指向命中节点node的后驱节点
//即删除了node节点
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
//返回删除的节点
return node;
}
}
return null;
}
remove()方法的功能是指出指定键在HashMap中键-值对映射,不难理解,删除的节点的步骤与存储方法put()和get()方法有相同的情况分析,首先是找到哈希桶数组中的键-值对映射Node,分成了三种情况:先判断了首节点,然后是红黑树,最后是链表。其次从树或者链表中删除指定的映射。从树中删除节点,这里不叙述。从链表中删除节点的比较简单,由于链表是单向的,只需要将命中节点Node的前驱节点指向Node的后驱节点,并且最后返回被删除的节点。
3.5 其他方法
不包含其他jdk1.8中加入的方法。
//HashMap是否包含指定的键的映射
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
//将m中所有的映射复制到此映射中
public void putAll(Map<? extends K, ? extends V> m) {
putMapEntries(m, true);
}
//清空此映射集
public void clear() {
Node<K, V>[] tab;
modCount++;
if ((tab = table) != null && size > 0) {
size = 0;
//遍历哈希桶数组,将所有索引位置元素置为null.
for (int i = 0; i < tab.length; ++i)
tab[i] = null;
}
}
//HashMap中包含指定的值,将会返回true
public boolean containsValue(Object value) {
Node<K, V>[] tab;
V v;
if ((tab = table) != null && size > 0) {
//遍历哈希桶数组,如果有节点的值与指定的值相同,返回true.
for (int i = 0; i < tab.length; ++i) {
for (Node<K, V> e = tab[i]; e != null; e = e.next) {
if ((v = e.value) == value || (value != null && value.equals(v)))
return true;
}
}
}
return false;
}
//返回HashMap中元素的数量
public int size() {
return size;
}
//如果HashMap中不存在任何的元素,将返回true.
public boolean isEmpty() {
return size == 0;
}
//返回所有的值的集合
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}
//返回所有的键的集合
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
迭代HashMap
1.遍历HashMap的键-值对。
通过调用entrySet()方法获取HashMap的键-值对映射集合Set,然后迭代Set集合获取key和value。
2.遍历HashMap的键。
调用keySet()方法获取HashMap的键集合Set,然后迭代Set集合获取键,通过调用get()方法获取指定键对应的值。
3.遍历HashMap的值。
调用values()方法获取HashMap的值集合Collection,迭代获取所有值。
需要注意的是前两种方式不管是遍历HashMap的键-值对还是键集,可以通过对应的方法获取所有的键和值。最后一种只能遍历HashMap的值,无法获取键值。
public class AIteratorHashMap {
public static void main(String[] args) {
Map<String,Integer> map = new HashMap<>();
map.put("first", 1);
map.put("second",2);
map.put("third", 3);
map.put("forth", 4);
iteratorHashMapByEntrySet(map);
iteratorHashMapByKeySet(map);
iteratorHashMapByValueSet(map);
}
//第一种方式迭代键-值对
private static void iteratorHashMapByEntrySet(Map<String,Integer> map) {
Set<Entry<String, Integer>> entrySet = map.entrySet();
//迭代器
Iterator<Entry<String, Integer>> iterator = entrySet.iterator();
while(iterator.hasNext()) {
Entry<String, Integer> entry = iterator.next();
System.out.println(entry.getKey()+"------"+entry.getValue());
}
//foreach循环
for(Entry<String,Integer> entry:entrySet) {
System.out.println(entry.getKey()+"------"+entry.getValue());
}
}
//第二种方式迭代键集合
private static void iteratorHashMapByKeySet(Map<String,Integer> map) {
Set<String> keySet = map.keySet();
//迭代器
Iterator<String> iterator = keySet.iterator();
while(iterator.hasNext()) {
String key = iterator.next();
System.out.println(key+"-------"+map.get(key));
}
//foreach循环
for(String key:keySet) {
System.out.println(key+"---------"+map.get(key));
}
}
//第三种方式迭代值
private static void iteratorHashMapByValueSet(Map<String,Integer> map) {
Collection<Integer> values = map.values();
//迭代器
Iterator<Integer> iterator = values.iterator();
while(iterator.hasNext()) {
System.out.println(iterator.next());
}
//forearch循环
for(Integer in:values) {
System.out.println(in);
}
}
}
总结
本文只对HashMap的几个基础的操作方法的源码进行了分析,可以看到插入,查询和移除等方法源码中具有部分相同判断逻辑。
1)首先会判断链表中首节点是否是目标节点(具有相同的key和hash值)
2)首节点存储形式是红黑树,遍历树,找到符合条件的节点。
3)首节点存储形式是链表,遍历链表,找到符合条件的节点。
HashMap对一些运算进行了优化,使用了位运算代替了算数运算符,提高了计算的效率。
分析也是基于笔者本身理解,如有错误,还望不吝指出。