《Java 编程的逻辑》笔记——第10章 Map和Set(一)

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

开头语

上一章介绍了 ArrayList、LinkedList,它们的一个共同特点是:查找元素的效率都比较低,都需要逐个进行比较,本章介绍各种 Map 和 Set,它们的查找效率要高得多

Map 和 Set 都是接口,Java 中有多个实现类,主要包括 HashMap、HashSet、TreeMap、TreeSet、LinkedHashMap、LinedHashSet、EnumMap、EnumSet 等,它们都有什么用?有什么不同?是如何实现的?本章进行深入剖析,我们先从最常用的 HashMap 开始。

10.1 剖析 HashMap

字面上看,HashMap 由两个单词组成,Hash 和 Map,这里 Map 不是地图的意思,而是表示映射关系,是一个接口,实现 Map 接口有多种方式,HashMap 实现的方式利用了哈希(Hash)

下面,我们先来看 Map 接口,接着看如何使用 HashMap,然后看实现原理,最后我们总结分析 HashMap 的特点。

10.1.1 基本概念

Map 有的概念,一个键映射到一个值,Map 按照键存储和访问值,键不能重复,即一个键只会存储一份,给同一个键重复设值会覆盖原来的值。使用 Map 可以方便地处理需要根据键访问对象的场景,比如:

  • 一个词典应用,键可以为单词,值可以为单词信息类,包括含义、发音、例句等。
  • 统计和记录一本书中所有单词出现的次数,可以以单词为键,出现次数为值。
  • 管理配置文件中的配置项,配置项是典型的键值对。
  • 根据身份证号查询人员信息,身份证号为键,人员信息为值。

数组、ArrayList、LinkedList 可以视为一种特殊的 Map,键为索引,值为对象。

10.1.2 接口定义

Map 接口的定义为:

public interface Map<K,V> {
    
     // K和V,分别表示键(Key)和值(Value)的类型
    V put(K key, V value); // 保存键值对,如果原来有key,覆盖,返回值为原来的值
    V get(Object key); // 根据键获取值,如果没找到,返回null
    V remove(Object key); // 根据键删除键值对,返回key原来的值,如果不存在,返回null
    int size(); // 查看Map中的键值对个数
    boolean isEmpty(); // 是否为空
    boolean containsKey(Object key); // 查看是否包含某个键
    boolean containsValue(Object value); // 查看是否包含某个值
    void putAll(Map<? extends K, ? extends V> m); // 批量保存
    void clear(); // 清空Map中所有键值对
    Set<K> keySet(); // 获取Map中键的集合
    Collection<V> values(); // 获取Map中所有值的集合
    Set<Map.Entry<K, V>> entrySet(); // 获取Map中的所有键值对
    interface Entry<K,V> {
    
    
        K getKey();
        V getValue();
        V setValue(V value);
        boolean equals(Object o);
        int hashCode();
    }
    boolean equals(Object o);
    int hashCode();
}

下面解释几个接口:

  1. 获取 Map 中键的集合

    Set<K> keySet();
    

    Set 是一个接口,表示的是数学中的集合概念,即没有重复的元素集合,它的定义为:

    public interface Set<E> extends Collection<E> {
          
          
    }
    

    它扩展了 Collection,但没有定义任何新的方法,不过,它要求所有实现者都必须确保 Set 的语义约束,即不能有重复元素。关于 Set,下节我们再详细介绍。

    Map 中的键是没有重复的,所以 ketSet() 返回了一个 Set。

  2. 获取 Map 中的所有键值对

    Set<Map.Entry<K, V>> entrySet();
    

    Map.Entry<K,V> 是一个嵌套接口,定义在 Map 接口内部,表示一条键值对,主要方法有:

    K getey();
    V getValue();
    

    keySet()/values()/entrySet() 有一个共同的特点,它们返回的都是视图,不是拷贝的值,基于返回值的修改会直接修改 Map 自身,比如说:

    map.keySet().clear();
    

    会删除所有键值对。

10.1.3 HashMap

10.1.3.1 使用例子

HashMap 实现了 Map 接口,我们通过一个简单的例子,来看如何使用。

在随机一节,我们介绍过如何产生随机数,现在,我们写一个程序,来看随机产生的数是否均匀,比如,随机产生 1000 个 0 到 3 的数,统计每个数的次数。代码可以这么写:

Random rnd = new Random();
Map<Integer, Integer> countMap = new HashMap<>();

for(int i=0; i<1000; i++){
    
    
    int num = rnd.nextInt(4);
    Integer count = countMap.get(num);
    if(count==null){
    
    
        countMap.put(num, 1);
    }else{
    
    
        countMap.put(num, count+1);
    }
}

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

一次运行的输出为:

0,269
1,236
2,261
3,234

代码比较简单,就不解释了。

10.1.3.2 构造方法

除了默认构造方法,HashMap 还有如下构造方法:

public HashMap(int initialCapacity)
public HashMap(int initialCapacity, float loadFactor)
public HashMap(Map<? extends K, ? extends V> m)

最后一个以一个已有的 Map 构造,拷贝其中的所有键值对到当前 Map,这容易理解。前两个涉及两个两个参数 initialCapacity 和 loadFactor,它们是什么意思呢?我们需要看下 HashMap 的实现原理。

10.1.4 实现原理

10.1.4.1 内部组成

HashMap 内部有如下几个主要的实例变量:

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
transient int size;
int threshold;
final float loadFactor;

size 表示实际键值对的个数

table 是一个 Entry 类型的数组,其中的每个元素指向一个单向链表,链表中的每个节点表示一个键值对,Entry 是一个内部类,它的实例变量和构造方法代码如下:

static class Entry<K,V> implements Map.Entry<K,V> {
    
    
    final K key;
    V value;
    Entry<K,V> next;
    int hash;

    Entry(int h, K k, V v, Entry<K,V> n) {
    
    
        value = v;
        next = n;
        key = k;
        hash = h;
    }
}

其中 key 和 value 分别表示键和值,next 指向下一个 Entry 节点,hash 是 key 的哈希值,待会我们会介绍其计算方法,直接存储 hash 值是为了在比较的时候加快计算,待会我们看代码。

table 的初始值为 EMPTY_TABLE,是一个空表,具体定义为:

static final Entry<?,?>[] EMPTY_TABLE = {
    
    };

当添加键值对后,table 就不是空表了,它会随着键值对的添加进行扩展,扩展的策略类似于 ArrayList,添加第一个元素时,默认分配的大小为 16,不过,并不是 size 大于 16 时再进行扩展,下次什么时候扩展与 threshold 有关。

threshold 表示阈值,当键值对个数 size 大于等于 threshold 时考虑进行扩展。threshold 是怎么算出来的呢?一般而言,threshold 等于 table.length 乘以 loadFactor,比如,如果 table.length 为 16,loadFactor 为 0.75,则 threshold 为 12。

loadFactor 是负载因子,表示整体上 table 被占用的程度,是一个浮点数,默认为 0.75,可以通过构造方法进行修改

下面,我们通过一些主要方法的代码来看下,HashMap 是如何利用这些内部数据实现 Map 接口的。先看默认构造方法。需要说明的是,为清晰和简单起见,我们可能会忽略一些非主要代码。

10.1.4.2 默认构造方法

代码为:

public HashMap() {
    
    
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

DEFAULT_INITIAL_CAPACITY 为 16,DEFAULT_LOAD_FACTOR 为 0.75,默认构造方法调用的构造方法主要代码为:

public HashMap(int initialCapacity, float loadFactor) {
    
    
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
}

主要就是设置 loadFactor 和 threshold 的初始值。

10.1.4.3 保存键值对

下面,我们来看 HashMap 是如何把一个键值对保存起来的,代码为:

public V put(K key, V value) {
    
    
    if (table == EMPTY_TABLE) {
    
    
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
    
    
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
    
    
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    addEntry(hash, key, value, i);
    return null;
}

如果是第一次保存,首先会调用 inflateTable() 方法给 table 分配实际的空间,inflateTable 的主要代码为:

private void inflateTable(int toSize) {
    
    
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);

    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
}

默认情况下,capacity 的值为 16,threshold 会变为 12,table 会分配一个长度为 16 的 Entry 数组

接下来,检查 key 是否为 null,如果是,调用 putForNullKey 单独处理,我们暂时忽略这种情况。

在 key 不为 null 的情况下,下一步调用 hash 方法计算 key 的哈希值,hash 方法的代码为:

final int hash(Object k) {
    
    
    int h = 0
    h ^= k.hashCode();
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

基于 key 自身的 hashCode 方法的返回值,又进行了一些位运算,目的是为了随机和均匀性

有了 hash 值之后,调用 indexFor 方法,计算应该将这个键值对放到 table 的哪个位置,代码为:

static int indexFor(int h, int length) {
    
    
    return h & (length-1);
}

HashMap 中,length 为 2 的幂次方,h&(length-1) 等同于求模运算:h%length。

找到了保存位置 i,table[i] 指向一个单向链表,接下来,就是在这个链表中逐个查找是否已经有这个键了,遍历代码为:

for (Entry<K,V> e = table[i]; e != null; e = e.next) 

而比较的时候,是先比较 hash 值,hash 相同的时候,再使用 equals 方法进行比较,代码为:

if (e.hash == hash && ((k = e.key) == key || key.equals(k)))

为什么要先比较 hash 呢?因为 hash 是整数,比较的性能一般要比 equals 比较高很多,hash 不同,就没有必要调用 equals 方法了,这样整体上可以提高比较性能。

如果能找到,直接修改 Entry 中的 value 即可

modCount++ 的含义与 ArrayList 和 LinkedList 中介绍一样,记录修改次数,方便在迭代中检测结构性变化。

如果没找到,则调用 addEntry 方法在给定的位置添加一条,代码为:

void addEntry(int hash, K key, V value, int bucketIndex) {
    
    
    if ((size >= threshold) && (null != table[bucketIndex])) {
    
    
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

如果空间是够的,不需要 resize,则调用 createEntry 添加,createEntry 的代码为:

void createEntry(int hash, K key, V value, int bucketIndex) {
    
    
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

代码比较直接,新建一个 Entry 对象,并插入单向链表的头部,并增加 size。

如果空间不够,即 size 已经要超过阈值 threshold 了,并且对应的 table 位置已经插入过对象了,具体检查代码为:

if ((size >= threshold) && (null != table[bucketIndex]))

则调用 resize 方法对 table 进行扩展,扩展策略是乘 2,resize 的主要代码为:

void resize(int newCapacity) {
    
    
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

分配一个容量为原来两倍的 Entry 数组,调用 transfer 方法将原来的键值对移植过来,然后更新内部的 table 变量,以及 threshold 的值。transfer 方法的代码为:

void transfer(Entry[] newTable, boolean rehash) {
    
    
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
    
    
        while(null != e) {
    
    
            Entry<K,V> next = e.next;
            if (rehash) {
    
    
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

参数 rehash 一般为 false。这段代码遍历原来的每个键值对,计算新位置,并保存到新位置,具体代码比较直接,就不解释了。

以上,就是保存键值对的主要代码,简单总结一下,基本步骤为:

  1. 计算键的哈希值
  2. 根据哈希值得到保存位置(取模)
  3. 插到对应位置的链表头部或更新已有值
  4. 根据需要扩展 table 大小

以上描述可能比较抽象,我们通过一个例子,用图示的方式,再来看下,代码是:

Map<String,Integer> countMap = new HashMap<>();
countMap.put("hello", 1);
countMap.put("world", 3);

countMap.put("position", 4);

在通过 new HashMap() 创建一个对象后,内存中的图示结构如图 10-1 所示。

在这里插入图片描述

接下来执行

countMap.put("hello", 1);

“hello” 的 hash 值为 96207088,模 16 的结果为 0,所以插入 table[0] 指向的链表头部,内存结构会变为图 10-2 所示。

在这里插入图片描述

“world” 的 hash 值为 111207038,模 16 结果为 15,所以保存完 “world” 后,内存结构会变为图 10-3 所示。

在这里插入图片描述

“position” 的 hash 值为 771782464,模 16 结果也为 0,table[0] 已经有节点了,新节点会插到链表头部,内存结构会变为图 10-4 所示。

在这里插入图片描述

理解了键值对在内存是如何存放的,就比较容易理解其他方法了,我们来看 get 方法。

10.1.4.4 查找方法

根据键获取值的 get 方法的代码为:

public V get(Object key) {
    
    
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}

HashMap 支持 key 为 null,key 为 null 的时候,放在 table[0],调用 getForNullKey() 获取值,如果 key 不为 null,则调用 getEntry() 获取键值对节点 entry,然后调用节点的 getValue() 方法获取值。getEntry 方法的代码是:

final Entry<K,V> getEntry(Object key) {
    
    
    if (size == 0) {
    
    
        return null;
    }

    int hash = (key == null) ? 0 : hash(key);
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
    
    
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

逻辑也比较简单:

  1. 计算键的 hash 值,代码为:

    int hash = (key == null) ? 0 : hash(key);
    
  2. 根据 hash 找到 table 中的对应链表,代码为:

    table[indexFor(hash, table.length)];
    
  3. 在链表中遍历查找,遍历代码:

    for (Entry<K,V> e = table[indexFor(hash, table.length)];
           e != null;
           e = e.next)
    
  4. 逐个比较,先通过 hash 快速比较,hash 相同再通过 equals 比较,代码为:

    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
    

10.1.4.5 查看是否包含某个键

containsKey 的逻辑与 get 是类似的,节点不为 null 就表示存在,具体代码为:

public boolean containsKey(Object key) {
    
    
    return getEntry(key) != null;
}

10.1.4.6 查看是否包含某个值

HashMap 可以方便高效的按照键进行操作,但如果要根据值进行操作,则需要遍历,containsValue 方法的代码为:

public boolean containsValue(Object value) {
    
    
    if (value == null)
        return containsNullValue();

    Entry[] tab = table;
    for (int i = 0; i < tab.length ; i++)
        for (Entry e = tab[i] ; e != null ; e = e.next)
            if (value.equals(e.value))
                return true;
    return false;
}

如果要查找的值为 null,则调用 containsNullValue 单独处理,我们看不为 null 的情况,遍历的逻辑也很简单,就是从 table 的第一个链表开始,从上到下,从左到右逐个节点进行访问,通过 equals 方法比较值,直到找到为止。

10.1.4.7 根据键删除键值对

代码为:

public V remove(Object key) {
    
    
    Entry<K,V> e = removeEntryForKey(key);
    return (e == null ? null : e.value);
}

removeEntryForKey 的代码为:

final Entry<K,V> removeEntryForKey(Object key) {
    
    
    if (size == 0) {
    
    
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);
    Entry<K,V> prev = table[i];
    Entry<K,V> e = prev;

    while (e != null) {
    
    
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
    
    
            modCount++;
            size--;
            if (prev == e)
                table[i] = next;
            else
                prev.next = next;
            e.recordRemoval(this);
            return e;
        }
        prev = e;
        e = next;
    }

    return e;
}

基本逻辑为:

  1. 计算 hash,根据 hash 找到对应的 table 索引,代码为:

    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);
    
  2. 遍历 table[i],查找待删节点,使用变量 prev 指向前一个节点,next 指向下一个节点,e 指向当前节点,遍历结构代码为:

    Entry<K,V> prev = table[i];
    Entry<K,V> e = prev;
    while (e != null) {
          
          
        Entry<K,V> next = e.next;
        if(找到了){
          
          
           //删除
           return;
        }
        prev = e;
        e = next;
    }
    
  3. 判断是否找到,依然是先比较 hash,hash 相同时再用 equals 方法比较

  4. 删除的逻辑就是让长度减小,然后让待删节点的前后节点连起来,如果待删节点是第一个节点,则让 table[i] 直接指向后一个节点,代码为:

    size--;
    if (prev == e)
        table[i] = next;
    else
        prev.next = next;
    

    e.recordRemoval(this); 在 HashMap 中代码为空,主要是为了 HashMap 的子类扩展使用。

10.1.4.8 实现原理小结

以上就是 HashMap 的基本实现原理,内部有一个数组 table,每个元素 table[i] 指向一个单向链表,根据键存取值,用键算出 hash,取模得到数组中的索引位置 buketIndex,然后操作 table[buketIndex] 指向的单向链表

存取的时候依据键的 hash 值,只在对应的链表中操作,不会访问别的链表,在对应链表操作时也是先比较 hash 值,相同的话才用 equals 方法比较,这就要求,相同的对象其 hashCode() 返回值必须相同,如果键是自定义的类,就特别需要注意这一点。这也是 hashCode 和 equals 方法的一个关键约束,这个约束我们在介绍包装类的时候也提到过。

需要说明的是,Java8 对 HashMap 的实现进行了优化,在哈希冲突比较严重的情况下,即大量元素映射到同一个链表的情况下(具体是至少 8 个元素,且总的键值对个数至少是 64),Java8 会将该链表转换为一个平衡的排序二叉树,以提高查询的效率,关于排序二叉树我们在 10.3 节介绍,Java8 的具体代码就不介绍了。

10.1.5 HashMap 特点分析

HashMap 实现了 Map 接口,内部使用数组链表和哈希的方式进行实现,这决定了它有如下特点:

  • 根据键保存和获取值的效率都很高,为 O(1),每个单向链表往往只有一个或少数几个节点,根据 hash 值就可以直接快速定位
  • HashMap 中的键值对没有顺序,因为 hash 值是随机的

10.1.6 小结

如果经常需要根据键存取值,而且不要求顺序,那 HashMap 就是理想的选择。如果要保持添加的顺序,可以使用 HashMap 的一个子类 LinkedHashMap,我们在 10.6 节介绍。Map 还有一个重要的实现类 TreeMap,它可以排序,我们在 10.4 节介绍。

需要说明的是,HashMap 不是线程安全的,Java 中还有一个类 Hashtable 是线程安全的,它是 Java 最早实现的容器类之一,实现了 Map 接口,实现原理与 HashMap 类似,但没有特别的优化,它内部通过 synchronized 实现了线程安全。在 HashMap 中,键和值都可以为 null,而在 Hashtable 中不可以。在不需要并发安全的场景中,推荐使用 HashMap。在高并发的场景中,推荐使用 17.2 节介绍的 ConcurrentHashMap

根据哈希值存取对象、比较对象是计算机程序中一种重要的思维方式,它使得存取对象主要依赖于自身哈希值,而不是与其他对象进行比较,存取效率也就与集合大小无关,高达 O(1),即使进行比较,也利用哈希值提高比较性能

10.2 剖析 HashSet

上一节介绍了 HashMap,提到了 Set 接口,Map 接口的两个方法 keySet 和 entrySet 返回的都是 Set,本节我们来看 Set 接口的一个重要实现类 HashSet。

与 HashMap 类似,字面上看,HashSet 由两个单词组成,Hash 和 Set,Set 表示接口,实现 Set 接口也有多种方式,各有特点,HashSet 实现的方式利用了 Hash。

下面,我们先来看 HashSet 的用法,然后看实现原理,最后我们总结分析下 HashSet 的特点。

10.2.1 用法

10.2.1.1 Set 接口

Set 表示的是没有重复元素、且不保证顺序的容器接口,它扩展了 Collection,但没有定义任何新的方法,不过,对于其中的一些方法,它有自己的规范。

Set 接口的完整定义为:

public interface Set<E> extends Collection<E> {
    
    
    int size();
    boolean isEmpty();
    boolean contains(Object o);
    // 迭代遍历时,不要求元素之间有特别的顺序。
    // HashSet的实现就是没有顺序,但有的Set实现可能会有特定的顺序,比如TreeSet
    Iterator<E> iterator();
    Object[] toArray();
    <T> T[] toArray(T[] a);
    // 添加元素,如果集合中已经存在相同元素了,则不会改变集合,直接返回false,
    // 只有不存在时,才会添加,并返回true。
    boolean add(E e);
    boolean remove(Object o);
    boolean containsAll(Collection<?> c);
    // 批量添加,重复的元素不添加,不重复的添加,如果集合有变化,返回true,没变化返回false。
    boolean addAll(Collection<? extends E> c);
    boolean retainAll(Collection<?> c);
    boolean removeAll(Collection<?> c);
    void clear();
    boolean equals(Object o);
    int hashCode();
}

10.2.1.2 HashSet

与 HashMap 类似,HashSet 的构造方法有:

public HashSet()
public HashSet(int initialCapacity)
public HashSet(int initialCapacity, float loadFactor)
public HashSet(Collection<? extends E> c)

initialCapacity 和 loadFactor 的含义与 HashMap 中的是一样的,待会我们再细看。

HashSet 的使用也很简单,比如:

Set<String> set = new HashSet<String>();
set.add("hello");
set.add("world");
set.addAll(Arrays.asList(new String[]{
    
    "hello","老马"}));

for(String s : set){
    
    
    System.out.print(s+" ");
}

输出为:

hello 老马 world 

“hello” 被添加了两次,但只会保存一份,输出也没有什么特别的顺序。

10.2.1.3 hashCode 与 equals

与 HashMap 类似,HashSet 要求元素重写 hashCode 和 equals 方法,且对两个对象,equals 相同,则 hashCode 也必须相同,如果元素是自定义的类,需要注意这一点

比如说,有一个表示规格的类 Spec,有大小和颜色两个属性:

class Spec {
    
    
    String size;
    String color;
    
    public Spec(String size, String color) {
    
    
        this.size = size;
        this.color = color;
    }

    @Override
    public String toString() {
    
    
        return "[size=" + size + ", color=" + color + "]";
    }
}

看一个 Spec 的 Set:

Set<Spec> set = new HashSet<Spec>();
set.add(new Spec("M","red"));
set.add(new Spec("M","red"));

System.out.println(set);

输出为:

[[size=M, color=red], [size=M, color=red]]

同一个规格输出了两次,为避免这一点,需要为 Spec 重写 hashCode 和 equals 方法,利用 IDE 开发工具往往可以自动生成这两个方法,比如 Eclipse 中,可以通过 “Source”->“Generate hashCode() and equals() …”,我们就不赘述了。

10.2.1.4 应用场景

HashSet 有很多应用场景,比如说:

  • 排重,如果对排重后的元素没有顺序要求,则 HashSet 可以方便的用于排重。
  • 保存特殊值,Set 可以用于保存各种特殊值,程序处理用户请求或数据记录时,根据是否为特殊值,进行特殊处理,比如保存 IP 地址的黑名单或白名单。
  • 集合运算,使用 Set 可以方便的进行数学集合中的运算,如交集、并集等运算,这些运算有一些很现实的意义。比如用户标签计算,每个用户都有一些标签,两个用户的标签交集就表示他们的共同特征,交集大小除以并集大小可以表示他们的相似长度。

10.2.2 实现原理

10.2.2.1 内部组成

HashSet 内部是用 HashMap 实现的,它内部有一个 HashMap 实例变量,如下所示:

private transient HashMap<E,Object> map;

我们知道,Map 有键和值,HashSet 相当于只有键,值都是相同的固定值,这个值的定义为:

private static final Object PRESENT = new Object();

理解了这个内部组成,它的实现方法也就比较容易理解了,我们来看下代码。

10.2.2.2 构造方法

HashSet 的构造方法,主要就是调用了对应的 HashMap 的构造方法,比如:

public HashSet(int initialCapacity, float loadFactor) {
    
    
    map = new HashMap<>(initialCapacity, loadFactor);
}

public HashSet(int initialCapacity) {
    
    
    map = new HashMap<>(initialCapacity);
}

public HashSet() {
    
    
    map = new HashMap<>();
}

接受 Collection 参数的构造方法稍微不一样,代码为:

public HashSet(Collection<? extends E> c) {
    
    
    map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
    addAll(c);
}

也很容易理解,c.size()/.75f 用于计算 initialCapacity,0.75f 是 loadFactor 的默认值。

10.2.2.3 添加元素

我们看 add 方法的代码:

public boolean add(E e) {
    
    
    return map.put(e, PRESENT)==null;
}

就是调用 map 的 put 方法,元素 e 用于键,值就是那个固定值 PRESENT,put 返回 null 表示原来没有对应的键,添加成功了。HashMap 中一个键只会保存一份,所以重复添加 HashMap 不会变化

10.2.2.4 检查是否包含元素

代码为:

public boolean contains(Object o) {
    
    
    return map.containsKey(o);
}

就是检查 map 中是否包含对应的键。

10.2.2.5 删除元素

代码为:

public boolean remove(Object o) {
    
    
    return map.remove(o)==PRESENT;
}

就是调用 map 的 remove 方法,返回值为 PRESENT 表示原来有对应的键且删除成功了。

10.2.2.6 迭代器

代码为:

public Iterator<E> iterator() {
    
    
    return map.keySet().iterator();
}

就是返回 map 的 keySet 的迭代器。

10.2.3 HashSet 特点分析

HashSet 实现了 Set 接口,内部是通过 HashMap 实现的,这决定了它有如下特点:

  • 没有重复元素
  • 可以高效的添加、删除元素、判断元素是否存在,效率都为 O(1)
  • 没有顺序

如果需求正好符合这些特点,那 HashSet 就是一个理想的选择。

10.2.4 小结

本节介绍了 HashSet 的用法和实现原理,它实现了 Set 接口,不含重复元素,内部实现利用了 HashMap,可以方便高效地实现如去重、集合运算等功能。

同 HashMap 一样,HashSet 没有顺序,如果要保持添加的顺序,可以使用 HashSet 的一个子类 LinkedHashSet。Set 还有一个重要的实现类,TreeSet,它可以排序。这两个类,我们留待后续章节介绍。

HashMap 和 HashSet 的共同实现机制是哈希表,Map 和 Set 还有一个重要的共同实现机制,树,实现类分别是 TreeMap 和 TreeSet,让我们在接下来的两节中探讨。

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/108230944