Guava 不止有 Lists 和 Maps:Cache、EventBus、Multimap 实战

栏目:轮子指南 | 环境: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 也过期了,但没人读它,仍然占着内存)

两个问题已经很明显:

  1. 过期是惰性的 ------没人读的过期 key 一直占着内存,size() 虚高;
  2. 并发场景还要自己处理 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 的库存完成

新增一个订阅者 = 新写一个类 + registerOrderService 一行都不用改。默认同步派发(本例按注册顺序依次执行)。

订阅者抛异常不影响其他人:默认记录日志并继续派发,也可以自定义处理器:

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

  1. 本地缓存别手写 :过期策略、容量上限、并发加载全是坑,CacheBuilder 三行配置 + LoadingCache 自动加载,省心且经过多年生产验证;
  2. 通知/联动用 EventBus 解耦:新增订阅者不用改发布方,订阅者异常默认被隔离;但它是 JVM 内同步机制,别拿它当 MQ 用;
  3. 集合增强干掉模板代码 :Multimap 替代 Map<K, List<V>>、BiMap 替代双 Map 手动同步、Table 替代嵌套 Map,判空和同步维护代码一起消失。

你用 Guava 最离不开的是哪个类?评论区聊聊,一起把"Guava 实用地图"画完整。

相关推荐
一只叫煤球的猫2 小时前
从 Java 21 到 Java 25:ThreadLocal 以外的选择—— ScopedValue
java·后端·架构
用户3169353811832 小时前
SpringBoot + Vue 项目生产环境部署完整指南
前端·后端
步行cgn2 小时前
MyBatis 多对一关联映射详解
java
仍然.2 小时前
SpringCloud---Eureka介绍
java·spring cloud·eureka
霸道流氓气质2 小时前
Spring 事务同步机制 —— AfterTransactionActionCollector 的异步解耦原理
java·后端·spring
Lightpwd2 小时前
PostgreSQL jsonb 写入踩坑:一条 `\u0000` 如何让整批同步失败,还把异常伪装成“数据完整性冲突”
java
Csvn2 小时前
📊 SQL 入门 Day 19:事务与隔离级别
后端·sql
周周哈哈哈3 小时前
线程创建、执行、退出、回收
java·开发语言