HashMap、HashTable、ConcurrentHashMap详解

三种散列表的公共部分

三者都是存储键值对的Key-Value

key会被映射到数组索引, Entry对象则是数组中对应的值。

Key通过Hash算法得到哈希码(HashCode), 通过哈希码与数组中的索引对应。

因此所有的键值对Hash表都是无序储存的。

键值对的查找过程: (hashCode()计算出索引,通过键的equals方法找到对应的键值对, 返回值对象)先对键做一个hashCode()的计算来得到它在bucket数组(这里的计算方法略有区别)中的位置来存储Entry对象。当获取对象时,通过get获取到bucket的位置,再通过键对象的equals()方法找到正确的键值对,然后在返回值对象。

负载因子为什么是0.75而不是1或者0.5?

  • 因为如果是0.5的话, 散列表会比较稀疏, 更大程度上避免了哈希碰撞的可能性, 索引效率高, 此时会对空间造成浪费

  • 如果是1的话, 散列表会变得密集, 空间利用率提高了, 但是哈希碰撞的可能性变大了, 链表和红黑树会变得复杂, 索引效率会变低

  • 经检测, 0.7-0.75的索引效率是最高的

HashTable

  • 采用数组和链表, 1.8依然是数组和链表

  • 线程安全的:使用synchronized来保证线程安全, 但是读写的时候会锁住整张表

  • 扩容机制: HashTable的初始大小是11,扩容通过rehash()来实现, 当HashTable的数组元素个数达到临界值的时候, 就会调用rehash来进行扩容, 扩容后的大小是2*oldSize + 1, 临界值就是数组大小乘以负载子(loacFactor) , 通常负载因子默认为0.75。因此数组元素个数达到数组大小的0.75倍时, 就会进行扩容。

  • 计算index方法:(取模方法) index = (hash & 0x7FFFFFFF) % tab.length,采用取模方法,减少哈希碰撞, 使数组分布更均匀, 但是效率比HashMap的位运算低。

  • 链表依然采用头插法,而没有像HashMap在jdk1.8中为了避免并发情况下扩容死锁改为尾插法,因为头插法的效率比尾插法更高,同时HashTable是线程安全的, 并发下不会出现扩容死锁的情况。

HashMap

  • 线程不安全的

  • 扩容机制:初始容量默认16, 元素个数达到临界值后扩容, 新的容量为 2*oldSize, 大小一定为2的n次幂 , 临界值依然是数组容量 * loadFactor(负载因子)

  • 扩容resize():

    • 1.初始化数组table

    • 2.当数组table的size达到阙值时即++size > load factor * capacity 时,也是在putVal函数中

    • 具体实现

      • 1.通过判断旧数组的容量是否大于0来判断数组是否初始化过

        • 否:进行初始化
        • 判断是否调用无参构造器,
          • 是:使用默认的大小和阙值
          • 否:使用构造函数中初始化的容量,当然这个容量是经过tableSizefor计算后的2的次幂数
      • 是,进行扩容,扩容成两倍(小于最大值的情况下),之后在进行将元素重新进行与运算复制到新的散列表中

  • put的过程

    • 通过hash() 计算出键的hashcode, 再通过indexFor() 方法计算出对应的索引
    • 如果散列表为空, 调用resize()初始化散列表
    • 添加元素到散列表中
      • 如果没有发生hash碰撞, 直接添加到散列表中
      • 如果发生碰撞
        • 无论是红黑树还是链表, 如果equals相同, 则替换旧的键值对
        • equals不同
          • 链表, 循环遍历整个链表, 知道链表的某个节点为空(jdk1.8尾插法, 但是在jdk1.7采用的是头插法会引起并发死锁), 查看链表长度是否达到8以及容量是否大于64, 如果满足条件, 将链表转化成红黑树
          • 红黑树插入方法
    • 如果元素个数大于临界值(数组容量*loadFactor), 则通过resize()进行扩容
  • 为什么扩容必须是2的幂

    • 为了数据的均匀分布,减少哈希碰撞。因为确定数组位置是用的位运算,若数据不是2的次幂则会增加哈希碰撞的次数和浪费数组空间。
    • 输入数据若不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字
  • 计算index方法:index = hash & (tab.length – 1)

ConcurrentHashMap

  • 线程安全的, jdk1.7利用分段锁来提高并发程度, jdk1.8采用CAS和synchronized 来保证并发线程安全

    • 如何保证线程安全的,
      • jdk1.7保证线程安全: 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(因为Segment实现了ReentrantLock, 读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
      • jdk1.8保证线程安全的方式:
        • 储存Map数据的数组时被volatile关键字修饰,一旦被修改,其他线程就可见修改。因为是数组存储,所以只有改变数组内存值是才会触发volatile的可见性
        • 进行put操作的时候通过hashcode计算出的索引位置没有哈希冲突, 采用自旋+CAS操作。如果还在进行扩容操作就先进行扩容。如果存在hash冲突, 就会使用synchronized 给首节点进行加锁, 如果是链表结构采用尾插法插入尾端, 如果是红黑树按照红黑树的结构进行插入。如果hash冲突形成链表长度超过8, 而且数组长度超过64,就会将链表转化成红黑树, 如果只是链表长度超过8, 数组长度没有超过64,就会进行扩容操作。如果添加成功就调用addCount()方法统计size, 来检查是否需要扩容
        • 一句话总结: 存储Map的数组被volatile关键字修饰, 一旦修改, 其他线程就可见修改, 方便进行CAS操作。读操作不加锁, 关键是写操作,如果索引位置没有哈希冲突, 采用自旋+CAS操作进行写操作, 如果有哈希冲突, 通过synchronized给首节点进行加锁, 后面的操作和HashMap类似(链表和红黑树以及扩容判断)
  • ConcurrentHashMap的键和值不能为空

    • 原因: 当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key)的时候,m可能已经不同了。
  • 扩容机制:

    • jdk1.7的扩容机制:

      • 进行写操作和扩容操作时候, 会对Segment进行加锁, 但是只会影响这一个Segment, 不同的Segment依然可以进行并发操作, 提高了并发效率, 解决了线程安全问题(一个Segment相当于一个HashMap)
    • jdk1.8的扩容机制:

      • sizeCtl 和 ForwordNode 在扩容中起关键作用

        1. sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。
        2. -1 代表table正在初始化
        3. -N 表示有N-1个线程正在进行扩容操作
        4. 其余情况:
        5. 如果table未初始化,表示table需要初始化的大小。
        6. 如果table初始化完成,表示table的容量,默认是table大小的0.75倍
      • 当数组元素个数超过临界值的时候, 就会进行扩容操作, 同时将sizeCtl的值修改。扩大后的长度和HashMap是一样的, 依然是原数组容量的2倍。扩容操作开始之后, 会将旧数组上的值迁移到新数组中,迁移完成的节点和空节点会被设置成ForwordNode节点, 其他线程进行读写操作时, 若节点是ForwordNode节点 , 也会加入到扩容操作。如果节点是正常节点, 读操作(get方法)依然和之前一样进行读操作, 写操作(put和remove)如果当前索引如果没有出现哈希碰撞, 采用CAS+自旋的方式进行写操作, 如果有哈希碰撞, 则进行扩容操作。

posted @ 2020-06-30 15:50  GoodForNothing  阅读(198)  评论(0编辑  收藏  举报
//看板娘