《Java 编程入门》10.1 List、Set、Map:三种容器的选择

从 library 仓储的三条查询出发,讲清 List、Set、Map 的契约差异、常见实现类的取舍,以及 equals/hashCode 为什么是放进 Set 和 Map 的门票;再把第 8 章的 ArrayList 仓储改成 Map 版,让 ISBN 查找、借阅状态和会员借阅列表各归其位。

本节目标:能按「是否允许重复、是否关心顺序、是否按 key 查找」选择 List、Set、Map,能说清 equals/hashCode 对集合行为的影响,并把内存仓储从 ArrayList 换成 Map。
适用版本:JDK 25 LTS(Temurin 25.0.5);集合框架的接口与行为在 JDK 21 上一致。

三条查询暴露的问题

第 8 章的 InMemoryLibraryRepository 用两个 ArrayList 存书和借阅。它有三条典型查询:

  • findByIsbn(isbn):按 ISBN 找一本书。现在要遍历整个列表,数据越多越慢。
  • save(book):ISBN 已存在就替换。现在也要遍历列表,先找到位置再 set。
  • findByAuthor(author):按作者找书。default 方法同样遍历整个列表。

三条查询都在做同一件事:按某个键找东西。 列表擅长「按位置访问」和「保持插入顺序」,不擅长「按业务键查找」。要解决这个问题,得先分清 List、Set、Map 三种容器各自承诺了什么。

List、Set、Map 的契约

三种接口的差异可以用三个问题概括:

接口允许重复顺序主要用途
List允许有索引,按插入位置有序序列、按位置访问
Set不允许取决于实现去重、成员判断
Mapkey 不允许重复取决于实现按 key 查找 value

用 library 的数据试一遍:

Book threeBody = new Book("9787536692930", "三体", "刘慈欣", 2008);
Book threeBodyReprint = new Book("9787536692930", "三体(重印)", "刘慈欣", 2008);

List<Book> shelf = new ArrayList<>();
shelf.add(threeBody);
shelf.add(threeBodyReprint);
System.out.println("List size = " + shelf.size());

Set<String> isbns = new HashSet<>();
isbns.add(threeBody.isbn());
isbns.add(threeBodyReprint.isbn());
System.out.println("Set size = " + isbns.size());

Map<String, Book> byIsbn = new HashMap<>();
byIsbn.put(threeBody.isbn(), threeBody);
byIsbn.put(threeBodyReprint.isbn(), threeBodyReprint);
System.out.println("Map size = " + byIsbn.size());
System.out.println("Map value = " + byIsbn.get("9787536692930").title());

本机 JDK 25 输出:

List size = 2
Set size = 1
Map size = 1
Map value = 三体(重印)

三行结果对应三条契约:

  • List 允许两个 ISBN 相同的 Book,因为它们不是同一个对象,列表也不做去重。
  • Set<String> 用 String.equals 判断重复,两个相同的 ISBN 只保留一个。
  • Map 的 key 重复时,后放入的 value 覆盖前一个,所以 Map value 是「三体(重印)」。

这里的 Map 行为很容易被忽略:put 不报错,也不保留两个值,而是静默覆盖。如果「同一 ISBN 不允许重复添加」是业务规则,必须在 LibraryService.addBook 里先 findByIsbn 检查,不能指望 Map 帮你拦住。

实现类怎么选

接口只定义契约,具体行为由实现类决定:

接口常用实现顺序查找适用场景
ListArrayList插入顺序按索引 O(1)大多数有序集合
ListLinkedList插入顺序按索引 O(n)频繁在两端增删(多数场景用 ArrayDeque)
SetHashSet不保证平均 O(1)去重、成员判断
SetLinkedHashSet插入顺序平均 O(1)去重且要稳定输出
SetTreeSet排序顺序O(log n)需要有序遍历
MapHashMap不保证平均 O(1)大多数 key-value 查找
MapLinkedHashMap插入顺序平均 O(1)查找且要稳定遍历
MapTreeMapkey 排序O(log n)需要按 key 范围查询

几条实践建议:

  • 默认用 ArrayList。 它的随机访问快、内存连续、遍历友好;LinkedList 只有在确实频繁在中间增删时才有优势,而那种场景往往可以用 ArrayDeque 或重建列表代替。
  • 需要去重又需要稳定输出时用 LinkedHashSet。 HashSet 的遍历顺序不保证,测试里断言顺序会随机失败。
  • 需要按 key 排序时用 TreeMap。 它的 key 必须可比较,或者构造时传入 Comparator;第 10.3 节会讲比较器。
  • 不要用 Hashtable、Vector 这些遗留类。 它们是早期同步实现,性能和 API 都不如新集合;需要线程安全时用 ConcurrentHashMap,卷三再讲并发。

LinkedHashSet 和 TreeSet 的差异可以直接看到:

Set<String> insertionOrder = new LinkedHashSet<>();
insertionOrder.add("三体");
insertionOrder.add("红楼梦");
insertionOrder.add("三体");
System.out.println("LinkedHashSet = " + insertionOrder);

Set<String> sorted = new TreeSet<>();
sorted.add("三体");
sorted.add("红楼梦");
sorted.add("Java 编程思想");
System.out.println("TreeSet = " + sorted);

本机 JDK 25 输出:

LinkedHashSet = [三体, 红楼梦]
TreeSet = [Java 编程思想, 三体, 红楼梦]

LinkedHashSet 保留第一次插入的顺序,重复的「三体」被忽略;TreeSet 按字符串的自然顺序(UTF-16 码元)排序,所以 Java 排在中文前面。注意这不是拼音顺序,中文排序要用 Collator,10.3 会讲。

equals 与 hashCode 是门票

HashSet 和 HashMap 能快速判断「这个元素在不在」,靠的是 hashCode() 定位桶、equals() 确认相等。两个方法必须一起正确实现:

  • 两个对象 equals 为 true,hashCode 必须相等。
  • hashCode 相等的对象,equals 不一定为 true(哈希碰撞)。
  • 对象放进 HashSet/HashMap 后,如果参与 hashCode 的字段变了,就再也找不回来。

9.1 的 record 自动生成基于全部组件的 equals/hashCode,所以 Book 放进 Set 能正确去重。如果手写普通类,漏掉一个字段就会出问题:两个 ISBN 相同的 Book 可能被当成不同对象,或者 hashCode 不一致导致 get 返回 null。

反例用可变对象当 key:

Map<StringBuilder, Integer> badKeys = new HashMap<>();
StringBuilder key = new StringBuilder("a");
badKeys.put(key, 1);
System.out.println("get(new StringBuilder(\"a\")) = " + badKeys.get(new StringBuilder("a")));
System.out.println("get(same instance) = " + badKeys.get(key));

本机 JDK 25 输出:

get(new StringBuilder("a")) = null
get(same instance) = 1

StringBuilder 没有覆写 equals/hashCode,两个内容相同的实例并不相等。HashMap 用新的 StringBuilder 去查,哈希不同、equals 也不成立,所以返回 null。

这条规则对 library 的直接含义是:key 用不可变类型。 ISBN 用 String,会员 ID 用 String,枚举用枚举常量;不要把 Book 的可变字段、List、数组当 key。如果非要用对象做 key,用 record 或确保它不可变且 equals/hashCode 正确。

在 library 里落地:Map 版仓储

把两个 ArrayList 换成三个 Map:

  • booksByIsbn:ISBN -> Book,负责按 ISBN 查找和去重。
  • activeLoansByIsbn:ISBN -> 当前未结束的 Loan,负责「一本书同时只能借给一个人」。
  • loansByMember:会员 ID -> 该会员的借阅列表,负责按会员查询。
package com.plume.library.repository;

import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public final class InMemoryLibraryRepository implements LibraryRepository {
    private final Map<String, Book> booksByIsbn = new LinkedHashMap<>();
    private final Map<String, Loan> activeLoansByIsbn = new HashMap<>();
    private final Map<String, List<Loan>> loansByMember = new HashMap<>();

    @Override
    public void save(Book book) {
        booksByIsbn.put(book.isbn(), book);
    }

    @Override
    public Optional<Book> findByIsbn(String isbn) {
        return Optional.ofNullable(booksByIsbn.get(isbn));
    }

    @Override
    public List<Book> findAll() {
        return List.copyOf(booksByIsbn.values());
    }

    @Override
    public boolean deleteByIsbn(String isbn) {
        return booksByIsbn.remove(isbn) != null;
    }

    @Override
    public void saveLoan(Loan loan) {
        if (loan.status().isOpen()) {
            activeLoansByIsbn.put(loan.isbn(), loan);
        } else {
            activeLoansByIsbn.remove(loan.isbn());
        }
        List<Loan> memberLoans = loansByMember.computeIfAbsent(
                loan.memberId(), key -> new ArrayList<>());
        for (int i = 0; i < memberLoans.size(); i++) {
            Loan existing = memberLoans.get(i);
            if (existing.isbn().equals(loan.isbn())
                    && existing.borrowedOn().equals(loan.borrowedOn())) {
                memberLoans.set(i, loan);
                return;
            }
        }
        memberLoans.add(loan);
    }

    @Override
    public Optional<Loan> findActiveLoanByIsbn(String isbn) {
        return Optional.ofNullable(activeLoansByIsbn.get(isbn));
    }

    @Override
    public List<Loan> findLoansByMember(String memberId) {
        return List.copyOf(loansByMember.getOrDefault(memberId, List.of()));
    }
}

几个设计点:

  • booksByIsbn 用 LinkedHashMap,findAll() 的输出顺序稳定,CLI 的 list 命令不会每次换顺序;按 key 查找仍是平均 O(1)。
  • activeLoansByIsbn 用 HashMap,key 是 ISBN,value 是当前借阅;归还时 remove,续借时 put 覆盖。
  • loansByMember 的 value 是 List<Loan>,因为一个会员可以有多条借阅;computeIfAbsent 在 key 不存在时创建列表。
  • saveLoan 做 upsert:同一 ISBN + 借出日期视为同一条借阅,续借和归还时替换,不重复添加。
  • findByAuthor 没有出现在实现类里,接口的 default 方法仍然遍历 findAll()。10.2 会加作者索引,把它从 O(n) 降到平均 O(1)。

LibraryService 一行都不用改。它只依赖 LibraryRepository 接口,findByIsbn、findActiveLoanByIsbn、findLoansByMember 的签名不变,换实现不影响业务。

搜索没有单独的数据结构,仍然是遍历 findAll() 做子串匹配:

public List<Book> search(String keyword) {
    String needle = keyword.strip().toLowerCase(Locale.ROOT);
    List<Book> matched = new ArrayList<>();
    for (Book book : repository.findAll()) {
        if (book.title().toLowerCase(Locale.ROOT).contains(needle)
                || book.author().toLowerCase(Locale.ROOT).contains(needle)) {
            matched.add(book);
        }
    }
    return List.copyOf(matched);
}

这是 O(n) 的线性搜索,因为「标题包含某个词」不是 Map 的 key 查找能直接表达的。10.2 会给作者精确查询加索引,但子串搜索仍然要扫描;要让它变快,得引入分词或 n-gram 索引,那是另一个量级的复杂度。

本机跑一遍:

total = 6
byIsbn = 9787536692930
byAuthor(刘慈欣) = 1
borrow due = 2026-10-26
return fine = 300

这里 findByAuthor 仍走 default 的线性扫描,所以结果正确;数据量上来后,它的成本会在 10.2 被单独拎出来处理。

常见错误与边界

把 Map.get 返回 null 当成「key 不存在」。 如果 value 本身允许 null,两者无法区分。仓储里 value 不允许 null,所以 get != null 可以用;更稳妥的写法是 containsKey 或 Optional.ofNullable。Map.copyOf 和 List.copyOf 都不接受 null 元素,这条约束要提前定好。

用 Map 去重却忘了 key 的选择。 Map<String, Book> 按 ISBN 去重;如果 key 用 Book 对象,两个 ISBN 相同但标题不同的 Book 会被当成不同 key,去重失效。

在遍历 Map 时修改它。 for (var entry : map.entrySet()) { map.remove(...); } 会抛 ConcurrentModificationException;用 Iterator.remove、removeIf 或先收集 key 再删。10.3 会讲遍历的细节。

把 List.of 当成可变列表。 List.of(...)、Set.of(...)、Map.of(...) 返回不可变集合,add、put、remove 会抛 UnsupportedOperationException。需要修改时用 new ArrayList<>(List.of(...))。

小结

  • List 允许重复、按位置访问;Set 去重;Map 按 key 查找,key 重复会覆盖。
  • 默认用 ArrayList 和 HashMap;需要稳定顺序用 LinkedHashSet/LinkedHashMap,需要排序用 TreeSet/TreeMap。
  • equals/hashCode 是 HashSet、HashMap 的门票;record 自动生成,手写类必须写对。
  • key 用不可变类型;可变对象做 key,修改后就再也查不到。
  • library 用三个 Map 分别承担 ISBN 查找、当前借阅、会员借阅列表,接口不变,业务代码不变。
  • findByAuthor 的 default 实现仍是 O(n),作者索引和容量规划留到 10.2。

容器选对了,性能问题才刚开始。10.2 会用真实查询对比线性扫描和哈希查找,讲清扩容、负载因子、索引和内存开销,回答「为什么 map 快了三个数量级,以及它贵在哪」。

阅读导航:上一节:9.3 sealed 与模式匹配 · 下一节:10.2 复杂度与内存 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发