栏目:轮子指南 | 环境:Spring Boot 3.2.7(父工程)| Guava 33.6.0-jre | JDK 21
引言
提起 Guava,很多人的印象还停留在 Lists.newArrayList()、Maps.newHashMap()、Strings.isNullOrEmpty() 这些"省几行代码"的 API 上。
但 Guava 真正的宝藏是另外三样东西:Cache(本地缓存)、EventBus(事件总线)、集合增强(Multimap / BiMap / Table)。它们能直接干掉项目里三坨高频手写代码:手写本地缓存、硬编码通知链、Map 套 Map 的模板代码。
本文按"没它之前 vs 用了它之后"的方式演示这三块,所有输出都是本机真实运行结果(guava 33.6.0-jre / JDK 21,2026-08 实测)。
环境信息
| 组件 | 版本 |
|---|---|
| Guava | 33.6.0-jre |
| JDK | 21 |
| Maven | 3.9+ |
Maven 依赖:
xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.6.0-jre</version>
</dependency>
1. Cache:本地缓存别再手写了
没它之前:手写"带过期时间的本地缓存"
最常见的需求:本地缓存 + 500ms 过期。自己写大概是这个样子(Demo 里的完整实现):
java
public class ManualTtlCache<K, V> {
private final Map<K, Entry<V>> map = new ConcurrentHashMap<>();
private final long ttlMillis;
public V get(K key) {
Entry<V> entry = map.get(key);
if (entry == null) return null;
if (System.currentTimeMillis() > entry.expireAt) {
map.remove(key, entry); // 惰性删除:读到才删
return null;
}
return entry.value;
}
// put / size 实现略,完整代码见 Demo
}
跑起来(ttl=500ms):
text
===== Cache 0: 手写版(带过期时间的本地缓存) =====
写入 token1/token2(ttl=500ms),size()=2
ttl 内 get token1 -> abc
等待 800ms 后......
过期后 get token1 -> null(发现过期,顺手删除)
此时 size()=1(token2 也过期了,但没人读它,仍然占着内存)
两个问题已经很明显:
- 过期是惰性的 ------没人读的过期 key 一直占着内存,
size()虚高; - 并发场景还要自己处理 get/put 竞态(两个线程同时 load 同一个 key)、加锁、容量上限......越写越像在造轮子。
用了它之后:CacheBuilder 三行配置
java
LoadingCache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(100)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
System.out.println(" [load] 从数据源加载 " + key);
return "value-of-" + key;
}
});
LoadingCache 的加载逻辑只写一次:第一次 get 自动加载,第二次直接命中,同一个 key 的 load 只执行一次,并发线程会等待同一个加载结果:
text
===== Cache 1: LoadingCache 自动加载 =====
[load] 从数据源加载 user:1(第 1 次)
第 1 次 get(user:1) -> value-of-user:1
第 2 次 get(user:1) -> value-of-user:1
[load] 从数据源加载 user:2(第 2 次)
第 1 次 get(user:2) -> value-of-user:2
load 总次数 = 2(user:1 第二次没有再触发 load)
过期策略一行搞定------写后 2 秒过期:
java
LoadingCache<String, String> cache = CacheBuilder.newBuilder()
.expireAfterWrite(Duration.ofSeconds(2))
.build(new CacheLoader<String, String>() { /* load 实现同上 */ });
text
===== Cache 2: expireAfterWrite 写后过期 =====
[load] 第 1 次加载 price
t=0s get(price) -> price-1999
t≈1.1s get(price) -> price-1999(还没过期,命中)
[load] 第 2 次加载 price
t≈2.2s get(price) -> price-1999(已过期,重新加载)
结论:过期是惰性的------不访问它,就发现不了它过期。
容量上限防止无限膨胀:
java
LoadingCache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(3)
.build(new CacheLoader<String, String>() { /* load 实现同上 */ });
text
===== Cache 3: maximumSize 容量上限 =====
连续放入 5 个 key 后,size()=3(上限 3,多余的被淘汰)
还留在缓存里的:key3 key4 key5
淘汰策略是 W-TinyLFU(按访问频率近似),不是严格的 LRU。
用 Guava Cache 前必须知道的几点:
- 过期是惰性清理:条目过期后不会立刻消失,而是在下次读写时被清理。"过期时间"是近似语义,别拿它做精确的定时任务;
- 淘汰策略是 W-TinyLFU,按访问频率近似淘汰,不是 LRU------高频小热点比低频大热点更抗淘汰;
refreshAfterWrite是"刷新"而非"失效" :到期后第一次访问触发重新加载,加载完成前其他线程仍能读到旧值(想要完全不阻塞,需要把reload实现为异步);- 33.x 推荐用
Duration版本配置过期(expireAfterWrite(long, TimeUnit)那组重载已标记过时)。
2. EventBus:通知链路的解耦神器
没它之前:通知逻辑硬编码
java
public class OrderService {
public void createOrder(Order order) {
// 创建订单......
emailService.sendOrderEmail(order); // 发邮件
smsService.sendOrderSms(order); // 发短信
warehouseService.deductStock(order); // 扣库存
}
}
问题:订单创建后每多一个"关心这件事"的模块,就要改 OrderService;而且任何一个通知抛异常,都可能把创建订单的主流程打断。
用了它之后:@Subscribe 一个注解完成订阅
java
public class EmailSubscriber {
@Subscribe
public void onOrderCreated(OrderCreatedEvent event) {
System.out.println(" [邮件] 给 " + event.userId() + " 发送订单 " + event.orderNo() + " 的确认邮件");
}
}
发布方只做两件事:注册订阅者、post 事件。
java
EventBus eventBus = new EventBus("order-bus");
eventBus.register(new EmailSubscriber());
eventBus.register(new SmsSubscriber());
eventBus.register(new InventorySubscriber());
eventBus.post(new OrderCreatedEvent("NO20260813001", "u-1001", 299.0));
真实输出:
text
===== EventBus 1: 一个事件,多个订阅者 =====
[邮件] 给 u-1001 发送订单 NO20260813001 的确认邮件
[短信] 给 u-1001 发送订单 NO20260813001 的短信提醒
[库存] 扣减订单 NO20260813001 的库存完成
新增一个订阅者 = 新写一个类 + register,OrderService 一行都不用改。默认同步派发(本例按注册顺序依次执行)。
订阅者抛异常不影响其他人:默认记录日志并继续派发,也可以自定义处理器:
java
EventBus resilientBus = new EventBus((exception, context) ->
System.out.println(" [异常处理] 订阅者 " + context.getSubscriber().getClass().getSimpleName()
+ " 抛出 " + exception.getClass().getSimpleName() + ",已记录,继续派发"));
text
===== EventBus 2: 一个订阅者抛异常,其他订阅者照常收到 =====
[库存] 开始扣减订单 NO20260813002 的库存
[异常处理] 订阅者 FlakyInventorySubscriber 抛出 IllegalStateException,已记录,继续派发
[短信] 给 u-1002 发送订单 NO20260813002 的短信提醒
没人订阅的事件会包装成 DeadEvent,注册一个兜底订阅者就能发现"发错事件"的问题:
text
===== EventBus 3: 没人订阅的事件走 DeadEvent =====
[DeadEvent] 没人订阅的事件类型:String
什么时候别用 EventBus
- 默认同步派发,订阅者里别放重逻辑,否则发布线程会被拖慢;想异步用
AsyncEventBus(配线程池),但异常处理、顺序性要重新想清楚; - 事务边界 :如果
post发生在事务提交前,订阅者读到的可能是未提交的数据------"创建后通知"在意一致性的场景,先看 Spring 的@TransactionalEventListener; - 它只在 JVM 进程内有效,跨服务通知要靠 MQ。
3. 集合补全:Multimap / BiMap / Table
这三兄弟专治"集合套集合"的样板代码。
Multimap:一个 key 对应多个 value
按部门分组,没它之前:
java
Map<String, List<String>> grouped = new HashMap<>();
for (Member m : members) {
grouped.computeIfAbsent(m.getDept(), k -> new ArrayList<>()).add(m.getName());
}
用了它之后:
java
ArrayListMultimap<String, String> multimap = ArrayListMultimap.create();
for (Member m : members) {
multimap.put(m.getDept(), m.getName());
}
真实输出(同一份数据):
text
-- 没它之前:Map<String, List<String>> 手动分组 --
分组结果:{测试=[赵六], 前端=[李四, 钱七], 后端=[张三, 王五]}
取不存在的部门:null(返回 null,要判空)
-- 用了它之后:ArrayListMultimap --
分组结果:{测试=[赵六], 前端=[李四, 钱七], 后端=[张三, 王五]}
取不存在的部门:[](返回空集合,不用判空)
size()=5(所有 key-value 对的总数)
除了省掉 computeIfAbsent 模板,get() 对不存在的 key 返回空集合而不是 null------判空代码直接消失 。要"同一个 key 下值不重复",用 HashMultimap(不保序)或 LinkedHashMultimap(保插入序)。
BiMap:双向映射
用户名 <-> 用户 ID,没它之前要两个 Map 手动同步:
java
Map<String, Long> userIdByName = new HashMap<>();
Map<Long, String> userNameById = new HashMap<>();
userIdByName.put("张三", 1001L);
userNameById.put(1001L, "张三"); // 忘写这一行?反查就炸
用了它之后:
java
BiMap<String, Long> biMap = HashBiMap.create();
biMap.put("张三", 1001L);
biMap.put("李四", 1002L);
biMap.get("张三"); // 1001
biMap.inverse().get(1001L); // 张三
真实输出:
text
-- 没它之前:两个 Map 手动同步 --
名字查 ID:1001
ID 查名字:张三
缺点:增删要同时维护两个 Map,忘了同步就出 bug
-- 用了它之后:HashBiMap + inverse() --
名字查 ID:1001
ID 查名字:张三
inverse 视图:{1001=张三, 1002=李四}
重复 value 直接抛异常:value already present: 1001(防止脏数据)
inverse() 返回的是视图,两边数据永远一致;插入重复 value 直接抛异常,把"脏数据"挡在门外。
Table:二维映射
地区 x 月份的销量,没它之前:
java
Map<String, Map<String, Integer>> nested = new HashMap<>();
nested.computeIfAbsent("华北", k -> new HashMap<>()).put("一月", 120);
用了它之后:
java
Table<String, String, Integer> table = HashBasedTable.create();
table.put("华北", "一月", 120);
table.put("华北", "二月", 90);
table.put("华南", "一月", 80);
table.get("华北", "一月"); // 120
table.row("华北"); // {一月=120, 二月=90}
table.column("一月"); // {华北=120, 华南=80}
真实输出:
text
-- 没它之前:Map<row, Map<col, value>> 嵌套 --
嵌套 Map:{华南={一月=80}, 华北={一月=120, 二月=90}}
取华北一月:120
-- 用了它之后:HashBasedTable --
table:{华北={一月=120, 二月=90}, 华南={一月=80}}
取华北一月:120
按行取:{一月=120, 二月=90}
按列取:{华北=120, 华南=80}
嵌套 Map 取"华北一月"要 get("华北").get("一月"),任何一层取到 null 就 NPE;Table 一行 get(row, col) 搞定,还能直接拿行视图、列视图做报表聚合。
总结:3 个 Key Takeaways
- 本地缓存别手写 :过期策略、容量上限、并发加载全是坑,
CacheBuilder三行配置 +LoadingCache自动加载,省心且经过多年生产验证; - 通知/联动用 EventBus 解耦:新增订阅者不用改发布方,订阅者异常默认被隔离;但它是 JVM 内同步机制,别拿它当 MQ 用;
- 集合增强干掉模板代码 :Multimap 替代
Map<K, List<V>>、BiMap 替代双 Map 手动同步、Table 替代嵌套 Map,判空和同步维护代码一起消失。
你用 Guava 最离不开的是哪个类?评论区聊聊,一起把"Guava 实用地图"画完整。