• 欢迎访问开心洋葱网站,在线教程,推荐使用最新版火狐浏览器和Chrome浏览器访问本网站,欢迎加入开心洋葱 QQ群
  • 为方便开心洋葱网用户,开心洋葱官网已经开启复制功能!
  • 欢迎访问开心洋葱网站,手机也能访问哦~欢迎加入开心洋葱多维思维学习平台 QQ群
  • 如果您觉得本站非常有看点,那么赶紧使用Ctrl+D 收藏开心洋葱吧~~~~~~~~~~~~~!
  • 由于近期流量激增,小站的ECS没能经的起亲们的访问,本站依然没有盈利,如果各位看如果觉着文字不错,还请看官给小站打个赏~~~~~~~~~~~~~!

HashMap和ConcurrentHashMap

JAVA相关 supingemail 1518次浏览 0个评论

好记忆不如烂笔头, 能记下点什么, 就记下点什么, 方便后期的巩固..

1.前言

Map 这样的 KeyValue 在软件开发中是非常经典的结构,常用于在内存中存放数据。下面我们来谈谈这个有意思的API。

HashMap

众所周知 HashMap 底层是基于 `数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。

JDK1.7 1.7中的数据结构图

接着我们再来看看1.7中的实现

这是HashMap 中比较核心的几个成员变量;看看分别是什么意思?

  1. 初始化桶大小,因为底层是数组,所以这是数组默认的大小。

  2. 桶最大值。

  3. 默认的负载因子 (0.75)

  4. table 真正存放数据的数组。

  5. Map存放数量的大小。

  6. 桶大小,可在初始化时显式指定。

  7. 负载因子,可在初始化时显式指定。

在这里小乖解释一下负载因子 若有疑问,还请大佬们帮忙纠正。

由于给定的 HashMap 的容量大小是固定的,比如默认初始化:

给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。

因此通常建议能提前预估 HashMap 的大小最好,尽量的减少扩容带来的性能损耗。 根据代码可以看到其实真正存放数据的是 transient Entry[] table = (Entry[]) EMPTY_TABLE下面我们看下这个数组,它是如何定义的?

Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出:

  • key 就是写入时的键。

  • value 自然就是值。

  • 开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。

  • hash 存放的是当前 key 的 hashcode。

然后我们来看看写入、获取的函数计算

put方法

  • 判断当前数组是否需要初始化。

  • 如果 key 为空,则 put 一个空值进去。

  • 根据 key 计算出 hashcode。

  • 根据计算出的 hashcode 定位出所在桶。

  • 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。

  • 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。

当调用 addEntry 写入 Entry 时需要判断是否需要扩容,如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位,而在 createEntry 中会将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表。

get方法

再来看看get函数

  • 首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。

  • 判断该位置是否为链表。

  • 不是链表就根据 key、key 的 hashcode 是否相等来返回值。

  • 为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。

  • 啥都没取到就直接返回 null 。

JDK1.8在1.7中有一个很明显的地方是:当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。所以1.8中优化了这个查询效率。

1.8HashMap结构图:

和上面一样我们先看看核心的成员变量:

大体上和1.7的参数都差不多,但是还是有几个有区别: TREEIFY_THRESHOLD 用于判断是否需要将链表转换为红黑树的阈值。 HashEntry 修改为 Node。 Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next 等数据。 紧接着我们接着看核心方法 put

我们一步步拆分来看: 1.判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。 2.根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。 3.如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。 4.如果当前桶为红黑树,那就要按照红黑树的方式写入数据。 5.如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。 6.接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。 7.如果在遍历过程中找到 key 相同时直接退出遍历。 8.如果e!=null 就相当于存在相同的Key,那么就需要将值覆盖。 9.最后判断是否需要扩容。

get方法

  • 首先将 key hash 之后取得所定位的桶。

  • 如果桶为空则直接返回 null。

  • 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。

  • 如果第一个不匹配,则判断它的下一个是红黑树还是链表。

  • 红黑树就按照树的查找方式返回值。

  • 不然就按照链表的方式遍历匹配返回值。 从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)

但是 HashMap 原有的问题也都存在,比如在并发场景下使用时容易出现死循环。

因为在HashMap扩容的时候会调用resize()方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。如图:

遍历方式

值得注意的是 HashMap 的遍历方式,通常有以下几种:

 
  1. Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator();

  2. while (entryIterator.hasNext()) {

  3.    Map.Entry<String, Integer> next = entryIterator.next();

  4.    System.out.println("key=" + next.getKey() + " value=" + next.getValue());

  5. }

  6.  

  7. Iterator<String> iterator = map.keySet().iterator();

  8. while (iterator.hasNext()){

  9.    String key = iterator.next();

  10.    System.out.println("key=" + key + " value=" + map.get(key));

  11. }

强烈建议使用第一种EntrySet进行遍历。第一种可以把Key Value同时取出,第二种还需要通过Key再取一次Value,效率较低。

简单总结一下就是:HashMap无论是 1.7 还是 1.8 其实都能看出 JDK 没有对它做任何的同步操作,所以并发会出问题,甚至出现死循环导致系统不可用。所以接下来就专门研究一下ConcurrentHashMapConcurrentHashMap 同样也分为 1.7 、1.8 版,两者在实现上略有不同。

1.7的结构图

如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。核心变量:

Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

  1. static final class Segment<K,V> extends ReentrantLock implements Serializable {

  2.    private static final long serialVersionUID = 2249069246763182397L;

  3.    // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶

  4.    transient volatile HashEntry<K,V>[] table;

  5.    transient int count;

  6.    transient int modCount;

  7.    transient int threshold;

  8.    final float loadFactor;

  9. }

看看其中 HashEntry 的组成:

和 HashMap 非常类似,唯一的区别就是其中的核心数据如 value ,以及链表都是 volatile 修饰的,保证了获取时的可见性。 原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。

put 方法

首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put。虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。 首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

1.尝试自旋获取锁。 2.如果重试的次数达到了 MAXSCANRETRIES 则改为阻塞锁获取,保证能获取成功。

再结合图看看 put 的流程: 1.将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。 2.遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。 3.不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。 4.最后会解除在 1 中所获取当前 Segment 的锁。

get方法

 
  1. public V get(Object key) {

  2.    Segment<K,V> s;

  3.    HashEntry<K,V>[] tab;

  4.    int h = hash(key);

  5.    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;

  6.    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {

  7.            for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile

  8.            (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);

  9.             e != null; e = e.next) {

  10.                K k;

  11.                if ((k = e.key) == key || (e.hash == h && key.equals(k)))

  12.                    return e.value;

  13.             }

  14.    }

  15.    renturn null;

  16. }

get 逻辑比较简单: 只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。 由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。 ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。

1.8的结构图1.7 已经解决了并发问题,并且能支持 N 个 Segment 这么多次数的并发,但依然存在 HashMap 在 1.7 版本中的问题。查询遍历链表效率太低1.8 做了一些数据结构上的调整,先来看下底层的组成结构:看起来是不是和 1.8 HashMap 结构类似?其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。也将 1.7 中存放数据的 HashEntry 改为 Node,但作用都是相同的,其中的 val next 都用了 volatile 修饰,保证了可见性。

put方法我们来看看put的函数:

  • 根据 key 计算出 hashcode 。

  • 判断是否需要进行初始化。

  • f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。

  • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。

  • 如果都不满足,则利用 synchronized 锁写入数据。

  • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

get方法

  • 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。

  • 如果是红黑树那就按照树的方式获取值。

  • 就不满足那就按照链表的方式遍历获取值。

1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很好的。

总结:

看完了整个 HashMap 和 ConcurrentHashMap 在 1.7 和 1.8 中不同的实现方式相信大家对他们的理解应该会更加到位。

这篇文章对 HashMap ,ConcurrentHashMap 的分析还是比较到位的,对于Map源码不熟悉的人来说,还是值得一看.

文章来自:https://mp.weixin.qq.com/s/ndZI2HbODXMD1Bvy712IGw

 


开心洋葱 , 版权所有丨如未注明 , 均为原创丨未经授权请勿修改 , 转载请注明HashMap和ConcurrentHashMap
喜欢 (0)

您必须 登录 才能发表评论!

加载中……