Java集合:Map面试题

162 阅读21分钟

image.png 虽然前面通过HashMap源码分析了解了HashMap的原理,但是很多时候,面试官换种方式提问的时候,仍然一脸懵逼。

image.png

这应该有两个原因:1)对原理理解的仍然不够深刻;2)没有提前分析过,面试官可能会问哪些问题,没有想在面试官的前面。

所以接下来通过一些面试题的分析,促进对HashMap的理解。

HashMap的实现原理

你看过HashMap源码吗,知道底层的原理吗

(或者问:知道HashMapput元素的过程是什么样吗?)

很宽泛的问题,需要提前梳理一下,从哪开始讲起,以及哪些是要讲的。

  • 首先说一下底层原理概述
    • JDK8之前的HashMap由数组+链表组成,用Entry数组来存储数据(Entry对象包括:key、value、hash和指向下一个entry的引用变量),使用链表来解决哈希冲突。

    • JDK8之后的HashMap由数组+链表+红黑树组成。用Node数组来存储数据(Node对象包括的属性:key、value、hash和指向下一个node的引用变量),链表和红黑树是为了解决哈希冲突的。

(描述的时候过渡一下:主要通过put元素的流程来说一下现在主流JDK8版本的底层原理)

  • put元素的流程
    • 往数组中put第1个元素:
      • 1)首先会触发resize()方法进行扩容。如果new HashMap的时候调用的是空的构造方法,那么在resize方法里面,会将新数组的容量设为DEFAULT_INITIAL_CAPACITYthresholdDEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY计算,然后直接new一个空Node数组,长度是DEFAULT_INITIAL_CAPACITY

      • 2)扩容结束之后,通过Node数组长度和传入的hash值,计算元素应该存放的位置,生成Node结点放在计算的位置上

    • 数组中已有元素,再put元素:
      • 1)计算元素应该存放的位置。如果计算的位置上没有元素,则直接将传入的元素生成一个Node结点,放在计算的位置上;

      • 2)如果计算的位置上有元素,则说明出现了hash冲突

        • 首先判断这个位置上的key是否与传入的key相同,如果相同,则直接用传入的值替换旧值。

        • 如果不相同,则要转链表或转红黑树

          • 如果这个位置上的元素是红黑树结点,那么需要调用红黑树的插值方法

          • 如果这个位置上的元素是链表结点,就使用尾插法在链表尾部插入新结点。插入的过程中会判断链表上的结点数量是否达到阈值。

            • 1)如果链表长度大于等于8,会调用转红黑树的方法,这个方法里面会先判断存储数据的数组长度是否大于等于64,如果大于等于64,则会将链表转红黑树(后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。);否则会对数组进行扩容;

            • 2)如果链表长度小于8,则直接插入链表。

类似的问题如下:

  1. HashMap怎么解决hash冲突的。
  2. HashMap是怎么扩容的。
  3. HashMap是先插入元素还是先扩容?为什么这么设计。
  4. HashMap有没有容量限制

为什么使用数组+链表/红黑树

既然问了为什么用xx,那就需要对比才能体现优势,所以回答的时候可以用一些常见的数据结构组合,来进行对比。

比如:用LinkedList + 链表组合 或者 ArrayList + 链表组合

  1. 首先说用链表和红黑树的目的是为了解决hash冲突

  2. 再说为什么采用数组

  • 和LinkedList进行对比:查找效率比LinkedList(底层是链表结构)高。

  • 和ArrayList进行对比:数组的扩容可以自己定义,目前HashMap的扩容机制是原数组长度的2的次幂,ArrayList是固定1.5倍扩容。

知道get过程是是什么样吗?

(HashMap的get方法逻辑是怎样的)

  • 根据传入key的hash值,计算所在位置,如果该位置上第一个结点不为空,则

    • 先判断这个元素的hash值是否与传入的hash值相同,以及该元素的key是否与传入的key相同,如果都相同,直接返回这个元素的值。

    • 如果不相同,说明后面有链表或者红黑树,就需要判断这个元素是红黑树节点还是链表节点。

      • 如果是红黑树,则调用红黑树获取节点的方法,然后返回节点的值。

      • 如果是链表,则循环遍历链表,找到和传入key、hash都相同的元素,并返回。

你还知道哪些hash算法?(可忽略)

先说一下hash算法干嘛的,Hash函数是指把⼀个⼤范围映射到⼀个⼩范围。把⼤范围映射到⼀个⼩范围的⽬的往往是为了 节省空间,使得数据容易保存。

比较出名的有MurmurHashMD4MD5等等

String中hashcode的实现

public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

String类中的hashCode计算⽅法还是⽐较简单的,就是以31为权,每⼀位为字符的ASCII值进⾏运算,⽤⾃然溢出来等效 取模。
哈希计算公式可以计为ss[[00]]3311^^((nn–11)) ++ ss[[11]]3311^^((nn–22)) ++ …… ++ ss[[nn–11]]
那为什么以31为质数呢? 主要是因为31是⼀个奇质数,所以31i=32i-i=(i<<5)-i,这种位移与减法结合的计算相⽐⼀般的运算快很多

知道jdk1.8中hashmap改了什么吗

  1. 由数组+链表的结构改为数组+链表+红黑树(当链表元素数量大于等于8,而且数组的容量大于等于64的时候,链表会转为红黑树。)
  2. 优化了hash方法,将key进行hashCode得到h之后,再将h的16位与低16位进行异或运算,这样可以有效降低冲突概率。???
  3. 扩容后,元素要么是在原位置,要么是在原位置+旧容量。

Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么?

为什么hashmap在链表元素数量超过8时候改为红黑树。

链表元素数量超过8的时候,查询速度比较慢,需要红黑树来加快查询速度。

为什么在解决hash冲突时候,不直接用红黑树,而是先用链表,再用红黑树

因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。

  • 当元素小于8个的时候,此时做查询操作,链表结构已经能保证查询性能。
  • 当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
    因此,如果一开始就⽤红黑树结构,元素太少,新增效率又比较慢,是会浪费性能的。

类似的问题还有很多:

  1. JDK8中,HashMap什么情况会用红黑树。
  2. JDK8中,HashMap链表转红黑树,为啥是链表长度达到8才转,为什么是8。
  3. JDK8中,HashMap为啥不直接用红黑树。

当链表转为红黑树,什么时候退化为链表

为6的时候退化为链表。中间有个差值7可以防止链表和树之间频繁的转换。

假设:如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表, 如果一个HashMap不停的插⼊、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

HashMap的负载因子为什么是0.75

官方的回答是说:0.75是在空间和时间复杂度之间的良好平衡。

负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。0.75刚刚好。

HashMap的长度为什么是2的幂次方

  • 结论:为了能够高效的计算元素索引位置,并且让位置均匀分布。

  • 具体来说:往HashMap中put元素的时候,需要计算索引位置,为了减少hash碰撞,可以使用将hash值对数组长度取模(hash%length)的方式来计算,但是取模运算的效率不如位运算。要使用位运算来代替取模运算,就必须满足数组长度为2的幂次方。

JDK8中,HashMap为什么引入红黑树,而不是AVL树

为什么用A,而不是B,这类问题本质上是比较两者的区别,看看分别有什么优势和劣势。

红黑树是弱平衡二叉树,AVL树是严格平衡二叉树(所有节点的左右子树高度差的绝对值不超过1)。

所以:红黑树的插入和删除操作可能需要进行1到2次的旋转来维护,而AVL树为了保持严格平衡可能需要进行多次旋转。因此,红黑树的插入和删除操作性能更高。

HashMap的并发问题

可参考文章:一文看懂 jdk8 中的 ConcurrentHashMap

HashMap在并发环境下会有什么问题

  • 在JDK1.7中,并发环境下可能会出现查询时死循环的问题。 因为在JDK1.7里面,出现hash冲突的时候采用头插法,在进行扩容,将旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,所以最后的结果打乱了插入的顺序,就可能发生环形链和数据丢失的问题。环形链会引起死循环,导致CPU利用率接近100%。

  • 在JDK1.8中对HashMap进行了优化,发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程环境下,会发生数据覆盖的情况。 如果没有hash碰撞的时候,它会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,线程A会把线程B插入的数据给覆盖,导致数据发生覆盖的情况,发生线程不安全。

并发环境下,推荐使用ConcurrentHashMap

参考文章:HashMap并发修改异常

说一下为什么会出现线程的不安全性

同上

一般是如何解决的

或者问:HashMap如何实现同步?

  • 使用Collections.synchronizedMap()方法
Map<Integer, String> map = new HashMap<>();
Map<Integer, String> synchronizedMap = Collections.synchronizedMap(map);

synchronizedMap实现线程安全的原理也很简单,它首先基于当前的map对象生成一个新的map类型,它的所有操作(get/put/resize等方法)都用了synchronized加了一个对象锁。

  • 使用ConcurrentHashMap;

ConcurrentHashMap

ConcurrentHashMap线程安全的实现原理

JDK1.7(可作为了解)

image.png

底层数据结构

1.7中的ConcurrentHashMap 是由 Segment 数组和 HashEntry 数组组成。 1个ConcurrentHashMap有多个Segment(初始貌似是16个),一个Segment有多个HashEntry。

Segment 实现了 ReentrantLock,是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

实现线程安全的原理详解

ConcurrentHashMap 在 put 方法中进行了两次 hash 计算去定位数据的存储位置,尽可能的减小哈希冲突的可能性,然后再根据 hash 值以 Unsafe 调用方式,直接获取相应的 Segment,最终将数据添加到[容器]中是由 segment对象的 put 方法来完成。由于 Segment 对象本身就是一把锁,所以在新增数据的时候,相应的 Segment对象块是被锁住的,其他线程并不能操作这个 Segment 对象,这样就保证了数据的安全性。在扩容时也是这样的,在 JDK1.7 中的 ConcurrentHashMap扩容只是针对 Segment 对象中的 HashEntry 数组进行扩容,还是因为 Segment 对象是一把锁,所以在 rehash 的过程中,其他线程无法对 segment 的 hash 表做操作,这就解决了 HashMap 中 put 数据引起的闭环问题。

以下是put元素的源码,用于理解原理。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
   // 在往该 Segment 写入前,先确保获取到锁
   HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
   V oldValue;
   try {
       // Segment 内部数组
       HashEntry<K,V>[] tab = table;
       int index = (tab.length - 1) & hash;
       HashEntry<K,V> first = entryAt(tab, index);
       for (HashEntry<K,V> e = first;;) {
           if (e != null) {
               K k;
               // 更新已有值...
           }
           else {
               // 放置 HashEntry 到特定位置,如果超过阈值则进行 rehash
               // 忽略其他代码...
           }
       }
   } finally {
       // 释放锁
       unlock();
   }
   return oldValue;
}

JDK1.8 之后

Java8 ConcurrentHashMap 存储结构

Java8 ConcurrentHashMap 存储结构

Java 8 几乎完全重写了 ConcurrentHashMap,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。

ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。

Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

ConcurrentHashMap 能保证复合操作的原子性吗?

ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!

复合操作是指由多个基本操作(如putgetremovecontainsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。

例如,有两个线程 A 和 B 同时对 ConcurrentHashMap 进行复合操作,如下:

// 线程 A
if (!map.containsKey(key)) {
map.put(key, value);
}
// 线程 B
if (!map.containsKey(key)) {
map.put(key, anotherValue);
}

如果线程 A 和 B 的执行顺序是这样:

  1. 线程 A 判断 map 中不存在 key
  2. 线程 B 判断 map 中不存在 key
  3. 线程 B 将 (key, anotherValue) 插入 map
  4. 线程 A 将 (key, value) 插入 map

那么最终的结果是 (key, value),而不是预期的 (key, anotherValue)。这就是复合操作的非原子性导致的问题。

那如何保证 ConcurrentHashMap 复合操作的原子性呢?

ConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsentcomputecomputeIfAbsentcomputeIfPresentmerge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。

上面的代码可以改写为:

// 线程 A
map.putIfAbsent(key, value);
// 线程 B
map.putIfAbsent(key, anotherValue);

或者

// 线程 A
map.computeIfAbsent(key, k -> value);
// 线程 B
map.computeIfAbsent(key, k -> anotherValue);

ConcurrentHashMap和HashTable的区别

底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; 实现线程安全的方式(重要):

  • 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
  • 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
  • Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

ConcurrentHashMap 为什么 key 和 value 不能为 null

HashMap的使用

key可以是null吗,value可以是null吗

可以,不会报错

一般用什么作为key值

  • 基本类型的包装类

  • String类

用可变类当HashMap的Key会有什么问题

当类中的属性变化时,会导致key的hash值发生变化,进而导致找不到之前的key对应的value。

让你实现一个自定义的class作为HashMap的Key该如何实现

对于这个问题考查到了下面的两个知识点

  • 重写hashcode和equals方法需要注意什么?

    • 两个对象相等,hashcode一定相等
    • 两个对象不等,hashcode不一定不等
    • hashcode相等,两个对象不一定相等
    • hashcode不等,两个对象一定不等
  • 如何设计一个不变的类。

    • 类添加final修饰符,保证类不被继承。
    • 保证所有成员变量必须私有,并且加上final修饰 通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4 点弥补这个不⾜。
    • 不提供改变成员变量的方法,包括setter。避免通过其他接⼝改变成员变量的值,破坏不可变特性。
    • 通过构造器初始化所有成员,进行深拷贝(deep copy)
    • getter方法中,不要直接返回对象本⾝,⽽是克隆对象,并返回对象的拷贝。这种做法也是防⽌对象外泄,防⽌通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生编程。

HashMap与Hashtable的区别

  • 线程是否安全:HashMap是非线程安全的,Hashtable是线程安全的。因为Hashtable内部的方法基本都是synchronized关键字修饰的。但实际工作中,保证线程安全使用的是ConcurrentHashMap。Hashtable已经基本淘汰了。

  • 空键和空值:HashMap:key和value都可以是NULL;Hashtable:不允许空的key和空值,否则会抛空指针异常

  • 初始容量大小和每次扩容大小不同

    • 创建时如果不指定容量,Hashtable默认的是11,之后每次扩容,容量变为原来的2n+1;HashMap默认的初始大小是16,每次扩容,容量是原来的2倍
    • 创建时如果指定了容量,Hashtable会使用指定的大小,HashMap会将其扩充为2的幂次方大小。
  • 底层数据结构:HashMap底层是Node数组+链表+红黑树的结构。链表是为了解决Hash冲突,红黑树是为了解决链表长度过长时搜索性能较差的问题。Hashtable没有这样的机制,底层就一个Entry数组。

HashMap和TreeMap如何选择

  • 源码上的区别:两者都继承自AbstractMap,但是TreeMap还实现了Navigable接口和SortedMap接口。
    • 实现Navigable接口,让TreeMap有了对集合内元素的搜索的能力
    • 实现SortMap接口,让TreeMap有了对集合中的元素根据键排序的能力。默认是按照key的升序排序,但是也可以自己指定排序比较器。

所以两者分别有以下特性

  • 插入和查找性能

    • HashMap:提供常数时间的平均性能O(1)来添加和检索键值对。但在某些情况下可能会退化到线性时间O(n)。
    • TreeMap:添加、删除、查找的时间复杂度都是O(logn)。
  • 键的顺序

    • HashMap:key的插入顺序和遍历时的顺序不一致
    • TreeMap:根据插入时的顺序或者构造函数传入的Comparator来维护键的顺序
public class Person {
    private Integer age;

    public Person(Integer age) {
        this.age = age;
    }

    public Integer getAge() {
        return age;
    }


    public static void main(String[] args) {
        TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
            @Override
            public int compare(Person person1, Person person2) {
                int num = person1.getAge() - person2.getAge();
                return Integer.compare(num, 0);
            }
        });
        treeMap.put(new Person(3), "person1");
        treeMap.put(new Person(18), "person2");
        treeMap.put(new Person(35), "person3");
        treeMap.put(new Person(16), "person4");
        treeMap.entrySet().stream().forEach(personStringEntry -> {
            System.out.println(personStringEntry.getValue());
        });
    }
}
  • 空键和空值

    • HashMap:key和value都可以是NULL
    • TreeMap:不允许空的key,允许空值。
  • 总结

    • 如果需要快速的插入、删除和查找操作,且不需要任何排序功能,那么HashMap是一个很好的选择
    • 如果需要有序的键、或者特定的排序要求,那么可以使用TreeMap。

HashMap和HashSet的区别

如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()writeObject()readObject()HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。

HashMapHashSet
实现了 Map 接口实现 Set 接口
存储键值对仅存储对象
调用 put()向 map 中添加元素调用 add()方法向 Set 中添加元素
HashMap 使用键(Key)计算 hashcodeHashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性

HashSet如何检查重复

HashSet加入一个元素的时候,是直接调用HashMap的put方法,规则是:

  1. 计算加入元素的hashcode是否与已有元素的hashcode相同,如果不相同,则加入成功,add方法会返回true;
  2. 如果相同,会再调用equals方法比较两个对象是否相同,如果不相同,则重新计算hascode值?如果相同,则加入失败,add方法会返回false

当把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcodeHashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。 在 JDK1.8 中,HashSetadd()方法只是简单的调用了HashMapput()方法,并且判断了一下返回值以确保是否有重复元素。直接看一下HashSet中的源码:

// Returns: true if this set did not already contain the specified element
// 返回值:当 set 中没有包含 add 的元素时返回真
public boolean add(E e) {
        return map.put(e, PRESENT)==null;
}

而在HashMapputVal()方法中也能看到如下说明:

// Returns : previous value, or null if none
// 返回值:如果插入位置没有元素返回null,否则返回上一个元素
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
...
}

也就是说,在 JDK1.8 中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。

HashMap的七种遍历方式

详见:HashMap的七种遍历方式与性能

参考文章

  1. zhuanlan.zhihu.com/p/127147909
  2. 学弟说他面试时被问到了HashMap,差点就遭老罪了
  3. HashMap精选面试题
  4. JavaGuide面试题
  5. 面试官:说说 ConcurrentHashMap 线程安全的实现原理?
  6. 面试突击18:为什么ConcurrentHashMap是线程安全的?
  7. 一文看懂 jdk8 中的 ConcurrentHashMap
  8. ConcurrentHashMap源码&底层数据结构分析