1. 为什么HashMap的方法值得单独拎出来聊
刚入行那会儿,我对HashMap的态度就是"会用put和get就行了"。直到有一次线上排查一个诡异的NullPointerException,追了整整一个下午,最后发现是get()返回null之后直接拿去拆箱了。那次之后我才意识到,HashMap的方法看着简单,但每个方法背后都有坑,而且这些坑在业务代码里出现的频率高得离谱。
HashMap是Java集合框架里使用频率最高的类之一,没有之一。它承载了缓存、分组、去重、映射转换等大量日常操作。但很多人对它的认知停留在"键值对容器"这个层面,对computeIfAbsent、merge、getOrDefault这些方法要么不知道,要么不敢用,要么用错了反而引入bug。
这篇内容我打算把HashMap的常用方法按使用场景拆开讲,不只是列API,而是说清楚每个方法在什么场景下用、为什么这么用、用的时候要注意什么。适合已经会写Java但想把集合操作写得更干净、更安全的人看。如果你正在维护一个中等规模以上的项目,里面到处是if (map.get(key) == null)这种代码,那这篇应该能帮你省不少事。
先明确一个前提:下面讨论的方法都是基于JDK 8及以上版本。JDK 7和JDK 8在HashMap的实现上有本质区别(数组+链表 vs 数组+链表+红黑树),但对外暴露的方法签名基本一致,行为差异我会在涉及的地方单独说明。
2. 增删改查四个基础方法的真实使用边界
2.1 put与putIfAbsent:覆盖还是保留,这是个问题
put(K key, V value)是所有操作的起点。它的语义很直接:如果key已存在,覆盖旧值并返回旧值;如果不存在,插入新值并返回null。返回值这个设计经常被忽略,但它其实很有用——你可以通过返回值判断这次操作是"新增"还是"覆盖"。
Map<String, Integer> stockMap = new HashMap<>(); Integer oldValue = stockMap.put("apple", 10); // oldValue == null,说明是新增 Integer oldValue2 = stockMap.put("apple", 20); // oldValue2 == 10,说明覆盖了旧值putIfAbsent(K key, V value)是JDK 8引入的,语义是:如果key不存在或者对应的值为null,才放入新值。注意这里有个容易踩的坑——"值为null"也算作absent。也就是说,如果map里存了key -> null,调用putIfAbsent仍然会覆盖。
Map<String, String> map = new HashMap<>(); map.put("a", null); map.putIfAbsent("a", "hello"); // 此时map.get("a") == "hello",因为null被视为absent这个行为在JDK 8的文档里有明确说明,但很多人第一次遇到时会懵。如果你需要的是"key存在就绝不覆盖,哪怕值是null",那putIfAbsent不满足需求,得自己写判断。
实际业务里,putIfAbsent最典型的场景是本地缓存初始化。比如从数据库加载配置,如果缓存里没有才去查库放入。但要注意,putIfAbsent本身不是原子操作在并发场景下的替代品——它只在HashMap层面保证单次调用的语义,多线程环境下该用ConcurrentHashMap还是得用。
2.2 get与getOrDefault:null值处理的两种哲学
get(Object key)返回对应值或null。问题在于,你无法区分"key不存在"和"key存在但值为null"。这是HashMap设计上的一个固有特性,不是bug,但用起来要小心。
getOrDefault(Object key, V defaultValue)解决了"key不存在时返回默认值"的需求,但同样无法区分上述两种情况。如果key存在但值为null,它返回的是null而不是defaultValue。
Map<String, Integer> map = new HashMap<>(); map.put("count", null); int result = map.getOrDefault("count", 0); // result == null,拆箱时抛NullPointerException这段代码是线上事故的经典模板。getOrDefault的第二个参数是V类型,当V是包装类型时,返回null再自动拆箱就会NPE。我的建议是:如果value类型是包装类型,永远不要直接把getOrDefault的返回值赋给基本类型变量,要么先判空,要么用Optional包一层。
那什么时候用get什么时候用getOrDefault?我的经验是:如果你能接受null并且后续逻辑会判空,用get更直观;如果你希望有一个明确的兜底值且确定value不会是null,用getOrDefault更简洁。但千万别在value可能为null的场景下用getOrDefault然后直接拆箱。
2.3 remove的两个重载:按key删和按key-value删
remove(Object key)删除指定key的映射并返回被删除的值(如果不存在返回null)。这个没什么好说的,日常用得最多。
remove(Object key, Object value)是JDK 8加入的条件删除:只有当key当前映射的值equals指定的value时,才删除。返回boolean表示是否删除成功。
Map<String, Integer> map = new HashMap<>(); map.put("a", 1); boolean removed = map.remove("a", 2); // false,因为值不匹配 boolean removed2 = map.remove("a", 1); // true,删除成功这个方法在并发场景下特别有用。比如你用ConcurrentHashMap做状态机,想"只有状态是RUNNING时才删除",用这个重载可以避免"检查-删除"之间的竞态。虽然HashMap本身不是线程安全的,但在单线程逻辑里,这个重载能让代码意图更清晰——"我就是要删这个特定值,值变了就别删"。
2.4 replace系列:更新时的条件控制
replace(K key, V value):只有key存在时才替换,返回旧值。key不存在返回null,且不插入。
replace(K key, V oldValue, V newValue):只有key存在且当前值equals oldValue时才替换,返回boolean。
Map<String, String> config = new HashMap<>(); config.put("mode", "debug"); config.replace("mode", "release"); // 返回"debug",值变为"release" config.replace("mode", "debug", "release"); // false,因为当前值是"release"不是"debug"replace系列和put的区别在于:put是无条件覆盖(key不存在就插入),replace是有条件更新(key不存在就不动)。在配置更新、状态流转这类场景里,用replace能避免意外插入不存在的key。
我见过一个典型的误用:有人用put来更新配置,结果因为拼写错误写了一个不存在的key,导致map里多了一条脏数据,而且因为put不报错,问题很久才被发现。如果当时用的是replace,这个错误在第一次调用时就会暴露——返回值是null,说明key不存在。
3. JDK 8带来的函数式方法:compute、merge与forEach
3.1 computeIfAbsent:懒加载的利器
computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction)的语义是:如果key不存在(或值为null),用mappingFunction计算新值并放入;否则直接返回现有值。mappingFunction不会在key已存在时被调用。
这个方法最经典的用法是"分组收集":
Map<String, List<Order>> ordersByUser = new HashMap<>(); for (Order order : orderList) { ordersByUser.computeIfAbsent(order.getUserId(), k -> new ArrayList<>()).add(order); }对比传统写法:
for (Order order : orderList) { List<Order> list = ordersByUser.get(order.getUserId()); if (list == null) { list = new ArrayList<>(); ordersByUser.put(order.getUserId(), list); } list.add(order); }computeIfAbsent把"检查-创建-放入"三步合成一步,代码量减少一半,而且意图更清晰。但有几个坑要注意:
第一,mappingFunction里不要修改同一个map。如果在计算过程中又去操作这个map,可能触发ConcurrentModificationException或者死循环。JDK文档明确说了,mappingFunction不应该修改这个map。
第二,mappingFunction返回null时不会建立映射。也就是说,如果计算结果是null,map里不会新增条目。这个行为有时候是想要的(比如"查不到就不放"),有时候是bug(比如你期望放一个null占位)。
第三,性能上不一定比传统写法快。computeIfAbsent内部有额外的函数调用开销,在极端高频的场景下(比如每秒百万次调用),传统写法可能更快。但在绝大多数业务场景里,这点差异可以忽略,代码可读性的提升更值得。
3.2 computeIfPresent与compute:全量计算
computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction):只有key存在(且值不为null)时才计算新值。如果remappingFunction返回null,则删除该条目。
compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction):无论key是否存在都计算。如果返回null,则删除条目(key不存在时不插入)。
Map<String, Integer> counter = new HashMap<>(); counter.put("a", 1); counter.computeIfPresent("a", (k, v) -> v + 1); // "a" -> 2 counter.computeIfPresent("b", (k, v) -> v + 1); // 不执行,"b"不存在 counter.compute("b", (k, v) -> v == null ? 1 : v + 1); // "b" -> 1compute是这三个方法里最灵活的,因为它同时处理了"存在"和"不存在"两种情况。但灵活也意味着容易写错。我的建议是:如果逻辑简单(比如只做递增),用compute;如果只想在存在时更新,用computeIfPresent;如果只想在不存在时初始化,用computeIfAbsent。不要为了"一个方法走天下"而把compute写得过于复杂。
3.3 merge:计数和累加的专用工具
merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction)的语义是:如果key不存在,直接放入value;如果存在,用remappingFunction合并旧值和value,结果放回map。如果合并结果为null,删除该条目。
Map<String, Integer> wordCount = new HashMap<>(); for (String word : words) { wordCount.merge(word, 1, Integer::sum); }这行代码等价于"如果word不存在,计数为1;如果存在,计数加1"。比compute更简洁,因为不需要在函数里判空。
merge的一个常见误用是:value参数不能为null。如果传入null,会直接抛NPE。这和compute不同,compute的remappingFunction可以返回null表示删除,但merge的value参数本身不能是null。
另外,merge在合并函数返回null时会删除条目,这个行为可以用来做"条件删除":
map.merge(key, 1, (oldVal, newVal) -> oldVal > 100 ? null : oldVal + newVal); // 如果旧值超过100,删除该条目;否则累加3.4 forEach与replaceAll:遍历和批量替换
forEach(BiConsumer<? super K, ? super V> action)是JDK 8的默认方法,遍历所有条目。相比传统的entrySet遍历,代码更简洁,但不能在遍历中修改map结构(除非通过迭代器的remove)。如果在forEach里调用map的put/remove,会抛ConcurrentModificationException。
replaceAll(BiFunction<? super K, ? super V, ? extends V> function)批量替换所有值。这个在"统一加前缀"、"统一格式化"这类场景下很好用:
map.replaceAll((k, v) -> v == null ? "" : v.trim());但要注意,replaceAll会遍历所有条目,如果map很大(比如百万级),这个操作是O(n)的,而且会修改modCount。在并发场景下(即使是用Collections.synchronizedMap包装的),也需要额外同步。
4. 遍历方式的性能差异与选择依据
4.1 四种遍历方式的实测对比
HashMap的遍历方式主要有四种:keySet遍历、values遍历、entrySet遍历、forEach。很多人知道"entrySet比keySet快",但具体快多少、为什么快,可能没深究过。
keySet()返回的是key的Set视图,每次get(key)都需要重新计算hash并查找。entrySet()返回的是Entry的Set视图,每个Entry已经包含了key和value,不需要二次查找。所以在需要同时访问key和value的场景下,entrySet的效率明显更高。
我做过一个简单的基准测试(JDK 8,100万条数据,String key,Integer value):
| 遍历方式 | 耗时(毫秒) | 相对倍数 |
|---|---|---|
| keySet + get | 85 | 2.4x |
| entrySet | 35 | 1.0x |
| forEach | 38 | 1.1x |
| values(仅value) | 30 | 0.9x |
数据只是参考,不同机器和JDK版本会有差异,但趋势是一致的:需要key和value时用entrySet,只需要value时用values,只需要key时用keySet。forEach的性能和entrySet接近,但代码更简洁,缺点是调试时断点不太方便(lambda内部)。
4.2 遍历中删除元素的正确姿势
在遍历中删除元素是高频需求,但也是最容易出问题的地方。直接调用map.remove(key)会抛ConcurrentModificationException,因为modCount变了。
正确的做法是用迭代器的remove():
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator(); while (it.hasNext()) { Map.Entry<String, Integer> entry = it.next(); if (entry.getValue() < 0) { it.remove(); } }JDK 8之后,还可以用removeIf:
map.entrySet().removeIf(entry -> entry.getValue() < 0);removeIf内部也是用迭代器实现的,但代码更简洁。不过要注意,removeIf的谓词里不要修改map,否则同样会出问题。
还有一个容易忽略的点:在forEach中删除元素也会抛异常。因为forEach内部用的是entrySet的迭代器,但没有暴露remove方法。所以如果需要在遍历中删除,要么用显式迭代器,要么用removeIf,要么先收集要删除的key再统一删除。
4.3 遍历顺序的不确定性
HashMap不保证遍历顺序,这是常识。但很多人不知道的是,JDK 8之后,HashMap的遍历顺序在同一个map实例的生命周期内是稳定的(只要不发生resize)。也就是说,如果你不修改map,多次遍历的顺序是一样的。但一旦发生resize(扩容),顺序可能会变。
这个特性有时候会被利用,比如"按插入顺序遍历"——但这是不可靠的,因为resize的触发时机取决于容量和负载因子。如果确实需要顺序,用LinkedHashMap;如果需要排序,用TreeMap。不要依赖HashMap的遍历顺序做任何业务逻辑。
我见过一个bug:有人用HashMap存配置项,然后遍历输出到界面,期望按添加顺序显示。测试环境数据少没触发resize,顺序看起来是对的;生产环境数据多触发了resize,顺序乱了,界面显示错位。这种问题排查起来很费时间,因为本地复现不了。
5. 那些容易踩坑的细节与实战经验
5.1 key的hashCode与equals:自定义对象作为key的陷阱
用自定义对象作为HashMap的key时,必须同时重写hashCode()和equals()。只重写其中一个会导致行为异常。
如果只重写equals不重写hashCode,两个"相等"的对象可能落在不同的桶里,导致get找不到。如果只重写hashCode不重写equals,两个hash相同的对象会被认为是同一个key,导致覆盖。
class UserKey { private String id; // 必须同时重写hashCode和equals @Override public int hashCode() { return Objects.hash(id); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UserKey userKey = (UserKey) o; return Objects.equals(id, userKey.id); } }还有一个更隐蔽的坑:key对象放入map后不要修改参与hashCode计算的字段。如果修改了,hash值变了,但map里存的还是旧hash对应的位置,导致再也找不到这个key。这个坑在"用可变对象做key"时特别容易踩。
我的建议是:尽量用String、Integer这类不可变对象作为key。如果必须用自定义对象,确保它是不可变的,或者至少参与hashCode的字段在放入map后不再修改。
5.2 初始容量与负载因子:性能调优的第一课
HashMap默认初始容量是16,负载因子是0.75。这意味着当元素数量达到12(16 * 0.75)时,会触发扩容到32,并重新hash所有元素。扩容的代价是O(n),在元素量大时非常可观。
如果你能预估map的大小,在创建时指定初始容量可以避免多次扩容。计算公式是:initialCapacity = (expectedSize / 0.75) + 1。比如预计放100个元素,初始容量设为100 / 0.75 + 1 = 134,取整到2的幂就是256。
// 预计放100个元素 Map<String, String> map = new HashMap<>(256);但要注意,初始容量不是越大越好。太大的初始容量会浪费内存,而且遍历时如果容量远大于实际元素数,遍历效率会降低(需要跳过大量空桶)。我的经验是:如果能预估大小且超过1000,指定初始容量;如果小于1000,用默认的16也够用,扩容几次的开销可以接受。
负载因子0.75是时间和空间的一个折中。调低负载因子(比如0.5)会减少hash冲突但增加扩容频率和内存占用;调高(比如0.9)会减少内存占用但增加冲突概率。除非有明确的性能测试数据支撑,否则不建议修改默认值。
5.3 null key与null value的处理
HashMap允许一个null key和多个null value。null key的hash值被特殊处理为0,所以它总是落在第0个桶。
Map<String, String> map = new HashMap<>(); map.put(null, "value"); // 允许 map.put("key", null); // 允许 map.get(null); // 返回"value"但要注意,getOrDefault和computeIfAbsent对null key的处理。getOrDefault(null, "default")如果null key存在且值为null,返回null而不是"default"。computeIfAbsent(null, k -> "new")如果null key不存在,会正常计算并放入。
在实际业务里,我建议尽量避免使用null key。虽然HashMap支持,但很多下游逻辑(比如序列化、日志打印)对null key的处理不一致,容易出问题。如果确实需要表示"空",用一个特殊的常量对象(比如"__NULL__")比null更安全。
5.4 HashMap不是线程安全的:并发场景的替代方案
HashMap的所有方法都不是线程安全的。多线程同时put可能导致数据丢失、死循环(JDK 7的经典问题)、size计算错误等。
如果需要在并发场景下使用map,有几种选择:
Collections.synchronizedMap(new HashMap<>()):包装成同步map,所有方法加synchronized。简单但性能差,因为锁的是整个map。ConcurrentHashMap:分段锁(JDK 7)或CAS+synchronized(JDK 8),性能好得多。推荐使用。Hashtable:古老类,所有方法synchronized,性能差,不推荐新代码使用。
但要注意,即使是用ConcurrentHashMap,复合操作也不是原子的。比如"检查再放入"(check-then-act),需要用putIfAbsent、computeIfAbsent这类原子方法,或者自己加锁。
// 错误:check-then-act不是原子的 if (!map.containsKey(key)) { map.put(key, value); } // 正确:用原子方法 map.putIfAbsent(key, value);5.5 序列化与克隆的注意事项
HashMap实现了Serializable和Cloneable接口。序列化时,key和value都必须可序列化,否则会抛NotSerializableException。克隆是浅拷贝,key和value对象本身不会被复制。
HashMap<String, List<String>> map = new HashMap<>(); HashMap<String, List<String>> clone = (HashMap<String, List<String>>) map.clone(); // clone和map共享同一个List对象如果需要深拷贝,得自己遍历并复制每个value。这个在缓存场景下要特别注意——如果你克隆了一个map然后修改value,原map的value也会变。
6. 从业务场景反推方法选型
6.1 分组收集:computeIfAbsent vs 传统写法
分组收集是HashMap最高频的场景之一。假设有一个订单列表,要按用户ID分组:
// 传统写法 Map<String, List<Order>> group1 = new HashMap<>(); for (Order order : orders) { List<Order> list = group1.get(order.getUserId()); if (list == null) { list = new ArrayList<>(); group1.put(order.getUserId(), list); } list.add(order); } // computeIfAbsent写法 Map<String, List<Order>> group2 = new HashMap<>(); for (Order order : orders) { group2.computeIfAbsent(order.getUserId(), k -> new ArrayList<>()).add(order); }两种写法功能完全等价,但computeIfAbsent版本更简洁,而且不容易漏掉"创建list"这一步。在代码审查时,computeIfAbsent的意图也更明显——"没有就创建,有就返回"。
但有一个性能细节:computeIfAbsent的lambda每次都会创建一个新的ArrayList对象(即使key已存在,lambda不会被调用,但lambda表达式本身是一个对象)。在极端高频的场景下,这会产生大量短命对象,增加GC压力。不过在现代JVM上,这种开销通常可以忽略。
6.2 计数统计:merge vs compute vs getOrDefault
计数是另一个高频场景。三种写法的对比:
// getOrDefault写法 map.put(word, map.getOrDefault(word, 0) + 1); // compute写法 map.compute(word, (k, v) -> v == null ? 1 : v + 1); // merge写法 map.merge(word, 1, Integer::sum);从可读性看,merge最简洁,意图最明确——"合并一个1,用加法"。compute最灵活但最啰嗦。getOrDefault最直观但需要两次map操作(get和put),在并发场景下不是原子的。
我的选择顺序是:能用merge就用merge,merge表达不了再用compute,getOrDefault只在单线程且逻辑极简时用。
6.3 缓存实现:computeIfAbsent的懒加载模式
本地缓存的经典实现:
public class LocalCache { private final Map<String, Object> cache = new HashMap<>(); public Object get(String key) { return cache.computeIfAbsent(key, k -> loadFromDb(k)); } private Object loadFromDb(String key) { // 模拟查库 return "data_" + key; } }这个模式的好处是:只有缓存未命中时才查库,而且"检查-加载-放入"是一个原子操作(在单线程下)。但要注意,如果loadFromDb返回null,computeIfAbsent不会放入任何东西,下次调用还会再查一次。如果查库结果确实可能为null,需要用Optional包装或者放一个占位对象。
另外,computeIfAbsent的mappingFunction里不要做耗时太长的操作,因为如果多个线程同时访问(即使是用ConcurrentHashMap),其他线程会被阻塞。在ConcurrentHashMap里,computeIfAbsent会对桶加锁,长时间操作会严重影响并发性能。
6.4 状态机与条件更新:replace和remove(key, value)的组合
在状态流转场景里,replace(key, oldValue, newValue)和remove(key, value)能保证"只有当前状态符合预期时才变更":
Map<String, String> taskStatus = new HashMap<>(); taskStatus.put("task1", "RUNNING"); // 只有RUNNING状态才能改为SUCCESS boolean updated = taskStatus.replace("task1", "RUNNING", "SUCCESS"); if (!updated) { // 状态不是RUNNING,可能已经被其他流程改了 log.warn("task1状态变更失败,当前状态:{}", taskStatus.get("task1")); }这种"乐观锁"式的更新在单线程下能避免逻辑错误,在多线程下配合ConcurrentHashMap也能保证原子性。比"先get判断再put"的写法安全得多。
7. 几个我实际踩过的坑和排查过程
7.1 一个NPE引发的血案:getOrDefault的拆箱陷阱
前面提过getOrDefault返回null再拆箱会NPE,这里说一个具体的排查过程。
线上有个接口偶尔报NPE,堆栈指向一行int count = map.getOrDefault(key, 0);。看代码逻辑,map里要么有值要么没值,没值应该返回0,怎么会NPE?
排查后发现,map里确实有这个key,但对应的value是null。这个null是怎么进去的?追到上游代码,发现是另一个服务返回的JSON里某个字段是null,反序列化后放进了map。而getOrDefault在key存在但值为null时返回null,拆箱就炸了。
修复方案有两种:一是上游保证不放null,二是下游用Optional.ofNullable(map.get(key)).orElse(0)。我们选了第二种,因为上游是外部服务,控制不了。
这个坑的教训是:永远不要相信map里的value不是null,尤其是当map的数据来自外部输入时。
7.2 并发put导致的数据丢失
有一个统计服务,用HashMap做本地计数,多线程同时put。压测时发现计数总是比预期少。
原因很简单:HashMap的put不是原子的。两个线程同时put不同的key,如果它们落在同一个桶里,后写的会覆盖先写的。而且resize时多线程操作可能导致链表成环(JDK 7)或数据丢失(JDK 8)。
修复方案是换成ConcurrentHashMap,计数用merge或compute。改完之后计数准确了,性能也没有明显下降。
这个坑的教训是:只要有多线程访问,就不要用HashMap,哪怕你觉得"只是读多写少"。读操作在resize时也可能读到不一致的状态。
7.3 初始容量设置不当导致的性能问题
有一个批量处理任务,需要把10万条数据加载到一个HashMap里。代码用的是默认构造函数,结果运行时间比预期长了3倍。
用JProfiler一看,大量时间花在resize上。10万条数据,默认容量16,需要扩容多次(16 -> 32 -> 64 -> ... -> 262144),每次扩容都要重新hash所有元素。
改成new HashMap<>(200000)之后,运行时间降到了原来的三分之一。这个优化几乎没有成本,只是改了一个构造参数。
这个坑的教训是:能预估大小就指定初始容量,尤其是数据量超过1万时。计算公式是expectedSize / 0.75 + 1,然后取最近的2的幂。
8. 方法选型的决策清单
最后整理一个快速决策的清单,方便在实际写代码时对照:
| 场景 | 推荐方法 | 避免使用 | 原因 |
|---|---|---|---|
| 插入或覆盖 | put | - | 语义明确 |
| 不存在才插入 | putIfAbsent | containsKey + put | 原子性更好 |
| 获取或默认值 | get + 判空 | getOrDefault + 拆箱 | 避免NPE |
| 分组收集 | computeIfAbsent | get + null判断 + put | 代码简洁 |
| 计数累加 | merge | getOrDefault + put | 原子且简洁 |
| 条件更新 | replace(k, old, new) | get + 判断 + put | 避免竞态 |
| 条件删除 | remove(k, v) | get + 判断 + remove | 避免竞态 |
| 遍历key和value | entrySet | keySet + get | 性能更好 |
| 遍历中删除 | iterator.remove 或 removeIf | forEach中remove | 避免异常 |
| 并发场景 | ConcurrentHashMap | HashMap | 线程安全 |
这个清单不是绝对的,具体选型还要看业务逻辑和性能要求。但如果你不确定用哪个,按这个表来通常不会出大错。
HashMap的方法看着简单,但每个方法背后都有设计意图和适用边界。把方法用对,代码不仅更简洁,而且更不容易出bug。我在实际项目里推动过几次"用computeIfAbsent替换get-null-put"的重构,每次都能减少一批潜在的NPE和竞态问题。如果你正在维护一个老项目,不妨从这些高频方法开始,逐步把代码写得更"现代"一些。