集合中,HashMap绝对是是面试中的一大常考点,特别重要,特别是它的另两个兄弟Hashtable和ConcurrentHashMap,加起来更加让人混淆,今天我大致梳理以下:
1、HashMap
1.1、HashMap的底层实现原理
我们先以JDK1.7为例说。
上面我们说道:
1、数组使用下标维护数据,所以查找起来比较快,但是插入和删除的话需要移动后面的数据,数据插入和删除比较慢。
2、链表使用指针来维护链表结构,查找起来比较慢,因为要从第一个结点开始查找,但是插入和删除的话不需要移动数据,只需要改变指针的指向就可以了,所以插入和删除比较快。
那么我们也肯定学习到了,HashMap提供高效的查找,插入和删除。那么它啥都牛逼,具体是怎么做到的?
1、HashMap底层依旧是以数组方式进行存储的。但是他不是存储一个数据,而是将key-value键值对作为数组的一个元素进行存储,看似数组,但是跟链表很像。
2、Key-value都是Map.Entry中的属性。其中将key的值进行hash之后进行存储,即每一个key都是计算hash值,然后再存储。每一个hash值对应一个数组下标,数组下标是根据hash值和数组长度计算得来的。
3、由于不同的key值可能具有相同的hash值,即一个数组的某个位置出现两个相同的元素,对于这种情况,hashmap采用链表的形式进行存储。
如图所示:
也就是说,HashMap不局限于数组和链表,而是将他们两个都用了,集大成者。
1.1.1、table数组
table数组就是HashMap中的那个存放数据的数组,那么table数组是怎么运作的呢?
当第一次put的时候,HashMap会判断当前table是否为空,如果是空,会调用resize方法进行初始化。
resize方法会初始化一个容量大小为16 的数组,并赋值给table。
并计算threshold,threshold是一种约束,表示存储临界值,如果table数组中插入的数据到了这个大小,就应该扩容了,threshold=16*0.75=12,HashMap扩容的时候是会扩容二倍的。
1.1.2、HaspMap中的Put方法
说起HashMap的存储结构,就不得不提及HashMap中最重要的一个方法:Put方法
map.put("key0", "value0");
它的具体存储过程为:
1、首先计算key的hash值,(Hash值=(hashcode)^(hashcode >>> 16))我们假设一个hash(“key0”) = 3288451。
2、计算这次put要存入数组位置的索引值:index=(数组大小 - 1) & hash = 3(hash&(16-1) = hash%16)
3、判断 if (table[index] == null) ,为空就new一个Node放到这里,所以直接new Node放到3上,此时table如下:
4、然后判断当前已使用容量大小(size)是否已经超过临界值threshold,此时size=1,小于12,不做任何操作,put方法结束(如果超过临界值,需要resize扩容)。
注意:从这里可以看出,HashMap也是先放在扩容的。
5、继续put直至已使用容量大小(size)等于临界值threshold,下一次put将触发扩容(resize)。
6、若是再有table位置的索引值被计算出来已存在,但是我们又必须将它存进去,这就发生了哈希冲突(哈希碰撞)。
这时链表就派上用场了,HashMap就是通过链表解决哈希冲突的。HashMap会创建一个新的Node,并放到table[3]链表的最后面,(Jdk1.8之在链表长度大于8的时候,就会转化为红黑树,下面有说)如图:
1.1.3、HaspMap中的Get方法
从上面的Put方法我们了解到HashMap存放数据的过程,而get方法就相对简单一些。
在Get的时候,和put一样我们也会计算要get的元素Key的hash值,然后直接按照和计算元素存放位置一样的方法计算出我们要找的元素的位置,直接取出,所以HashMap的get方法它的查询速度非常快,复杂的是O(1)。
1.1.3、HashMap扩容中的循环链表问题及解决办法(JDK1.7中有)
1、单线程扩容
从上面我们知道了HashMap中添加元素的时候,会发生Hash冲突,而HashMap解决hahs冲突的方法就是链式地址法,而且它往链表中插入数据的时候采用的是头插法,什莫事头插法呢?顾名思义就是往链表的头部插入数据。
如图所示:
看的出来,链表头插法有一个一起下移的过程,那么在扩容的时候(对于链表无所谓扩容,扩容的是数组),而扩容的时候,HashMap在put完成发现当前已使用的size已经到了临界点,就会扩容,扩容实际上是生成一个长度为原来二倍的新数组,然后将老数组里面的链表再一个个复制过来,再HashMap源码中有一个这样的方法叫做transfer方法,来完成这个过程,这个过程中会有两个指针,在源码中一个叫做e一个叫做next,e指向当前需要复制转移的节点,这个节点会指向新的数组中的空节点,而next指向e指针的下一个节点,然后复制完成后e指向next,next指向下一个。最终完成复制转移。
但是,复制的过程自然是从链表的头开始的经历双循环进行链表复制的,继续再经历一次头插法,就会造成链表被反转了一次。也就是说顺序变了,这种现象看似好像没什么问题,反正我们get数据是用hash值get的,但是多线程的时候,就会发生问题。
2、多线程扩容:
假设有对个线程同时进行到需要resize的时候,那么这时候就可能会造成两个线程都开始扩容,生成了两个数组,然后各自生成e指针和next指针,开始复制数组到新的数组里面去,如果这时候有一个线程被阻塞,另一个线程正常运作完成数组之后,将链表反转。
然后阻塞的线程一旦开始工作,那么就会出现问题,因为刚刚的链表已经被反转了,这时候这个线程是从已经被反转了的链表开始复制的,这时候这线程它的e指针和next指针也已经被反转了,就会出现这样一个问题:
因为e和next被反转,那么next终有一天下一次指向的就不是我们要复制转移的心节点,而是已经被复制过去的节点,而e指向的是next,也指向了已经被复制过去的节点,复制已经完成复制的节点,这就是死循环!,如图所示:
这就是HashMap中扩容所引起的链表死循环。
而JDK1.8改善了这个循环,将头插法变成了尾插法,从而避免了这种现象,而且使用了效率更高的红黑树结构,来代替数组加链表。
1.1.5、红黑树(Jdk1.8之后)
既然HashMap是一个数组加链表的结构,那么当单线链表达到一定长度后,效率也会变得非常低。于是在jdk1.8以后的话,为了再次提高效率,我们加入了红黑树,也就是说单线列表达到一定长度后就会变成一个红黑树。
红黑树:是一种特殊的二叉树,当链表达到一定长度后,链表就会变成红黑树,当链表的长度大于8的时候就会将后面的数据存在红黑树中。
对于红黑树,具体内容我会在另一篇博客中讲解,敬请期待!!!
2、HahsMap知识总结
1、Hashmap的数据结构:数组,单线链表,红黑树(1.8)
2、Hashmap的特点:
> 1、快速存储
> 2、快速查找
> 3、可伸缩
3、Hash算法:
为什么要用hash算法。在我们Java中任何对象都有hashcode,hash算法就是通过hashcode与自己进行向右位移16的异或运算。这样做是为了计算出来的hash值足够随机,足够分散,还有产生的数组下标足够随机。
3、Hashtable和ConcurrentHashMap
学习了HashMap我们就会知道,HashMap是线程不安全的,但是我们肯定是需要线程安全的,有时候甚至为了安全可以牺牲空间时间,所以线程安全比起增删查改的速度快,更加重要。
而ConcurrentHashMap和Hashtable都实现了线程安全,面试中都很常见,我们将这两个放在一起比较着说。
3.1、Hashtable
HashTable和HashMap的实现原理几乎一样,只有一丁点微小的差别,对于它的存储结构我们就不在赘述,上述的HashMap就是,我们主要就区别讲一讲Hashtable,为大家加深理解:
1、HashMap可以允许存在一个为null的key和任意个为null的value,但是HashTable中的key和value都不允许为null。
当HashMap遇到为null的key时,它会调用putForNullKey方法来进行处理。对于value没有进行任何处理,只要是对象都可以。
if (key == null){
return putForNullKey(value);
}
当HashTable遇到null时,他会直接抛出NullPointerException异常信息。
if (value == null) {
throw new NullPointerException();
}
2.Hashtable的方法是同步的,而HashMap的方法不是。所以有人一般都建议如果是涉及到多线程同步时采用Hashtable,没有涉及就采用HashMap。
HashTable线程安全的策略实现代价比较大,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞。
3、哈希值的使用不同。
HashTable直接使用对象的hashCode:
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
HashMap重新计算hash值,而且用与代替求模:
int hash = hash(k);
int i = indexFor(hash, table.length);
4、Hashtable和HashMap它们两个内部实现方式的数组的初始大小和扩容的方式。
HashTable中hash数组默认大小是11,增加的方式是 old*2+1。
HashMap中hash数组的默认大小是16,而且一定是2的指数。
3.2、ConcurrentHashMap
ConcurrentHashMap也是线程安全的,我们来谈一下为什么要使用ConcurrentHashMap:
1)线程不安全的HashMap
在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。
2)效率低下的HashTable
Hashtable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。
3)ConcurrentHashMap的锁分段技术可有效提升并发访问率
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
下面我们来详细介绍一下ConcurrentHashMap的结构
1、在jdk1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:
从图上我们可以看得出来,本质上Segment类就是一个小的hashmap,里面table数组存储了各个节点的数据,继承了ReentrantLock, 可以作为互拆锁使用,与hashtable相比,这么设计的目的是对于put, remove等操作,可以减少并发冲突,对不属于同一个片段的节点可以并发操作,大大提高了性能。
ConcurrentHashMap初始化时,计算出Segment数组的大小size和每个Segment中HashEntry数组的大小cap,并初始化Segment数组的第一个元素;其中size大小为2的幂次方,默认为16,cap大小也是2的幂次方,最小值为2,最终结果根据根据初始化容量initialCapacity进行计算,其中Segment在实现上继承了ReentrantLock,这样就自带了锁的功能。
put方法:
那么,ConcurrentHashMap具体是怎么实现线程安全的呢?
具体步骤如下:
1、线程A执行tryLock()方法成功获取锁,则把HashEntry对象插入到相应的位置;
2、线程B获取锁失败,则执行scanAndLockForPut()方法,在scanAndLockForPut方法中,会通过重复执行tryLock()方法尝试获取锁,在多处理器环境下,重复次数为64,单处理器重复次数为1,当执行tryLock()方法的次数超过上限时,则执行lock()方法挂起线程B;3、当线程A执行完插入操作时,会通过unlock()方法释放锁,接着唤醒线程B继续执行;
2、JDK1.8实现,数组+链表(红黑树)的结构:
ConcurrentHashMap在1.8中的实现,相比于1.7的版本基本上全部都变掉了。首先,取消了Segment分段锁的数据结构,而是采用了CAS技术来实现线程安全。
然后是定位节点的hash算法被简化了,这样带来的弊端是Hash冲突会加剧。因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。这样一来,查询的时间复杂度就会由原先的O(n)变为O(logN)。
put方法实现:
当执行put方法插入数据时,根据key的hash值,在Node数组中找到相应的位置,实现如下:
1、如果相应位置的Node还未初始化,则通过CAS插入相应的数据;
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
2、如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
3、如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
4、如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;
5、如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数baseCount;
该部分转载自博客Concurrenthashmap的实现原理分析