MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java HashMap与TreeMap的数据存储差异

2022-01-052.4k 阅读

Java HashMap与TreeMap的数据存储差异

简介

在Java的集合框架中,HashMapTreeMap是两种非常常用的用于存储键值对的数据结构。它们虽然都实现了Map接口,但在数据存储和操作的许多方面存在显著差异。了解这些差异对于开发者在实际编程中根据具体需求选择合适的数据结构至关重要。

数据结构基础

  1. HashMap的数据结构
    • HashMap基于哈希表实现。哈希表是一种通过哈希函数将键映射到存储位置的数据结构。在HashMap中,当我们向其中添加一个键值对时,首先会计算键的哈希值,然后通过哈希值确定该键值对在哈希表中的存储位置。
    • 哈希表通常由数组和链表(在Java 8及之后还引入了红黑树)组成。当不同的键计算出相同的哈希值(哈希冲突)时,这些键值对会以链表(或红黑树)的形式存储在数组的同一个位置。例如,假设我们有一个简单的哈希表,数组长度为8。当我们插入键值对(key1, value1)(key2, value2),如果key1key2计算出的哈希值对8取模后结果相同,那么它们就会被存储在数组的同一个位置,以链表形式连接起来。
    • 下面是一个简单的示意代码,展示HashMap的基本结构(这里只是简化示意,实际HashMap的实现更为复杂):
import java.util.HashMap;

public class HashMapStructureExample {
    public static void main(String[] args) {
        HashMap<String, Integer> hashMap = new HashMap<>();
        hashMap.put("one", 1);
        hashMap.put("two", 2);
        hashMap.put("three", 3);
    }
}
  1. TreeMap的数据结构
    • TreeMap基于红黑树实现。红黑树是一种自平衡的二叉搜索树,它满足以下几个特性:
      • 每个节点要么是红色,要么是黑色。
      • 根节点是黑色的。
      • 每个叶节点(NIL节点,空节点)是黑色的。
      • 如果一个节点是红色的,则它的两个子节点都是黑色的。
      • 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑色节点。
    • TreeMap中,键值对按照键的自然顺序(如果键实现了Comparable接口)或自定义顺序(通过构造函数传入Comparator)存储在红黑树中。这意味着TreeMap中的键始终是有序的。例如,当我们向TreeMap中插入整数类型的键时,它们会按照从小到大的顺序存储在红黑树中。
    • 以下是一个简单的TreeMap使用示例代码:
import java.util.TreeMap;

public class TreeMapStructureExample {
    public static void main(String[] args) {
        TreeMap<String, Integer> treeMap = new TreeMap<>();
        treeMap.put("two", 2);
        treeMap.put("one", 1);
        treeMap.put("three", 3);
        System.out.println(treeMap);
    }
}

在上述代码中,虽然插入顺序是“two”、“one”、“three”,但输出时会按照键的自然顺序(字典序)输出,即“one”、“three”、“two”。

存储顺序

  1. HashMap的存储顺序
    • HashMap不保证元素的顺序。因为它是基于哈希值来确定存储位置的,所以插入顺序和存储顺序没有必然联系。当我们迭代HashMap时,得到的元素顺序可能与插入顺序不同,而且在不同的运行环境或不同的插入操作后,迭代顺序也可能发生变化。
    • 例如:
import java.util.HashMap;
import java.util.Map;

public class HashMapOrderExample {
    public static void main(String[] args) {
        Map<String, Integer> hashMap = new HashMap<>();
        hashMap.put("apple", 1);
        hashMap.put("banana", 2);
        hashMap.put("cherry", 3);

        for (Map.Entry<String, Integer> entry : hashMap.entrySet()) {
            System.out.println(entry.getKey() + " : " + entry.getValue());
        }
    }
}

多次运行上述代码,输出的顺序可能不同。 2. TreeMap的存储顺序 - TreeMap会按照键的顺序存储元素。如前所述,如果键实现了Comparable接口,那么会按照自然顺序存储;如果通过构造函数传入了Comparator,则会按照自定义的比较顺序存储。 - 比如,我们可以定义一个自定义的比较器来按照字符串长度对键进行排序:

import java.util.Comparator;
import java.util.TreeMap;

public class TreeMapCustomOrderExample {
    public static void main(String[] args) {
        Comparator<String> lengthComparator = (s1, s2) -> Integer.compare(s1.length(), s2.length());
        TreeMap<String, Integer> treeMap = new TreeMap<>(lengthComparator);
        treeMap.put("apple", 1);
        treeMap.put("banana", 2);
        treeMap.put("cherry", 3);

        for (String key : treeMap.keySet()) {
            System.out.println(key + " : " + treeMap.get(key));
        }
    }
}

在上述代码中,会按照字符串键的长度从小到大输出。

性能特点

  1. HashMap的性能
    • 插入性能:在没有哈希冲突或者哈希冲突较少的情况下,HashMap的插入操作非常快,时间复杂度接近O(1)。这是因为通过哈希函数可以直接定位到存储位置。然而,当哈希冲突严重时,链表会变长,插入操作的时间复杂度会退化为O(n),n为链表的长度。在Java 8中引入红黑树后,当链表长度达到一定阈值(默认为8)时,链表会转换为红黑树,插入的时间复杂度变为O(log n),这在一定程度上缓解了哈希冲突严重时的性能问题。
    • 查找性能:和插入性能类似,理想情况下,查找操作可以通过哈希值直接定位到元素,时间复杂度为O(1)。但哈希冲突会导致性能下降。如果是链表结构,查找需要遍历链表,时间复杂度为O(n);如果是红黑树结构,查找的时间复杂度为O(log n)。
    • 删除性能:删除操作也依赖于哈希值定位元素。如果要删除的元素在链表头部,直接删除即可,时间复杂度为O(1);如果在链表中间或尾部,需要遍历链表找到元素再删除,时间复杂度为O(n)。对于红黑树结构,删除操作的时间复杂度为O(log n)。
    • 下面是一个简单的性能测试代码,比较HashMap插入操作的性能:
import java.util.HashMap;
import java.util.Map;

public class HashMapPerformanceTest {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        Map<Integer, Integer> hashMap = new HashMap<>();
        for (int i = 0; i < 1000000; i++) {
            hashMap.put(i, i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("HashMap插入100万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}
  1. TreeMap的性能
    • 插入性能TreeMap的插入操作时间复杂度始终为O(log n),因为红黑树的自平衡特性保证了树的高度始终保持在对数级别。无论插入多少元素,插入操作的时间消耗相对稳定。
    • 查找性能:查找操作同样具有O(log n)的时间复杂度,因为红黑树的结构使得可以通过比较键的大小快速定位到目标元素所在的子树,从而减少查找范围。
    • 删除性能:删除操作的时间复杂度也是O(log n)。红黑树在删除元素后会自动进行调整以保持平衡,虽然调整过程相对复杂,但时间复杂度仍然保持在对数级别。
    • 以下是TreeMap插入操作的性能测试代码:
import java.util.TreeMap;

public class TreeMapPerformanceTest {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        TreeMap<Integer, Integer> treeMap = new TreeMap<>();
        for (int i = 0; i < 1000000; i++) {
            treeMap.put(i, i);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("TreeMap插入100万个元素耗时:" + (endTime - startTime) + " 毫秒");
    }
}

通过对比上述两个性能测试代码的结果,可以明显看出在大规模插入操作时,HashMap在理想情况下性能优于TreeMap,但TreeMap的性能更为稳定。

键的要求

  1. HashMap对键的要求
    • HashMap的键可以为null。当键为null时,HashMap会将其存储在一个特殊的位置。因为null无法调用hashCode()方法,所以HashMapnull键做了特殊处理。在HashMap中,最多只能有一个键为null的键值对。
    • 对于非null键,要求键的类必须正确重写hashCode()equals()方法。hashCode()方法用于计算键的哈希值,equals()方法用于在哈希冲突时比较两个键是否相等。如果这两个方法没有正确实现,可能会导致在HashMap中无法正确存储或获取键值对。例如:
import java.util.HashMap;
import java.util.Map;

class CustomKey {
    private int value;

    public CustomKey(int value) {
        this.value = value;
    }

    // 没有重写hashCode()方法,会导致问题
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        CustomKey customKey = (CustomKey) o;
        return value == customKey.value;
    }
}

public class HashMapKeyRequirementExample {
    public static void main(String[] args) {
        Map<CustomKey, String> hashMap = new HashMap<>();
        CustomKey key1 = new CustomKey(1);
        CustomKey key2 = new CustomKey(1);
        hashMap.put(key1, "value1");
        System.out.println(hashMap.get(key2)); // 预期输出"value1",但由于未正确重写hashCode(),可能输出null
    }
}
  1. TreeMap对键的要求
    • TreeMap的键不能为null。因为TreeMap是基于比较顺序存储的,null无法参与比较操作。如果尝试将null作为键插入TreeMap,会抛出NullPointerException
    • 键的类必须实现Comparable接口或者在创建TreeMap时传入一个Comparator。这是因为TreeMap需要通过比较键的大小来确定存储位置和顺序。例如:
import java.util.TreeMap;

class CustomComparableKey implements Comparable<CustomComparableKey> {
    private int value;

    public CustomComparableKey(int value) {
        this.value = value;
    }

    @Override
    public int compareTo(CustomComparableKey o) {
        return Integer.compare(this.value, o.value);
    }
}

public class TreeMapKeyRequirementExample {
    public static void main(String[] args) {
        TreeMap<CustomComparableKey, String> treeMap = new TreeMap<>();
        CustomComparableKey key1 = new CustomComparableKey(1);
        CustomComparableKey key2 = new CustomComparableKey(2);
        treeMap.put(key1, "value1");
        treeMap.put(key2, "value2");
        System.out.println(treeMap);
    }
}

在上述代码中,CustomComparableKey实现了Comparable接口,使得TreeMap能够正确比较和存储键值对。

内存占用

  1. HashMap的内存占用
    • HashMap的内存占用主要包括数组、链表(或红黑树)节点以及键值对等部分。数组部分的大小会根据元素数量和负载因子动态调整。负载因子是一个表示哈希表填满程度的参数,默认值为0.75。当哈希表中的元素数量达到数组大小乘以负载因子时,数组会进行扩容,通常扩容为原来的2倍。这会导致一定的内存开销,因为需要重新计算元素的哈希值并重新分配存储位置。
    • 链表(或红黑树)节点也会占用一定内存,每个节点除了存储键值对外,还需要存储指向下一个节点(或子节点)的引用。此外,键和值对象本身也占用内存。例如,对于一个存储大量字符串键值对的HashMap,字符串对象会占用较多内存。
    • 下面是一个简单示例,通过Instrumentation类来获取HashMap的近似内存占用(实际情况会更复杂,这里只是一个简单示意):
import java.lang.instrument.Instrumentation;

public class HashMapMemoryUsage {
    private static Instrumentation instrumentation;

    public static void premain(String agentArgs, Instrumentation inst) {
        instrumentation = inst;
    }

    public static long getObjectSize(Object obj) {
        return instrumentation.getObjectSize(obj);
    }

    public static void main(String[] args) {
        java.util.HashMap<String, Integer> hashMap = new java.util.HashMap<>();
        hashMap.put("one", 1);
        hashMap.put("two", 2);
        long hashMapSize = getObjectSize(hashMap);
        System.out.println("HashMap近似内存占用:" + hashMapSize + " 字节");
    }
}
  1. TreeMap的内存占用
    • TreeMap的内存主要用于存储红黑树节点,每个节点除了存储键值对外,还需要存储父节点、左子节点、右子节点的引用以及节点颜色信息。相比HashMapTreeMap由于树结构的特性,每个节点需要更多的引用信息,所以在存储相同数量元素时,TreeMap通常会占用更多内存。
    • 例如,同样存储1000个键值对,TreeMap的节点结构决定了它会比HashMap多占用一些内存来维护树的结构信息。以下是一个简单示意代码获取TreeMap近似内存占用:
import java.lang.instrument.Instrumentation;

public class TreeMapMemoryUsage {
    private static Instrumentation instrumentation;

    public static void premain(String agentArgs, Instrumentation inst) {
        instrumentation = inst;
    }

    public static long getObjectSize(Object obj) {
        return instrumentation.getObjectSize(obj);
    }

    public static void main(String[] args) {
        java.util.TreeMap<String, Integer> treeMap = new java.util.TreeMap<>();
        treeMap.put("one", 1);
        treeMap.put("two", 2);
        long treeMapSize = getObjectSize(treeMap);
        System.out.println("TreeMap近似内存占用:" + treeMapSize + " 字节");
    }
}

通过对比上述两个代码获取的内存占用数据,可以直观感受到TreeMap在内存占用上相对HashMap会更高一些。

适用场景

  1. HashMap的适用场景
    • 当需要快速插入、查找和删除操作,并且对元素顺序没有要求时,HashMap是一个很好的选择。例如,在缓存系统中,我们通常希望能够快速地根据键获取值,并且缓存的插入和删除操作频繁,此时HashMap就非常合适。
    • 另外,在统计词频等场景中,我们只关心每个单词出现的次数,而不关心单词的顺序,HashMap也能高效地完成任务。如下代码展示用HashMap统计字符串中单词出现的次数:
import java.util.HashMap;
import java.util.Map;

public class HashMapWordFrequencyExample {
    public static void main(String[] args) {
        String text = "this is a test this is another test";
        String[] words = text.split(" ");
        Map<String, Integer> wordFrequencyMap = new HashMap<>();
        for (String word : words) {
            wordFrequencyMap.put(word, wordFrequencyMap.getOrDefault(word, 0) + 1);
        }
        for (Map.Entry<String, Integer> entry : wordFrequencyMap.entrySet()) {
            System.out.println(entry.getKey() + " : " + entry.getValue());
        }
    }
}
  1. TreeMap的适用场景
    • 当需要按照键的顺序遍历元素,或者需要获取键的范围数据时,TreeMap是首选。例如,在实现一个成绩排名系统时,我们可以使用TreeMap以学生成绩为键,学生信息为值,这样可以方便地按照成绩顺序查看学生排名。
    • 另外,在需要维护有序集合,并且需要频繁进行插入、删除和查找操作时,TreeMap虽然性能不如HashMap在理想情况下,但由于其稳定的时间复杂度,也是一个不错的选择。比如,在一个股票交易系统中,需要按照股票价格对交易记录进行排序存储,TreeMap就可以满足需求。以下是一个简单的模拟股票交易记录存储的代码:
import java.util.TreeMap;

class StockTrade {
    private String stockName;
    private double price;

    public StockTrade(String stockName, double price) {
        this.stockName = stockName;
        this.price = price;
    }

    @Override
    public String toString() {
        return "StockTrade{" +
                "stockName='" + stockName + '\'' +
                ", price=" + price +
                '}';
    }
}

public class TreeMapStockTradeExample {
    public static void main(String[] args) {
        TreeMap<Double, StockTrade> stockTradeTreeMap = new TreeMap<>();
        stockTradeTreeMap.put(100.5, new StockTrade("AAPL", 100.5));
        stockTradeTreeMap.put(95.2, new StockTrade("GOOG", 95.2));
        stockTradeTreeMap.put(105.0, new StockTrade("MSFT", 105.0));

        for (StockTrade trade : stockTradeTreeMap.values()) {
            System.out.println(trade);
        }
    }
}

在上述代码中,TreeMap按照股票价格对交易记录进行了排序存储。

总结

通过对HashMapTreeMap在数据结构、存储顺序、性能特点、键的要求、内存占用以及适用场景等方面的详细分析,可以看出它们各有优劣。在实际编程中,开发者需要根据具体的需求来选择合适的数据结构。如果追求快速的插入、查找和删除操作,并且对顺序没有要求,HashMap是最佳选择;如果需要元素按照键的顺序存储,或者需要进行范围查找等操作,TreeMap则更为合适。深入理解它们的差异,有助于编写高效、健壮的Java程序。