JAVA面试题分享二百九十:HashMap有几种遍历方法?推荐使用哪种?说一下HashMap底层实现?

目录

1.JDK 8 之前的遍历

1.1 EntrySet 遍历

1.2 KeySet 遍历

KeySet 性能问题

1.3 EntrySet 迭代器遍历

1.4 KeySet 迭代器遍历

1.5 迭代器的作用

不使用迭代器删除

使用迭代器删除

2.JDK 8 之后的遍历

2.1 Lambda 遍历

2.2 Stream 单线程遍历

2.3 Stream 多线程遍历

推荐使用哪种遍历方式?

总结

面试突击:说一下HashMap底层实现?以及插入流程?

HashMap 底层实现

HashMap 插入流程

为什么要将链表转红黑树?

哈希算法实现

总结


HashMap 的遍历方法有很多种,不同的 JDK 版本有不同的写法,其中 JDK 8 就提供了 3 种 HashMap 的遍历方法,并且一举打破了之前遍历方法“很臃肿”的尴尬。

1.JDK 8 之前的遍历

JDK 8 之前主要使用 EntrySet 和 KeySet 进行遍历,具体实现代码如下。

1.1 EntrySet 遍历

EntrySet 是早期 HashMap 遍历的主要方法,其实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    // 循环遍历
    for (Map.Entry<String, String> entry : map.entrySet()) {
        System.out.println(entry.getKey() + ":" + entry.getValue());
    }
}

以上程序的执行结果,如下图所示:

图片

1.2 KeySet 遍历

KeySet 的遍历方式是循环 Key 内容,再通过 map.get(key) 获取 Value 的值,具体实现如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    // 循环遍历
    for (String key : map.keySet()) {
        System.out.println(key + ":" + map.get(key));
    }
}

以上程序的执行结果,如下图所示:

图片

KeySet 性能问题

通过以上代码,我们可以看出使用 KeySet 遍历,其性能是不如 EntrySet 的,因为 KeySet 其实循环了两遍集合,第一遍循环是循环 Key,而获取 Value 有需要使用 map.get(key),相当于有循环了一遍集合,所以 KeySet 循环不能建议使用,因为循环了两次,效率比较低

1.3 EntrySet 迭代器遍历

EntrySet 和 KeySet 除了以上直接循环外,我们还可以使用它们的迭代器进行循环,如 EntrySet 的迭代器实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    // 循环遍历
    Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
    while (iterator.hasNext()) {
        Map.Entry<String, String> entry = iterator.next();
        System.out.println(entry.getKey() + ":" + entry.getValue());
    }
}

以上程序的执行结果,如下图所示:

图片

1.4 KeySet 迭代器遍历

KeySet 也可以使用迭代器的方式进行遍历,实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    // 循环遍历
    Iterator<String> iterator = map.keySet().iterator();
    while (iterator.hasNext()) {
        String key = iterator.next();
        System.out.println(key + ":" + map.get(key));
    }
}

以上程序的执行结果,如下图所示:

图片

虽然 KeySet 循环方式不推荐使用,但还是有必要了解一下的。

1.5 迭代器的作用

既然能直接遍历,那为什么还要用迭代器呢?通过以下例子我们就知道了。

不使用迭代器删除

如果不使用迭代器,假如我们在遍历 EntrySet 时,在遍历代码中删除元素,代码的实现如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    // 循环遍历
    for (Map.Entry<String, String> entry : map.entrySet()) {
        if ("Java".equals(entry.getKey())) {
            // 删除此项
            map.remove(entry.getKey());
            continue;
        }
        System.out.println(entry.getKey() + ":" + entry.getValue());
    }
}

以上程序的执行结果,如下图所示:

图片

可以看到,如果在遍历的代码中动态删除元素,非迭代器的方式就会报错。

使用迭代器删除

接下来,我们使用迭代器循环 EntrySet,并且在循环中动态删除元素,实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    // 循环遍历
    Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
    while (iterator.hasNext()) {
        Map.Entry<String, String> entry = iterator.next();
        if ("Java".equals(entry.getKey())) {
            // 删除此项
            iterator.remove();
            continue;
        }
        System.out.println(entry.getKey() + ":" + entry.getValue());
    }
}

以上程序的执行结果,如下图所示:

图片

从上述结果可以看出,使用迭代器的优点是可以在循环的时候,动态的删除集合中的元素。而上面非迭代器的方式则不能在循环的过程中删除元素(程序会报错)。

2.JDK 8 之后的遍历

在 JDK 8 之后 HashMap 的遍历就变得方便很多了,JDK 8 中包含了以下 3 种遍历方法:

  • 使用 Lambda 遍历

  • 使用 Stream 单线程遍历

  • 使用 Stream 多线程遍历

我们分别来看。

2.1 Lambda 遍历

使用 Lambda 表达式的遍历方法实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    
    // 循环遍历
    map.forEach((key, value) -> {
        System.out.println(key + ":" + value);
    });
}

以上程序的执行结果,如下图所示:

图片

2.2 Stream 单线程遍历

Stream 遍历是先得到 map 集合的 EntrySet,然后再执行 forEach 循环,实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    
    // 循环遍历
    map.entrySet().stream().forEach((entry) -> {
        System.out.println(entry.getKey() + ":" + entry.getValue());
    });
}

以上程序的执行结果,如下图所示:

图片

2.3 Stream 多线程遍历

Stream 多线程的遍历方式和上一种遍历方式类似,只是多执行了一个 parallel 并发执行的方法,此方法会根据当前的硬件配置生成对应的线程数,然后再进行遍历操作,实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    // 循环遍历
    map.entrySet().stream().parallel().forEach((entry) -> {
        System.out.println(entry.getKey() + ":" + entry.getValue());
    });
}

以上程序的执行结果,如下图所示:

图片

注意上述图片的执行结果,可以看出当前执行结果和之前的所有遍历结果都不一样(打印元素的顺序不一样),因为程序是并发执行的,所以没有办法保证元素的执行顺序和打印顺序,这就是并发编程的特点。

推荐使用哪种遍历方式?

不同的场景推荐使用的遍历方式是不同的,例如,如果是 JDK 8 之后的开发环境,推荐使用 Stream 的遍历方式,因为它足够简洁;而如果在遍历的过程中需要动态的删除元素,那么推荐使用迭代器的遍历方式;如果在遍历的时候,比较在意程序的执行效率,那么推荐使用 Stream 多线程遍历的方式,因为它足够快。所以这个问题的答案是不固定的,我们需要知道每种遍历方法的优缺点,再根据不同的场景灵活变通。

总结

本文介绍了 7 种 HashMap 的遍历方式,其中 JDK 8 之前主要使用 EntrySet 和 KeySet 的遍历方式,而 KeySet 的遍历方式性能比较低,一般不推荐使用。然而在 JDK 8 之后遍历方式就有了新的选择,可以使用比较简洁的 Lambda 遍历,也可以使用性能比较高的 Stream 多线程遍历。

面试突击:说一下HashMap底层实现?以及插入流程?

HashMap 是使用频率最高的数据类型之一,同时也是面试必问的问题之一,尤其是它的底层实现原理,既是常见的面试题又是理解 HashMap 的基石,所以重要程度不言而喻。

HashMap 底层实现

HashMap 在 JDK 1.7 和 JDK 1.8 的底层实现是不一样的,在 JDK 1.7 中,HashMap 使用的是数组 + 链表实现的,而 JDK 1.8 中使用的是数组 + 链表或红黑树实现的。HashMap 在 JDK 1.7 中的实现如下图所示:

图片

HashMap 在 JDK 1.8 中的实现如下图所示:

图片

我们本文重点来学习主流版本 JDK 1.8 中的 HashMap。HashMap 中每个元素称之为一个哈希桶(bucket),哈希桶包含的内容有 4 个:

  • hash 值

  • key

  • value

  • next(下一个节点)

HashMap 插入流程

HashMap 元素新增的实现源码如下(下文源码都是基于主流版本 JDK 1.8):

public V put(K key, V value) {
    // 对 key 进行哈希操作
    return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 哈希表为空则创建表
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 根据 key 的哈希值计算出要插入的数组索引 i
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 如果 table[i] 等于 null,则直接插入
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 如果 key 已经存在了,直接覆盖 value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果 key 不存在,判断是否为红黑树
        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
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 超过最大容量,扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

上述的源码都添加了相应的代码注释,简单来说 HashMap 的元素添加流程是,先将 key 值进行 hash 得到哈希值,根据哈希值得到元素位置,判断元素位置是否为空,如果为空直接插入,不为空判断是否为红黑树,如果是红黑树则直接插入,否则判断链表是否大于 8,且数组长度大于 64,如果满足这两个条件则把链表转成红黑树,然后插入元素,如果不满足这两个条件中的任意一个,则遍历链表进行插入,它的执行流程如下图所示:

图片

为什么要将链表转红黑树?

JDK 1.8 中引入了新的数据结构红黑树来实现 HashMap,主要是出于性能的考量。因为链表超过一定长度之后查询效率就会很低,它的时间复杂度是 O(n),而红黑树的时间复杂度是 O(logn),因此引入红黑树可以加快 HashMap 在数据量比较大情况下的查询效率。

哈希算法实现

HashMap 的哈希算法实现源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

其中,key.hashCode() 是 Java 中自带的 hashCode() 方法,返回一个 int 类型的散列值,后面 hashCode 再右移 16 位,正好是 32bit 的一半,与自己本身做异或操作(相同为 0,不同为 1),主要是为了混合哈希值的高位和地位,增加低位的随机性,这样就实现了 HashMap 的哈希算法。

总结

HashMap 在 JDK 1.7 时,使用的是数组 + 链表实现的,而在 JDK 1.8 时,使用的是数组 + 链表或红黑树的方式来实现的,JDK 1.8 之所以引入红黑树主要是出于性能方面的考虑。HashMap 在插入时,会判断当前链表的长度是否大于 8 且数组的长度大于 64,如果满足这两个条件就会把链表转成红黑树再进行插入,否则就是遍历链表插入。

  • 28
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

之乎者也·

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值