目录
-
/ 前言 /
-
HashMap是Java开发中最常用的集合之一 , 其独特数据结构使其适用于大部分场景 , 比ArrayList及HashSet有着更广泛的应用空间 , 但是也因为其独特的数据结构使其源码异常复杂 , 尤其是JDK1.8版本后的HashMap,使用了更加复杂的数据结构 , 本文主要讲解的是JDK1.7的HashMap, 本文会涉及到的内容如下所示
-
数据结构分析 |
源码解析 |
hash碰撞的处理方式 |
计算hash值的方式 |
HashMap的容量长度为什么一定要是2的非零次幂 |
链地址法、开放定址法、再哈希法 |
HashMap最常见的问题 |
对JDK1.8HashMap有兴趣的朋友可以看一下我的另一篇博文 HashMap 1.8 源码解析
-
/ 1 / 数据结构
-
HashMap的数据结构是数组 + 单向链表
数组 : 存储的是Entry,当发生hash碰撞时 , 存储的是Entry链表的head元素
链表 : 存储的是Entry
Entry是HashMap的内部类,Entry中存储的是key、value、key的hash值、链表的next元素 , Entry本身就是就是一个链表 , 它的源码中含有next属性
我们来举个例子看一下什么时候Entry会变成链表
场景如下 :
有俩个Entry A 、 B , EntryA已经存储到了HashMap的数组中 , 此时要存储EntryB,他们的hash值相同 , 也就是说他们通过计算获取到的数组索引是相同的 , 此时HashMap会将旧的EntryA先取出并将EntryA放到EntryB的next属性中 , 然后将EntryB放到数组中该索引位置
-
-
/ 2 / 源码解析
-
核心参数
我们来看一下HashMap源码中定义的一些属性及构造器
//默认初始化table数组容量16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //table最大容量1073741824 static final int MAXIMUM_CAPACITY = 1 << 30; //默认加载因子 , 即当现有数组长度达到容量的75%时会进行扩容操作 static final float DEFAULT_LOAD_FACTOR = 0.75f; //定义一个类型为Entry<K,V>的数组 static final Entry<?,?>[] EMPTY_TABLE = {}; transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; //table数组的长度 transient int size; //实际的扩容的阈值 threshold = 容量 * 加载因子 //在构造器中会被初始化为DEFAULT_INITIAL_CAPACITY的值16 //在第一次存储数据时会在inflateTable()方法中再次赋值threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); int threshold; //实际的加载 , 子 , 在构造器中进行初始化 //如果创建HashMap时没有指定loadFactor的大小则会初始化为DEFAULT_INITIAL_CAPACITY的值 final float loadFactor; //HashMap更改的次数 //用来作为并发下判断是否有其它线程修改了该HashMap,抛出ConcurrentModificationException transient int modCount; //在初始化时指定初始长度及加载因子的构造器 public HashMap(int initialCapacity, float loadFactor) { ... } //在初始化时指定初始长度的构造器 public HashMap(int initialCapacity) { //这里调用的其实还是上面的构造器 this(initialCapacity, DEFAULT_LOAD_FACTOR); } //什么也不指定的构造器 public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); }
我们来重点介绍几个核心参数
loadFactor
我们通常使用的构造器都是最后一个构造器 , 什么都不会传 , 如果我们需要更改加载因子的话需要注意几个点
1. 加载因子并不是越大越好的 , 虽然加载因子越大就意味着HashMap的实际容量越大 , 扩容的次数越少 , 但是因为
实际存储的数据大了 , 俩个相同容量的HashMap加载因子越大的那个读取的速度更慢 , 所以我们需要根据自己的
实际使用情况来进行判断 , 是要存储更多的数据呢 , 还是要更快的读取速度
2. 加载因⼦子是会影响到扩容的次数的 , 如果加载因⼦子太⼩小的话HashMap会频繁的进⾏行行扩容 , 导致在存储
的时候性能下降
3. 如果我们在创建HashMap时就已经知道了要存储的数据量 , 那么我们完全可以通过实际存储数量 ÷ 0.75来计算出
我们初始化的HashMap容量 , 这样可以避免HashMap再进行扩容操作 , 提升代码效率modCount
关于modCount这里做一下解释 , 这个元素是用来干什么的呢?
我们知道HashMap不是线程安全的 , 也就是说你在操作的同时可能会有其它的线程也在操作该map,那样会造成脏数据 , 所以为了避免这种情况发生HashMap、ArrayList等使用了fail-fast策略 , 用modCount来记录修改集合修改次数
我们在边迭代边删除集合元素时会碰到一个异常
ConcurrentModificationException
, 原因是不管你使用entrySet()
方法也好 ,keySet()
方法也好 , 其实在for循环的时候还是会使用该集合内置的Iterator
迭代器中的nextEntry()
方法 , 如果你没有使用Iterator
内置的remove()
方法 , 那么迭代器内部的记录更改次数的值便不会被同步 , 当你下一次循环时调用nextEntry()
方法便会抛出异常
//篇幅有限 , 这里只贴出了部分源码 private abstract class HashIterator<E> implements Iterator<E> { Entry<K,V> next; // next entry to return int expectedModCount; // For fast-fail int index; // current slot Entry<K,V> current; // current entry HashIterator() { //在构造器中初始化了expectedModCount = modCount expectedModCount = modCount; if (size > 0) { // advance to first entry Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } } final Entry<K,V> nextEntry() { //当迭代器中的修改次数与HashMap中记录的修改次数不相等时抛出异常 if (modCount != expectedModCount) throw new ConcurrentModificationException(); Entry<K,V> e = next; if (e == null) throw new NoSuchElementException(); if ((next = e.next) == null) { Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } current = e; return e; } //迭代器内置的删除方法 , 每次删除后都会将expectedModCount重置为modCount的值 public void remove() { if (current == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); Object k = current.key; current = null; HashMap.this.removeEntryForKey(k); expectedModCount = modCount; } }
-
存储的流程
我们先看流程图再看源码
源码解析
public V put(K key, V value) { //如果数组为空 , 则填充数组 , 默认容量是16 if (table == EMPTY_TABLE) { //目前jdk1.7是在inflateTable方法中处理HashMap容量的问题 //inflateTable内部调用了roundUpToPowerOf2方法 , 这个方法使用了Integer.highestOneBit((number - 1) << 1)这样的操作保证了HashMap的容量肯定是2的次方 , 有兴趣的朋友可以校验一下(number - 1) << 1的运算结果 inflateTable(threshold); } //如果key为null,存储位置为table[0]或table[0]的冲突链上 if (key == null) return putForNullKey(value); //对key的hashCode值进行计算 int hash = hash(key); //获取元素在数组中的位置(索引) int i = indexFor(hash, table.length); //存的时候判断Entry数组中该位置是否已有元素 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; //如果有元素并且hash值和equals对比都为true //java中如果俩个元素的hash值比较和equals比较都为true的话则认为这俩个元素相等 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { //将value替换为新的value V oldValue = e.value; e.value = value; //recordAccess是一个空的方法 , 没有方法体 , 在子类LinkedHashMap中有覆写 //但是在HashMap中该方法没有存在价值 , 在jdk1.8中已经去掉了 e.recordAccess(this); //注意:put方法是有返回的 , 只有当key值相同时才会返回旧的value值 , 平常都返回null值 return oldValue; } } //保证并发访问时,若HashMap内部结构发生变化,快速响应失败 modCount++; //新增一个entry addEntry(hash, key, value, i); return null; }
-
hash()
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
这个方法是用来计算当前key的hash值的 , 为了减少hash碰撞发生的概率 , 这里总共进行了5次异或运算 , 4次位运算 , 进行这么多次的运算就是为了尽可能的使hash的值随机分布 , 避免发生hash碰撞
其实在这里我一直存在一个问题没有得到答案 , 为什么在JDK1.8中`hash()`方法只进行了1次异或运算 , 1次位运算
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
我的推测是可能HashMap的开发人员发现使用1.8的运算方式和1.7的运算方式发生hash碰撞的概率基本相同的 , 当然这只是一个猜测 , 我会在得到最终答案后回来更新的 , 如果你有答案的话请私信或评论告诉我 , 谢谢
-
indexFor()
static int indexFor(int h, int length) { //这里有一句注释掉的代码 , 是用来判断容量是否是2的非零幂(2的非0次方)的 //但是后来容量的处理在inflateTable方法中解决了 , 所以这里也就注释掉了 return h & (length-1); }
这个方法是用来计算出存储在table数组的什么位置
重头戏来了,HashMap为什么要限定容量必须为2的2次方呢
我们来看2的次方的二进制2 : 0010
4 : 0100
8 : 0000 1000
.....
length - 1的二进制码
1 : 0001
3 : 0011
7 : 0111
.....
我们可以看到如果容量是2的次方 , 那么length - 1得到的二进制的除了补位外都是1,根据
&
运算符的规则 ,
0&0=0; 0&1=0; 1&1=1;那么也就意味着不论h的值是什么 , 只要length - 1的二进制码是这样的规律的 , 那么就
可以保证hash的值只有和length - 1的同位参与了运算 , 例如二进制码A(10101011)&B(00001111)的结果就是
C(00001011) , C的结果只会受到b二进制码后四位的影响,因为b的补位都是0 , 也就是说h & (length - 1)
得到的索引不会大于 , length,也就不会越界
-
addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) { //判断当前table数组长度是否 >= 扩容阈值 if ((size >= threshold) && (null != table[bucketIndex])) { //将table数组的容量扩容为之前的俩倍 , 源码解读在下面↓ resize(2 * table.length); //重新计算key的hash值 hash = (null != key) ? hash(key) : 0; //重新计算索引值 bucketIndex = indexFor(hash, table.length); } //创建Entry createEntry(hash, key, value, bucketIndex); }
-
resize()
我们来看一下void resize(int newCapacity) { //将现在的table数组存储到oldTable中 Entry[] oldTable = table; int oldCapacity = oldTable.length; //判断当前table数组的容量是否是HashMap限定的最大容量1073741824 if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } //重新创建一个Entry数组newTable Entry[] newTable = new Entry[newCapacity]; //将旧数组的数据转移到新数组中 , 源码解码在下面↓ transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; //重新计算扩容阈值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
transfer
方法的源码
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; //迭代table数组 for (Entry<K,V> e : table) { //如果当前table索引位置存在Entry的链表则会继续循环知道链表尾部 while(null != e) { Entry<K,V> next = e.next; //是否需要重新计算hash值,rehash的结果是通过调用initHashSeedAsNeeded()实现的 //如果当前hashSeed!=0且需要扩容时或者hashSeed==0且不需要扩容时rehash才是true if (rehash) { //重新计算当前key的hash值 e.hash = null == e.key ? 0 : hash(e.key); } //计算索引 int i = indexFor(e.hash, newCapacity); //请看下面详细描述 e.next = newTable[i]; newTable[i] = e; //将当前e的next元素赋值给e,使while循环继续 e = next; } } }
详细描述 :
我们知道在正常的存储流程中 , 如果发生了hash碰撞 , 则会将旧的Entry放到新的Entry的next元素中 , 然后
会将新的Entry作为head放到table数组中 , 这里则是将之前的因hash碰撞而产生的链表反转 -
createEntry()
这里会解决大家另一个疑问,HashMap是在什么时候生成链表的?
void createEntry(int hash, K key, V value, int bucketIndex) { //将当前索引位置的Entry取出存到e里面 Entry<K,V> e = table[bucketIndex]; //创建一个Entry并将之前该索引位置的Entry作为新创建Entry的next元素 //将新创建Entry(head)放入到table的该索引位置 , 此时因为Entry有了nexy元素 , 所以形成了链表 table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }
总结
我们总结一下在put()
方法的源码中我们找到的答案1 . 问 : hash碰撞的处理方式
答 : 当发生hash碰撞时会将之前该索引位置存储的Entry作为新Entry的next元素从而形成链表 , 并将
新的Entry(链表的head元素)存储到该索引位置
2 . 问 : 为什么不直接使用key的hashCode值作为hash值而且还要进行多次的扰动运算?答 : 因为hashCode的值作为hash值的话indexFor计算后可能会造成数组越界 , 而且既然Java将
hashCode与equal的比较作为衡量俩个对象是否相等的标准 , 那么则意味着hashCode是有可能
重复的 , 这样会造成多余的hash碰撞 , 所以才会对hashCode进行多次的扰动运算
3 . 问 : 为什么HashMap的容量一定要是2的非零次幂而且indexFor里面要h & (length-1)
这样运算?答 : 因为HashMap的容量如果是2的次方的话可以保证length - 1得到的二进制的除了补位外都是1,根
据
&
运算符的规则 , 0&0=0; 0&1=0; 1&1=1;那么也就意味着不论h的值是什么 , 只要length - 1的二进制码是这样的规律的 , 那么就可以保证hash的值只有和length - 1的同位参与了运算 , 也就是
说
h & (length - 1)
得到的索引不会大于length,也就不会越界
4 . 问 : HashMap在扩容后容量是多少?答 : 原有容量的2倍
5 . 问 : HashMap在发生hash碰撞时 , 什么时候生成了链表答 : 在创建新的Entry的时候 , 也就是
createEntry()
方法里面
6 . 问 : HashMap在扩容后之前hash冲突产生的链表的顺序会变吗?答 : 会将链表的顺序反转
-
取值的流程
我们还是先看流程图再看源码
源码解析public V get(Object key) { //key如果为null直接调用getForNullKey方法返回null键对应的value if (key == null) return getForNullKey(); //获取当前key值的Entry Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
我们来看下
getEntry()
方法final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } //计算当前key的hash值 int hash = (key == null) ? 0 : hash(key); //迭代当前索引位置的Entry,如果Entry没有next元素则停止循环 for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; //判断key与Entry中的key是否相等 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
相比较于存储流程 , 取值的流程很简单 , 相信大家在看完存储流程的源码分析后再来看取值流程的源码肯定非常
容易看懂 , 里面的hash()
方法和indexFor()
方法在存储流程都有过详细的讲解 , 这里就不再水字数了...... -
一些有意思的源码
HashMap的核心源码其实就是存储的流程和取值的流程 , 我们这里来看看HashMap其它的一些有趣的源码
entrySet()
public Set<Map.Entry<K,V>> entrySet() { return entrySet0(); } private Set<Map.Entry<K,V>> entrySet0() { Set<Map.Entry<K,V>> es = entrySet; //如果entrySet为null的话就创建一个新的EntrySet return es != null ? es : (entrySet = new EntrySet()); } //HashMap的内部类EntrySet private final class EntrySet extends AbstractSet<Map.Entry<K,V>> { public Iterator<Map.Entry<K,V>> iterator() { return newEntryIterator(); } public boolean contains(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry<K,V> e = (Map.Entry<K,V>) o; Entry<K,V> candidate = getEntry(e.getKey()); return candidate != null && candidate.equals(e); } public boolean remove(Object o) { return removeMapping(o) != null; } public int size() { return size; } public void clear() { HashMap.this.clear(); } }
HashMap在内部维护了一个EntrySet,其拥有Set集合的通用操作 , 所以我们可以得到结论HashMap的
entrySet()
方法其实获取到的set集合和HashSet
是没有关系的keySet()
的原理其实和entrySet
的原理是一样的 , 都是内部维护了一个Set集合 , 这里就不上源码了 , 感兴趣
的朋友可以自己去看一下
源码分析就到此为止了 , 如果有朋友对HashMap中的其它源码感兴趣可以评论或者私信告诉我 , 我会更新给大家
-
-
-
/ 3 / 链地址法、开放定址法、再哈希法
-
定义及原理
我们先设定一个场景 , 有2个Entry,分别是
EntryA
和EntryB
, 先将A存储到了HashMap的table中 , 索引值为index
, 再将B存储到HashMap的table中 , 通过计算 ,EntryA
和EntryB
的索引是相同的 , 此时发生了hash碰撞-
链地址法
链地址法会将产生冲突的Entry合并成一个单向链表 , 这也是HashMap现在使用的方式
-
开发地址法
在存入A之后存入B,此时索引为
index
的位置上已经有了A,我们就以index
为基础 , 生成另一个地址index2
, 如果index2
的地址也存有元素 , 就生成index3
, 直到找到空余的位置 ,index2,index3
不是通过计算得到的 , 只是以index
为基础 , 进行寻找下一个地址开发地址法的三种寻找方式
-
线性探测再散列 : 从
index
开始 , 按照顺序找寻下一个位置是否空余 , 空余则插入 -
二次探测再散列 : 以
index为起点 , 在
index`的左右进行探测 , 如果有空余则插入 -
伪随机探测再散列 : 随机找到除
index
之外的位置 , 空余则插入
-
-
再哈希法
A存入之后 , 存入B,此时索引
indexz
的位置已经有元素存在 , 对B再次进行哈希计算 ,index2 = hash(B)
, 如果index2
任不为空 , 则进行index3 = hash(B)
的计算 , 直到找到空余位置
-
-
比较
-
开放地址法
优点 : 在存储数据比较少的情况下能非常快速的寻找到空余的位置
缺点 : 一旦数据量比较大的话 , 那么可能就得靠运气了......
-
再哈希法
优点 : 再次进行哈希计算的情况下冲突的可能性会被无限的缩小
缺点 : 与开放地址法一样 , 仅适用于数据量少的情况小 , 数据量大了计算的次数会比较多 , 耗时较
长
-
链地址法
优点 : 即时冲突了也不用再去寻找新的位置 , 直接存到链表里即可
缺点 : 大多数情况下产生冲突的原因是我们存储了相同的key,所以可能链表的数据我们只需要最后一
个 , 而且如果链表中存储的数据较多 , 遍历起来会比较耗时(这个问题在JDK 1.8之后优化了 ,
当链表中的数据达到阈值(8)后会自动生成红黑树来存储冲突的数据)
-
-
-
/ 4 / HashMap的最常见的问题
我们来总结一下在HashMap中我们常见的问题
-
问 : hash碰撞的处理方式
答 : 当发生hash碰撞时会将之前该索引位置存储的Entry作为新Entry的next元素从而形成链表 , 并将新
的Entry(链表的head元素)存储到该索引位置
-
问 : 为什么不直接使用key的hashCode值作为hash值而且还要进行多次的扰动运算?
答 : 因为hashCode的值作为hash值的话indexFor计算后可能会造成数组越界 , 而且既然Java将
hashCode与equal的比较作为衡量俩个对象是否相等的标准 , 那么则意味着hashCode是有可能
重复的 , 这样会造成多余的hash碰撞 , 所以才会对hashCode进行多次的扰动运算
-
问 : 为什么HashMap的容量一定要是2的非零次幂而且indexFor里面要
h & (length-1)
这样运算?答 : 因为HashMap的容量如果是2的次方的话可以保证length - 1得到的二进制的除了补位外都是1,根
据
&
运算符的规则 , 0&0=0; 0&1=0; 1&1=1;那么也就意味着不论h的值是什么 , 只要length - 1的二进制码是这样的规律的 , 那么就可以保证hash的值只有和length - 1的同位参与了运算 , 也就是
说
h & (length - 1)
得到的索引不会大于length,也就不会越界 -
问 : HashMap在扩容后容量是多少?
答 : 原有容量的2倍
-
问 : HashMap在发生hash碰撞时 , 什么时候生成了链表
答 : 在创建新的Entry的时候 , 也就是
createEntry()
方法里面 -
问 : HashMap在扩容后之前hash冲突产生的链表的顺序会变吗?
答 : 会将链表的顺序反转
-
问 : HashMap的默认加载因子是多少?
答 : 0.75
-
问 : HashMap的默认容量是多少?
答 : 16
-
问 : HashMap的默认实际存储容量是多少?
答 : 12
-
问 : HashMap为什么是线程不安全的?
答 : 因为HashMap没有同步锁
-
问 : 线程不安全会造成什么现象?
答 : 并发情况下会导致取出来的数据并不是想要的数据 , 数据已经被其它线程修改了 , 而且当多个线程同
时触发扩容操作时 , 最后一个线程新生成的table会覆盖之前所有扩容的table
-
问 : HashMap形成环形链表的原因?
答 : 在HashMap的扩容流程中 , 会将旧数组的元素转移到新数组中 , 并且会将因hash碰撞而产生的链表反
转 , 此时如果发生并发 , 多个线程触发扩后可能会导致
e.next
永远会有元素 , 从而形成环形链表
-
-
/ 5 / 结语
HashMap1.7的分享到此就告一段落了,HashMap在Java开发中有着广泛的应用场景 , 也是面试绕不过的坎 , 熟悉HashMap的源码可以帮助我们知其然并知其所以然 , 而且jdk经过了这么久的磨炼 , 留下来的都是精华 , 多阅读源码可以帮助我们提高对架构的理解 , 去看看优秀的架构师是怎么去做设计
希望这篇文章能帮到大家 , HashMap1.8的以及ConcurrentHashMap的源码分享我也会尽快整理出来 , 敬请期待
HashMap1.8的博文已出炉, 感兴趣的朋友看一下HashMap 1.8 源码解析