<源码>HashMap、LinkedHashMap、ConCurrentHashMap

1,406 阅读16分钟

总结:

  • HashMap:线程不安全,数组+链表(红黑树)
  • LinkedHashMap:线程不安全,继承自HashMap,双向链表
  • ConCurrentHashMap:线程安全,采用桶节点锁

HashMap

1、概述

  • HashMap可存null键和值
  • HashMap 中有 两个影响其性能的参数:初始容量负载系数
  • HashMap是在bucket(Node<K,V>[] table)中储存键对象和值对象,作为Map.Entry
  • 其所需的容量表示bucket的数量,初始容量是在创建时的容量。
  • 负载系数是在自动增加哈希表容量之前允许哈希表获取的程度的度量。
  • 当哈希表中的条目数超过负载系数和当前容量的乘积时,哈希表进行rehbed(即内部数据结构被重建),使得哈希表扩大至两倍的bucket
  • 作为一般规则,默认负载系数(0.75)提供良好的性能时间和空间成本之间的折衷。较高的值降低了空间开销,但会增加查找成本(反映在大多数情况下)。

简单来说,HashMap有个初始容量(默认为16),当表中的条目超过这个负载容量(负载系数 x 当前容量),就会进行扩充到其两倍大小。

    //默认大小
    /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  • 最小树容量:static final int MIN_TREEIFY_CAPACITY = 64;
    • 链表转化为红黑树,除了有阈值的限制,还有另外一个限制,需要数组容量至少达到64,才会树化。
    • 这是为了避免,数组扩容和树化阈值之间的冲突。

2、原理

  • 在Java1.7中,HashMap的实现方式是:数组+链表
  • 在Java1.8中,HashMap的实现方式是:数组+链表(链表和红黑树会转换)

hash()计算原理

  • 为了减少哈希碰撞的概率:
    • 高16位和低16位进行混合计算:为了保留高16位的特征,避免出现高16位都为0的情况
      (h = key.hashCode()) ^ (h >>> 16)
    • 异或运算,让结果出现的随机性更大
      (n - 1) & hash

put方法

  • 先根据table数组是否为空,进行扩容操作(2的N次方)
  • 计算key的hash值,并查找在数组的位置
  • 如果此位置没有元素,则将key和value 包装为Node节点,添加到此位置
  • 如果此位置已有元素
    • ① 此位置元素的hash值和传入的hash值相等,且key值也相等,处理hash碰撞(即bucket位置相同),即将其转化为链表形式存储(依然存的是键值对即Node节点)
    • ② 此位置为红黑树结构,将新节点加入到红黑树上
    • ③ 此位置为普通链表,将新节点插到链表尾部,插入过程中链表长度超过8则转化为红黑树

get方法

  • 如果查找的节点为null,则返回null,否则返回节点的value
  • 根据key的hash值计算出下标位置的第一个元素
  • 若hash值和key值都相等,即为第一个元素,直接返回Node节点
  • 若hash值和key值不等,则遍历链表(或红黑树)
    • ① 若为红黑树,则根据hash值和key(key通过equals比较)查找当前key的节点,并返回Node节点
    • ② 若为普通链表,则向后遍历查找Node节点(hash值和key(key通过equals比较)都相等时),并返回Node节点

remove方法

  • 如果删除的节点为null,则返回null,否则返回节点的value
  • 先根据key算出hash,然后根据hash得到在table上的index
  • 找到要删除的Node<K, V>
    • 若hash值和key值都相等,即要删除的是第一个元素
    • 若hash值和key值不等,则遍历链表(或红黑树)
      • ① 若为红黑树,则根据hash值和key查找当前key的节点
      • ② 若为普通链表,则向后遍历查找Node节点(hash值和key(key通过equals比较)都相等时)
  • 找到指定的节点Node后
    • 若为红黑树,则直接从树上删除
    • 若为数组上的节点(即链表表头),则直接让tab[index] = node.next;将下一个节点存于数组位置
    • 否则将链表上的节点删除,即p.next = node.next;
  • 将删除的节点直接返回

resize扩容数组

  • 由于元素的索引是通过hash&(n - 1)得到的,所以扩容,并非简单的数组长度翻倍。
  • JDK1.7是通过rehash操作,重新计算节点的索引值。
  • JDK1.8是进行了优化,根据新增节点索引的bit高位是0还是1,是1则变为 原索引 + oldCap ,可均匀的将之前冲突的节点分散到新的bucket 了。

JDK1.7的HashMap链表会形成死循环的原因

  • JDK1.7采用的是头插法,在多线程环境下可能会使链表形成环状,从而导致死循环
  • JDK1.8采用的是尾插法,不会产生死循环

3、说明

  • 链表的作用就是处理hash碰撞的(产生碰撞的原因就是在计算key的时候是使用除留余数法,可能导致不同的key值计算出来的结果是一样的,因此产生碰撞),将其插入到数组同一个位置的链表上。
  • 链表中存储的是Entry<K, V>Node<K, V>),即键值对。
  • 在Java1.8中,链表达到阈值时(7或8)转为红黑树结构提高性能,当长度缩小到阈值(6)时,将红黑树转化为单向链表提高性能。
  • hashCode()方法是用来确定bucket位置的
  • equals() 方法是用来获取值对象的
  • 减少碰撞的发生,并提高效率,可以使用不可变的、声明final的对象,并采用合适的equals()hashCode()
    • 不可变性能缓存不同间的hashcode,这将提高这个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。
    • String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。
    • 其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。
    • 如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。

4、HashMap、HashTable、HashSet

  • 1】HashMap与HashTable:

    • 两者几乎可以等价,都实现了Map接口。
    • 主要区别在于HashMap是非同步的,并可接收null的键和值,HashTable反之
    • 单线程中推荐使用HashMap,因为HashTable是线程同步的,速度较HashMap慢
    • HashMap不能保证随着时间的推移Map中的元素次序是不变的。
    • 通过Map m = Collections.synchronizeMap(hashMap); 让HashMap进行同步。
  • 2】HashMap和HashSet:

    • 通过看HashSet 的源码可以看出,其内部就是依赖的一个HashMap,将HashSet的值作为key存储到HashMap 中(value为静态常量PRESENT = new Object())。
    • 两者都不能保证随着时间的推移,其中的元素次序不变。
    • 对比: | | | | | --------------- | ------------------------ | ------------------------- | | HashMap | HashSet | | HashMap实现了Map接口 | HashSet实现了Set接口 | | HashMap储存键值对 | HashSet仅仅存储对象 | | 使用put()方法将元素放入map中 | 使用add()方法将元素放入set中 | | 用键对象来计算hashcode值 | 用成员对象来计算hashcode值,若相等再用quals()判断对象的相等性 | | HashMap比较快,因为是使用唯一的键来获取对象 | HashSet较HashMap来说比较慢 |

5、相关链接


LinkedHashMap

1、概述

  • LinkedHashMapHashMap的子类
  • LinkedHashMap = HashMap + 双链表。
  • LinkedHashMap类新增三个主要属性:head(list头部节点)、tail(list尾部节点)、accessOrder(list节点顺序是否随get方法的调用而改变)
  • LinkedHashMap中存储key-value的内部类Entry<K, V>继承自HashMap.Node<K, V>,并新增了beforeafter指针

LinkedHashMap结构示意图

2、原理

构造器

LinkedHashMap类一共有5个构造器,涉及initialCapacityloadFactoraccessOrder三个参数的初始化。

  • LinkedHashMap调用的是父类HashMap的构造器
  • 默认accessOrder = false;,可通过构造函数进行赋值
  • HashMap.table长度默认16HashMap默认负载因子为0.75

LinkedHashMap操作后回调

HashMap中有三个方法是用于LinkedHashMap操作后回调,在HashMap中的各个方法中都有相应的调用,三个方法都是空实现,都要在LinkedHashMap中进行实现

  • afterNodeRemoval:删除某个节点时,进行的操作:

    • 先将需要删除的节点的前后指针赋值给两个变量
    • 将删除的节点的beforeafter置为null
    • 将节点的后继的before指向节点的前驱节点
    • 将节点的前驱的after指向节点的后继节点
  • afterNodeInsertion:插入某个节点时的操作:

    • 实现中,判断了removeEldestEntry(first)方法的返回值,默认为false,也就是说并不会进入if中的逻辑,即不会执行其中的removeNode方法
       /**
        * 插入一个节点后的操作
        */
       void afterNodeInsertion(boolean evict) { // possibly remove eldest
            LinkedHashMap.Entry<K,V> first;
            // LinkedHashMap中的removeEldestEntry方法永远返回false(方法体见下文)
            if (evict && (first = head) != null && removeEldestEntry(first)) {
                K key = first.key;
                // 这里不会执行
                removeNode(hash(key), key, null, false, true);
            }
       }
       /**
        * removeEldestEntry方法永远返回false
        */
       protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
           return false;
       }
       
    
  • afterNodeAccess:访问某个节点时的操作

    • 此方法是否执行依据accessOrder == true,当调用get方法,会将key-value节点移动到链表尾端
    • accessOrder == true,且节点不在尾端时,执行移动操作
    • ① 先将节点的前后指针赋值给两个变量
    • ② 将节点的前驱的after指向节点的后继节点
    • ③ 将节点的后继的before指向节点的前驱节点
    • ④ 将节点指向链表的尾端
    • ⑤ 更新链表的新尾端tail

链表实现的原理

  • get方法:LinkedHashMap中重写了get方法

    • 调用HashMap#getNode方法,若为null,则直接返回null
    • accessOrder == true时,调用afterNodeAccess方法,即将访问节点放到链表尾端
  • getOrDefault方法:LinkedHashMap提供一个getOrDefault方法,没找到就返回提供的默认值

    • get方法一样,只不过提供了一个默认参数值,在HashMap#getNode方法为null时返回默认值
  • put方法:在LinkedHashMap中并没有重写put相关的方法,直接调用的是HashMap#put方法

    • 当节点插入成功时,调用afterNodeAccess方法
    • put方法最后,会调用afterNodeInsertion方法
  • remove方法:在LinkedHashMap中并没有重写remove相关的方法,直接调用的是HashMap#remove方法

    • remove方法中仅调用了removeNode方法
    • removeNode方法中,在节点移除成功后,调用了afterNodeRemoval方法

3、LRU基于LinkedHashMap的实现

  • LRU,最近最少原则,若保存的数据满了,则将最近最少使用的数据删除
  • 通过将LinkedHashMap#accessOrder设为true时,可满足此特性
  • 重写removeEldestEntry方法,返回size() > capacity来决定是否要执行删除节点的操作,上面提到,在LinkedHashMap中此方法默认返回false,则不会执行删除的节点
  • LRU的逻辑为:
    • accessOrder置为true,则调用get方法,会将访问的节点移到链表尾端
    • 根据集合大小是否超过容量,来决定是否要删除头部节点,以此来删除最久未用的节点
class LRUCache extends LinkedHashMap {

    private int capacity;

    public LRUCache(int capacity) {
        //accessOrder为true
        super(capacity, 0.75F, true);
        this.capacity = capacity;
    }

    public int get(int key) {
        return (int)super.getOrDefault(key, -1);
    }

    public void put(int key, int value) {
        super.put(key, value);
    }

    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > capacity;
    }
}

4、相关链接


ConCurrentHashMap

  • JDK1.7和JDK1.8的实现方式有很大不同
  • JDK1.7采用分段锁(为Segment加锁ReentrantLock)的方式实现
  • JDK1.8采用CAS + synchronized,即链头加锁的方式
  • CASCompare And Swap,它是一种乐观锁,认为对于同一个数据的并发操作不一定会发生修改,在更新数据的时候,尝试去更新数据,如果失败就不断尝试。

一、原理

1、jdk1.7实现原理

  • ConcurrentHashMap 类所采用的是分段锁的思想,将 HashMap 进行切割
  • 把 HashMap 中的哈希数组切分成小数组,每个小数组有 n 个 HashEntry 组成
  • 小数组继承自ReentrantLock(可重入锁),这个小数组名叫Segment
  • 可以将 ConcurrentHashMap 看作一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。
  • 当hash冲突的链表过长时,在查询遍历的时候依然很慢!

get方法

  • 根据key的hash值,找到对应位置的Segment对象
  • 再通过key的hash值,定位到Segment对象中数组的对应位置

put方法

  • 根据key的hash值,找到对应位置的Segment对象
  • 获取ReentrantLock可重入锁
  • 再通过key的hash值,定位到Segment对象中数组的对应位置
  • 插入或覆盖HashEntry对象
  • 释放锁

2、jdk1.8实现原理

  • ConcurrentHashMap采用了CAS + synchronized 来保证并发的安全性
  • 节点Node类中的共享变量,使用了volatile关键字,保证多线程操作的可见性,JDK1.7一样

get方法

  • 通过key的hash值定位到数组位置
  • 判断node节点的首个元素是否为目标元素,是则返回
  • 若是红黑树结构,则从树中查找
  • 若是链表结构,则遍历向后查找

remove方法

  • ① 循环遍历数组,接着校验参数;
  • ② 判断是否有别的线程正在扩容,如果是一起扩容;
  • ③ 用 synchronized 同步锁,保证并发时元素移除安全;
  • ④ 因为 check= -1,所以不会进行扩容操作,利用CAS操作修改baseCount值。

put方法

  • key和value都不能为null,否则抛出空指针异常
  • 判断节点数组容量是否为null,为null则初始化数组initTable
  • 根据hash是否查找到节点,若不存在,则通过CAS方式插入:若已有在插入的元素则进入下一次循环;若插入成功,则跳出循环,方法结束
  • 判断是否有其他线程在扩容,即f.hash == MOVE(MOVE为-1)时,f为ForwardingNode节点;则执行helpTransfer,一起扩容
  • 上面条件都不满足时,则在synchronized中进行插入赋值,即把新的Node节点按链表或红黑树的方式插入到合适的位置
  • 当在synchronized中插入成功后,判断是否要进行链表向红黑树的转化
  • 在最后,当成功插入元素,元素个数加1,判断是否要扩容

initTable初始化数组

  • 当数组为null或容量为0时,进行初始化操作
  • 先判断sizeCtl<0,即是否正在初始化或扩容,则Thread.yield();让出CPU
  • 否则无初始化或扩容操作时,通过CAS锁控制只有一个线程进行初始化tab数组,sizeCtl更新为-1成功
    • 先检查table是否为null或容量为0,避免ABA问题
    • 新建Node数组,根据sc>0来设定容量,默认为16;并赋值为table
    • 设置sc大小为数组长度的0.75倍
    • 最后,将sc赋值给sizeCtl,设定扩容门槛
    • 结束

helpTransfer 协助扩容

  • 当满足一定条件(传入tab数组不为null,且tab首个元素f为ForwardingNode类型,且f的nextTab不为null)时,才会进行协助扩容
    • 说明当前tab已迁完,才会去协助迁移其他tab元素
    • 在扩容时会把旧tab的首个元素置为ForwardingNode,并让其nextTab指向新tab数组
  • sizeCtl<0,说明正在扩容,才会协助扩容
  • 扩容线程加1后,让当前线程协助迁移元素:transfer(tab, nextTab);

addCount 扩容判断

  • ① 利用CAS将方法更新baseCount的值
  • ② 检查是否需要扩容,默认check = 1,需要检查;
  • ③ 如果满足扩容条件,判断当前是否正在扩容,如果是正在扩容就一起扩容;
  • ④ 如果不在扩容,将sizeCtl更新为负数,并进行扩容处理。

3、几种场景下使用ConcurrentHashMap

不存在(null)则插入

当多线程下同时调用如下unsafeUpdate方法,可能会导致在get()之后if之前有其他线程进行put操作了,则当前线程的put会覆盖之前线程的put值;可使用putIfAbsent()替换:

private static final Map<Integer, Integer> map = new ConcurrentHashMap<>();

public void unsafeUpdate(Integer key, Integer value) {
    Integer oldValue = map.get(key);
    if (oldValue == null) {
        map.put(key, value);
    }
}

// 修改如下:
public void safeUpdate(Integer key, Integer value) {
    map.putIfAbsent(key, value);
}

特定值则修改

当多线程下同时调用如下unsafeUpdate方法,,可以使用replace(K key, V oldValue, V newValue)方法,但传入的newValue若为null,则表示删除此元素

private static final Map<Integer, Integer> map = new ConcurrentHashMap<>();

public void unsafeUpdate(Integer key, Integer value) {
    Integer oldValue = map.get(key);
    if (oldValue == 1) {
        map.put(key, value);
    }
}

// 修改如下:
public void safeUpdate(Integer key, Integer value) {
    map.replace(key, 1, value);
}

处理后插入

get值之后,进行相关逻辑的处理后,再进行put操作的情况下,ConcurrentHashMap提供的方法则不一定能保证线程安全了,则可以通过加同步锁synchronized的方法来进行处理,如:

public void unsafeUpdate(Integer key, Integer value) {
    Integer oldValue = map.get(key);
    if (oldValue == 1) {
        System.out.println(System.currentTimeMillis());
        /**
         * 其它业务操作
         */
        System.out.println(System.currentTimeMillis());
      
        map.put(key, value);
    }
}

// 修改后:
public void safeUpdate(Integer key, Integer value) {
    synchronized (map) {
        Integer oldValue = map.get(key);
        if (oldValue == null) {
            System.out.println(System.currentTimeMillis());
            /**
             * 其它业务操作
             */
            System.out.println(System.currentTimeMillis());

            map.put(key, value);
        }
    }
}

但此时使用ConcurrentHashMap意义不大,可以换成普通的HashMap进行同步安全的操作

4、几种实现线程安全集合的方式:

Hashtable线程安全类

  • Hashtable 是一个线程安全的类
  • Hashtable 几乎所有的添加、删除、查询方法都加了synchronized同步锁!
  • 相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差,所以 Hashtable 不推荐使用!

Collections.synchronizedMap方法

  • Collections.synchronizedMap 里面使用对象锁来保证多线程场景下,操作安全,本质也是对 HashMap 进行全表锁
  • 使用Collections.synchronizedMap方法,在竞争激烈的多线程环境下性能依然也非常差,所以不推荐使用!

二、延伸

ConcurrentHashMap的并发优化历程

JDK5 :分段锁,必要时加锁

key 是整数,对于3万以下的数,其key 计算出的hash 值的高位始终为15;3万~几十万的数,都分布在高位为14或15的段中,随着数的增加,才会均匀的分布在各段中,这就会导致都会堆在一个段中,达不到均匀分布的目的,缺陷所在。

  //JDK 5 小整数的 Hash 高位 15
  static int hash(Object x) {
    int h = x.hasCode();
    h += ~(h << 9);
    h ^= (h >>> 14);
    h += ~(h << 4);
    h ^= (h >>> 10);
    return h;
  }

JDK6:优化二次Hash算法

使用一个single-word Wang/Jenkins hash0xfffcd7d),多了两步计算,得到的值是均匀分布在各段中的

  //JDK 6 高低位均匀分布
  private static int hash(int h) {
    h += ~(h << 15) ^ 0xfffcd7d;
    h ^= (h >>> 10);
    h += ~(h << 3);
    h ^= (h >>> 6);
    h += ~(h << 2) + (h << 14);
    return h ^ (h >>> 16);
  }

JDK7:段懒加载,volatile & cas

和之前的初始化方式不同了:

  • JDK1.6前:Segment 直接初始化,全部先实例化出来
  • JDK1.7后:Segment 在使用时初始化,懒加载,大量的使用了对数组的volatile的访问,保证segment在使用时的可见性。(如图:段懒加载,虚线表示未加载)

段懒加载,虚线表示未加载

JDK8:摒弃段,基于HashMap 原理的并发实现

JDK1.8摒弃段(segment),也没有分段锁了,直接用getObjectVolatile 来访问table[] ,而加锁只针对table 中的entry 访问,将新来的元素加入到其后面的链表里。

JDK8无段

CHM如何计数

  • JDK 5~7 基于段元素个数求和,二次不同就加锁
  • JDK 8 引入CounterCell,本质上也是分段计数(总数是循环累加得到)

CHM是弱一致性的

  • 添加元素后不一定马上能读到
    可能添加的时候,已经读过去了,则读不到了
  • 清空之后可能仍然会有元素
    清空是一段一段清空的,已经清空的那段,可能又添加了新的元素
  • 遍历之前的段元素的变化会读到
    在还没遍历到时,如果后面的有变化了,当读到的时候,就会读取这个变化了的元素
  • 遍历之后的段元素变化读不到
    已经遍历过的元素,如果发生了变化,就读不到这个变化了
  • 遍历时元素发生变化不抛出异常
    由于ConCurrentHashMap对于会产生并发操作的node节点都会有加锁同步处理,且迭代器获取tab[index]首个节点时都会从主存来获取(保证获取到的数据是最新的),从而保证了迭代器在迭代过程中即使有putremove等操作同时发生,也可以保证迭代的安全性,不会出现ConcurrentModeificationException
    不过这样的迭代没办法保证数据的完整,即tab[0]迭代后,其又追加内容则无法遍历到\

如何进行锁优化

  • 长锁不如短锁:尽可能只锁必要的部分
    减少加锁代码的长度
  • 大锁不如小锁:尽可能对加锁的对象拆分
  • 公锁不如私锁:尽可能将锁的逻辑放到私有代码中
    在逻辑层面上,尽量将锁放到私有代码中。
    若对外暴露,则可能带来锁的不正当使用,甚至可能导致死锁等问题
  • 嵌套锁不如扁平锁:尽可能在代码设计时避免锁嵌套
  • 分离读写锁:尽可能将读锁和写锁分离
    一般来说,资源的读和写的频次是不一样的,大部分是在读,少部分是在写,此时写要加一个比较重的锁,而读使用volatile 就够了,甚至不用加锁。
  • 粗化高频锁:尽可能合并处理频繁过短的锁
    每加一次锁,就需要一些开销,如果加锁和释放锁的频次特比高,开销就会很大
    可以考虑进行合并,减少加锁的开销
  • 消除无用锁:尽可能不加锁,或用volatile 替代锁
    volatile 很多时候都能保证原子性和可见性,可以替代锁

参考链接