公司动态

Google Guava 实战:集合增强、本地缓存与并发工具全解析

📅 2026/8/28 9:45:37
Google Guava 实战:集合增强、本地缓存与并发工具全解析
google/guava 是 Google 开源的核心 Java 类库GitHub 上的同名项目承载了 Google 内部大量 Java 服务验证过的集合、缓存、并发、字符串与哈希工具。在实际 Java 工程中真正消耗时间的往往不是复杂的业务算法而是集合转换、字符串解析、本地缓存、边界条件处理这些基础工作。很多团队在代码评审时看到的各种StringUtil、CollectionHelper、CacheManager很大一部分都能用 Guava 直接替代而且替代后逻辑更严谨、代码更短。这篇文章不会只罗列 API 文档。我会从“为什么需要 Guava”开始讲然后带你把依赖引入、集合增强、缓存、并发、哈希筛选器这几个核心模块依次跑通最后给出生产环境常见的版本冲突与缓存失效排查路径。学完以后你可以直接在项目里引入 Guava也可以拿这套思路去审查现有代码里那些手工编写的集合处理逻辑。1. 为什么 Java 项目需要 Guava而不是继续手写工具类1.1 Guava 解决的是 JDK 开发中的重复劳动JDK 自带的集合框架和字符串工具足够基础但离“好用”还有距离。举几个每天都在重复的代码场景要把MapString, ListString这种结构塞数据时必须先判断 key 是否存在不存在就 new 一个 List再 put 进去。要做双向映射时只能同时维护两个 Map还要自己保证同步。要把 List 按逗号拼成字符串时JDK 没有顺手的方法只能 for 循环拼接还要处理最后一个分隔符。要做本地缓存时ConcurrentHashMap只能保证线程安全但没有「写入后过期多久」「最大条数是多少」「命中率如何统计」这些缓存语义。要解析字符串时String.split需要处理正则语义、空串保留和前后空格踩过一次坑的人都会记得。这些场景不是单点难题而是大量重复出现的“低水平复杂性”。Guava 把 Google 内部总结出来的实现沉淀成统一 API让你不需要在每个项目里重新发明一次轮子。1.2 Guava 与 JDK、Apache Commons 的定位差异有人会问Apache Commons 也有类似工具为什么要用 Guava两者定位不完全一致。Apache Commons 偏“工具大礼包”很多方法按类划分比如StringUtils、CollectionUtils使用上比较直接。Guava 则更强调“数据结构和函数式约定的统一”它不只是提供工具方法而是提供更合适的容器类型例如不可变集合、Multimap、BiMap、Table这些在 JDK 和 Commons 里都没有直接的等价物。下面用一张表说明三者的关系能力维度JDKApache CommonsGuava线程安全 MapConcurrentHashMapConcurrentMapUtilsCache、ConcurrentHashMultiset不可变集合Collections.unmodifiableListCollectionUtils.unmodifiableCollectionImmutableList、ImmutableMapMapK, List 手写判断MultiMap 相关工具Multimap双向查找维护两个 Map无BiMap本地缓存语义无无CacheBuilder字符串拼接数组手写StringUtils.joinJoiner布隆过滤器无无BloomFilter从这个对比能看出Guava 不是“又一个工具类库”而是补上了 Java 集合体系里缺失的多种数据结构。1.3 什么项目适合引入什么场景要谨慎适合引入的场景很多中大型后端服务、微服务中的公共模块、Web 项目里的缓存需求、数据清洗和接口转译逻辑。只要项目使用 Java 8 以上并且没有强依赖体积限制引入 Guava 基本都是正向收益。需要谨慎的场景是非常小的命令行脚本、纯 Android 低版本兼容项目、或者项目里已经通过其他方式实现了类似的不可变集合与缓存语义。这种情况下引入一个新依赖要评估维护成本和依赖体积。另外如果你的团队已经全面转向java.util.concurrent.CompletableFuture和 Stream API那么 Guava 里的部分并发工具可以只按需引入不需要把全部模块都铺开。2. 依赖引入与版本选择环境准备阶段就把冲突风险降下来2.1 Maven 和 Gradle 怎么引入 GuavaMaven 项目在pom.xml中加入坐标dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.3-jre/version /dependencyGradle 项目在build.gradle中加入依赖implementation com.google.guava:guava:32.1.3-jre这里版本号只是示例。实际项目使用前先到 Maven Central 或公司私服确认当前稳定版本再结合自己项目的 JDK 版本选择。不要凭直觉填一个版本容易在引入后触发传递依赖冲突。2.2 版本后缀 jre 和 android 有什么区别Guava 的版本号后面常见-jre和-android两种后缀。jre版本面向标准 Java 平台包含完整 API一般要求 JDK 8 以上。android版本面向 Android 环境会裁剪掉一些 Android 不支持的 API同时方法与类数量更精简。后端服务选jre后缀即可。Android 项目才需要选android后缀。不要在生产 Java 服务里引入android版本避免因为 API 裁剪导致类方法找不到。注意Guava 版本更新较快且不同大版本之间可能存在行为调整。升级大版本时不能只换版本号要先看官方 release notes 中关于行为变更和废弃 API 的说明。2.3 依赖冲突要在引入前就预防Guava 是很多框架的间接依赖比如 Hadoop、Cassandra、Flink、部分 ORM 工具都会传递引入 Guava。最常见的故障是一个项目里同时存在多个不同版本的 Guava编译时没问题运行时报NoSuchMethodError或NoClassDefFoundError。预防手段有两步。第一步用 Maven 依赖树检查项目中已有的 Guavamvn dependency:tree -Dincludescom.google.guava:guavaGradle 项目用gradle dependencies --configuration runtimeClasspath第二步在父工程的dependencyManagement中显式指定 Guava 版本把传递依赖统一起来dependencyManagement dependencies dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.3-jre/version /dependency /dependencies /dependencyManagement这样做不是“一定要锁死版本”而是确保构建时只有一个显式版本避免运行时因为多个版本的类混入 classpath 而产生诡异错误。3. 集合增强用不可变集合、Multimap、BiMap、Table 替代繁琐 JDK 写法3.1 不可变集合为什么推荐 ImmutableList 而不是 ArrayList不可变集合是 Guava 最值得先引入的模块之一。所谓不可变是指集合在创建后不能被修改不能 add、remove、clear也不能修改已存在的元素引用。它的价值体现在三处防御性方法入参和返回值使用不可变集合可以避免调用方意外修改内部数据。可读性看到ImmutableList读者立刻知道这段数据是固定的代码意图更明确。线程安全不可变对象天然适合多线程共享不需要额外加锁。最小示例import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; public class ImmutableDemo { public static void main(String[] args) { ImmutableListString names ImmutableList.of(Alice, Bob, Carol); System.out.println(names); ImmutableMapString, Integer ageMap ImmutableMap.of(Alice, 20, Bob, 30); System.out.println(ageMap); ImmutableListString copy ImmutableList.Stringbuilder() .add(David) .addAll(names) .build(); System.out.println(copy); } }关键点ImmutableList不允许null元素。如果往 builder 里添加null会在 build 时抛出NullPointerException。这是 Guava 有意设计的原因是“不可变集合应该尽量避免 nullnull 只代表不确定不适合作为固定数据的组成部分”。如果你的业务数据确实可能出现 null就要在进入集合之前处理掉而不是试图把 null 存进去。3.2 Multimap替代 MapK, List 常见的分组需求是MapString, ListString。手工写法很啰嗦MapString, ListString map new HashMap(); ListString list map.computeIfAbsent(java, k - new ArrayList()); list.add(Spring);虽然 JDK 8 之后computeIfAbsent可以简化但每次写这行代码仍然属于重复劳动。Guava 的Multimap把“一个 key 对应多个 value”变成一等公民import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; public class MultimapDemo { public static void main(String[] args) { MultimapString, String skills ArrayListMultimap.create(); skills.put(Alice, Java); skills.put(Alice, Spring); skills.put(Bob, Go); System.out.println(skills.get(Alice)); // [Java, Spring] System.out.println(skills.keySet()); // [Alice, Bob] } }get(key)返回的是 value 集合的视图直接对返回值进行修改会同步反映到Multimap中。这里要特别注意get(key)不会返回null即使 key 不存在也会返回一个空集合视图。所以在遍历结果时不需要再做if (list ! null)判断。常用实现类包括实现类特点适用场景ArrayListMultimapvalue 用 ArrayList 存储允许重复普通列表分组LinkedHashMultimapvalue 用 LinkedHashSet 存储去重且保序去重分组HashMultimapvalue 用 HashSet 存储无序去重只要去重不关注顺序TreeMultimapkey 和 value 都按自然顺序排序需要有序遍历3.3 BiMap双向查找不用维护两个 Map很多配置类数据需要“根据 code 查 name”和“根据 name 查 code”两种查询。手工做法是维护两个 Map并在写入时保证同步。Guava 的BiMap直接提供反向视图import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; public class BiMapDemo { public static void main(String[] args) { BiMapString, String countryCode HashBiMap.create(); countryCode.put(CN, 中国); countryCode.put(US, 美国); System.out.println(countryCode.get(CN)); // 中国 System.out.println(countryCode.inverse().get(美国)); // US } }inverse()返回的是反向视图反向视图的变化也会同步到原 BiMap 上。常见坑BiMap 要求 value 也必须唯一。如果放入重复 value会抛出IllegalArgumentException。这是它的设计约束用来保证反向查找结果唯一。如果业务上允许 value 重复就不适合用 BiMap应当改用普通 Map 或 Multimap。3.4 Table处理双键映射结构业务中经常出现“行、列、值”结构例如按“用户、商品、数量”存储某个矩阵。用Maprow, Mapcol, value写起来层级很深遍历也不方便。Guava 的Table专门处理这种双键映射import com.google.common.collect.HashBasedTable; import com.google.common.collect.Table; public class TableDemo { public static void main(String[] args) { TableString, String, Integer scores HashBasedTable.create(); scores.put(Alice, Math, 90); scores.put(Alice, English, 85); scores.put(Bob, Math, 88); System.out.println(scores.get(Alice, Math)); // 90 System.out.println(scores.row(Alice)); // {English85, Math90} System.out.println(scores.column(Math)); // {Alice90, Bob88} } }HashBasedTable底层相当于MapR, MapC, V适合行列都较稀疏的结构。如果业务还需要按行、列排序可以考虑TreeBasedTable。这里不需要背 API记住一个判断标准当嵌套 Map 结构让人写出两层循环时Table可能就是更清晰的选择。3.5 集合类型速查表需求JDK 常见做法Guava 推荐做法不变列表Collections.unmodifiableListImmutableList一个 key 多个 valueMapK, List computeIfAbsentMultimap双向查找两个 MapBiMap双键矩阵MapR, MapC, VTable区间集合手写排序和判断RangeSetkey 计数MapK, IntegerMultiset4. 字符串与对象工具Joiner、Splitter、CharMatcher、Preconditions 的日常用法4.1 Joiner拼接字符串不需要手工处理分隔符把集合拼成“a,b,c”是最常见的文本需求。手工 for 循环拼接时要处理最后一个元素后面不出现逗号代码又长又容易写错。Guava 的Joiner写法import com.google.common.base.Joiner; import java.util.Arrays; import java.util.List; public class JoinerDemo { public static void main(String[] args) { ListString list Arrays.asList(Java, Go, Python); String result Joiner.on(,).join(list); System.out.println(result); // Java,Go,Python ListString withNull Arrays.asList(Java, null, Python); String skipNull Joiner.on(,).skipNulls().join(withNull); System.out.println(skipNull); // Java,Python String useDefault Joiner.on(,).useForNull(未知).join(withNull); System.out.println(useDefault); // Java,未知,Python } }skipNulls()适合忽略 nulluseForNull()适合把 null 替换成默认值。二者只能选一种API 设计上不允许同时调用因为语义冲突。选择哪种要看你后续解析时是否关心原始元素顺序和占位。4.2 Splitter解析字符串比 String.split 更可控String.split有两个容易踩的坑参数是正则表达式且默认保留最后的空字符串具体行为由 JDK 版本决定。Guava 的Splitter是函数式写法解析规则通过链式调用表达import com.google.common.base.Splitter; public class SplitterDemo { public static void main(String[] args) { String raw java, go, python,,; IterableString parts Splitter.on(,) .trimResults() .omitEmptyStrings() .split(raw); for (String part : parts) { System.out.println([ part ]); } } }输出结果会自动去掉每个元素两端的空格并过滤掉空字符串。这个行为用String.split手写实现需要不少判断。建议团队在处理 CSV、配置项、日志字段拆分时统一使用Splitter避免不同开发者写出不同的边界处理。4.3 CharMatcher字符清洗的声明式写法CharMatcher用于匹配、移除、保留、缩并字符集合。比如清洗用户输入时只需要保留数字和字母import com.google.common.base.CharMatcher; public class CharMatcherDemo { public static void main(String[] args) { String raw hello world ; String collapsed CharMatcher.whitespace().collapseFrom(raw, ); System.out.println([ collapsed ]); // [ hello world ] String digitOnly CharMatcher.inRange(0, 9).retainFrom(订单号2024-X12); System.out.println(digitOnly); // 202412 } }collapseFrom可以把连续空白缩成一个空格适合文本规范化。retainFrom只保留匹配字符适合从脏数据里抽取数字。这里要注意如果业务文本包含中文CharMatcher.inRange(0, 9)不会匹配中文数字只匹配 ASCII 数字。做国际化场景时需要确认字符范围是否符合预期。4.4 Preconditions校验参数时让错误信息更准确方法开头做参数校验是每个开发者都会写的代码。直接用if判断再抛异常有两个问题错误信息不统一缺少参数上下文。Guava 的Preconditions提供断言式校验import com.google.common.base.Preconditions; public class PreconditionsDemo { public static void main(String[] args) { String name Alice; Preconditions.checkNotNull(name, name 不能为空); Preconditions.checkArgument(name.length() 3, name 长度必须不小于 3当前是 %s, name.length()); } }checkArgument失败时抛出IllegalArgumentExceptioncheckNotNull失败时抛出NullPointerExceptioncheckState失败时抛出IllegalStateException。它们解决的问题不是“省一行代码”而是让异常信息带上参数值方便排查。日志里出现“name 不能为空”和“name 长度必须不小于 3当前是 2”相比定位难度完全不同。其他常用对象工具还包括MoreObjects.firstNonNull(a, b)、MoreObjects.toStringHelper(obj)简化 toString以及ComparisonChain简化 compareTo 实现import com.google.common.collect.ComparisonChain; public class User implements ComparableUser { private final String name; private final int age; public User(String name, int age) { this.name name; this.age age; } Override public int compareTo(User o) { return ComparisonChain.start() .compare(this.name, o.name) .compare(this.age, o.age) .result(); } }ComparisonChain的好处在于先比较哪个字段就写哪个不需要自己处理“前一个字段相等时继续比较下一个”的嵌套逻辑。5. 本地缓存CacheBuilder 的机制、参数与回收策略5.1 本地缓存的典型场景与适用边界本地缓存指缓存数据保存在 JVM 进程内常见于数据字典、配置项、热点商品信息、用户摘要等读取频繁但更新不频繁的数据。相比 Redis 这类分布式缓存本地缓存没有网络开销读取延迟低但缺点是数据无法跨节点共享节点之间可能不一致。因此本地缓存适合“允许短暂不一致”的数据。如果业务要求强一致比如库存扣减、账户余额这些场景应该走数据库或分布式缓存而不是把数据塞进本地缓存。Guava 的CacheBuilder与ConcurrentHashMap的区别是ConcurrentHashMap只提供 Map 语义没有过期时间、最大容量、命中率统计、自动加载这些能力。CacheBuilder把这些能力集中到一个类里。5.2 构建一个最简缓存下面的代码实现“从数据库加载字典配置并缓存 10 分钟”import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import java.util.concurrent.TimeUnit; public class CacheDemo { private static final LoadingCacheString, String DICT_CACHE CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoaderString, String() { Override public String load(String key) { return loadFromDatabase(key); } }); public static void main(String[] args) throws Exception { String value DICT_CACHE.get(user_status:1); System.out.println(value); String again DICT_CACHE.get(user_status:1); System.out.println(again); } private static String loadFromDatabase(String key) { System.out.println(load from database: key); return 正常; } }第一次get(user_status:1)时缓存中没有数据会触发CacheLoader.load()加载第二次get直接返回缓存值不会再次加载。所以控制台只会打印一次load from database。5.3 核心参数与回收策略CacheBuilder的常见参数如下参数含义典型值错误配置表现maximumSize缓存最大条数1_000 到 100_000设置过小会导致频繁淘汰命中率低expireAfterWrite写入后多久过期10 分钟设置过短会频繁回源数据库expireAfterAccess最后一次访问后多久过期10 分钟适合“不活跃数据自然过期”的场景refreshAfterWrite写入后多久刷新与 expireAfterWrite 组合使用单靠它无法强制过期只会异步刷新recordStats开启命中率统计开启不开启则无法通过 CacheStats 观察效果expireAfterWrite和expireAfterAccess是“过期”元素从缓存中移除refreshAfterWrite是“刷新”元素可以在后台重新加载旧值在加载完成前仍然可用。注意Guava 缓存没有独立的清理线程。过期元素的清理是懒惰式的通常在后续 get、put 等写操作触发时才执行。因此缓存条数可能不会在过期时间到达瞬间立刻消失这是设计行为不代表配置失效。5.4 缓存使用中的常见坑第一个坑是CacheLoader.load()返回null。Guava 不允许缓存 value 为 nullload()返回 null 时会抛出CacheLoader.InvalidCacheLoadException。如果业务上某条数据确实可能不存在建议用 Optional 包装或者在 load 中抛业务异常。第二个坑是每次请求都动态创建CacheBuilder实例。缓存实例应当作为单例或 Spring Bean 使用。如果放在方法里 new 一个相当于每次请求都创建新缓存所有数据都不会复用命中率必然为 0。第三个坑是依赖getIfPresent判断数据是否存在时忽略“加载失败”的情况。get(key)是自动加载的入口getIfPresent(key)只查不加载。生产代码中要根据业务需求决定是使用get自动加载还是getIfPresent只读查询不要混用。// 推荐使用 get 自动加载 String config DICT_CACHE.get(config:language); // 谨慎getIfPresent 不会自动加载可能返回 null String maybe DICT_CACHE.getIfPresent(config:language);对于缓存命中率、加载耗时、淘汰条数等指标可以通过cache.stats()获取CacheStats再接入日志或监控系统CacheStats stats DICT_CACHE.stats(); System.out.println(hit rate: stats.hitRate()); System.out.println(load count: stats.loadCount());6. 并发、限流与事件总线ListenableFuture、RateLimiter、EventBus6.1 ListenableFuture给 Future 增加完成回调JDK 自带Future的唯一问题是“获取结果只能阻塞等待”。CompletableFuture解决了回调问题但在一些老项目中仍在使用 ExecutorService 返回的Future。Guava 的ListenableFuture可以理解为“带监听器的 Future”示例import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import java.util.concurrent.Executors; public class ListenableFutureDemo { public static void main(String[] args) { ListeningExecutorService service MoreExecutors.listeningDecorator( Executors.newFixedThreadPool(4)); ListenableFutureInteger future service.submit(() - { return 20 22; }); Futures.addCallback(future, new com.google.common.util.concurrent.FutureCallbackInteger() { Override public void onSuccess(Integer result) { System.out.println(result: result); } Override public void onFailure(Throwable t) { System.err.println(failed: t.getMessage()); } }, MoreExecutors.directExecutor()); service.shutdown(); } }核心步骤是用MoreExecutors.listeningDecorator包装现有线程池提交任务后得到ListenableFuture再通过Futures.addCallback注册成功和失败回调。相比直接使用CompletableFutureGuava 的优势是能与老代码中的ExecutorService无缝结合不需要改造线程池的创建方式。如果你的项目已经全部使用CompletableFuture就不必再引入这套回调机制。6.2 RateLimiter进程内限流的一种简洁实现RateLimiter是 Guava 提供的令牌桶限流器。它解决的问题是单位时间内最多允许多少次操作。典型场景包括调用第三方接口、限制用户频繁请求、保护数据库连接等。import com.google.common.util.concurrent.RateLimiter; public class RateLimiterDemo { public static void main(String[] args) { RateLimiter limiter RateLimiter.create(2.0); // 每秒钟最多 2 个许可 for (int i 0; i 5; i) { double waitTime limiter.acquire(); System.out.println(task i wait waitTime s); } } }acquire()会阻塞直到获取许可返回等待时间。如果需要“获取不到就立即失败”使用tryAcquire()if (limiter.tryAcquire()) { // 执行操作 } else { // 直接拒绝 }RateLimiter.create(2.0)这里的 2.0 表示每秒发放 2 个许可。如果希望“每秒最多 100 次调用”就设置create(100.0)。注意RateLimiter是进程内限流不是分布式限流。多个服务实例各自持有自己的 RateLimiter整体通过量会是单机限流值乘以实例数。分布式限流需要额外使用 Redis 或网关层限流组件。6.3 EventBus进程内的事件发布与订阅EventBus提供观察者模式的简化实现。相比手写 Listener 接口EventBus不需要事件源和订阅者实现公共接口只需要用注解标记方法。定义事件类public class OrderCreatedEvent { private final Long orderId; public OrderCreatedEvent(Long orderId) { this.orderId orderId; } public Long getOrderId() { return orderId; } }定义订阅者import com.google.common.eventbus.Subscribe; public class OrderEventListener { Subscribe public void onOrderCreated(OrderCreatedEvent event) { System.out.println(处理订单事件: event.getOrderId()); } }发布事件import com.google.common.eventbus.EventBus; public class EventBusDemo { public static void main(String[] args) { EventBus eventBus new EventBus(); eventBus.register(new OrderEventListener()); eventBus.post(new OrderCreatedEvent(10001L)); } }EventBus的核心机制是类型匹配。post的事件对象类型会精确匹配订阅方法中声明参数的类型而不是匹配父类。默认的EventBus同步执行订阅方法异常会直接抛出。如果需要异步发布可以使用AsyncEventBus但必须传入线程池。EventBus的坑在于如果订阅方法没有标记Subscribe或者方法参数类型与发布对象类型不匹配事件会静默丢失。调试时可以先订阅DeadEvent观察未匹配事件import com.google.common.eventbus.DeadEvent; import com.google.common.eventbus.Subscribe; public class DeadEventListener { Subscribe public void onDeadEvent(DeadEvent event) { System.out.println(未匹配事件: event.getEvent()); } }7. 哈希与布隆过滤器Hashing 和 BloomFilter 到底能做什么7.1 Hashing 类用来做哈希而不只是 MD5Guava 的Hashing类提供多种哈希函数常见用途包括分片、一致性哈希、数据指纹、布隆过滤器等。import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; import java.nio.charset.StandardCharsets; public class HashingDemo { public static void main(String[] args) { HashFunction murmur Hashing.murmur3_128(); int bucket murmur.newHasher() .putString(user_10001, StandardCharsets.UTF_8) .hash() .asInt(); int mod Math.abs(bucket) % 16; System.out.println(shard index: mod); } }常用的 HashFunction 包括方法说明适用场景murmur3_32 / murmur3_128非加密哈希速度快分片、布隆过滤器、一致性哈希sha256加密哈希数据指纹、文件校验md5常见哈希老系统兼容不要再用于涉及安全的场景crc32c校验和数据完整性校验要注意murmur3不是加密哈希不适合存储密码、签名等安全场景。涉及密码存储要使用 BCrypt、PBKDF2 等专用算法。Hashing类更多解决的是“把字符串转成均匀分布的整数”这类分布问题而不是安全问题。7.2 BloomFilter用很少的内存判断元素“一定不存在”BloomFilter是 Guava 里非常有价值但容易被忽略的一个类。它解决一类典型问题在数据量很大时快速判断某个元素是否可能在集合中。如果判断结果为 false元素一定不在集合中如果为 true元素可能在集合中但不一定。举例来说防止缓存穿透时可以先用布隆过滤器拦一下不存在的 key。代码import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import java.nio.charset.StandardCharsets; public class BloomFilterDemo { public static void main(String[] args) { BloomFilterString filter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 10_000, 0.01); filter.put(order_10001); filter.put(order_10002); System.out.println(filter.mightContain(order_10001)); // true System.out.println(filter.mightContain(order_99999)); // false 或小概率 true } }BloomFilter.create三个参数分别是funnel如何把对象转换成字节流。expectedInsertions预期放入的元素数量。fpp允许的误判率越小越占内存。参数默认值调大影响调小影响expectedInsertions无默认内存占用增大插入数量超过预期后误判率快速上升fpp0.03误判率变高内存占用变大BloomFilter不支持删除元素。如果把元素误加入后再想移除没有一个 safe remove 方法。因此适合只增不减的场景比如 URL 去重、黑名单、缓存穿透防护。需要频繁删除时应该考虑CountingBloomFilter或直接使用外部存储。8. 运行验证一个可运行的 Guava 综合小例子这一节把前面几个模块组合起来模拟一个“字典加载 缓存 事件通知 布隆过滤器防穿透”的简化流程。核心目的是验证依赖配置是否正常以及各模块之间能否协同工作。8.1 示例需求与设计假设有一个订单状态字典数据量不大但读取频率高。我们在加载缓存时发布一条事件记录加载来源同时在查询前用布隆过滤器判断 key 是否存在减少对数据库的无效查询。步骤拆解使用BloomFilter预置一批合法 key。查询时先mightContain判断false 直接返回“不存在”减少 DB 访问。缓存加载时通过EventBus发布“缓存加载”事件。第二次查询直接命中缓存不再触发加载。8.2 完整代码import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; public class GuavaDemoApplication { private static final ListString VALID_KEYS Arrays.asList(status:1, status:2, status:3); private static final BloomFilterString KEY_FILTER BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 1000, 0.01); private static final EventBus EVENT_BUS new EventBus(); private static final LoadingCacheString, String CACHE CacheBuilder.newBuilder() .maximumSize(100) .expireAfterWrite(5, TimeUnit.MINUTES) .recordStats() .build(new CacheLoaderString, String() { Override public String load(String key) { EVENT_BUS.post(new CacheLoadEvent(key)); return loadFromDb(key); } }); public static void main(String[] args) throws Exception { for (String key : VALID_KEYS) { KEY_FILTER.put(key); } EVENT_BUS.register(new CacheLoadListener()); String queryKey status:1; if (!KEY_FILTER.mightContain(queryKey)) { System.out.println(key 一定不存在直接返回); return; } System.out.println(第一次查询 CACHE.get(queryKey)); System.out.println(第二次查询 CACHE.get(queryKey)); System.out.println(命中率 CACHE.stats().hitRate()); } private static String loadFromDb(String key) { return 状态值: key; } public static class CacheLoadEvent { private final String key; CacheLoadEvent(String key) { this.key key; } public String getKey() { return key; } } public static class CacheLoadListener { Subscribe public void onCacheLoad(CacheLoadEvent event) { System.out.println([event] 加载缓存 key event.getKey()); } } }8.3 预期输出与检查点正常运行时输出应该类似第一次查询状态值:status:1 [event] 加载缓存 keystatus:1 第二次查询状态值:status:1 命中率0.5这里的顺序可能与示例不完全一致取决于事件总线的同步打印时机。关键检查点有三个第二次查询没有打印[event]说明缓存命中没有重复加载。命中率为 0.5说明两次查询中一次没命中、一次命中。修改queryKey为一个不在VALID_KEYS中的值时程序应提前返回不会触发数据库加载。这个例子虽然小但足以验证 Guava 的依赖是否可用、CacheBuilder是否自动加载、EventBus是否能收到事件以及BloomFilter是否按预期拦截。学习时建议自己动手把代码跑一遍再尝试调大maximumSize、缩短过期时间观察行为变化。9. 常见问题排查从异常日志倒推原因9.1 NoSuchMethodError 与 NoClassDefFoundError现象启动正常运行某个调用 Guava 方法的地方突然报NoSuchMethodError。可能原因classpath 中存在多个 Guava 版本且实际加载的是旧版本旧版本没有新版本中的方法。检查方式mvn dependency:tree -Dincludescom.google.guava:guava或者排查指定包mvn dependency:tree -Dincludescom.google.guava解决方案在父工程显式声明版本使所有依赖统一使用同一个版本。如果业务模块确实需要不同版本至少要确认是同一个大版本线。注意部分框架传递依赖的是guava的测试包或guava-gwt这类 artifact 与主包版本不匹配也可能导致类加载异常。统一版本时最好把相关 artifact 一并检查。9.2 缓存配置明明写了过期却好像一直不过期现象expireAfterWrite(10, TimeUnit.MINUTES)已配置但过了时间缓存数据仍然存在或数量没有下降。可能原因没有理解 Guava 缓存的惰性清理机制。缓存没有后台线程主动清除过期数据只有后续读写操作触发时才会顺带清理。检查方式在长时间无访问后再执行一次get观察是否重新加载。如果重新加载了说明过期机制正常。解决方案明确过期时间不是“绝对精确到秒级”的。需要更精准淘汰语义时调整refreshAfterWrite结合后台刷新线程。对缓存命中率有严格监控需求时开启recordStats()并定期打印。9.3 EventBus 事件发布后订阅方法没有执行现象执行eventBus.post()后没有调用订阅方法也没有异常。排查顺序确认订阅对象执行过eventBus.register(listener)。确认订阅方法的参数类型与post的对象类型完全一致。确认订阅方法标注了Subscribe且方法是 public。如果事件被发布到异步AsyncEventBus确认线程池正常执行。EventBus 默认不匹配父类类型。比如post(new String(hello))和参数为Object的订阅方法不会自动匹配。需要处理这类事件时可以在 register 一个DeadEvent监听器观察未匹配事件。9.4 Immutable 集合添加元素时报 NPE现象ImmutableList.builder().add(x).build()抛出NullPointerException但代码里没看到明显空指针。原因Guava 不可变集合不允许 null 元素。build()内部会拒绝 null并通过 NPE 提醒开发者。解决方案在 add 之前过滤 null或者确认业务数据中 null 的含义必要时使用 Optional 包装。不要试图用反射绕过这个约束。9.5 BloomFilter 误判率高于预期现象设置了fpp0.01但实际误判率明显偏高。可能原因expectedInsertions设置过小实际插入数据量远超预期或者funnel使用错误导致不同元素生成了相同的哈希位。检查方式查看插入数据的规模在构造函数中加大expectedInsertions。如果数据量本身不确定建议按最高值估算因为布隆过滤器在元素数量超过预期后误判率会快速上升。问题现象常见原因检查方式处理建议运行时 NoSuchMethodErrorGuava 多版本冲突dependency:tree锁定统一版本缓存过期后仍存在惰性清理机制观察下一次 get 是否加载明确过期周期或使用 refreshAfterWriteEventBus 事件未触发类型不匹配或未注册增加 DeadEvent 监听检查订阅方法类型Immutable 集合