当前位置: 首页 > 图灵资讯 > 技术篇> 集合详解之 Map(附面试题)

集合详解之 Map(附面试题)

来源:图灵教育
时间:2023-06-08 09:23:57

集合有两个大接口:Collection 和 Map,本文重点介绍了集合中另一种常用的集合类型 Map。

以下是 Map 继承关系图:

集合详解之 Map(附面试题)_链表

Map 简介

Map 常用实现如下:

  • Hashtable:Java 实现了早期提供的哈希表,线程安全,不支持 null 键和值,因为它的性能不如 ConcurrentHashMap,所以很少推荐使用。
  • HashMap:如果程序中没有多线程的需求,最常用的哈希表就会实现,HashMap 这是一个很好的选择,支持 null 如果可以在多线程中使用键和值 ConcurrentHashMap 替代。
  • TreeMap:一种基于红黑树的顺序访问 Map,自身实现了 key 也可以指定自然排序 Comparator 来自定义排序。
  • LinkedHashMap:HashMap 一个子类,保存了记录的插入顺序,可以在遍历时保持与插入相同的顺序。
Map 常用方法

常用方法包括:put、remove、get、size 等,所有方法如下图所示:

集合详解之 Map(附面试题)_哈希冲突_02

使用示例,请参考以下代码:

Map hashMap = new HashMap();// hashmapp添加元素.put("name", "老王");hashMap.put("age", "30");hashMap.put("sex", "你猜");// hashmapp删除元素.remove("age");// 搜索单元元素Systemem.out.println(hashMap.get("age"));// 循环所有的 keyfor (Object k : hashMap.keySet()) {    System.out.println(k);}// 所有值for循环 (Object v : hashMap.values()) {    System.out.println(v);}

以上为 HashMap 其他类别的使用示例也类似。

HashMap 数据结构

HashMap 底层数据是数组成哈希桶,每个桶都存储在链表中,链表中的每个节点都是 HashMap 每一个元素。在 JDK 8 当链表长度大于或等于时 8 为了提高查询和插入的效率,它将转化为红黑树的数据结构。

HashMap 数据结构如下图所示:

集合详解之 Map(附面试题)_链表_03

HashMap 1)添加方法:put(Object key, Object value)

执行流程如下:

  • 对 key 进行 hash 操作,计算存储 index;
  • 判断是否有哈希碰撞,如果没有碰撞直接放入哈希桶,如果有碰撞,则以链表的形式存储;
  • 判断现有元素的类型,决定是加树还是加链表。当链表大于或等于时 8 将链表转换成红黑树;
  • 若节点已存在,则替换旧值;
  • 判断是否超过阀值,如果超过,将扩大容量。

源代码及说明:

public V put(K key, V value) {    // 对 key 进行 hash()    return putVal(hash(key), key, value, false, true);}static final int hash(Object key) {    int h;  // 对 key 进行 hash() 的具体实现    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}final V putVal(int hash, K key, V value, boolean onlyIfAbsent,               boolean evict) {    Node<K,V>[] tab; Node<K,V> p; int n, i;    // 为空创建tab    if ((tab = table) == null || (n = tab.length) == 0)        n = (tab = resize()).length;    // 计算 index,并对 null 做处理    if ((p = tab[i = (n - 1) & hash]) == null)        tab[i] = newNode(hash, key, value, null);    else {        Node<K,V> e; K k;        // 节点存在        if (p.hash == hash &&            ((k = p.key) == key || (key != null && key.equals(k))))            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;                }                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;    // 超过load factor*current capacity,resize    if (++size > threshold)        resize();    afterNodeInsertion(evict);    return null;}

put() 执行流程图如下:

2)获取方法:get(Object key)

执行流程如下:

  • 首先,如果第一节点是第一节点,比较第一节点 hash 值和 key 的 hash 值相同,第一节点的键对象和 key 相同的(相同的地址或 equals 等),返回节点;
  • 如果第一个节点的比较不同,看看下一个节点是否存在。如果存在,可以继续比较。如果不存在,就意味着 key 没有匹配的键值对。

源代码及说明:

public V get(Object key) {  Node<K,V> e;  return (e = getNode(hash(key), key)) == null ? null : e.value;}/*** 该方法是 Map.get 具体实现方法* 接收两个参数* @param hash key 的 hash 值,根据 hash 该值在节点数组中找到, hash 值是通过 hash(key) 得到的* @param key key 对象,当存在 hash 碰撞时,应逐一比较是否相等* @return 找到后,将键值返回到节点对象,否则返回 null*/final Node<K,V> getNode(int hash, Object key) {    Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 声明节点数组对象、链表的第一个节点对象、当前节点对象、数组长度、节点的键对象    // 链表的第一个节点由节点数组赋值、数组长度赋值、通过位置操作获得求模结果确定    if ((tab = table) != null && (n = tab.length) > 0 &&        (first = tab[(n - 1) & hash]) != null) {        if (first.hash == hash && // 首先,如果第一节点是第一节点,比较第一节点 hash 值和 key 的 hash 值相同,第一节点的键对象和 key 相同的(相同的地址或 equals 相等),返回节点            ((k = first.key) == key || (key != null && key.equals(k))))            return first; // 返回首节点        // 假如第一节点比较不同,那么看看下一节点是否存在,如果存在,可以继续比较,若不存在,则意味着 key 没有匹配的键值对            if ((e = first.next) != null) {            // 假如下一个节点存在 e,所以首先看看这个首节点是否是一个树节点            if (first instanceof TreeNode)                // 假如第一节点是树节点,然后找遍历树                return ((TreeNode<K,V>)first).getTreeNode(hash, key);             // 假如第一节点不是树节点,说明还是一个普通的链表,然后可以一个一个的比较经验                do {                if (e.hash == hash &&                    ((k = e.key) == key || (key != null && key.equals(k)))) // 先看比较 hash 值是否相同,然后查看地址或 equals                    return e; // 假如当前节点e的键对象与key相同,那么返回 e            } while ((e = e.next) != null); // 看看是否有下一个节点,如果有,继续下一轮比较,否则跳出循环        }    }    return null; // 树节点应该在比较后进行比较 或所有链表节点 没有匹配 key,那么就返回 null

相关面试题1.Map 常见的实现类有哪些?

答:Map 常见实现如下列表所示:

  • Hashtable:Java 实现了早期提供的哈希表,线程安全,不支持 null 键和值,因为它的性能不如 ConcurrentHashMap,因此很少推荐使用;
  • HashMap:如果程序中没有多线程的需求,最常用的哈希表就会实现,HashMap 这是一个很好的选择,支持 null 如果可以在多线程中使用键和值 ConcurrentHashMap 替代;
  • TreeMap:一种基于红黑树的顺序访问 Map,自身实现了 key 自然排名,也可以指定 Comparator 定义排序;
  • LinkedHashMap:HashMap 一个子类,保存了记录的插入顺序,可以在遍历时保持与插入相同的顺序。
2.使用 HashMap 可能会遇到什么问题?如何避免?

答:HashMap 死循环问题可能发生在并发场景中,因为 HashMap 扩展时,链表将倒序。假设两个线程同时扩展,第一个线程正在执行 B→A 当第二个线程再次执行时 A→B ,这个时候就会出现 B→A→B 造成死循环的问题。解决方案:升级 JDK 版本,在 JDK 8 之后扩容不会倒序,所以死循环的问题有了很大的改善,但这不是最终的解决方案,因为 HashMap 它不是在多线程版本下使用的。如果可以使用多线程 ConcurrentHashMap 替代 HashMap。

3.以下说法是正确的?

A:Hashtable 和 HashMap 都是非线程安全的B:ConcurrentHashMap 允许 null 作为 keyC:HashMap 允许 null 作为 keyD:Hashtable 允许 null 作为 key答:C题目分析:Hashtable 是线程安全,ConcurrentHashMap 和 Hashtable 是不允许 null 作为键和值。

4.TreeMap 如何实现基础 value 值倒序?

答:使用 Collections.sort(list, new Comparator<Map.Entry<String, String>>() 实现自定义比较器,首先实现 TreeMap 转换为 ArrayList,在使用 Collections.sort() 根据 value 倒序,完整实现代码如下。

TreeMap<String, String> treeMap = new TreeMap();treeMap.put("dog", "dog");treeMap.put("camel", "camel");treeMap.put("cat", "cat");treeMap.put("ant", "ant");// map.entrySet() 转成 ListList<Map.Entry<String, String>> list = new ArrayList<>(treeMap.entrySet());// Collections通过比较器实现比较排序.sort(list, new Comparator<Map.Entry<String, String>>() {  public int compare(Map.Entry<String, String> m1, Map.Entry<String, String> m2) {    return m2.getValue().compareTo(m1.getValue());  }});// for打印结果 (Map.Entry<String, String> item : list) {  System.out.println(item.getKey() + ":" + item.getValue());}

程序执行结果:

dog:dogcat:catcamel:camelant:ant
5.以下哪个 Set 实现自动排序?

A:LinedHashSetB:HashSetC:TreeSetD:AbstractSet

答:C

6.以下程序运行的结果是什么?
Hashtable hashtable = new Hashtable();hashtable.put("table", null);System.out.println(hashtable.get("table"));

答:程序执行报错:java.lang.NullPointerException。Hashtable 不允许 null 键和值。

7.HashMap 重要参数是什么?用途是什么?

答:HashMap 容量有两个重要参数:容量(Capacity)和负载因子(LoadFactor)。

  • 容量(Capacity):是指 HashMap 默认情况下,中桶数量的初始值为 16。
  • 负载因子(LoadFactor):又称装载因子,LoadFactor 是用来判定 HashMap 默认值是否扩容的依据 0.75f,装载因子的计算公式 = HashMap 存放的 KV 总和(size)/ Capacity。
8.HashMap 和 Hashtable 有什么区别?

答:HashMap 和 Hashtable 区别如下:

  • Hashtable 使用了 synchronized 确保线程安全的关键字, HashMap 非线程安全;
  • HashMap 允许 K/V 都为 null,而 Hashtable K/V 都不允许 null;
  • HashMap 继承自 AbstractMap 类;而 Hashtable 继承自 Dictionary 类。
9.什么是哈希冲突?

答:当输入两个不同的值,根据相同的散列函数计算相同的散列值时,我们称之为碰撞(哈希碰撞)。

10.解决哈希冲突的方法有哪些?

答:哈希冲突的常见解决方案如下 4 种。

  • 开放定址法:关键词哈希地址 p=H(key)出现冲突时,以 p 在此基础上,生成另一个哈希地址 p1,如果 p1 还是冲突,然后再来 p 在此基础上,生成另一个哈希地址 p2,循环这个过程,直到找到一个不冲突的哈希地址,并将相应的元素存储在其中。
  • 再哈希法:当哈希地址同时构建多个不同的哈希函数时,这种方法是 Hi=RH1(key)冲突发生时,再计算 Hi=RH2(key),这种方法唯一的缺点是增加了计算时间,直到找到一个不冲突的哈希地址。
  • 链地址法:该方法的基本思想是将所有哈希地址作为 i 元素构成单链表,称为同义词链,单链表的头指针存在于哈希表的第一位 i 在单元中,搜索、插入和删除主要在同义词链中进行。链地址法适用于经常插入和删除。
  • 建立公共溢出区:将哈希表分为基本表和溢出表两部分,所有与基本表发生冲突的元素均填写溢出表。
11.HashMap 用什么方法解决哈希冲突(哈希冲突)?

答:HashMap 使用链表和红黑树来解决哈希冲突,详见本文 put() 执行方法的过程。

12.HashMap 为什么是扩容? 2^n ?

答:这样做的目的是使散列更加均匀,从而减少哈希碰撞,从而提供代码执行效率。

13.如果有哈希冲突, HashMap 如何取值?

答:如果有哈希冲突,HashMap 会循环链表中的每一项 key 进行 equals 对比,返回相应元素。相关源代码如下:

do {    if (e.hash == hash &&        ((k = e.key) == key || (key != null && key.equals(k)))) // 先看比较 hash 值是否相同,然后查看地址或 equals        return e; // 若当前节点 e 的键对象和 key 如果是一样的,那么返回 e} while ((e = e.next) != null); // 看看是否有下一个节点,如果有,继续下一轮比较,否则跳出循环
14.以下程序会输出什么结果?
class Person {    private Integer age;    public boolean equals(Object o) {        if (o == null || !(o instanceof Person)) {            return false;        } else {            return this.getAge().equals(((Person) o).getAge());        }    }    public int hashCode() {        return age.hashCode();    }    public Person(int age) {        this.age = age;    }    public void setAge(int age) {        this.age = age;    }    public Integer getAge() {        return age;    }    public static void main(String[] args) {        HashMap<Person, Integer> hashMap = new HashMap<>();        Person person = new Person(18);        hashMap.put(person, 1);        System.out.println(hashMap.get(new Person(18)));    }}

 

答:1题目分析:因为 Person 重写了 equals 和 hashCode 方法,所有 person 对象和 new Person(18) 键值是一样的,所以结果是 1。

15.为什么重写? equals() 一定要重写 hashCode()?

答:因为 Java 规定,如果有两个对象 equals 比较相等(结果是 true),那么调用 hashCode 也必须是平等的。如果重写 equals() 但没有重写 hashCode()会违反规定,如以下代码(故意注释) hashCode 方法):

class Person {    private Integer age;    public boolean equals(Object o) {        if (o == null || !(o instanceof Person)) {            return false;        } else {            return this.getAge().equals(((Person) o).getAge());        }    }//    public int hashCode() {//        return age.hashCode();//    }    public Person(int age) {        this.age = age;    }    public void setAge(int age) {        this.age = age;    }    public Integer getAge() {        return age;    }    public static void main(String[] args) {        Person p1 = new Person(18);        Person p2 = new Person(18);        System.out.println(p1.equals(p2));        System.out.println(p1.hashCode() + " : " + p2.hashCode());    }}

执行结果:

true21685669 : 2133927002

如果重写 hashCode() 之后,执行结果如下:

true18 : 18

这样就符合了 Java 因此,重写规定 equals() 一定要重写 hashCode()。

16.HashMap 在 JDK 7 使用多线程会导致哪些问题?

答:HashMap 在 JDK 7 会导致死循环问题。因为在 JDK 7 中、多线程进行 HashMap 扩容会导致链表的循环引用,此时使用 get() 获取元素会导致死循环,导致 CPU 100% 的情况。

17.HashMap 在 JDK 7 和 JDK 8 有什么区别?

答:HashMap 在 JDK 7 和 JDK 8 主要区别如下。

  • 存储结构:JDK 7 使用的是数组 + 链表;JDK 8 使用的是数组 + 链表 + 红黑树。
  • 存储数据的规则:JDK 7 无冲突时,存储数组;冲突时,存储链表;JDK 8 数组直接存储在没有冲突的情况下,当链表长度小于时 8 当链表长度大于单链表结构时,存储在单链表结构中 8 树化并存储在红黑树的数据结构中。
  • 插入数据:JDK 7 使用头插法(先将原位置的数据移到后面 1 位置,然后将数据插入位置);JDK 8 采用尾插法(直接插入链表尾/红黑树)。
总结

本文可以理解:

  • Map 常用实现类 Hashtable 是 Java 实现早期线程安全哈希表;
  • HashMap 哈希表是最常用的,但它是非线程安全的,可以使用 ConcurrentHashMap 替代;
  • TreeMap 哈希表是基于红黑树提供顺序访问的;
  • LinkedHashMap 是 HashMap 一个子类,保存了记录的插入顺序,可以在遍历时保持与插入相同的顺序。

HashMap 在 JDK 7 在扩容过程中,可能会引起链表的循环引用 CPU 100%,HashMap 在 JDK 8 时数据结构变化为:数组 + 链表 + 当链表长度小于时,红黑树的存储方式直接存储在数组中,没有冲突。 8 当链表长度大于单链表结构时,存储在单链表结构中 8 时,树化并存储在红黑树的数据结构中。