原创

Java HashMap 常用操作:合并数量、遍历与安全删除

同一个 SKU 在需求清单里出现两次,第二次应该覆盖数量,还是累加?HashMap.put 不会替业务做选择:它只把这个键的旧值换掉。如果把每一行导入记录都直接 put 进去,最后得到的不是汇总,而是每种商品最后一行的数量。

下面做一份内存中的商品需求清单:重复 SKU 合并数量,取消的零数量项从清单移除,再按订单号收集商品编号。数据不落库,使用 JDK 17 即可运行。重点是把 Map 的常用操作接起来,而不是背完方法名以后仍不知道该用哪一个。

先跑完一张清单

保存为 CartMapDemo.java。输入规则是大写 SKU、每次加入正整数数量,每种商品合计最多 99;演练中把某项设为 0,表示这项需求已被取消、等待清理。

import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;

public class CartMapDemo {
    static void add(Map<String, Integer> quantities, String sku, int amount) {
        if (sku == null || !sku.matches("[A-Z][A-Z0-9_-]{0,19}")) {
            throw new IllegalArgumentException("invalid SKU");
        }
        if (amount < 1 || amount > 99) throw new IllegalArgumentException("amount must be 1..99");
        quantities.merge(sku, amount, (before, incoming) -> {
            int total = Math.addExact(before, incoming);
            if (total > 99) throw new IllegalArgumentException("total must not exceed 99");
            return total;
        });
    }

    static void removeCancelled(Map<String, Integer> quantities) {
        Iterator<Map.Entry<String, Integer>> iterator = quantities.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry<String, Integer> entry = iterator.next();
            if (entry.getValue() == 0) iterator.remove();
        }
    }

    static void equal(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError("expected=" + expected + " actual=" + actual);
        }
    }

    static void fails(Class<? extends RuntimeException> type, Runnable action) {
        try {
            action.run();
        } catch (RuntimeException error) {
            if (type.isInstance(error)) return;
            throw error;
        }
        throw new AssertionError("expected " + type.getSimpleName());
    }

    public static void main(String[] args) {
        Map<String, Integer> quantities = new HashMap<>();
        add(quantities, "KEYBOARD", 2);
        add(quantities, "KEYBOARD", 3);
        add(quantities, "MOUSE", 1);
        quantities.put("CABLE", 0);
        equal(Map.of("KEYBOARD", 5, "MOUSE", 1, "CABLE", 0), quantities);
        equal(0, quantities.getOrDefault("MONITOR", 0));

        fails(IllegalArgumentException.class, () -> add(quantities, "KEYBOARD", 99));
        equal(5, quantities.get("KEYBOARD"));
        fails(IllegalArgumentException.class, () -> add(quantities, "bad sku", 1));
        fails(IllegalArgumentException.class, () -> add(quantities, "MOUSE", 0));
        removeCancelled(quantities);
        equal(Map.of("KEYBOARD", 5, "MOUSE", 1), quantities);
        System.out.println("QUANTITIES=" + new TreeMap<>(quantities));

        int total = 0;
        for (Map.Entry<String, Integer> entry : quantities.entrySet()) {
            total = Math.addExact(total, entry.getValue());
        }
        equal(6, total);
        System.out.println("TOTAL_UNITS=" + total);

        Map<String, Integer> replacement = new HashMap<>();
        equal(null, replacement.put("KEYBOARD", 2));
        equal(2, replacement.put("KEYBOARD", 3));
        equal(3, replacement.get("KEYBOARD"));
        System.out.println("PUT_REPLACES=" + replacement.get("KEYBOARD"));

        Map<String, List<String>> orderItems = new HashMap<>();
        orderItems.getOrDefault("O100", new ArrayList<>()).add("LOST");
        equal(false, orderItems.containsKey("O100"));
        orderItems.computeIfAbsent("O100", key -> new ArrayList<>()).add("KEYBOARD");
        orderItems.computeIfAbsent("O100", key -> new ArrayList<>()).add("MOUSE");
        equal(List.of("KEYBOARD", "MOUSE"), orderItems.get("O100"));
        System.out.println("ORDER_ITEMS=" + orderItems.get("O100"));

        Map<String, Integer> nullable = new HashMap<>();
        nullable.put("known", null);
        equal(null, nullable.get("missing"));
        equal(null, nullable.get("known"));
        equal(false, nullable.containsKey("missing"));
        equal(true, nullable.containsKey("known"));
        equal(9, nullable.getOrDefault("missing", 9));
        equal(null, nullable.getOrDefault("known", 9));
        System.out.println("NULL_AND_MISSING=DIFFERENT");

        Map<String, Integer> broken = new HashMap<>(Map.of("A", 1, "B", 2, "C", 3));
        Iterator<Map.Entry<String, Integer>> iterator = broken.entrySet().iterator();
        String current = iterator.next().getKey();
        broken.remove(current);
        fails(ConcurrentModificationException.class, iterator::next);
        System.out.println("OUTSIDE_REMOVE=ConcurrentModificationException");
        System.out.println("ALL_CHECKS_PASSED");
    }
}

编译运行:

javac -encoding UTF-8 CartMapDemo.java
java CartMapDemo

应得到:

QUANTITIES={KEYBOARD=5, MOUSE=1}
TOTAL_UNITS=6
PUT_REPLACES=3
ORDER_ITEMS=[KEYBOARD, MOUSE]
NULL_AND_MISSING=DIFFERENT
OUTSIDE_REMOVE=ConcurrentModificationException
ALL_CHECKS_PASSED

输出数量清单时特意用了另一份 TreeMap 来排序。HashMap 没有承诺遍历次序,不能因为这次看到 KEYBOARD 在前,就把它当成下次也会保持的输出合同。

put 是替换,merge 才在这里承担汇总

put 返回的是旧值。第二次 put 后,KEYBOARD 的值为 3,前一次的 2 已经被替换。导入需求行时,若要求累加,应该把这个规则明确写进更新步骤。

merge(sku, amount, remappingFunction) 在键不存在或原值为 null 时直接采用 amount;已有非空值时,才把原值和传入值交给函数。这里在函数里计算总量并检查 99 的上限。超限抛异常以后,本例原来的 5 仍是 5,没有先写成 104 再补救。

这不表示任何放进函数的动作都能回滚。如果在函数里先写文件、发请求或修改别的对象,随后抛异常,HashMap 不会把这些外部动作撤销。这个函数只计算新数值,保持边界很窄。

还要区分两个 null:输入数量清单不把 null 当成合法数量,但 HashMap 这个容器本身允许存 null。比较程序单独建了 nullable,用来展示这个容器合同。get("known") 与 get("missing") 都返回 null,必须结合 containsKey 才能区分是否有映射。

getOrDefault 也只在没有映射时采用默认值。键已经映射为 null,结果仍是 null;直接拆箱成 int 可能因此抛空指针。不要把它理解成“任何空结果都自动变成默认数量”。

清理完成后,清单有两个键,但合计需求是六件。quantities.size() 统计的是商品编号种数,不能用来当商品总数量。总量需要遍历 value 相加;这里选择 entrySet,是因为后续若需要输出编号与数量,同一条目就能同时提供两个值,不必遍历 keySet 后再按键查询。计算依然使用 Math.addExact,超出 int 范围应让汇总失败,而不是让计数悄悄绕回负数。

默认列表被改了,Map 为什么还是空的

按订单收集商品时,下面这类写法很迷惑:getOrDefault(orderNumber, new ArrayList<>()).add(sku)。add 确实成功了,但新列表只是本次调用拿到的默认值,并没有自动放回 Map。下一次读取这个订单,仍然没有对应列表。

computeIfAbsent 明确表达了另一件事:键没有非空映射时,计算一个值并建立映射。后面的 add 修改的是已经登记在 Map 里的那个列表,所以第二个 SKU 会进入同一份 O100 清单。

这里返回的列表可修改,是为了继续收集明细。若映射函数返回 List.of(),建立映射后再 add 就会因不可修改而失败;若映射函数返回 null,则不会建立新映射。方法名不能替你保证返回值满足后续操作。

订单里的 List 保留每一行加入记录,并不会帮忙去重。如果同一商品出现两次,它会存两次;这与前面 merge 合并数量的业务不同。先确定需要保留明细、收集唯一编号还是累计数量,才能决定 value 应该用 List、Set 还是 Integer。容器类型选错以后,补一个默认值通常只能掩盖症状。

计算函数也不该顺便增删当前 Map 的其他键。把建立一个值的逻辑限制为“构造这一个值”,比依赖嵌套修改时某个版本恰好如何执行更容易维护。

遍历时清理键,用正在遍历的那个入口

清理零数量项时,循环拿到的是 entrySet 的迭代器,删除也通过这个迭代器执行。删除当前条目以后,迭代器能继续维护自己的遍历状态,不需要再用键重新查一次。

清理函数里的 entry.getValue() == 0 会拆箱比较,依赖数量表没有 null 的输入约束。若调用者绕过 add 直接放入 null,这一行仍会抛空指针;允许容器保存 null 不等于业务函数必须接受它。需要把清单交给其他模块时,应该把修改入口一并设计清楚,别让验证规则只存在于一段示例调用中。

错误对照则在取得第一个条目后,绕过迭代器调用 broken.remove(current)。本机 JDK 17 在下一次 next 时实际抛出 ConcurrentModificationException。增强 for 底层也依赖迭代器,换成 for-each 语法不会消除这个问题。

这个异常名并不说明必须有两个线程:同一个线程采用两个修改入口,也会触发示例中的结果。反过来,fail-fast 检查不是线程安全协议,不能依靠“没有抛异常”证明多线程更新正确。

本例全部操作都由同一个线程拥有。真正跨线程合并数量时,应另选符合并发合同的容器及原子操作;不能把这里的 HashMap 放到共享字段里,再认为用上 merge 就自动获得了并发保护。

正文到此结束
评论插件初始化中...
Loading...