Skip to main content

Map 接口

David LiuAbout 9 min

Map 接口

主要根据集合的特点来选用,比如我们需要根据键值获取到元素值时就选用 Map 接口下的集合,需 要排序时选择 TreeMap ,不需要排序时就选择 HashMap ,需要保证线程安全就选用 ConcurrentHashMap 。

当我们只需要存放元素值时,就选择实现 Collection 接口的集合,需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSet 或 HashSet,不需要就选择实现 List 接口的比如 ArrayList 或 LinkedList ,然后再根据实现这些接口的集合的特点来选用。

HashMap

capacity,factor

capacity 是元素个数,factor 只有构造的时候考虑(容量初始设置比 capacity/factor 大的第一个 2^n),后面还是按 3/4

扰动函数:hash

HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置 (这里的 n 指的是数组的⻓度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现 比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。

return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。

所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

扩容机制:Resize

HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap 总是使用 2 的幂作为哈希表的大小。

  • 1.8 及以后,满 3/4 时就扩容
  • 1.8 以前,超 3/4 时扩容

扩容的时候,从后往前迁移

迁移的时候划分区间来迁移

进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。

我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。

树形哈希:treeifyBin

当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。

遍历方式

HashMap 4 大类遍历方式:

  • 迭代器
  • for
  • lambda
  • stream

具体为 7 种遍历方法,除了 Stream 的并行循环,其他几种遍历方法的性能差别不大,但从简洁性和优雅性上来看,Lambda 和 Stream 无疑是最适合的遍历方式。

除此之外我们还从「安全性」方面测试了 4 大类遍历结果,从安全性来讲,我们应该使用迭代器提供的 iterator.remove() 方法来进行删除,这种方式是安全的在遍历中删除集合的方式,或者使用 Stream 中的 filter 过滤掉要删除的数据再进行循环,也是安全的操作方式。

我们不能在遍历中使用集合 map.remove() 来删除数据,这是非安全的操作方式,但我们可以使用迭代器的 iterator.remove() 的方法来删除数据,这是安全的删除集合的方式。同样的我们也可以使用 Lambda 中的 removeIf 来提前删除数据,或者是使用 Stream 中的 filter 过滤掉要删除的数据进行循环,这样都是安全的,当然我们也可以在 for 循环前删除数据在遍历也是线程安全的。

注意事项

对象 key

需要重写对象的 hashcode 和 equals 方法

先用 hashcode 放入 node 的位置,如果 hash 冲突,就是放入这个位置链表的末端(如果遍历的时候 equals 方法发现元素已经存在则不放)

判断相等的过程是:先判断两个对象的 HashCode 的值是否相等,若相等,再进行 equals()判断。若 HashCode 不相等,则判断两个对象不相等,直接加入到容器中,不再进行 equals()比较。

例如:

import java.util.Objects;
class Student {
    private String name;
    public String getName(String name){
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public Student(String name) {
        this.name = name;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Student student = (Student) o;
        return Objects.equals(name, student.name);
    }
    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

数组 key

存放的是其地址,无法重写 hashcode 等

所以一般不能把数组作为 key,因为无法重写 hashcode 和 equals

  • TreeMap 底层使用红黑树,能够按照添加元素的顺序进行遍历,排序的方式有自然排序和定制排序。

红黑树

红黑树是一种特化的 AVL 树(平衡二叉树),都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而保证较高的查找性能。

它虽然是复杂的,但它的最坏情况运行时间也是非常良好的,并且在实践中是高效的: 它可以在 O(log n) 时间内做查找,插入和删除

  • 性质 1. 结点是红色或黑色。
  • 性质 2. 根结点是黑色。
  • 性质 3. 所有叶子都是黑色。(叶子是 NIL 结点)
  • 性质 4. 每个红色结点的两个子结点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色结点)
  • 性质 5. 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点。

使用技巧

用 merge,来简化统计频率

occurrences1.merge(word, 1, Integer::sum);

// 旧的版本
occurrences1.put(word, 1 + occurrences1.getOrDefault(word, 0));

用 computeIfAbsent,来简化value为集合类型的情况下的一些操作

idToEmails.computeIfAbsent(id, k -> new ArrayList<>()).add(email);

// 三行代码版本
List<String> account = indexToEmails.getOrDefault(index, new ArrayList<String>());
account.add(email);
indexToEmails.put(index, account);

putIfAbsent

getOrDefault

HashTable

  • 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  • 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它;
  • 对 Null key 和 Null value 的支持:
    1. HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;
      • key
        • hash 方法(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        • equals 方法调用前都有判断是否为 null
      • value 不作规定(也不会调用他的什么方法,所以不会 NPE)
    2. Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
      • put 方法先检查 value 是否为 null,抛出 NPE
      • put 方法中调用int hash = key.hashCode();如果 Key 为 null 则 NPE
  • 初始容量大小和每次扩充容量大小的不同 :
    1. 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
    2. 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable 没有这样的机制。

TreeMap

HashMap 和 TreeMap 区别

TreeMapHashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap接口。

实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。

实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。

相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力,同时也提供了范围查找的可行性。