HashMap源码分析(基于JDK1.8版本)

简介

作为一个Java语言的使用者,HashMap是一个绕不开的话题。这段时间我准备完整的学习一遍HashMap的源码并将自己的收获记录一下。这是我记录的第一篇Java源码分析,有疏漏或者错误之处欢迎大家批评指正。

1 注解内容

HashMap前面的注解内容大概一百来行,逐行翻译的事情就没必要做了,在这里简单的对这一部分内容进行一个归纳,并记录下一些我觉得值得讨论的地方。

1.1 结构性修改(Structurally modification)

源码中有一段注解内容是:

如果多线程并发的访问hashmap,并且至少一个线程结构性地(structurally)修改了map,那么它必须被从外部进行同步(synchronized)。(结构性修改是添加或删除一个或多个映射的任何操作;仅仅改变与实例已经包含的键相关联的值不是结构修改。)

在这里我一开始的疑问是:如果仅改变已经包含了的键对应的值不是结构性修改,那么没用从外部进行同步就使用多线程的话不还是可能会产生数据竞争(data race)从而出现错误吗?

后面我查了一些资料也验证了我的想法。确实没错,这样是可能会产生冲突,但是结构性修改会导致很严重的后果,而仅修改已存在的值可能造成的问题相对来说小一些。(可参考这个描述https://stackoverflow.com/questions/57867464/understanding-structural-modification-in-hashmap

简单的来说就是:
put(),remove()等操作可能导致map的rehash从而在并发编程中引起严重的数据错误或者丢失。而仅仅改变已经存在的键值对的值导致的问题较小,一些特殊情况如:

thread1: map.get("A");
thread2: map.put("B", "1");   // Assume "B" was in the map already
thread3: map.get("C");

这样各个线程之间没有使用同样的key-value,也没用改变map的结构,则不会发生问题。

1.2 fail-fast机制

如果在迭代器iterator被创造之后使用除了iterator意外的方法对map进行结构性修改的话,会抛出ConcurrentModificationException异常。(我遇到过这个问题,当时还困扰了好一会,查了才知道有这个机制)。详细的内容将在后文解释。

2 主要成员

2.1 主要静态变量

//默认的初始容量为2^4 = 16,这个值必须是2的次方。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//最大容量2^30 = 1073741824。这个值必须是一个小于它的2的次方数。
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认加载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//将链表转化为树的阈值。需要注意的是是否转化还有一个参数要考虑(MIN_TREEIFY_CAPACITY)
static final int TREEIFY_THRESHOLD = 8;

//将树转化回链表的阈值。需要小于TREEIFY_THRESHOLD
static final int UNTREEIFY_THRESHOLD = 6;

//将链表转化为树时需要的最小的数组容量值。
//如果数组小于这个值,意味着在一个bin中有很多的节点,这个时候将会执行resize()方法
static final int MIN_TREEIFY_CAPACITY = 64;

2.2 主要的类

//没有树化的时候基础的hash bin节点,也就是数组里面存的节点,在一个数组元素内组成链表的节点。
static class Node<K, V> implements Map.Entry<K, V> {
	final int hash;
	final K key;
	V value;
	Node<K, V> next;
	//...
}

//转化为红黑树的节点类
static final class TreeNode<K, V> extends LinkedHashMap.Entry<K, V> {}

2.3 field

//hashmap中的数组,长度必须为2的幂
transient Node<K, V>[] table;

transient Set<Map.Entry<K, V>> entrySet;

//hashmap中的键值对的个数
transient int size;

//HashMap被结构性修改的次数,用于fail-fast机制
transient int modCount;

3 主要方法介绍与代码剖析

3.1 hash()

static final int hash(Object key) {
	int j;
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这个方法先获得key的hashcode,然后与这个值的高16位相异或。

这叫做扰动函数,这样做是为了减少hash碰撞的概率,将对象尽可能的分布到整个数组table中。同时,因为这个操作是一个高频操作,所以采用位运算来进行。那么为什么这样做可以实现这个效果呢?

因为在hashmap中数组分配位置的时候采用的是(n - 1) & hash这个操作。n - 1相当于是一段“11…1”(因为数组长度n一定是2的幂),和hash进行与操作将会保留所有位数低于n - 1的二进制长度的值,所以这个操作就相当于取余数,一定能将hash分配到[0, n - 1]的范围之间。但是很多时候低位的hashCode可能相似度很高,如果不考虑高位的话,就算原本的key的hash算法分布的再怎么松散,只考虑低几位的话,分配到同一个位置的概率也会很高。这个时候,如果将hashCode的高位特征也加入进去,则可以大大降低hash碰撞的概率。

3.2 tableSizeFor()

static final int tableSizeFor(int cap) {
	int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
	return (n < 0) ? 1 : (n >= MAXIMUN_CAPACITY) ? MAXIMUN_CAPACITY : n + 1;
}

这个方法的作用是获取一个刚好大于给定容量的最小二的幂的值。例如输入为6输入就为8,输入为14输出就为16。

那么这是如何做到的呢?我们以cap = 6为例来计算:

  1. cap - 1 = 5 转化为二进制
    0000 0000 0000 0000 0000 0000 0000 0101
    它有29个Leading Zero。
  2. -1 转化为二进制
    1111 1111 1111 1111 1111 1111 1111 1111
  3. 这个数右移位29,也就是
    0000 0000 0000 0000 0000 0000 0000 0111
  4. 加一,也就是
    0000 0000 0000 0000 0000 0000 0000 1000
    即为8。

在第三步的时候,我们一定会得到一个形如“11…11"的数字。然后加一,就会得到一个形如“10…00”的数字,也就是一个刚好大于给定cap的二的幂。

3.3 get()

get()方法会调用getNode()方法。所以核心在于getNode()方法。
getNode()方法的流程如下:

  1. 如果table为空或者使用(n - 1) & hash方法找到的对应下标出为空,返回null
  2. 不为空,分为三种情况
    2.1 第一个节点就是要找的key,直接返回对应的节点
    2.2 当前节点是红黑树类型,在红黑树中找对应的节点
    2.3 当前是一个链表,在链表中找对应节点。

这个方法的逻辑其实是较为简单的,put()方法则会复杂一些。看完put()之后你会对get()有一个更深入的理解。

3.4 put()

这个方法由于代码量较多,在这里就不展示出来了,大家直接打开对应的源码来看就行了。在这里只对它的流程和原理进行展示和分析。

总结起来就是

  1. 如果数组为null或者容量为0,就进行resize()扩容
  2. 通过i = (n - 1) & hash计算数组下标
  3. 如果当前数组中当前位置为null,直接插入新节点,进入步骤6
  4. 此时有三种状态:
    4.1. 如果数组中当前位置元素hash等于传入的hash并且key的值也相等(使用key对象的equals()方法判断),进入步骤5
    4.2. 如果当前节点是一个红黑树节点,将键值对加入红黑树,进入步骤6
    4.3. 当前数组位置是一个链表,如果链表中存在相同的key,进入步骤5,否则将新节点插入链表末尾,并判断是否需要使用treeifyBin()方法。然后进入步骤6
  5. 使用新值替换原本的旧值,跳过步骤6进入步骤7
  6. modCount++
  7. 判断是否需要resize()

其中需要注意的几个点是:

  1. 数组下标的计算方法i = (n - 1) & hash。原理在上文中已经讲解过了。
  2. 如果是往链表中插入新节点,那么调用treeifyBin()方法并不一定会将链表转化为红黑树,在这个方法中还有一步判断条件,也就是前面提到的MIN_TREEIFY_CAPACITY。如果数组长度小于这个值则会执行resize();
  3. 只有加入了新的键值对的时候才会进行modCount++,修改原来的键值对则不会。

3.5 resize()

resize()方法是这几个方法中较为复杂的方法,也是最为重要的几个方法之一。它负责HashMap的扩容。

  1. 有oldCap, oldThr, newCap, newThr这四个变量,分别表示旧的容量和阈值,新的容量和阈值。新的容量和阈值先初始化为0。还有一个oldTab表示旧的数组。
  2. 判断之前是否扩容过
    2.1 如果扩容过(oldCap > 0),就将新容量和新阈值都变成原来的两倍,或者旧容量就已经大于等于最大值的情况下就不变,直接返回。
    2.2 如果没有扩容过(即第一次调用resize()方法),会视之前的构造HashMap时的构造方法来设定此时的newCap和newThr,具体情况在这里就不继续深入讨论了。只需要注意容量一定要是2的整数幂。
  3. 如果原本的数组不为空(若数组为空,我们并不需要重新分配位置),按照如下策略将数组中的元素分配到新的数组中。
  4. 遍历原本的数组,取每一个下标中的第一个元素e,此时有4种情况
    4.1 e为空,不用处理
    4.2 e.next为空,说明只有一个元素,使用e.hash & (newCap - 1)来获取应该分配到的新数组中的位置
    4.3 e是一个红黑树节点类型(e instanced TreeNode),调用split方法将树拆分成两颗,如果树太小的就转化回链表
    4.4 e表示一个长度大于1的链表,此时需要计算判断是否将元素移动到新的位置:
    会有两个链表,一个将留在原地,一个将移到新的位置(原位置index + oldCap)
    计算方法为:e.hash & oldCap是否为0,如果为0,这个元素放入留在原地的那个链表;如果不为0,将移到新位置的那个链表。

这就是整个resize()的逻辑了,那么为什么会进行e.hash & oldCap这个判断呢?

我们可以发现,oldCap一定是一个形如“10…0”的数,我们以16为例,16 = 10000,和这样一个数相与如果结果为0,说明hash的第5位一定是0。这种情况下,由于扩容之后容量为32 = 100000。在进行寻找数组下标的操作(hash & (n - 1)),也就是和11111相与,得到的结果一定和之前容量为16时一样,因为hash的第5位为0。这样就保证了地址的一致。

4 jdk1.8中的HashMap相对于1.7的优化

4.1 hash函数发生了变化

一次扰动就已经够用了

4.2 数组+链表改成了数组加链表或者红黑树

在链表过长的时候降低了查找的时间复杂度

4.3 链表插入的方法从头插法改成了尾插法

避免了多线程时产生环。

4.4 扩容时的分配位置的判断

1.8中不需要重新hash就可以直接定位到新的位置,原理已经在上面的resize()方法中讲过。

4.5 先插入再扩容

5 HashMap存在的一些问题

5.1 并发的情况下会不会导致死循环?

在1.8版本中,由于头插法改成了尾插法,已经不会导致死循环了。但是它仍然是线程不安全的。

例如多线程时还是会出现数据覆盖的问题。

猜你喜欢

转载自blog.csdn.net/qq_35824577/article/details/106900381