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

Java LinkedHashSet如何维护元素插入顺序

2021-06-075.5k 阅读

Java LinkedHashSet概述

在Java集合框架中,LinkedHashSetHashSet的一个子类,它继承了HashSet的特性,同时还维护了元素的插入顺序。这意味着,当遍历LinkedHashSet时,元素的顺序与它们被插入到集合中的顺序是一致的。这种特性使得LinkedHashSet在某些需要维护元素插入顺序的场景下非常有用,比如缓存系统中,我们可能希望按照元素的访问顺序(可类比为插入顺序)来管理缓存中的数据。

LinkedHashSet既利用了哈希表的快速查找特性,又通过链表结构维护了元素的插入顺序,可谓是鱼与熊掌兼得。它允许null元素,并且它的容量是可以动态增长的。

数据结构基础

要理解LinkedHashSet如何维护元素插入顺序,首先需要了解它底层的数据结构。LinkedHashSet底层是基于LinkedHashMap实现的。LinkedHashMapHashMap的一个子类,它在HashMap的基础上增加了一个双向链表来维护元素的顺序。

HashMap中,数据是以键值对的形式存储在哈希表中的,哈希表通过哈希值来快速定位元素的存储位置,以提高查找效率。而LinkedHashMap在这个基础上,为每个键值对增加了两个额外的指针,一个指向前一个键值对(前驱),另一个指向后一个键值对(后继),从而形成了一个双向链表。

LinkedHashSet实际上只是对LinkedHashMap进行了一层包装,它使用LinkedHashMap来存储元素,并且只使用了LinkedHashMap的键部分,值部分则统一使用一个固定的对象PRESENT

维护插入顺序的原理

  1. 插入操作 当向LinkedHashSet中插入一个元素时,首先会调用LinkedHashMapput方法。LinkedHashMapput方法会先计算元素的哈希值,然后根据哈希值确定元素在哈希表中的存储位置。如果该位置为空,就直接将元素插入到该位置。如果该位置已经有元素(发生哈希冲突),则通过链表结构将新元素链接到链表的末尾。

同时,LinkedHashMap会将新插入的元素添加到双向链表的尾部,从而维护元素的插入顺序。这是因为双向链表的头部和尾部都有相应的指针,添加到尾部可以保证新元素总是排在最后插入的位置。

以下是简化后的LinkedHashMapput方法中与维护顺序相关的部分代码:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    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;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

// 新节点创建时会被添加到双向链表尾部
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    LinkedHashMap.Entry<K,V> e =
        new LinkedHashMap.Entry<K,V>(hash, key, value, next);
    linkNodeLast(e);
    return e;
}

private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
}
  1. 遍历操作 由于LinkedHashSet底层的LinkedHashMap通过双向链表维护了元素的插入顺序,当对LinkedHashSet进行遍历时,实际上是按照双向链表的顺序进行遍历的。从链表的头部开始,依次访问每个节点,从而保证了遍历顺序与插入顺序一致。

以下是LinkedHashSet的遍历示例代码:

import java.util.LinkedHashSet;
import java.util.Iterator;

public class LinkedHashSetTraversalExample {
    public static void main(String[] args) {
        LinkedHashSet<String> linkedHashSet = new LinkedHashSet<>();
        linkedHashSet.add("Apple");
        linkedHashSet.add("Banana");
        linkedHashSet.add("Cherry");

        Iterator<String> iterator = linkedHashSet.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

在上述代码中,LinkedHashSet按照元素的插入顺序依次输出AppleBananaCherry

与其他集合的对比

  1. 与HashSet对比
    • HashSet只保证元素的唯一性,不保证元素的顺序。它完全依赖于哈希表来存储和查找元素,插入顺序在遍历HashSet时是不确定的。而LinkedHashSet在保证元素唯一性的同时,维护了元素的插入顺序。
    • 例如,下面的代码展示了HashSetLinkedHashSet在插入和遍历顺序上的差异:
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;

public class HashSetVsLinkedHashSet {
    public static void main(String[] args) {
        Set<String> hashSet = new HashSet<>();
        hashSet.add("Apple");
        hashSet.add("Banana");
        hashSet.add("Cherry");

        Set<String> linkedHashSet = new LinkedHashSet<>();
        linkedHashSet.add("Apple");
        linkedHashSet.add("Banana");
        linkedHashSet.add("Cherry");

        System.out.println("HashSet遍历顺序:");
        for (String element : hashSet) {
            System.out.println(element);
        }

        System.out.println("LinkedHashSet遍历顺序:");
        for (String element : linkedHashSet) {
            System.out.println(element);
        }
    }
}

在上述代码中,HashSet的遍历顺序可能与插入顺序不同,而LinkedHashSet的遍历顺序与插入顺序一致。

  1. 与TreeSet对比
    • TreeSet是基于红黑树实现的,它保证元素的有序性,但这种有序性是基于元素的自然顺序(如果元素实现了Comparable接口)或者通过自定义的比较器来定义的。而LinkedHashSet的有序性是基于元素的插入顺序。
    • 例如,假设有一个自定义类Person,实现了Comparable接口:
class Person implements Comparable<Person> {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    @Override
    public int compareTo(Person other) {
        return this.age - other.age;
    }
}

然后可以通过以下代码对比TreeSetLinkedHashSetPerson对象的排序和顺序维护情况:

import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;

public class TreeSetVsLinkedHashSet {
    public static void main(String[] args) {
        Set<Person> treeSet = new TreeSet<>();
        treeSet.add(new Person("Alice", 25));
        treeSet.add(new Person("Bob", 20));
        treeSet.add(new Person("Charlie", 30));

        Set<Person> linkedHashSet = new LinkedHashSet<>();
        linkedHashSet.add(new Person("Alice", 25));
        linkedHashSet.add(new Person("Bob", 20));
        linkedHashSet.add(new Person("Charlie", 30));

        System.out.println("TreeSet遍历顺序:");
        for (Person person : treeSet) {
            System.out.println(person.getName() + " - " + person.getAge());
        }

        System.out.println("LinkedHashSet遍历顺序:");
        for (Person person : linkedHashSet) {
            System.out.println(person.getName() + " - " + person.getAge());
        }
    }
}

在上述代码中,TreeSet会按照Person对象的年龄进行排序遍历,而LinkedHashSet会按照对象的插入顺序进行遍历。

应用场景

  1. 缓存系统 在缓存系统中,经常需要按照元素的访问顺序(类似于插入顺序)来管理缓存中的数据。例如,当缓存满了需要淘汰数据时,可以选择淘汰最早访问(插入)的元素。LinkedHashSet可以很方便地实现这种功能,因为它维护了元素的插入顺序。通过结合LinkedHashSet和其他数据结构(如HashMap用于快速查找),可以构建一个简单高效的缓存系统。

以下是一个简单的基于LinkedHashSet的缓存示例代码:

import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

public class SimpleCache<K, V> {
    private int capacity;
    private Map<K, V> cacheMap;
    private Set<K> accessOrderSet;

    public SimpleCache(int capacity) {
        this.capacity = capacity;
        this.cacheMap = new HashMap<>();
        this.accessOrderSet = new LinkedHashSet<>();
    }

    public V get(K key) {
        if (cacheMap.containsKey(key)) {
            accessOrderSet.remove(key);
            accessOrderSet.add(key);
            return cacheMap.get(key);
        }
        return null;
    }

    public void put(K key, V value) {
        if (cacheMap.containsKey(key)) {
            accessOrderSet.remove(key);
        } else if (cacheMap.size() >= capacity) {
            K oldestKey = accessOrderSet.iterator().next();
            accessOrderSet.remove(oldestKey);
            cacheMap.remove(oldestKey);
        }
        accessOrderSet.add(key);
        cacheMap.put(key, value);
    }
}

在上述代码中,SimpleCache类使用LinkedHashSet来维护元素的访问顺序,当缓存满时,通过LinkedHashSet找到最早访问的元素并淘汰它。

  1. 记录用户操作顺序 在一些应用中,需要记录用户的操作顺序,比如用户在一个界面上依次点击了多个按钮。LinkedHashSet可以用来记录这些操作的顺序,方便后续的分析和处理。例如,在一个游戏中,记录玩家依次完成的关卡,LinkedHashSet可以保证关卡的记录顺序与玩家实际完成的顺序一致。

以下是一个简单的记录用户操作顺序的示例代码:

import java.util.LinkedHashSet;
import java.util.Set;

public class UserActionRecorder {
    private Set<String> actionSet;

    public UserActionRecorder() {
        this.actionSet = new LinkedHashSet<>();
    }

    public void recordAction(String action) {
        actionSet.add(action);
    }

    public void printActions() {
        for (String action : actionSet) {
            System.out.println(action);
        }
    }
}

在上述代码中,UserActionRecorder类使用LinkedHashSet来记录用户的操作,printActions方法可以按照操作的插入顺序输出所有操作。

性能分析

  1. 插入性能
    • LinkedHashSet的插入操作性能与HashSet相比,略微慢一些。因为LinkedHashSet在插入元素时,除了要像HashSet一样进行哈希计算和在哈希表中插入操作外,还需要维护双向链表的顺序,即需要更新链表的指针。在平均情况下,HashSet的插入操作时间复杂度为O(1),而LinkedHashSet由于链表操作,其插入操作的时间复杂度仍然接近O(1),但常数因子略大。
  2. 查找性能
    • LinkedHashSet的查找性能与HashSet基本相同。因为它们都基于哈希表来存储元素,查找元素时都是通过计算哈希值来快速定位元素在哈希表中的位置。在平均情况下,查找操作的时间复杂度都是O(1)。不过,如果哈希冲突严重,链表长度变长,查找性能会下降,此时时间复杂度会趋近于O(n),n为链表长度。
  3. 遍历性能
    • LinkedHashSet的遍历性能相对稳定,因为它是按照双向链表的顺序进行遍历的,不需要额外的排序操作。遍历LinkedHashSet的时间复杂度为O(n),n为集合中元素的个数。而HashSet的遍历顺序是不确定的,且由于哈希表的结构特性,在某些情况下遍历性能可能不如LinkedHashSet(例如当哈希冲突严重时)。TreeSet的遍历性能取决于红黑树的结构,虽然也是O(n),但由于红黑树的节点结构比双向链表复杂,遍历操作可能会涉及更多的内存访问和计算,所以实际性能在某些场景下可能不如LinkedHashSet

注意事项

  1. 元素的可变性
    • 如果LinkedHashSet中的元素是可变的,并且修改了元素中影响哈希值或equals方法结果的属性,可能会导致集合的行为异常。因为LinkedHashSet依赖哈希值和equals方法来维护元素的唯一性和顺序。例如,如果一个自定义类Point作为LinkedHashSet的元素,并且Point类的hashCodeequals方法依赖于xy坐标,如果在LinkedHashSet中修改了xy坐标,可能会导致该元素在哈希表中的位置发生变化,从而破坏集合的一致性。
    • 为了避免这种情况,建议使用不可变类作为LinkedHashSet的元素,或者在修改元素后,从集合中移除该元素并重新插入。
  2. 内存消耗
    • LinkedHashSet由于要维护双向链表结构,相比HashSet会消耗更多的内存。每个元素除了需要存储自身的数据外,还需要额外的空间来存储前驱和后继指针。在处理大量元素时,这种额外的内存消耗可能会变得显著,所以在内存敏感的应用中使用LinkedHashSet需要谨慎考虑。
  3. 迭代器的使用
    • 当使用LinkedHashSet的迭代器时,要注意在迭代过程中不要修改集合的结构(除了通过迭代器自身的remove方法),否则会抛出ConcurrentModificationException。这是因为迭代器依赖于集合的内部结构状态,结构的意外修改可能会导致迭代器出现错误。例如,以下代码会抛出异常:
import java.util.LinkedHashSet;
import java.util.Iterator;

public class IteratorModificationExample {
    public static void main(String[] args) {
        LinkedHashSet<String> linkedHashSet = new LinkedHashSet<>();
        linkedHashSet.add("Apple");
        linkedHashSet.add("Banana");
        linkedHashSet.add("Cherry");

        Iterator<String> iterator = linkedHashSet.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            if ("Banana".equals(element)) {
                linkedHashSet.remove("Cherry"); // 会抛出ConcurrentModificationException
            }
        }
    }
}

正确的做法是使用迭代器的remove方法:

import java.util.LinkedHashSet;
import java.util.Iterator;

public class IteratorRemoveExample {
    public static void main(String[] args) {
        LinkedHashSet<String> linkedHashSet = new LinkedHashSet<>();
        linkedHashSet.add("Apple");
        linkedHashSet.add("Banana");
        linkedHashSet.add("Cherry");

        Iterator<String> iterator = linkedHashSet.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            if ("Banana".equals(element)) {
                iterator.remove(); // 正确的移除方式
            }
        }
    }
}

通过深入了解LinkedHashSet如何维护元素插入顺序,以及它的特性、性能和注意事项,开发者可以在合适的场景中准确地使用它,充分发挥其优势,避免潜在的问题。无论是构建缓存系统、记录操作顺序还是其他需要维护插入顺序的场景,LinkedHashSet都是一个强大而实用的工具。