【重难点】【Java集合 01】HashMap

文章目录

  • 【重难点】【Java集合 01】HashMap
  • 一、HashMap
    • 1.概述
    • 2.JDK 1.8 中的变化
    • 3.链表转换为红黑树
    • 4.扩容问题
    • 5.加载因子
    • 6.添加新结点的完整流程
    • 7.扩容时的循环链表问题
    • 8.HashMap 和 TreeMap 的对比
    • 9.HashMap 和 LinkedHashMap 的对比
  • 二、ConcurrentHashMap
    • 1.ConcurrentHashMap 和 HashMap 的对比
    • 2.JDK 1.7
    • 3.JDK 1.8

一、HashMap

1.概述

HashMap 是一个散列表,它存储的内容是键值对(Key-Value)映射,JDK 1.2 时引入

从结构实现来讲,HashMap 是数组 + 链表 + 红黑树(JDK 1.8 引入)实现的,如下图所示

HashMap 实现了 Map 接口,根据键的 HashCode 值存储数据,具有很快的访问速度,最多允许一条记录的键为 null,不支持线程同步

其继承关系如下图所示:

2.JDK 1.8 中的变化

数据结构的变化:引入了红黑树

有关红黑树的内容请移步【Java 数据结构与算法】第十四章 红黑树,我进行了详细地讲解

JDK 1.8 之前,HashMap 由数组 + 链表组成,数组是 HashMap 的主体,链表主要是为了解决哈希冲突而存在的,我在【重难点】【Java基础 02】Arrays.sort() 、创建对象的 5 种方式、hashCode() 的作用、解决哈希冲突的方法 中简单介绍了解决哈希冲突的四种方法

JDK 1.8 以后,在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8),且当前数组的长度大于 64 时,此索引位置上的数据改为使用红黑树存储

table 数组的类型由 Entry 改成了 Node

Entry 是 HashMap 中在 JDK 1.8 之前的一个静态内部类,在 1.8 改成了 Node

并且在 JDK 1.8 以后就不是在构造时创建数组了,而是在第一次调用 put 方法时才创建

简化了 hash() 函数算法

JDK 1.8 之前

    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);}

JDK 1.8 以后

    static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}

若 key 为 null,返回 0;若 key 不为 null,则计算 key 的 hashCode 值,将结果右移 16 位之后,做异或运算

向链表中加入数据时采用尾插法

因为在多线程的场景下使用原来的头插法会出现循环链表的情况

3.链表转换为红黑树

上面提到,当链表长度超过阈值时会将当前链表上的数据使用红黑树进行存储。这里的阈值是可以自定义的,默认为 8。此外,还有一个很重要的条件,就是数组的长度也要大于等于 64,否则即使有链表长度超过阈值,也不会转换为红黑树,而是进行扩容,重新散列

树结点的大小约是普通结点的两倍,当数组较小时,我们要尽量避免使用红黑树,这种情况下将链表变为红黑树结构反而会降低效率。不仅仅是因为树节点占更多空间,而且每次添加新结点时,红黑树可能需要进行一些较为复杂的操作来保持红黑树的结构稳定

JDK 1.8 引入红黑树的原因当然是为了提高查找效率,虽然红黑树使得 HashMap 底层数据结构变得复杂,但是当链表结点数超过 8,且数组长度大于 64 时,使用红黑树效率比链表更高

长度为 8 时,红黑树的平均查找时间为 log(8) = 3,链表的平均查找时间为 8 / 2 = 4,且长度越大,红黑树的优势越明显

当长度为 6 时,红黑树的平均查找时间为 log(6) = 2.6,链表的平均查找时间为 6 / 2 = 3,虽然红黑树还是比链表查找得要快,但是随着长度变小,红黑树的优势就逐渐被链表追上,并且将链表转换为树结构也要花费不少的时间

所以在结点数减少为 6 时,红黑树又会转化为链表了。那么为什么不是结点数为 7 的时候退化呢?这也很好理解,为 8 就转化为红黑树,为 7 就转化为链表,如果仅仅是变化了 1 个结点就要进行来回转化,这也太耗费时间了,因此把 7 作为缓冲

4.扩容问题

在不断地添加数据的过程中,会涉及到扩容问题。当数组长度即将超过阈值时就会进行扩容,默认的扩容方式是将数组容量扩容到原来的 2 倍,并重新散列

HashMap 的容量总是 2 的 n 次方,这是什么原因呢?

当向 HashMap 中添加一个元素的时候,需要根据 Key 的 hash 值确定其在数组中的具体位置,这就需要进行取模运算。而 CPU 是采用二进制进行运算的,所以取模运算的效率远不如位运算,在一个公式下,是可以用位运算代替取模运算的,这个公式为:

hash % length = hash & (length - 1)

length 为 2 的 n 次方是这个公式成立的条件。并且,当 length 不为 2 的 n 次方时,计算出的索引就特别容易相同,导致数据存储集中在几个索引中,链表和红黑树也会随之变长,查找效率大打折扣

并且这个规则是固定的,即使我们在创建 HashMap 时指定的数组长度不为 2 的 n 次方,底层也会自动将其变为大于我们指定长度且离其最近的 2 的 n 次方

数组的最大上限为 2 的 30 次方

5.加载因子

加载因子(loadFactor)也叫负载因子,是用来衡量 HashMap 是否需要扩容的重要条件,通过 length * loadFactory 可以算出需要扩容的阈值,loadFactory 默认为 0.75,当 HashMap 里面已用索引长度已经达到数组总长度的 75% 时说明 HashMap 太挤了,需要进行扩容

我们知道,如果进行扩容就需要重新散列,这是非常消耗性能的,所以开发中需要尽量减少扩容的次数,那是不是加载因子越大越好呢?

当然不是,JDK 开发人员一定是进行过严谨的计算才得出 0.75 这个数值的。加载因子越趋近于 1 ,数组中存放的数据也就越多,导致查询效率降低;加载因子越小,数组中存放的数据也就越少,导致数据利用率过低

6.添加新结点的完整流程

7.扩容时的循环链表问题

这个问题只会出现在 JDK 1.7 头插法和多线程的场景下

有线程 α 和 线程 β,此时线程 α 正在扩容,在扩容时,原本的最后一个元素 A 会重新放入到数组中;同时,线程 β 添加结点 B 进来,如果是头插法,理想的情况是这样的:

而实际可能出现如下的问题:

在 HashMap 中,扩容的方法为 resize(int newCapacity)

resize() 中调用了一个方法 transfer(newTable, initHashSeedASNeeded(newCapacity))

transfer 里会执行具体的扩容操作,newTable 是一个新的 Entry[] 数组

void transfer(Entry[] newTable, boolean rehash){int newCapacity = newTable.length;  //容量for(Entry<K,V> e : table){    //遍历 table[1,2,3,4,5]while(null != e){ //遍历 table 中的链表 table[i]Entry<K,V> next = e.next;    //α 线程在跑,而 β 线程没在跑if(rehash){    //如果 rehash 需要重新计算 hash 值e.hash = null == e.key ? 0 : hash(e.key);}int i = indexFor(e.hash, newCapacity);   //定位索引//元素插入对应链表中,头插法//newTable[i] 的值总是最新插入的值newTable[i] = e;//继续下一个元素e = next;}}
}

我们举一个例子,有两个线程,线程 1 和线程 2。线程 1 和线程 2 都处于 if(rehash) 的位置,即将进行扩容,只是线程 1 处于唤醒状态即将进行扩容,线程 2 要等到线程 1 扩容完成后才能被唤醒再进行扩容操作

下图是线程 1 还未进行扩容的情形,此时线程 1 和线程 2 的指针都指向 3 和 2 :

线程1 扩容完成之后,线程 2 被唤醒,我们可以发现,e2 和 next 2 的位置已经颠倒了,已经开始出错,但是线程 2 并不知道:


线程 2 从 if(rehash) 处一步一步将代码全部执行完就会出现下面的情形:


综上我们可以看出,导致环形链表出现的根本原因就是头插法,扩容时头插法打乱了链表的顺序,导致两个线程的数据结构不一致

8.HashMap 和 TreeMap 的对比

相同点:

都继承了 AbstractMap,都是非线程安全的

不同点:

  • 实现方式

    • HashMap 基于哈希表实现,使用 HashMap 要求添加的键类明确定义了 hashCode() 和 equals(),可以通过调整初始容量和加载因子优化 HashMap 的空间使用
    • TreeMap 基于红黑树实现,该树总是处于平衡状态,所以没有调优选项
  • 存储方式
    • HashMap:随机存储
    • TreeMap:默认按键的升序排序
  • 遍历方式
    • HashMap:Iterator 遍历是随机的
    • TreeMap:Iterator 遍历是有序的
  • 性能损耗:
    • HashMap:基本没有
    • TreeMap:插入和删除时有
  • 是否允许为 NULL
    • HashMap:只允许键、值均为 NULL
    • TreeMap:键、值均不能为 NULL
  • 效率
    • HashMap:高
    • TreeMap:低

总结:

一般情况下我们选用 HashMap,因为 HashMap 的键值对再取出时是随机的,其依据键的 hashCode 和键的 equals 方法存取数据,具有很快的访问速度,所以在 Map 中插入、删除及索引元素时其是效率最高的实现

而 TreeMap 的键值对在取出时是排过序的,所以效率会低一些

9.HashMap 和 LinkedHashMap 的对比

LinkedHashMap 是 HashMap 的子类,只是 LinkedHashMap 重写了 newNode() 方法,在该方法中将每个新生成的节点与已经存在的节点用双向链表连接起来,可以理解为 LinkedHashMap 及采用了哈希表(拉链法和红黑树)存储,也采用了双向链表存储,更准确地说,它是一个将所有 Entry 节点链入一个双向链表的 HashMap。值得一提的是,因为它额外维护了一个双向链表用于保持迭代顺序,因此 LinkedHashMap 可以很好地支持 LRU(Least Recently Used)算法

二、ConcurrentHashMap

1.ConcurrentHashMap 和 HashMap 的对比

ConcurrentHashMap 和 HashMap 之间的第一个重要的区别就是 ConcurrentHashMap 是线程安全的,且在并发环境下不需要加额外的同步

ConcurrentHashMap 具有很好的扩展性,在多线程环境下性能方面比做了同步的 HashMap 要好,但在单线程环境下,HashMap 会更好

ConcurrentHashMap 比 HashMap 更适合用于缓存

ConcurrentHashMap 更适合读操作线程数多于写操作线程数的情况

ConcurrentHashMap 既不允许 key 值为 NULL,也不允许 value 值为 NULL

2.JDK 1.7

基本介绍

通过使用段 (Segment) 将 ConcurrentHashMap 划分为不同的部分,ConcurrentHashMap 就可以使用不同的锁来控制对哈希表的不同部分的修改,从而允许多个修改操作并发进行, 这是 ConcurrentHashMap 锁分段技术的核心内涵

ConcurrentHashMap 类中包含两个静态内部类 HashEntry 和 Segment,其中 HashEntry 用来封装具体的键值对,而 Segement 用来充当锁的角色,每个 Segment 对象守护整个 ConcurrentHashMap 的若干个桶(可以把 Segment 看作是一个小型的哈希表),其中每个桶是由若干个 HashEntry 对象链接起来的链表。也就是说一个 ConcurrentHashMap 实例中包含若干个 Segement 实例组成的数组,而一个 Segment 实例又包含若干个桶,每个桶中都包含一条有若干个 HashEntry 对象链接起来的链表。ConcurrentHashMap 在默认并发级别下会创建 16 个 Segment 对象的数组,如果键能均匀散列,每个 Segment 大约守护整个散列表中桶总数的 1 / 16

ConcurrentHashMap 继承了 AbstractMap,并实现了 ConcurrentMap 接口

与 HashMap 相比,ConcurrentHashMap 增加了两个属性用于定位段,分别是 segmentMask 和 segmentShift。此外,ConcurrentHashMap 底层结构是一个 Segment 数组,而不是 Object 数组

Segment

Segment 类继承自 ReentrantLock 类,从而使得 Segment 对象能充当锁的角色。每个 Segment 对象用来守护它的成员对象 table 中包含的若干个桶。table 是一个由 HashEntry 对象组成的链表数组,table 数组的每一个数组成员就是一个桶

在 Segment 类中,count 变量是一个计数器,它表示每个 Segment 对象管理的 table 数组包含的 HashEntry 对象的个数,也就是 Segment 中包含的 HashEntry 对象的总数。特别需要注意的是,之所以在每个 Segment 对象中包含一个计数器,而不是在 ConcurrentHashMap 中使用全局计数器,是对 ConcurrentHashMap 并发性的考虑,当需要更新计数器时,不用锁定整个 ConcurrentHashMap。事实上,每次对段进行结构上的改变,如在段中进行增加 / 删除节点(修改节点的值不算结构上的改变),都要更新 count 的值。此外,在 JDK 的实现中,每次读取操作开始时都要先读取 count 的值。特别需要注意的是,count 被 volatile 修饰,这使得 count 的任何更新对其它线程都是立即可见的。modCount 用于统计段结构改变的次数,主要是为了检测对多个段进行遍历过程中某个段是否发生改变,这一点具体在谈到跨段操作时会详述u。threashold 用来表示段需要进行扩容的阈值。loadFactor 表示段的加载因子,其值等同于 ConcurrentHashMap 的加载因子。table 是一个典型的链表数组,而且也被 volatile 修饰,这使得 table 的任何更新对其它线程也是立即可见的

ConcurrentHashMap 允许多个修改(写)操作并发进行,其关键在于使用了锁分段技术,它使用了不同的锁来控制对哈希表的不同部分进行修改(写),而 ConcurrentHashMap 内部使用段(Segment)来表示这些不同的部分。实际上,每个段实质上时一个小的哈希表,每个段都有自己的锁。这样,只要多个修改(写)操作发生在不同的段上,它们就可以并发进行,下图是依次插入 ABC 三个 HashEntry 结点后,Segment 的结构示意图:

HashEntry

HashEntry 用来封装具体的键值对,与 HashMap 中的 Entry 类似,HashEntry 也包括同样的四个域,分别是 Key、hash、value 和 next。不同的是,在 HashEntry 类中,key、hash 和 next 域都被声明为 final,而 value 域被 volatile 修饰,因此 HashEntry 对象几乎是不可变的,这是 ConcurrentHashMap 读操作并不需要加锁的一个重要原因。next 域被声明为 fianl 本身就意味着我们不能从 hash 链的中间或尾部添加或删除节点,因为这需要修改 next 引用值,因此所有节点的修改只能从头部开始。对于 put 操作,可以一律添加到 Hash 链的头部。但是对于 remove 操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有的节点复制一份,让最后一个节点指向要删除节点的下一个节点。特别地,由于 value 域被 volatile 修饰,所以其可以确保被读线程读到最新的值,这是 ConcurrentHashMap 读操作并不需要加锁的另一个重要原因。实际上,ConcurrentHashMap 完全允许多个读操作并发进行,读操作并不需要加锁

put 操作

Sement 继承了 ReentrantLock,也就带有锁的功能,当执行 put 操作时。首先会对 key 进行第一次 hash 计算,来定位 Segment 的位置,,然后进行第二次 hash 计算,找到对应的 HashEntry 的位置,这里会利用继承过来的锁的特性,在将数据插入指定的 HashEntry 位置时(链表的尾端),会通过继承 ReentrantLock 的 tryLock() 方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该 Segment 的锁,那当前线程会以自旋的方式继续调用 tryLock() 方法去获取锁,超过指定次数就挂起,等待唤醒

get 操作

ConcurrentHashMap 的 get 操作跟 HashMap 类似,只是 ConcurrentHashMap 需要先进行一次 hash 计算定位到 Segment 的位置,然后进行一次 hash 计算,定位到指定的 HashEntry

获取 size

计算 ConcurrentHashMap 的 size是一个有趣的问题,因为它是并发操作的。当你计算 size 的时候,它还在并发地插入数据,可能会导致结果偏差,要解决这个问题,JDK 1.7 采用两种方案:

  1. 使用不加锁的模式去尝试多此计算 size,对多三次,比较计算结果,结果一致就认为当前没有元素加入
  2. 如果第一种方案结果不一致,就会给每个 Sement 加锁,然后再计算 ConcurrentHashMap 的 size

3.JDK 1.8

改进一:摒弃了 Segment 的概念,直接采用 transient volatile HashEntry<K,V>[ ] table 保存数据,使用 table 数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。虽然在 JDK 1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本

改进二:将原先 table 数组 + 单向链表的数据结构,变更为 table 数组 + 单向链表 + 红黑树的结构

put 操作

  1. 如果没有初始化就先调用 initTable() 初始化
  2. 如果没有哈希冲突就直接 CAS 插入
  3. 如果还在进行扩容操作就先进行扩容
  4. 如果存在哈希冲突就加锁来保证线程安全,这里有两种情况:一种是链表形式尾插法;另一种是红黑树
  5. 如果该链表的节点数大于阈值,就要先转换成红黑树
  6. 如果添加成功就调用 addCount() 统计 size,并检查是否需要扩容

put 操作在并发处理中使用的是乐观锁,当有冲突的时候才进行并发处理

get 操作

  1. 计算 hash 值,定位到 table 索引位置,如果首节点符合就返回
  2. 如果遇到扩容的时候,会调用标志正在扩容节点 ForwardingNode 的 find 方法,查找该节点,匹配到就返回
  3. 以上都不符合的化就向下遍历节点,匹配到就返回,否则返回 NUL

获取 size

对于 size 的计算,在扩容和 addCount() 就已经计算好了,你需要获取 size 的时候直接给你

【重难点】【Java集合 01】HashMap 和 ConcurrentHashMap相关推荐

  1. java集合之HashMap相关原理 方法

    java集合之HashMap Map接口的基于哈希表的实现. 此实现提供所有可选的映射操作,并允许空null值和空null键.(除了非同步和允许使用 null 之外,HashMap 类与 Hashta ...

  2. java温故笔记(二)java的数组HashMap、ConcurrentHashMap、ArrayList、LinkedList

    为什么80%的码农都做不了架构师?>>>    HashMap 摘要 HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型.随着JDK(Java Develo ...

  3. Java集合:HashMap源码剖析

    一.HashMap概述 二.HashMap的数据结构 三.HashMap源码分析      1.关键属性      2.构造方法      3.存储数据      4.调整大小 5.数据读取     ...

  4. Java 7:HashMap与ConcurrentHashMap

    从我过去有关性能的文章和HashMap案例研究中可能已经看到,Java线程安全性问题可以很轻松地使Java EE应用程序和Java EE容器崩溃. 在对Java EE性能问题进行故障排除时,我观察到的 ...

  5. Java集合之一—HashMap

    深入浅出学Java--HashMap 哈希表(hash table) 也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈 ...

  6. hashmap修改对应key的值_死磕 java集合之HashMap源码分析

    简介 HashMap采用key/value存储结构,每个key对应唯一的value,查询和修改的速度都很快,能达到O(1)的平均时间复杂度.它是非线程安全的,且不保证元素存储的顺序: 继承体系 Has ...

  7. Java集合:HashMap线程不安全?有哪些表现?

    HashMap是线程不安全的!主要表现在多线程情况下: 1)hash冲突时,put方法不是同步的,先存的值会被后存的值覆盖.(1.7和1.8都有的表现) 2)在resize的时候,可能会导致死循环(环 ...

  8. Java源码HashMap、ConcurrentHashMap:JDK1.8HashMap静态常量以及设置的目的,初始容量、最大容量、扩容缩容树化条件

    HashMap核心源码 作为工作中最重要.最常用的容器之一,当然还是要自己动手写一篇 HashMap 的源码解析来加深对其的印象咯,而且它的设计与实现 也有很多值得学习的地方. 以下包含HashMap ...

  9. Java集合之HashMap

    在总集篇中我大概梳理了一下整个集合类的关系,这篇文章是对总集篇的扩展,本文将详细讨论HashMap的实现原理.所有涉及到的源码都是基于JDK11.顺便插一句,大家要学就学新的嘛,毕竟11可是长期支持版 ...

最新文章

  1. 机器学习理论导引 线上阅读
  2. springboot 搭建分布式_爱了!阿里巴巴内部出品“SpringBoot+微服务指南”,理论与实战...
  3. linux服务器静态ip,Ubuntu Linux系统下设置静态IP的方法
  4. M.2 固态硬盘的两种类型:SATA 和 NVMe 的区别?
  5. 使用bcftools提取指定样本的vcf文件(extract specified samples in vcf format)
  6. python和c先学哪一个_python和c先学哪个
  7. win10创建c语言文件,c – 如何在Windows中创建扩展(自定义)文件属性?
  8. 典型的SPI控制器的结构
  9. python exceptions怎么用_Python基础介绍 | Exceptions异常
  10. X 射线技术揭示芯片的秘密!
  11. helix server配置教程
  12. 基于SECS协议开发的简明教程(1)
  13. [转载]快速实现微信扫码关注公众号/用户注册并登陆
  14. Greenplum外表gpfdist加载数据
  15. 惠普Elite Mini 800 G9 评测
  16. Linux下各压缩工具的解压压缩命令
  17. aix的ps命令详解
  18. 毕业论文知网查重之应对办法
  19. 【BZOJ3470】Freda’s Walk
  20. ajax 获取数据并展示到前台

热门文章

  1. android studio创建一个类继承application_带你全方位了解Android中的Context
  2. 数据接口请求异常:parerror_接口测试用例编写和测试关注点
  3. 医用应用计算机,计算机在医疗方面应用.doc
  4. 用计算机听音乐和看电影的ppt,五年级下册信息技术课件-第六课 用计算机听音乐和看电影 川教版 (共13张PPT)...
  5. java appium_Android应用开发之AS+Appium+Java+Win自动化测试之Appium的Java测试脚本封装(Android测试)...
  6. android 蒙版图片带拖动_黑橙修图:新手入门篇2-一句话带你认识图层蒙版
  7. 后台系统应该具备的素养
  8. 基于Windows8与Visual Studio2012开发内核隐藏注册表
  9. 转载 敏捷教练,从A到Z
  10. jmeter html 乱码,jmeter压测学习14-jmeter返回内容中文乱码问题