深入理解HashMap(底层,put源码分析,HashTable,ConcurrentHashMap,HashSet,LinkedSet,LinkedList

一、Map集合

1、HashMap

1、描述

  • HashMap是常用的Java集合之一,是基于哈希表的Map接口的实现。设计目标是尽量实现哈希表O(1)级别的增删改查效果,与HashTable主要区别为**不支持同步和允许null作为key和value**。
  • HashMap线程不安全,主要表现在:
    • 多线程同时put时可能会丢失值(前面的put被后面的覆盖)。
    • 多线程扩容时会出现环状结构,造成死循环。
    • 多线程使用迭代器时会触发fast-fail机制。

2、底层实现

  • JDK1.8之前:

    1、实现:

    采用 数组+链表 实现,形成一个数组带着多个桶的结构,每个数组元素就是一个桶,而数组索引就是每个桶中链表的表头,每一个Map元素的数据结构是一个 Entry

    • 数组存储区间连续,占用内存较多,寻址容易,插入和删除困难。
    • 链表存储区间离散,占用内存较少,寻址困难,插入和删除容易。

    2、遗留问题:

    ​ 在某些极端情况下,会导致大量元素都存放在同一个桶(数组索引是链表的表头)的链表中,此时的HashMap 就相当于一个单链表,假设链表中的元素个数为n个,则其==操作时间复杂度就变成了O(n)==,完全失去了哈希表的优势。

  • JDK1.8及以后:

    1、实现

    ​ 采用 数组+链表+红黑树 实现。每个Map元素的数据结构是 Node(链表)TreeNode(红黑树)

    2、解决遗留问题:

    ​ jdk1.8时默认还是使用 数组+链表 实现,只是元素的数据结构从 Entry 变为了 Node,当存储在同一个桶中的元素过多时,才会将链表转换为红黑树实现,保证其操作时间复杂度为 O(logn)

    3、链表与红黑树的转换条件

    • 链表->红黑树:

      整个 HashMap 中的元素个数 >= 64 同一个桶下的链表中的元素个数 > 8

      此时Map元素的数据结构从 Node 转为 TreeNode ;

    • 红黑树->链表:

      同一个桶下的链表中的元素个数 < 6

      此时元素的数据结构为 Node

    4、链表转红黑树的阈值为什么是8?

    ​ 因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

    ​ 还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

    5、为什么是红黑树,二叉平衡树不行吗?

    • 因为平衡二叉是高度平衡的树, 当平衡被破坏时,需要要 rebalance(再平衡), 开销会比红黑树大.

    • 原因:

      • 插入引起的不平衡:

        插入一个node引起了树的不平衡,平衡二叉树和红黑树都是最多只需要2次旋转操作,即两者都是O(1);

      • 删除引起的不平衡:

        最坏情况下,平衡二叉树需要维护从被删node到root这条路径上所有node的平衡性,因此需要旋转的量级是O(logN)此,而红黑树最多只需3次旋转,只需要O(1)的复杂度。

    5、JDK1.8中HashMap的插入源码分析

 /**
     * 将指定参数key和指定参数value插入map中,如果key已经存在,那就替换key对应的value
     * put(K key, V value)可以分为三个步骤:
     * 1.通过hash(Object key)方法计算key的哈希值。
     * 2.通过putVal(hash(key), key, value, false, true)方法实现功能。
     * 3.返回putVal方法返回的结果。
     *
     * @param key   指定key
     * @param value 指定value
     * @return 如果value被替换,则返回旧的value,否则返回null。当然,可能key对应的value就是null
     */
    public V put(K key, V value) {
        // 倒数第二个参数false:表示允许旧值替换
        // 最后一个参数true:表示HashMap不处于创建模式
        return putVal(hash(key), key, value, false, true);
    }

    /**
     * Map.put和其他相关方法的实现需要的方法
     * putVal方法可以分为下面的几个步骤:
     * 1.如果哈希表为空,调用resize()创建一个哈希表。
     * 2.如果指定参数hash在表中没有对应的桶,即为没有碰撞,直接将键值对插入到哈希表中即可。
     * 3.如果有碰撞,遍历桶,找到key映射的节点
     * 3.1桶中的第一个节点就匹配了,将桶中的第一个节点记录起来。
     * 3.2如果桶中的第一个节点没有匹配,且桶中结构为红黑树,则调用红黑树对应的方法插入键值对。
     * 3.3如果不是红黑树,那么就肯定是链表。遍历链表,如果找到了key映射的节点,就记录这个节点,退出循环。如果没有找到,在链表尾部插入节点。插入后,如果链的长度大于TREEIFY_THRESHOLD这个临界值,则使用treeifyBin方法把链表转为红黑树。
     * 4.如果找到了key映射的节点,且节点不为null
     * 4.1记录节点的vlaue。
     * 4.2如果参数onlyIfAbsent为false,或者oldValue为null,替换value,否则不替换。
     * 4.3返回记录下来的节点的value。
     * 5.如果没有找到key映射的节点(2、3步中讲了,这种情况会插入到hashMap中),插入节点后size会加1,这时要检查size是否大于临界值threshold,如果大于会使用resize方法进行扩容。
     *
     * @param hash         指定参数key的哈希值
     * @param key          指定参数key
     * @param value        指定参数value
     * @param onlyIfAbsent 如果为true,即使指定参数key在map中已经存在,也不会替换value
     * @param evict        如果为false,数组table在创建模式中
     * @return 如果value被替换,则返回旧的value,否则返回null。当然,可能key对应的value就是null。
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K, V>[] tab;
        Node<K, V> p;
        int n, i;
        //如果哈希表为空,调用resize()创建一个哈希表,并用变量n记录哈希表长度
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        /**
         * 如果指定参数hash在表中没有对应的桶,即为没有碰撞
         * Hash函数,(n - 1) & hash 计算key将被放置的槽位
         * (n - 1) & hash 本质上是hash % n,位运算更快
         */
        if ((p = tab[i = (n - 1) & hash]) == null)
            //直接将键值对插入到map中即可
            tab[i] = newNode(hash, key, value, null);
        else {// 桶中已经存在元素
            Node<K, V> e;
            K k;
            // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                // 将第一个元素赋值给e,用e来记录
                e = p;
                // 当前桶中无该键值对,且桶是红黑树结构,按照红黑树结构插入
            else if (p instanceof TreeNode)
                e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
                // 当前桶中无该键值对,且桶是链表结构,按照链表结构插入到尾部
            else {
                for (int binCount = 0; ; ++binCount) {
                    // 遍历到链表尾部
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 检查链表长度是否达到阈值,达到将该槽位节点组织形式转为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 链表节点的<key, value>与put操作<key, value>相同时,不做重复操作,跳出循环
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 找到或新建一个key和hashCode与插入元素相等的键值对,进行put操作
            if (e != null) { // existing mapping for key
                // 记录e的value
                V oldValue = e.value;
                /**
                 * onlyIfAbsent为false或旧值为null时,允许替换旧值
                 * 否则无需替换
                 */
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 访问后回调
                afterNodeAccess(e);
                // 返回旧值
                return oldValue;
            }
        }
        // 更新结构化修改信息
        ++modCount;
        // 键值对数目超过阈值时,进行rehash
        if (++size > threshold)
            resize();
        // 插入后回调
        afterNodeInsertion(evict);
        return null;
    }

3、各项默认值

  1. 初始容量(capacity):16(1<<4),即**2^4**。

  2. 最大容量:(1<<30),即**2^30**。

  3. 默认扩容因子(loadFactor):0.75

  4. 自动扩容阈值:当前HashMap**元素个数 > capacity * loadFactor**,会自动扩容,并重新hash。

  5. 扩容机制:扩容至**当前HashMap容量*2.0,且每次扩容后的容量必须为2的n次方**。

  6. 容量必须为2的n次方的原因:

    HashMap对元素进行读、写操作时,需要将Map元素的Key的哈希值对数组长度(HashMap 的容量)进行取模运算,结果作为该元素在数组中的索引(index),在计算机中,取模运算的代价远高于位运算的代价,当数组长度为 2^n 时,可以将Map元素的Key的hashcode对 (2^n)-1 进行 与运算(&),效果与对数组长度进行取模运算相等,所以为了提高 HashMap 的操作效率,规定 HashMap 的容量必须为2的n次方,即2^n。

4、线程安全

  1. 是否线程安全?不安全的场景。

    HashMap线程不安全,主要表现在:

    • 多线程同时put时可能会丢失值(前面的put被后面的覆盖)。
    • 多线程扩容时会出现环状结构,造成死循环。
    • 多线程使用迭代器时会触发fast-fail机制。
  2. 如何解决?

    • 使用 CollectionssynchronizedMap() 对其进行包装,使其线程安全。(锁整个表,效率差)
    • 直接使用 线程安全的ConcurrentHashMap。(分段锁/CAS,效率相对较高)

2、HashTable

1、描述

  • Hashtable 继承于 Dictionary 类(Dictionary类声明了操作键值对的接口方法),实现Map接口(定义键值对接口);
  • 与HashMap一样也是哈希表(散列表),存储元素也是键值对。
  • Hashtable 大部分情况下是线程安全的

2、底层实现

​ 继承于 Dictionary 类,实现Map接口,采用 Entry数组+链表 实现。

3、各项默认值

  1. 初始容量(capacity):11
  2. 最大容量:Integer.MAX_VALUE – 8,即**(2^31-1)-8**,可能会导致内存溢出。
  3. 默认扩容因子(loadFactor):0.75
  4. 自动扩容阈值:当前HashMap**元素个数 > capacity * loadFactor**,会自动扩容,并重新hash。
  5. 扩容机制:扩容至**当前HashMap容量*2.0+1**。

4、线程安全

​ Hashtable大部分方法采用 synchronized 修饰,说明Hashtable大部分情况下是线程安全的,但它采用的是 独占锁 机制,并发时会**锁住整个hash表,导致效率十分低下,且在执行一些复合操作时,也会有线程安全隐患。**

复合操作:

  • ​若不存在则添加。
  • 若存在则删除。
  • 等。

5、HashTable与 HashMap 的区别

HashMap HashTable
线程安全 不安全 大部分情况线程安全
允许为null key和value都允许为null key和value都不允许为null
初始容量 16 11
扩容机制 扩容至当前容量的2倍 扩容至当前容量的2倍+1

3、ConcurrentHashMap

1、描述

​ ConcurrentHashMap 在 JDK1.5 时被加入,是 HashMap 线程安全的版本,其使用方式与 HashMap 一样。

2、底层实现

​ 其他实现与 HashMap 一样,只是添加了线程安全的保障,这里主要讲线程安全的实现

  • JDK1.7:

    • 线程安全采用**分段锁机制来实现,底层数据结构仍然是数组+链表。与HashMap不同的是,ConcurrentHashMap最外层不是一个大的数组,而是一个Segment的数组**,每个Segment下管理一个与HashMap数据结构差不多的table数组,在并发操作加锁时,锁住的是整个Segment,也就是说Segment的个数就是ConcurrentHashMap的最大并发数。
    • 默认有16个Segment,即最大并发数为16。,这个值可以通过构造函数改变,但一经创建就不可更改。
    • 尽管采用分段锁使锁的粒度小了很多,不再像HashTable那样锁住整个Hash表,但JDK1.7的ConcurrentHashMap的并发量还是受到了Segment个数的影响,为了能提供更大的并发量,JDK1.8的ConcurrentHashMap直接抛弃了分段锁机制,转而采用**CAS算法+synchronized**实现。

    [外链图片转存失败(img-t71o1ESE-1563951531836)(C:/Users/Yang/Desktop/%E9%9D%A2%E8%AF%95%E5%87%86%E5%A4%87/img/ConcurrentHashMap-java7.jpg)]

  • JDK1.8及以后:

    • 为进一步提高并发性,JDK1.8放弃了分段锁机制,将锁的级别控制在了更细粒度的**table元素级别上**,也就是说,只需要锁住一个table元素链表的头节点(head),并不会影响其他的table元素的读写,好处在于锁的粒度更细,影响更小,从而并发效率更好。
    • 使用**CAS算法【Compare And Swap】 + synchronized关键字** 来保证**put操作**的线程安全,步骤如下:
      1. 先判断当前被put进Map的元素的Key在table中所对应的数组元素是否为null,若为null,则**通过CAS操作**将其设置为当前put的元素的value。
      2. 如果Key对应的数组元素(也即链表表头或者红黑树的根元素)不为null,则对该数组元素使用synchronized关键字申请锁,然后再进行操作。

    [外链图片转存失败(img-J9lyk2v2-1563951531837)(C:/Users/Yang/Desktop/%E9%9D%A2%E8%AF%95%E5%87%86%E5%A4%87/img/ConcurrentHashMap-java8.jpg)]

    • 详情请参考:基于CAS算法的ConcurrentHashMap

    • CAS算法原理:

      CAS即CompareAndSwap,中文意思是:比较并替换,是由底层硬件提供的一种同步算法,大致原理如下:

      • CAS需要有3个操作数:
        1. 内存地址V:即将要修改的主存变量所在的地址。
        2. 旧的预期值A:即将要修改的主存变量所在的地址的值。
        3. 计算后得到的新值B,即将要更新的目标值。
      • CAS改值操作步骤:
        1. 读取到主存变量的内存地址(V);
        2. 得到内存地址V的值(A)【旧的预期值】;
        3. 通过计算得到新的值(B);
        4. 在将要更新V的值之前,先将A与V的值进行比较(Compare);
        5. 当且仅当A==V的值时,才会将V的值改为B,否则什么都不做。
      • CAS核心:当且仅当内存地址V的值与预期值A相等时,才将内存地址V的值修改为B,否则就什么都不做。整个CAS(比较并替换)操作是一个原子操作。
    • JDK1.8是基于CAS算法的轮询访问改值方式实现同步(即一直循环CAS算法去尝试改值,直到修改成功为止),虽然对CUP的消耗会大些,但如此实现的**线程同步是非阻塞式**的,并发量将得到很大提高。

3、各项默认值

  • 默认最大并发数:16,即为 Segment 的个数。(jdk1.7)
  • 其他默认值与HashMap一致。

二、List集合

1、ArrayList

1、描述

  • ArrayList是List接口可调整大小的数组实现。实现所有可选的List操作,并允许所有元素,包括null,元素可重复。
  • ArrayList是线程不安全的,体现在并发修改时会触发**快速失败(fail-fast)**机制。

2、底层实现

​ 可调整大小的数组实现。

3、各项默认值

  1. 初始容量(capacity):10
  2. 最大容量:Integer.MAX_VALUE - 8,即**Integer.MAX_VALUE - 8**,可能会导致内存溢出。
  3. 默认扩容因子(loadFactor):1
  4. 自动扩容阈值:当前ArrayList**元素个数 > capacity * loadFactor**,会自动扩容。
  5. 扩容机制:扩容至**当前ArrayList容量*1.5**。

4、线程安全

  1. 是否线程安全?不安全场景?

    ArrayList 是**线程不安全**的,并发修改就会触发 **快速失败(fail-fast)**机制。

  2. 如何解决线程不安全

    • 使用 Collections.synchronizedList() 对其进行包装。
    • 使用 CopyOnWriteArrayList 。

2、Vector

1、描述

  • 与ArrayList一样,底层是数组结构。但因它的所有方法都用**synchronized**修饰,所以Vector是线程安全的。
  • 因为线程安全,所以它的效率比ArraList低。

2、底层实现

​ 数组。

3、各项默认值

  1. 初始容量(capacity):10
  2. 最大容量:Integer.MAX_VALUE - 8,即**Integer.MAX_VALUE - 8**,可能会导致内存溢出。
  3. 默认扩容因子(loadFactor):1
  4. 自动扩容阈值:当前ArrayList**元素个数 > capacity * loadFactor**,会自动扩容。
  5. 扩容机制:扩容至**当前ArrayList容量*2**。

4、线程安全

Vector是线程安全的

3、LinkedList

1、描述

  • LinkedList是List和Deque接口的**双向链表**的实现。实现了所有可选List操作,并允许包括null值。
  • LinkedList是线程不安全的。

2、底层实现

双向链表

3、各项默认值

无,因为是双向链表。

4、线程安全

LinkedList是线程不安全的,需要用 Collections.synchronizedList() 对其进行包装。

4、ArrayList、LinkedList、Vector的区别

ArrayList LinkedList Vector
底层实现 数组 双向链表 数组
性能 索引查询快,添加、删除慢(主要是扩容) 添加、删除快,索引查询慢 比ArrayList慢
扩容 动态扩容 双向链表,不用扩容 动态扩容
线程安全 不安全 不安全 安全

三、Set集合

1、HashSet

1、描述

  • HashSet是Set接口的实现,元素无序、不可重复,底层是一个HashMap,用以保存数据。
  • 不能保证元素的排列顺序,顺序有可能发生变化。
  • 线程不安全。
  • 集合元素可以是null,但只存在一个null。

2、底层实现

HashMap。

3、各项默认值

​ 与 HashMap 一致。

4、线程安全

HashSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。

2、LinkedSet

1、描述

  • LinkedSet是Set接口的实现,继承于HashSet,元素不可重复,底层是一个LinkedMap,用以保存数据。
  • LinkedSet**可保证元素的插入顺序。**

2、底层实现

LinkedMap

3、各项默认值

​ 与 HashMap 一致。

4、线程安全

LinkedSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。

5、与HashSet的区别

​ LinkedHashSet在迭代访问Set中的全部元素时,性能比HashSet好,但是插入时性能稍微逊色于HashSet。即**顺序访问性能好,插入、删除性能差。**

3、TreeSet

1、描述

  • TreeSet是SortedSet接口的唯一实现类,元素不可重复。底层是一个TreeMap,用以保存数据。
  • TreeSet**默认可保证元素的自然顺序(元素需要实现Comparable接口),可指定排序规则(需要重写元素的hashCode()方法和equals()方法)**。

2、底层实现

TreeMap

3、各项默认值

​ 与 HashMap 一致。

4、线程安全

TreeSet是线程不安全的,需要用 Collections.synchronizedSet() 对其进行包装。

4、HashSet、LinkedSet、TreeSet的区别

HashSet LinkedSet TreeSet
顺序 无序 保证插入顺序 默认保证自然顺序
底层实现 HashMap LinkedMap TreeMap
线程安全 不安全 不安全 不安全

四、Java8新特性

1、速度更快

更改了底层数据结构,如HashMap、HashSet。

2、代码更少

新增语法:lambda表达式

3、有强大的Stream API

Stream API

4、便于并行

优化 Fork/Join 框架。

5、最大化减少空指针异常

新增 Optional 容器类。

6、总结

eSet的区别

HashSet LinkedSet TreeSet
顺序 无序 保证插入顺序 默认保证自然顺序
底层实现 HashMap LinkedMap TreeMap
线程安全 不安全 不安全 不安全

四、Java8新特性

1、速度更快

更改了底层数据结构,如HashMap、HashSet。

2、代码更少

新增语法:lambda表达式

3、有强大的Stream API

Stream API

4、便于并行

优化 Fork/Join 框架。

5、最大化减少空指针异常

新增 Optional 容器类。

6、总结

最主要的核心还是 Lambda 表达式Stream API

posted @ 2019-07-24 15:01  MrOldx  阅读(104)  评论(0编辑  收藏  举报