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 就自动获得了并发保护。