作为一个Java语言的使用者,HashMap是一个绕不开的话题。这段时间我准备完整的学习一遍HashMap的源码并将自己的收获记录一下。这是我记录的第一篇Java源码分析,有疏漏或者错误之处欢迎大家批评指正。
HashMap前面的注解内容大概一百来行,逐行翻译的事情就没必要做了,在这里简单的对这一部分内容进行一个归纳,并记录下一些我觉得值得讨论的地方。
源码中有一段注解内容是:
如果多线程并发的访问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的结构,则不会发生问题。
如果在迭代器iterator被创造之后使用除了iterator意外的方法对map进行结构性修改的话,会抛出ConcurrentModificationException异常。(我遇到过这个问题,当时还困扰了好一会,查了才知道有这个机制)。详细的内容将在后文解释。
//默认的初始容量为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;
//没有树化的时候基础的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> {}
//hashmap中的数组,长度必须为2的幂
transient Node<K, V>[] table;
transient Set<Map.Entry<K, V>> entrySet;
//hashmap中的键值对的个数
transient int size;
//HashMap被结构性修改的次数,用于fail-fast机制
transient int modCount;
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碰撞的概率。
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为例来计算:
在第三步的时候,我们一定会得到一个形如“11…11"的数字。然后加一,就会得到一个形如“10…00”的数字,也就是一个刚好大于给定cap的二的幂。
get()方法会调用getNode()方法。所以核心在于getNode()方法。
getNode()方法的流程如下:
这个方法的逻辑其实是较为简单的,put()方法则会复杂一些。看完put()之后你会对get()有一个更深入的理解。
这个方法由于代码量较多,在这里就不展示出来了,大家直接打开对应的源码来看就行了。在这里只对它的流程和原理进行展示和分析。
总结起来就是
其中需要注意的几个点是:
resize()方法是这几个方法中较为复杂的方法,也是最为重要的几个方法之一。它负责HashMap的扩容。
这就是整个resize()的逻辑了,那么为什么会进行e.hash & oldCap这个判断呢?
我们可以发现,oldCap一定是一个形如“10…0”的数,我们以16为例,16 = 10000,和这样一个数相与如果结果为0,说明hash的第5位一定是0。这种情况下,由于扩容之后容量为32 = 100000。在进行寻找数组下标的操作(hash & (n - 1)),也就是和11111相与,得到的结果一定和之前容量为16时一样,因为hash的第5位为0。这样就保证了地址的一致。
一次扰动就已经够用了
在链表过长的时候降低了查找的时间复杂度
避免了多线程时产生环。
1.8中不需要重新hash就可以直接定位到新的位置,原理已经在上面的resize()方法中讲过。
在1.8版本中,由于头插法改成了尾插法,已经不会导致死循环了。但是它仍然是线程不安全的。
例如多线程时还是会出现数据覆盖的问题。