6

深入理解 Java Map

 2 years ago
source link: https://blog.zzhpro.com/2020/01/02/java-map/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

深入理解 Java Map

发表于2020-01-02|更新于2021-10-29|Java
字数总计:2.4k|阅读时长:7分钟|阅读量:59

在日常的编程活动中,使用 Map 类型是十分常见的,那么你们知道每天使用的 Map 底层是如何实现的呢?今天就带大家来深入了解一下内部的构造。

1. 常见的 Map 类型

  • HashMap
  • HashTable
  • ConcurrentHashMap

2. HashMap

a. 存储结构

HashMap 的底层结构采用可扩容的动态数组,而数组中的每个格子里则存储着另一种结构,它有两种可能性:

  1. 当元素个数较少时,存储的是一个链表结构
  2. 当元素个数大于等于一个阈值(默认是8)时,存储的是一个红黑树(Java 8 才引入的),而在扩容中会去判断是否需要转换成链表结构

引入红黑树的目的是减少链表查找的时间开销,从 O(n){O(n)}O(n) 减少到 O(log⁡(n)){O(\log(n))}O(log(n))

初始数组大小(capacity)

在 Java 8 中的 HashMap 的初始大小设置为 16,是 2n{2^n}2n 的整数次方,之所以选择 16,其实更多的是一种经验值,其实只要是 2n{2^n}2n 的大小都是合适的。

之所以选择 2n{2^n}2n 作为容量,当数组扩容则是在原来容量的基础上增大一倍,其容量变成 2n+1{2^{n+1}}2n+1,依然是 2 的整数次幂。而这样做的目的是为了减小取模运算的开销,因为对 2n{2^n}2n 进行取模操作可以简化为位运算。

数组的下标位置从 0 ~ 2n−1{2^n-1}2n−1,而 2n−1{2^n-1}2n−1 的二进制表示为 ....1111{....1111}....1111,数组位置的计算从而可以变成如下的位运算:

index=hash&(2n−1){index = hash \& (2^n-1)} index=hash&(2n−1)

而当扩容时,重新计算数组新位置的时候,相当于多了一个最新高一位的与运算 result=hash&2n{result = hash \& 2^n}result=hash&2n,其结果不是 0 就是 1,因此重新的计算的数组位置就只有以下两种可能,大大减小了 rehash 的开销:

  1. 原来存储的数组位置
  2. 原来位置向后移动 2n{2^n}2n 的新位置

那大家可能会问,HashMap 不是支持自定义的初始大小吗,那就不符合上面的简化运算的要求了。而现实是的确考虑到了这一点,所以在初始化时,会计算得到一个比传入数据大的最近的 2n{2^n}2n 值,然后赋值为初始容量。

计算最近的 2n{2^n}2n 值的代码如下:

int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);

public static int numberOfLeadingZeros(int i) {
// HD, Count leading 0's
if (i <= 0)
return i == 0 ? 32 : 0;
int n = 31;
if (i >= 1 << 16) { n -= 16; i >>>= 16; }
if (i >= 1 << 8) { n -= 8; i >>>= 8; }
if (i >= 1 << 4) { n -= 4; i >>>= 4; }
if (i >= 1 << 2) { n -= 2; i >>>= 2; }
return n - (i >>> 1);
}

负载因子(loadFactor)和扩容阈值(threshold)

这里简单介绍一下负载因子和扩容阈值的概念。这两个值有着如下的关系:

threshold=capacity∗loadFactor{threshold = capacity * loadFactor}threshold=capacity∗loadFactor

默认的负载因子是 0.75,即 34{\frac{3}{4}}43​,当元素实际个数大于容量的 34{\frac{3}{4}}43​ 时则进行扩容操作。

从这个公式可以看出当容量为 2n{2^n}2n 时,threashold 是一个整数,不需要进行向下取整操作。

hashmap 是可以存储 key 值为 null 的,其 hashcode 为 0,即会存储在数组位置为 0 的地方。

b. 数组元素结构(Node)

  • hash:该位置的 hash 值
  • key:该位置的键值
  • value:该位置的数值
  • next:指向下一个 Node 节点

继承上述的链表结构,多了一些属性。这里不详细介绍红黑树,下次有机会单独讲解红黑树的知识。

树节点是根据 key 的 hashcode 进行组织的,通常而言,树节点占据的空间要比普通节点大一倍。

  • parent:父亲节点
  • left:左儿子节点
  • right:右儿子节点
  • prev:前面的节点,主要用于删除操作
  • red:判断节点是红色还是黑色

c. 写入操作

在初始化的过程中,并不会进行动态数组的创建,相反是在第一个写入操作时才去创建数组,这样可以减少内存的开销。

  1. 判断数组是否已经创建,没有则创建
  2. 计算插入数据的 hash 值,根据 hash 值计算得到数组的位置
  3. 将数据插入到计算得到的数组位置
    • 数组位置没有任何元素

    • 数组位置上已经有元素,判断存储元素的类型
      • 红黑树节点,插入在树中的某个位置
      • 链表,先进行插入操作,然后判断是否需要转换成红黑树,当底层数组小于 64 时,不会转化成红黑树而是直接扩容
  4. 插入完成后,判断是否需要进行扩容,如需要则进行扩容操作

d. 读取操作

  1. 根据 key 值计算得到 hash 值,进而得到数组的位置
  2. 取出数组位置的元素,判断是否为空,进而判断是否是想找的元素
  3. 判断节点类型
    • 树节点,在树中进行查找
    • 链表节点:在链表中进行查找
  4. 没有找到,则返回空

e. 删除操作

删除操作和查找操作非常类似,只是在最后需要进行删除操作(元素存在的情况下),不过需要注意删除根节点的情况,即删除数组位置里的第一个元素

2. HashTable

HashTable 可以认为是 HashMap 的线程安全版,很多方面都是类似的。这里主要分析以下两者的差异

初始容量的不同

HashTable 的初始容量为 11,扩容的大小为原来的两倍加一,而 HashMap 的初始容量为 16,扩容为原来的两倍。这里可以看出 HashTable 的容量并不是 2n{2^n}2n,因此它在计算数组位置的时候采用的是取模操作,因此在效率上要差一些的。

另一点不同则是 HashTable 在初始化的时候就已经分配了数组的空间。

所有操作都加了 synchronized 关键字

由于在方法上加了同步锁,HashTable 的并发性能并不是很好。而利用 modcount 进行类似版本的管理(悲观锁)导致性能更差。

是否允许插入 Null 值

HashMap 允许插入 Null 值的 Key 和 Value 的,而 HashTable 则不允许。主要是无法在并发操作中无法区分是元素是否存在还是为空。

HashMap 是 fail-fast,而 HashTable fail-safe 的。

fail-safe:安全失败机制,此次读到的值不一定是最新的,允许并发修改

fail-fast:快速失败机制,是集合的一种机制,在迭代过程中对元素的修改会立刻报错

3. ConcurrentHashMap

HashMap 不支持并发,而 HashTable 性能又这么差,那并发的需求我们到底应该使用什么呢?哈哈,其实 Java 还有 ConcurrentHashMap。

线程安全的实现

Java 8 采用 CAS + synchronized 的方式来实现并发,抛弃了 Java 7 采用的 Segment 分段锁的方式。并用 volatile 关键字去保证 Map 中数值的可见行。

其中 CAS 主要用于数组元素为空,即第一次插入到该数组位置中;而 synchronized 则用于数组位置已经存在元素的情况,在进行操作时,锁住该位置上的节点。

CAS(Compare And Swap):是一种乐观锁的实现,同时也是一种轻量级锁。其原理首先记录拿到的原来的值,然后操作数据,等到要真正更新数据时,再次那一次值,并跟记录的原来的值进行相等的判断,如果不相等,则重新进行该操作,如果相等则可以真正更新数据。为了更好地知道数值是否更改过,可以记录版本号、时间戳等信息。

synchronized 获取锁(锁升级)的过程

  1. CAS 轻量级锁,并有短暂的自旋

与 HashMap 操作的不同

  1. ConCurrentHashMap 在插入过程中是首先插入,进而再去判断是否需要将链表结构转换成红黑树结构,而 HashMap 则是先判断,再插入。

通过使 Node 节点元素 val 和 next 指针设置为 volatile,使得可以不通过加锁的方式就能够读取相应的值。
值得注意的是在扩容状态下读取时,可能会去新的数组下进行查找。另外也有可能是红黑树结构,需要通过读写锁在红黑树下进行查找。

image-20200820235459849
image-20200820235459849
image-20200820235744135
image-20200820235744135

数组用 volatile 修饰来保证在数组扩容的时候对各个扩容线程保证其可见性。


看完这篇文章,有没有让你对 Java Map 有更深的了解呢?如果内容对你有所帮助,请点赞支持哦!

更多精彩内容请关注扫码:

KnowledgeCollision 微信公众号

Knowledge Collision 激发思维碰撞,IDEA 丛生


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK