【靶点突破】Java HashMap探究
- 哈希算法 & 哈希表 & 哈希冲突 & 哈希冲突的解决方案
- HashMap是什么 & 如何使用 & 缺点
- 基于JDK 1.8 的HashMap实现原理
- 聊聊JDK 1.7它存在的一个bug
Hello,大家好,我是Ellen,这是Android靶点突破系列文章,旨在帮助你更加了解Android技术开发的同时,把业务做到精致。思考自己的职业生涯,想成为怎样的技术人,想追求怎么样的生活。
当你遇到瓶颈时,不妨试着总结自己所学精华,与友人分享切磋,接下来的路就在脚下了。
| from Ellen缘言/2月
1.哈希算法 & 哈希表 & 哈希冲突 & 哈希冲突的解决方案
1.1 什么是哈希算法
哈希算法:哈希(Hash)也称为散列,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,这个输出值就是散列值。
1.2 什么是哈希表
哈希表:散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
上面一段来自度娘对哈希表的定义。简单来说哈希表就是一个方便查找数据的表,它有以下几个关键点:
- 1.Key:操作哈希表的钥匙
- 2.Value:哈希表里某个key对应的值
- 3.Value位置:哈希表Value的位置都是通过哈希算法,将key通过哈希算法获取一个散列值,最终确定该key对应哈希表存放的位置。
- 4.哈希函数:哈希算法,将key哈希算法之后获取一个散列值。
因此我们平时所说的键值对的键对应这里的key,键值对的值对应的value。
1.3 什么是哈希冲突
哈希冲突:当存放一个新的键值对时,key通过哈希算法之后获取的散列值,根据散列值查到对应的位置已经有value存放了,这就是所谓的哈希冲突。
1.4 哈希冲突的解决方案
解决哈希冲突的核心最终能让key找到对应的一个位置,去存放Value。那么哈希冲突有哪些方案呢?HashMap采用的哪种呢?其有四种方案如下所示:
- 1.开放定址法
- 2.再哈希法
- 3.链地址法
- 4.建立公共溢出区
下面我们分别聊聊这四种方案,再聊聊HashMap采用的哈希冲突方案以及为什么:
1.4.1 开放定址法
开发地址法的做法是,当冲突发生时,使用某种探测算法在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。按照探测序列的方法,一般将开放地址法区分为线性探查法、平方探查法、双重散列法等。
下面我们分别演示一下线性探查法&平方探查法&双哈希探查法:
线性探查法:当发生哈希冲突时,按照公式:f(i) = i进行探查,f(i)计算出的是偏移量,i为探查次数{1,2,3,4…},如果探查的位置为空,那么就确定了该key存放的位置。 缺点:需要不断处理冲突,无论是存入还是査找效率都会大大降低。
平方探查法:当发生哈希冲突时,按照公式:f(i) = i * i(i的平方)进行探查,f(i)计算出的是偏移量,i为探查次数{1,2,3,4…},如果探查的位置为空,那么就确定了该key存放的位置。 缺点:不能探查到所有位置,而且需要不断处理冲突,无论是存入还是査找效率都会大大降低。
双哈希探查法:当发生哈希冲突时,按照公式:f(i) = i⋅hash2(x),hash2(x),i为探查次数{1,2,3,4…},先计算出偏移量确定探查位置是否为空,如果不为空则需要用公式f(i) = i⋅hash2(x)进行探查,直到探查的位置不为空存放该key为止,如果hash2(x)计算的偏移量对应的位置为空,那么不需要在进行f(i) = i⋅hash2(x)公式的计算了,hash2(x)计算出的偏移量位置就为存放该key的位置。对于hash2(x)函数而言,它也是一个有关探查次数i有关的函数。缺点:计算十分耗时,不如平方探查法。
还有一些其它的开放定址法,例如:二次探查法等,笔者这里就不啰嗦了,其实就是发生冲突时去执行一个算法公式,确定下一次探查的位置,如果这个位置是空的,那么就确定了该key存放的位置,从而解决哈希冲突,计算过程耗时跟算法公式有关。
1.4.2 再哈希法
再哈希法的意思是当出现哈希冲突时,利用第二个哈希函数计算出位置,如果位置还是存放有value,那么就会用第三个哈希函数进行计算,直到不出现冲突为止。缺点特别明显,就是计算可能比较耗时。
1.4.3 链地址法
链地址法的意思就是每个哈希结点都存在一个next的指针,当出现冲突时,在冲突的位置每个结点通过next指针来构成一个链状数据结构,例如:单链表。JDK中HashMap采用的就是这种方式解决的哈希冲突。
1.4.4 建立公共溢出区
建立公共溢出区意思将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表
仔细阅读上面四种哈希冲突的解决方案,下面我们来分析分析JDK 中HashMap为什么采用链地址法:开放寻址和再哈希法会耗损计算时长,建立公共溢出区,显然也不是最好方案,溢出区去寻找冲突的元素也是个难题,更不提其它缺点的地方了。所以JDK中采用了链地址法,从增删改查操作来看,它的增加很简单,出现冲突时,用链表或者红黑树来组织冲突的键值对,它的查询,基本也是很简单,通过哈希算法计算出key对应的位置,然后在那个位置的链表或者红黑树顺腾摸瓜(JDK1.8 链表在某一条件下转化为红黑树,目的是为了加强查找效率,下章我们具体讲解),逐一比较,删和改也就不多讲解了,
2.HashMap是什么 & 如何使用
2.1 HashMap是什么?如何使用
对于Android程序员来说,HashMap真的熟悉的再熟悉不过的了,它是什么,很简单,不就是存储键值对关系集合嘛,它的键是唯一性的,可以为null,而值可以为任意的,甚至可以为null,下面笔者来演示下HashMap的增删改查等操作:
HashMap<String, String> hashMap = new HashMap<>();
//增
for (int i = 0; i < 100; i++) {
hashMap.put(String.valueOf(i),String.valueOf(i));
}
//删
hashMap.remove("1");
//改
hashMap.put("2","two");
//查
String s3 = hashMap.get("3");
//清空
hashMap.clear();
HashMap的5种遍历方式:
方式一:key方式遍历:
HashMap<String,String> hashMap = new HashMap<>();
for (int i = 0; i < 100; i++) {
hashMap.put("key:"+i,"value:"+i);
}
//key方式遍历
for(String key: hashMap.keySet()){
String value = hashMap.get(key);
}
方式二:Entry迭代器方式遍历:
HashMap<String,String> hashMap = new HashMap<>();
for (int i = 0; i < 100; i++) {
hashMap.put("key:"+i,"value:"+i);
}
//迭代器方式遍历
Iterator<Map.Entry<String, String>> ite = hashMap.entrySet().iterator();
while (ite.hasNext()){
Map.Entry<String,String> entry = ite.next();
String key = entry.getKey();
String value = entry.getValue();
}
方式三:Entry方式遍历:
HashMap<String,String> hashMap = new HashMap<>();
for (int i = 0; i < 100; i++) {
hashMap.put("key:"+i,"value:"+i);
}
//迭代器方式遍历
for(Map.Entry<String,String> entry: hashMap.entrySet()){
String key = entry.getKey();
String value = entry.getValue();
}
方式四:Value方式遍历,但是不能遍历key:
HashMap<String,String> hashMap = new HashMap<>();
for (int i = 0; i < 100; i++) {
hashMap.put("key:"+i,"value:"+i);
}
//迭代器方式遍历
for(String value: hashMap.values()){
}
方式五:key 迭代器方式遍历:
HashMap<String,String> hashMap = new HashMap<>();
for (int i = 0; i < 100; i++) {
hashMap.put("key:"+i,"value:"+i);
}
Iterator<String> iterator = hashMap.keySet().iterator();
//key 的迭代器方式遍历
while (iterator.hasNext()){
String key = iterator.next();
String value = hashMap.get(key);
}
这五种方式有什么区别呢?请读者自行探究,分别测试往里面添加100条数据遍历,和1w条数据遍历时间作对比。
此外还需要注意的是HashMap的key可以为null的,但是只能存在一个,因为HashMap的键是唯一的嘛,Value可以为null。
2.2 缺点
HashMap存在以下缺点:
- 线程不安全:HashMap每个操作都是非线程安全的,所以它不太适合去完成并发业务。
- 不支持缩容,造成内存空间浪费
3.基于JDK 1.8 的HashMap实现原理
研究HashMap我们需要把以下几个问题逐一弄明白,只要清晰了下面几个问题,那么HashMap的实现原理就非常清楚啦
- 实现HashMap的哈希算法是怎样的
- HashMap的哈希表实现的数据结构是什么?
- HashMap处理哈希冲突的方案是什么?& 如何确保唯一性呢
- HashMap 构造器执行流程 & 增删改查过程
- HashMap扩容
- JDK中1.7 & 1.8 的HashMap区别
3.1 实现HashMap的哈希算法是怎样的
了解一个哈希表的实现,我们首先要了解的是它的核心,也就是它的哈希算法如何实现的,哈希算法的目的就是确定键值对的键对应哈希表中的哪个位置。我们先来看看下面这段代码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这是HashMap中的hash方法,我们看到当key为null的时候,计算出 hash值为0,当key不为null的时候,key计算出的hash值就为(h = key.hashCode()) ^ (h >>> 16),^操作符为异或计算,>>> 16表示无符号右移16位,那么key不为空的时候,计算出的结果很明显看出与key的hashCode方法有关,hashCode方法是干嘛的呢?官方文档中有这样的意思:hashcode方法返回该对象的哈希码值。支持该方法是为哈希表提供一些优点。我们这里先不探讨hashCode方法内部逻辑是怎样的,我们现在只是认为听过这个方法,它可以获取到当前对象对应的哈希值,这对哈希结构的集合实现具有特殊的作用。
你认为上面已经聊完了HashMap的哈希算法吗?其实并没有,哈希算法最终的目的是为了计算出key对应HashMap中对应的位置,所以上面的hash(key)方法只是计算出key对应哈希值而已,还没有确定其位置呢?接下来我们就聊聊key通过hash(key)方法获取hash值之后确定位置的过程:
//n为数组容量
index = (n - 1) & hash
上述片段代码是JDK 1.8中HashMap用于计算存放的键值对在数组中的位置,HashMap底层数据结构有什么构成,我们暂且不聊,下个节点会聊到,从以上代码可以看出,最终确定存放的这个键值对的位置是有数组容量减1在与hash()方法计算的哈希值进行与运算得到最终的放入的位置,为什么这么做呢?
我们现在已经知道hash方法计算出来是一个整形数值,我们现在来探讨一个数学问题,给定一个大小为100的数组,你如何将它分类为16种?聪明的你立马就想到取余运算对吧,请看以下代码:
//待分类数组
int[] array = new int[100];
//分类集合
List<List<Integer>> sortList = new ArrayList<>(16);
//随机产生0~10000的整形数给数组
for(int i=0;i<100;i++){
array[i] = (int)(Math.random() * 10000);
}
//进行分类
for(int i=0;i<100;i++){
int index = array[i] % 16;
List<Integer> integerList = sortList.get(index);
if(integerList == null){
integerList = new ArrayList<>();
}
integerList.add(array[i]);
}
明白上述代码之后,我们要想想如何优化上述代码呢?我们的取余运算是不是可以用其它算法替代呢?没错,我们想到取余运算可以位运算中的"&"运算进行优化,我们举个栗子,比如132,进行16的取余运算,我们可以这么计算 132 & 15,就可以求得余数啦,我们来看看:
132转成二进制为"10000100",15的二进制为"1111",他们的"&"运算结果为:“00000100”,也就是为4
10000100
00001111
--------
00000100
而132 = 16*8 + 4,取余运算的结果的确为4,由此可见我们可以用"&“运算来替代取余运算,为什么能替代呢?首先替代的条件是除数必须是2的整数倍,因为只有2的整数倍,它的取余运算才能被”&“运算取代,它减1之后转化为二进制之后,对应的每个位置的数字都是1,只有全都是1,”&"运算算出的结果才是余数。这也就是为什么HashMap数组的大小必须是2的n次幂的原因所在。
我们接着聊,那个数学问题我们优化之后的代码如下所示:
//待分类数组
int[] array = new int[100];
//分类集合
List<List<Integer>> sortList = new ArrayList<>(16);
//随机产生0~10000的整形数给数组
for (int i = 0; i < 100; i++) {
array[i] = (int) (Math.random() * 10000);
}
//进行分类
for (int i = 0; i < 100; i++) {
int index = array[i] & 15;//这个地方的15实际上是由16-1计算出来的
List<Integer> integerList = sortList.get(index);
if (integerList == null) {
integerList = new ArrayList<>();
}
integerList.add(array[i]);
}
你也许会问了,为什么"&“运算比取余运算更好呢?”&“运算只计算了低位,而取余运算所有位置都进行了计算,自然”&“运算效率更高些,明白了这些,我们就可以总结啦,HashMap的哈希算法过程是这样的,当你往它里面放键值对(key,value)时,先通过hash()方法算出key的hash值,再由hash & (n-1)进行位运算,实际上是获取取余的结果,不过”&"运算比取余运算效率高,得到的结果最终为该键值对(key,value)最终存放的位置,如果出现冲突,就会采用拉链法进行解决,哈希冲突方案具体过程我们之后聊。
3.2 HashMap的哈希表实现的数据结构是什么?
HashMap实现的数据结构由三种支持:数组,链表,红黑树
- 数组:大小必须为2的幂次方,原因3.1中已经聊过,它存放的是链表或者是红黑树,它的索引含义是键值对(key,value),key进行哈希算法中"&"运算结果对应的位置。
- 链表:存放键值对结点Node的数据结构,它也是HashMap实现哈希冲突方案拉链法的关键。
- 红黑树:为提高HashMap查找效率引入的,它会在某些特定条件下将链表树化为红黑树,增加查找效率。它的每个结点通过TreeNode方式存储。
我们暂时不聊这些数据结构的细致配合过程来实现HashMap的一些功能,我们慢慢分析,慢慢体会。请继续往后看。
3.3 HashMap处理哈希冲突的方案是什么?& 如何确保唯一性呢
HashMap处理哈希冲突的方案采用的拉链法,前面我们已经聊过了它的哈希算法,接下来我们聊聊HashMap处理哈希冲突的具体细节。
当我们向HashMap里放键值对的时候执行了以下方法:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果map还是空的,则先开始初始化,table是map中用于存放索引的表
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果通过哈希算法定位到的位置没有放任何键值对结点,那么新建一个结点存放该键值对,这里对应的是索引位置未存放如何元素,才会创建一个结点放入进去
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果存放的减值对已经存在,那么就直接赋值就可以了,这里也是保障HashMap键值唯一的关键
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//HashMap中没有存放该键值对,且当前结点Node对应的是红黑树结点类型TreeNode,那么就走红黑树存放TreeNode流程
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//HashMap中没有存放该键值对,且当前结点Node对应的是链表结点类型Node
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//这里是处理哈希冲突方案拉链法的关键,看到next指针没有
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//满足一定条件之后,链表被树化
treeifyBin(tab, hash);
break;
}
//如果该键值对对应的结点已经存在,跳出循环,走赋值流程即可
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//赋值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
认真阅读上述代码,可以明显看到,当存放一个键值对时,首先会通过哈希算法得到该键值对存放的数组索引位置,然后再判断该索引位置是否存在结点Node,如果不存在,则没有发生哈希冲突,就会创建一个Node结点存放在该索引位置,如果该索引位置存在结点,接着就会判断该结点是否是当前键值对对应的结点,如果是,直接进行赋值value即可,如果不是,则继续判断该结点是不是红黑树结点,如果是红黑树结点,就走遍历红黑树流程,如果该键值对对应的TreeNode结点已经存在,则进行value赋值,如果遍历过程走完不存在对应该键值对的结点,那么就会创建新的TreeNode结点挂到红黑树上。
如果当前不是红黑树结点,那么它就一定属于Node结点,即链表对应的结点,接着就要遍历链表,逐一进行比对,在遍历过程中如何确定了该键值对对应了某个结点,那么就会跳出循环,走value赋值过程即可,如果遍历到某个结点的next指针为null时,说明已经遍历到链表尾部了,说明该键值对之前没有存放在HashMap中,此时就会新建Node结点,挂到链表尾部即可。
通过以上分析,HashMap保证键值唯一性的关键就是以下的判断:
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))
从以上片段代码可以看出,它存在三个关键的判断:
- 哈希值比较
- "=="内存地址比较
- equals比较
就是说哈希值不一样的,非常确定的是这两个键值对的键是不一样的,如果哈希值相同,还有比较内存地址和equals方法,这二者任一结果为true,则说明这二者是相同的键,否则,二者的键不一样。
3.4 HashMap 构造器执行流程 & 增删改查过程
HashMap的构造器一共分为以下3种:
- HashMap()
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
//加载因子为DEFAULT_LOAD_FACTOR,也就是0.75
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- HashMap(int initialCapacity)
public HashMap(int initialCapacity) {
//调用了HashMap(int initialCapacity, float loadFactor) 构造器
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- HashMap(int initialCapacity, float loadFactor)
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
//如果初始化容量 小于0则抛出异常
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果指定的容量超过MAXIMUM_CAPACITY,则赋值当前容量为initialCapacity
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//如果加载因子小于0或者loadFactor不为Float.NaN,则抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//赋值加载因子
this.loadFactor = loadFactor;
//确定最终的初次扩容容量标准
this.threshold = tableSizeFor(initialCapacity);
}
/**
* 返回给定目标容量的 2 次方。这个方法的意图如何,读者不妨猜猜
* Returns a power of two size for the given target capacity.
*/
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;
}
以上是HashMap的3个构造器的解析,要明白的是HashMap的构造器作用在于确定加载因子,如果没有传入自定义的加载因子的参数,加载因子loadFactor的值均为0.75,那么这个加载因子的作用是什么呢?
加载因子的作用:当HashMap存储的条目个数超过HashMap和加载因子的乘积,这时候就触发了HashMap的resize,rehash操作,也就是扩容。在某方面来讲,加载因子决定了HashMap的数据密度。那么为什么它默认情况下偏偏是0.75呢?
主要原因如下所示:
- (1)加载因子越大hash表数据密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
- (2)加载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内存空间。而且经常扩容也会影响性能(扩容会重新构造底层结构,以及原有数据的转移,非常耗时),建议初始化预设大一点的空间。
- (3)按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数。
- (4)0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。
以上四点是我照搬的结论,不需要记,只需要明白0.75是最佳的加载因子的值,会提升HashMap性能即可。
接下来聊聊它的增删改查过程:
增加 & 修改:由于增加修改,都是调用的HashMap的put(key,value)方法,这里我就一同分析啦:
首先我们来看看put方法:
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
//调用了putVal方法
return putVal(hash(key), key, value, false, true);
}
调用了putVal方法,接着我们来看看putVal方法:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//如果表为空,就进行首次扩容
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//如果表中对应存放的位置没有任何数据存在,那么新建一个结点存放即可
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//根节点跟当前存放的结点对应的键一样,直接赋值给e,然后走value赋值流程
e = p;
else if (p instanceof TreeNode)
/**
* 根节点和当前存放的结点对应的键不一样,且当前的结点类型是红黑树对应的TreeNode,
* 说明当前的此处的链状结构为红黑树,那么走红黑树遍历,一一比对,如果存在二者键是
* 一样的,那么就走赋值流程,如果没有与存放的键值一样,那么就创建一个TreeNode结点
* 找到红黑树中存放的位置存放即可
*/
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
/**
* 根节点跟当前存放的结点对应的键一样,且当前的结点类型是链表对应的Node,
* 就对该链表进行逐一遍历比较是否键值一样
*/
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
/**
* 当遍历到某个结点的next指针为null时,说明已经遍历到链表尾部,
* 说明逐一比较之后没有发现与当前存放的键值对相同的键
* 那么就创建一个新的Node结点挂到链表尾部即可
*/
p.next = newNode(hash, key, value, null);
/**
* 判断当前链表数量是否达到转化为红黑树的标准,
* 它的判断条件是数量达到8个且整体元素大小超过64就进行树化,
* TREEIFY_THRESHOLD为final静态int类型,值为8
* static final int TREEIFY_THRESHOLD = 8;
*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//方法里面还进行了整体元素大小是否超过64的判断
treeifyBin(tab, hash);
break;
}
/**
* 如果逐一比较时,发现二者的键是一样的,那么就跳出循环,走value赋值流程
*/
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//赋值value
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//将当前操作数+1
++modCount;
//将size+1,判断是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
请仔细看以上代码和注释,最好多阅读几遍,你就会非常清晰明白HashMap的put过程,我们将它分为几个步骤,我们再来整理一遍逻辑:
- 1.判断当前表是否为null,为null就进行首次扩容
- 2.根据哈希算法确定键值对存放的位置,判断根节点是否为null,如果为null,就新建Node结点存放即可,如果不为null,就判断节点是否和当前存放的键值对的键一样,一样的话走value赋值流程,不一样就接着以下步骤
- 3.判断根结点是否是TreeNode类型,如果是的,那么当前此处的链状结构已经红黑树化,那么就走红黑树遍历过程,比较是否存在与当前键值对的键一样的,存在则走value赋值流程,不存在,则新创建TreeNode结点,然后挂到红黑树对应的位置。如果当前结点不是TreeNode类型,那么它肯定是链表对应的结点Node类型,接着以下步骤:
- 4.逐一遍历链表,是否存在与当前键值对一样的键,如果存在,则走赋值流程,如果不存在,则新创建Node结点挂到链表尾部,接着就会判断该链表数目是否达到了8且整体元素大小超过64,如果达到了,就将当前链表树化为红黑树。
- 5.将当前存放元素的条目size+1,再判读是否达到扩容的条件,如果达到,则进行扩容。modCount为操作数
HashMap的增加和修改,大致步骤是以上5个,对于HashMap的扩容细节,我们在下一节聊。我们还有个没有分析,那就是红黑树的遍历过程,这里呢,笔者暂时就不分析了,读者自行分析,这部分是数据结构相关的,我会将红黑树作为一篇文章进行讲解。接下来我们改改删除和查询过程:
删除 & 查询:由于删除过程是先查到位置,然后再进行删除,所以删除和查询过程可以放在一起讲解:
public V get(Object key) {
Node<K,V> e;
//通过getNode方法进行查询到Node结点,然后返回Node的value
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode方法如下:
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
/**
* 判断哈希表是否为null && 哈希表的容量是否大于0 && 查询的key对应的位置的根节点不为null
*/
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//判断根结点是否为查询的key,如果是,则返回根结点即可
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//判断根结点是否存在next的结点
if ((e = first.next) != null) {
if (first instanceof TreeNode)
/**
* 如果根结点的类型是TreeNode,那么说明当前链状结构为红黑树,
* 接着通过TreeNode的getTreeNode方法遍历红黑树,直到查询到
* key对应的TreeNode结点并返回
*/
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
/**
* 如果根结点的类型不是TreeNode,,那肯定是Node,说明当前链状结构为链表,
* 接着就会遍历链表,直到查询到当前key对应的结点Node并返回
*/
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//如果上面都未曾找到key对应的结点,那么直接返回null,HashMap里没有存放该key对应的键值对
return null;
}
HashMap通过key查找value的步骤其实挺简单的,步骤如下:
- 1.判断哈希表是否为null && 判断哈希表容量是否大于0 && 根据哈希算法确定key对应存放位置的根结点是否为null,三个判断一个不满足,则代表该key在HashMap没有存放,返回null。
- 2.判断根结点是否为key对应的存放的Node,如果是,则返回根结点,如果不是,接着以下步骤。
- 3.判断根结点的类型是否为TreeNode,如果是,说明当前的链状结构为红黑树,那么接下来就是遍历红黑树,逐一比对是否是同一个key,如果是,则返回,如果遍历所有都没有发现同一个key的结点,那么就会返回null。
- 4.如果根结点不是TreeNode,那么它肯定是Node类型,说明当前的链状结构为链表,那么接下来就是遍历链表,逐一比对是否是同一个key,如果是,则返回,如果遍历所有都没有发现同一个key的结点,那么就会返回null。
下面我们看看删除过程:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
接着我们看看removeNode方法:
/**
* Implements Map.remove and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to match if matchValue, else ignored
* @param matchValue if true only remove if value is equal
* @param movable if false do not move other nodes while removing
* @return the node, or null if none
*/
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 && 表的容量是否大于0 && 根结点不为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) {
//判断根结点类型是否为TreeNode类型
if (p instanceof TreeNode)
//红黑树上查找对应key的结点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//如果根结点不为TreeNode,那么它就为Node类型,对应链表
//遍历链表找到key对应的结点Node
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
//这里的p = e,记录的是当前遍历的位置,对之后查找的Node结点移除有至关作用
p = e;
} while ((e = e.next) != null);
}
}
//如果找到的结点node不为null,matchValue之前传入的是false,所以后面的肯定返回true
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果找到的结点类型为TreeNode
if (node instanceof TreeNode)
//红黑树的结点移除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
//如果查找到是根结点,那么根结点就换成根结点的next指针之后的结点
tab[index] = node.next;
else
/**
* 如果是Node结点,那么就将寻找到的结点位置前的结点
* 的next指针指向寻找的结点的next指针指向的结点,即可删除node结点
*/
p.next = node.next;
++modCount;//操作数+1
--size;//size-1
afterNodeRemoval(node);
return node;
}
}
return null;
}
从以上代码总结删除步骤如下:
- 1.判断哈希表是否为null && 判断哈希表容量是否大于0 && 根据哈希算法确定key对应存放位置的根结点是否为null,三个判断一个不满足,则代表该key在HashMap没有存放,返回null。
- 2.判断根结点是否为key对应的存放的Node,如果是,则记录移除的结点为根结点。如果不是,接着以下步骤
- 3.判断根结点的类型是否为TreeNode,如果是,说明当前的链状结构为红黑树,那么接下来就是遍历红黑树,逐一比对是否是同一个key,如果是,则记录移除的结点为找到的TreeNode,如果遍历所有都没有发现同一个key的结点,那么就记录移除的结点node为null。
- 4.如果根结点不是TreeNode,那么它肯定是Node类型,说明当前的链状结构为链表,那么接下来就是遍历链表,逐一比对是否是同一个key,如果是,则记录移除的结点为找到的Node,如果遍历所有都没有发现同一个key的结点,那么就记录移除的结点node为null。
- 5.判断记录移除的结点是否为null,如果为null,则说明HashMap中没有存放该key,结束移除操作。如果移除的结点node类型为TreeNode,则调用TreeNode的removeTreeNode方法移除这个结点,接着就会判断当前移除的结点是否为根结点,如果是,只需要将根结点换成根结点的next指针指向的结点即可,不管它是否为null,如果不是,则只能说明移除的结点node为链表对应的结点,链表移除结点的方式很简单,就是把移除结点的上一个结点next指针指向移除结点的next对应的指针即可移除该结点。接着将操作数modCount+1,容量size-1即可。
以上便讲完了HashMap的增删改查操作,值得注意的是笔者没有讲解链表如何树化为红黑树,红黑树如何退化为链表的过程。这部分我放在了3.6节进行讲解。还有就是笔者没有把扩容过程非常细致讲解,接着来我们就来聊聊HashMap的扩容过程。
3.5 HashMap扩容
要分析HashMap的扩容,我们首先要分析清除HashMap的resize方法:
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//获取当前HashMap数组容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取当前扩容容量标准
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
/**
* 如果当前HashMap数组大小达到了最大容量MAXIMUM_CAPACITY
* 无法继续扩容,只能让threshold赋值为Integer.MAX_VALUE最大
* 数组无法进行扩容,整个HashMap的存放的元素大小为Integer.MAX_VALUE
*/
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
/**
* 否则就将数组容量扩大为原来的2倍,扩大2倍之后必须小于 MAXIMUM_CAPACITY
* && 当前数组容量小于默认初始容量16
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新的扩容容量标准 = 旧的扩容容量标准 * 2
newThr = oldThr << 1; // double threshold
}
//如果当前数组容量等于0同时,当前的扩容容量标准大于0
else if (oldThr > 0) // initial capacity was placed in threshold
//那么新的数组容量就等于当前扩容容量标准
newCap = oldThr;
else {
//如果当前数组容量等于0同时,当前的扩容容量标准等于0
// zero initial threshold signifies using defaults
//新的数组容量就为DEFAULT_INITIAL_CAPACITY,即HashMap的数组初始容量为16
newCap = DEFAULT_INITIAL_CAPACITY;
//新的扩容容量标准为= 0.75 * 16 = 12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新的扩容容量标准为0
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
/**
* 判断新的容量 & 新的容量标准 是否达到了最大容量MAXIMUM_CAPACITY
* 如果达到了,新的数组容量标准为Integer.MAX_VALUE
*/
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//当前容量扩容标准 = 新的容量扩容标准
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建容量为newCap的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//赋值新的数组
table = newTab;
//如果当前数组不为null
if (oldTab != null) {
/**
* 逐一遍历扩容前旧数组的每个结点,将每个结点重新进行哈希计算,
* 确定它在新的扩容数组的中位置,然后出现冲突还是用拉链法解决
* 注意这里重新整理的链表如果大于8且整体元素大小超过64是没有进行树化的,只有下一次在put
* 到那个位置时,链表长度大于8且整体元素大小超过64再进行树化
*/
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;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新数组
return newTab;
}
仔细阅读以上代码,我们来总结总结:
- 1.HashMap初始数组容量为16
- 2.HashMap每次扩容数组容量都会以2倍进行增长,但是不是无限增长呢?并不是,它最大只能增长到Integer.MAX_VALUE
- 3.当没有指定加载因子时,首次扩容为16时,扩容容量标准为数组容量的0.75倍,之后随着数组容量2倍增长而2倍增长,它最大也同样只能增长到Integer.MAX_VALUE。
- 4.扩容的操作是新创建一个新的容量数组,然后遍历旧数组的每个结点,然后重新针对每个结点重新进行哈希算法计算,确定它在新数组中的位置,逐一在这个过程中链表等于大于8时并没有进行树化为红黑树,而是下次put时再进行树化。
HashMap的扩容我们已经知道的很清晰了,那么HashMap什么时候进行扩容呢?
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//当前数组为null或者数组容量为0
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//当前容量已经大于扩容容量标准threshold,进行扩容,调用resize方法
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
从以上代码可以看出,HashMap扩容的2个条件:
- 1.HashMap的数组为null 或者 数组容量为0时(注意这里是数组容量)
- 2.当向里面添加键值对后,当前size已经大于扩容容量标准threshold时(注意这里是元素size)
3.6 JDK中1.7 & 1.8 的HashMap区别
JDK 1.7的HashMap实现数据结构为数组+链表,而JDK 1.8的HashMap实现的数据结构为数组+链表/红黑树
- 1.JDK 1.7 出现哈希冲突时拉链法采用的是头插法,而JDK 1.8采用的是尾插法
- 2.JDK 1.8 引入红黑树提高某个位置的查找效率,当链表的大小大于等于8且当前HashMap存放的元素大小超过64,就把链表树化为红黑树。
4.聊聊 JDK 1.7 HashMap存在的一个bug
在JDK 1.7中存在一个多线程扩容导致死循环的bug,我们来分析分析这个bug产生的场景。
多线程同时put键值对时,如果同时触发了扩容操作,可能会导致循环链表产生,进而使得后面get的时候,会死循环。为什么呢?
void transfer(Entry[] newTable, boolean rehash) {
//获取新表的容量
int newCapacity = newTable.length;
//遍历旧表
for (Entry<K,V> e : table) {
while(null != e) {
//标记点1,next指针指向的下个结点
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//这里用头插法完成将旧表Entry填充到新表
//标记点2
e.next = newTable[i];
//标记点3
newTable[i] = e;
//标记点4
e = next;
}
}
}
举个例子,假设现在有2个线程,HashMap的数组大小为4,加载因子为1,假设现在往里面先插入3个[c,b,a],现在HashMap的情况如下:
[0]
[1]
[2]
[3]->a->b->c
注意JDK 1.7的HashMap采用的头插法。此时如果接下来有两个线程1,2分别往里面插入了d和e,由于已经达到了4(数组容量)*1(加载因子)=4的元素个数,此时两个线程同时触发扩容操作,它们分别新建了新的数组如下:
线程1 线程2
[0] [0]
[1] [1]
[2] [2]
[3] [3]
[4] [4]
[5] [5]
[6] [6]
[7] [7]
此时当线程2获取CPU执行权,执行到标记点1"Entry<K,V> next = e.next;",此时线程2的e指向了a,next指向了b,此时线程2失去了CPU执行权,线程1开始进行扩容操作,假设扩容到如下情况:
线程1 线程2
[0] [0]
[1] [1]
[2] [2]
[3] [3]
[4] [4]
[5] [5]
[6] [6]
[7]->c->b->a [7]
此时线程1释放了CPU执行权,现在该线程2工作,由于内部的Table还没有设置成新的Table,此时线程2里e指向了a,next指向了b,接着会执行以下代码:
while(null != e) {
//标记点1,next指针指向的下个结点
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//这里用头插法完成将旧表Entry填充到新表
//标记点2
e.next = newTable[i];
//标记点3
newTable[i] = e;
//标记点4
e = next;
};
接着执行标记点2,3,4,执行后,e指向了b结点,情况如下:
线程1 线程2
[0] [0]
[1] [1]
[2] [2]
[3] [3]
[4] [4]
[5] [5]
[6] [6]
[7]->c->b->a [7]->b
因为e指向b结点之后不为null,所以跳不出while循环,又循环执行一次之后,e指向了a,情况如下:
线程1 线程2
[0] [0]
[1] [1]
[2] [2]
[3] [3]
[4] [4]
[5] [5]
[6] [6]
[7]->c->b->a [7]->a->b
因为e指向a结点之后不为null,所以又进入下一次循环,循环之后e执行了null,结束了真个while循环,最终情况如下:
线程1 线程2
[0] [0]
[1] [1]
[2] [2]
[3] [3]
[4] [4]
[5] [5]
[6] [6]
[7]->c->b->a [7]->a->b
惊喜且意外的发现,结合线程1和线程2,线程1中b的next指针指向a,线程2中a的next指针指向了b,形成了一个小圈,假设1线程最后执行完,那么线程1和线程2的扩容最终导致HashMap的内部结构如下:
[0]
[1]
[2]
[3]
[4]
[5]
[6]
[7]->c->b->a->b->a...(这里是个a和b构成的小环)
此时如果进行get操作时就有可能进入死循环,而且当线程2最后执行完,还有可能让c结点消失,这也是bug。
曾经有人把这个问题报给了Sun,不过Sun不认为这是一个bug,因为在HashMap本来就不支持多线程使用,要并发就用ConcurrentHashmap。
面试时如何你能将此场景给面试官说清晰,相信你对HashMap的了解面试官已了然于胸。