Java TreeMap与HashMap

2023/06/07

1. 概述

在本文中,我们将比较两个Map实现:TreeMap和HashMap。

这两种实现构成了Java集合框架的组成部分,并将数据存储为键值对。

2. 差异

2.1 实现

我们将首先讨论HashMap,这是一个基于哈希表的实现。它扩展了AbstractMap类并实现了Map接口。HashMap的工作原理是哈希

此Map实现通常用作存储桶哈希表,但当桶变得太大时,它们会转换为TreeNodes的节点,每个节点的结构与java.util.TreeMap中的节点类似。

另一方面,TreeMap扩展了AbstractMap类并实现了NavigableMap接口。TreeMap将Map元素存储在红黑树中,这是一种自平衡二叉搜索树

2.2 顺序

HashMap不对元素在Map中的排列方式提供任何保证

这意味着,我们不能在遍历HashMap的键和值时假定任何顺序

@Test
public void whenInsertObjectsHashMap_thenRandomOrder() {
    Map<Integer, String> hashmap = new HashMap<>();
    hashmap.put(3, "TreeMap");
    hashmap.put(2, "vs");
    hashmap.put(1, "HashMap");
    
    assertThat(hashmap.keySet(), containsInAnyOrder(1, 2, 3));
}

但是,TreeMap中的元素是根据它们的自然顺序排序的

如果TreeMap对象不能按照自然顺序排序,那么我们可以使用Comparator或Comparable来定义元素在Map中的排列顺序:

@Test
public void whenInsertObjectsTreeMap_thenNaturalOrder() {
    Map<Integer, String> treemap = new TreeMap<>();
    treemap.put(3, "TreeMap");
    treemap.put(2, "vs");
    treemap.put(1, "HashMap");
    
    assertThat(treemap.keySet(), contains(1, 2, 3));
}

2.3 空值

HashMap允许存储最多一个空键和多个空值。

让我们看一个例子:

@Test
public void whenInsertNullInHashMap_thenInsertsNull() {
    Map<Integer, String> hashmap = new HashMap<>();
    hashmap.put(null, null);
    
    assertNull(hashmap.get(null));
}

但是,TreeMap不允许空键,但可能包含许多空值。

不允许使用null键,因为compareTo()或compare()方法会抛出NullPointerException:

@Test(expected = NullPointerException.class)
public void whenInsertNullInTreeMap_thenException() {
    Map<Integer, String> treemap = new TreeMap<>();
    treemap.put(null, "NullPointerException");
}

如果我们将TreeMap与用户定义的Comparator一起使用,那么它取决于compare()方法的实现如何处理空值

3. 性能分析

性能是最关键的指标,可帮助我们了解给定用例的数据结构的适用性。

在本节中,我们将对HashMap和TreeMap的性能进行全面分析。

3.1 HashMap

HashMap是基于哈希表的实现,内部使用基于数组的数据结构根据hash函数组织其元素

HashMap为大多数操作(如add()、remove()和contains())提供预期的恒定时间性能O(1)。因此,它比TreeMap快得多。

在合理假设下,在哈希表中搜索元素的平均时间为O(1)。但是,hash函数的不正确实现可能会导致桶中值的分布不均,从而导致:

  • 内存开销:许多桶未被使用
  • 性能下降:碰撞次数越多,性能越低

在Java 8之前,分离链接是处理冲突的唯一首选方法。它通常使用链表实现,即,如果存在任何冲突或两个不同的元素具有相同的哈希值,则将这两个元素存储在同一个链表中。

因此,在最坏的情况下,在HashMap中搜索一个元素可能会花费与在链表中搜索元素一样长的时间,即O(n)。

然而,随着JEP 180的出现,元素在HashMap中的排列方式的实现发生了微妙的变化

根据规范,当桶变得太大并且包含足够多的节点时,它们会转换为TreeNodes模式,每个模式的结构都与TreeMap中的模式相似。

因此,在发生高哈希冲突的情况下,最坏情况下的性能将从O(n)提高到O(logn)

执行此转换的代码如下所示:

if(binCount >= TREEIFY_THRESHOLD - 1) {
    treeifyBin(tab, hash);
}

TREEIFY_THRESHOLD的值为8,它有效地表示使用树而不是链表作为存储桶的阈值计数。

很明显:

  • HashMap需要比保存其数据所需更多的内存
  • HashMap不应超过70%–75%满。如果它接近,它会调整大小并重新哈希条目
  • 重哈希需要n个操作,这是昂贵的,其中我们的常数时间插入变成O(n)阶
  • 哈希算法决定了在HashMap中插入对象的顺序

HashMap的性能可以通过在创建HashMap对象时设置自定义初始容量和负载因子来调整

但是,如果出现以下情况,我们应该选择HashMap:

  • 我们大约知道我们的集合中要保留多少元素
  • 我们不想以自然顺序提取元素

在上述情况下,HashMap是我们的最佳选择,因为它提供了恒定时间的插入、查找和删除。

3.2 TreeMap

TreeMap将其数据存储在分层树中,能够在自定义比较器的帮助下对元素进行排序。

其性能总结:

  • TreeMap为大多数操作提供O(log(n))的性能,如add()、remove()和contains()
  • TreeMap可以节省内存(与HashMap相比),因为它只使用保存其元素所需的内存量,这与使用连续内存区域的HashMap不同
  • 一棵树应该保持其平衡以保持其预期性能,这需要大量的努力,因此使实现复杂化

我们应该在以下情况下使用TreeMap:

  • 必须考虑内存限制
  • 我们不知道有多少元素必须存储在内存中
  • 我们想以自然顺序提取对象
  • 元素是否会持续添加和删除
  • 我们愿意接受O(logn)搜索时间

4. 相似之处

4.1 唯一元素

TreeMap和HashMap都不支持重复键。如果添加重复键,它将覆盖前一个元素(没有错误或异常):

@Test
public void givenHashMapAndTreeMap_whenputDuplicates_thenOnlyUnique() {
    Map<Integer, String> treeMap = new HashMap<>();
    treeMap.put(1, "Tuyucheng");
    treeMap.put(1, "Tuyucheng");

    assertTrue(treeMap.size() == 1);

    Map<Integer, String> treeMap2 = new TreeMap<>();
    treeMap2.put(1, "Tuyucheng");
    treeMap2.put(1, "Tuyucheng");

    assertTrue(treeMap2.size() == 1);
}

4.2 并发访问

两个Map实现不同步,我们需要自己管理并发访问。

每当多个线程同时访问它们并且至少有一个线程修改它们时,两者都必须在外部同步。

我们必须显式使用Collections.synchronizedMap(mapName)来获取所提供Map的同步视图。

4.3 快速失败迭代器

如果Map在创建迭代器后的任何时间以任何方式被修改,迭代器将抛出ConcurrentModificationException。

此外,我们可以使用迭代器的remove方法在迭代期间更改Map。

让我们看一个例子:

@Test
public void whenModifyMapDuringIteration_thenThrowExecption() {
    Map<Integer, String> hashmap = new HashMap<>();
    hashmap.put(1, "One");
    hashmap.put(2, "Two");
    
    Executable executable = () -> hashmap
        .forEach((key,value) -> hashmap.remove(1));
 
    assertThrows(ConcurrentModificationException.class, executable);
}

5. 使用哪个实现?

总的来说,这两种实现都有各自的优缺点,但是,这是关于理解潜在的期望和要求,这些期望和要求必须支配我们的选择

总结:

  • 如果我们想保持条目排序,我们应该使用TreeMap
  • 如果我们优先考虑性能而不是内存消耗,我们应该使用HashMap
  • 由于TreeMap具有更显著的局部性,因此如果我们想根据自然顺序访问彼此相对较近的对象,我们可能会考虑它
  • HashMap可以使用initialCapacity和loadFactor进行调整,这对于TreeMap是不可能的
  • 如果我们想保留插入顺序同时受益于恒定时间访问,我们可以使用LinkedHashMap

6. 总结

在本文中,我们展示了TreeMap和HashMap之间的异同。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章