news 2026/8/28 9:01:27

Java单机服务轻量级本地缓存实现:ConcurrentHashMap与定时清理策略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java单机服务轻量级本地缓存实现:ConcurrentHashMap与定时清理策略

1. 项目概述:为什么我们需要一个轻量级的本地存储方案?

在开发单机服务,比如一个后台管理工具、一个数据批处理脚本,或者一个轻量级的API网关时,我们经常会遇到一个看似简单却让人纠结的问题:一些临时数据,比如用户登录后的Token、短信验证码、第三方接口的调用凭证,该存到哪里?这些数据有几个共同特点:数据量极小(可能就是几个字符串)、生命周期短(几分钟到几小时)、不需要严格持久化(服务重启丢了可以重新生成),但对读写性能便捷性要求却很高。

第一时间,很多开发者会想到Redis或者Memcached。没错,它们是分布式缓存的标杆,性能强悍,功能丰富。但为了存几个Token而引入一个Redis服务,就像为了喝杯牛奶而养一头奶牛。你需要额外维护一个服务进程,考虑它的高可用、内存配置、网络连接,对于单机部署的小型服务来说,这无疑是架构上的过度设计,增加了部署复杂度和运维成本。尤其是在一些资源受限的边缘计算环境、快速原型验证,或者希望分发出去就是一个可执行JAR包的工具中,这种“重型”依赖显得尤为不合时宜。

那么,用数据库行吗?哪怕是轻量级的SQLite或者H2?对于每秒可能产生上百次的Token校验请求,频繁的数据库IO操作会成为明显的性能瓶颈,而且同样引入了额外的依赖和复杂度。

因此,这个项目的核心价值就凸显出来了:在Java单机服务中,实现一个无需任何第三方中间件(如Redis、Memcached、数据库)的、轻量级、高性能的临时数据存储方案。它瞄准的就是上述那个“尴尬”的场景——数据不值得兴师动众,但又必须有个地方高效地暂存。这个方案的核心诉求很明确:零外部依赖、内存级速度、实现简单、资源消耗极低。接下来,我们就深入拆解如何从零构建这样一个“瑞士军刀”式的本地存储组件。

2. 核心需求解析与技术选型背后的逻辑

在动手写代码之前,我们必须把需求掰开揉碎,搞清楚我们要的究竟是什么,以及为什么某些技术路径比另一些更合适。

2.1 需求场景的深度剖析

  1. 数据特性与生命周期管理

    • Token(如JWT):通常具有明确的过期时间(exp)。存储核心是高效的key-value查询(根据Token查用户信息)和自动过期清理。Token一旦签发,在有效期内基本是只读的,失效后需立即清理。
    • 验证码:生命周期极短(60-180秒),过期后必须失效。并发场景下需注意防重放攻击(同一个验证码不能使用两次)。存储需要支持简单的key-value,并可能附带生成时间戳。
    • 接口调用凭证(如AccessToken):这类凭证往往有调用频率限制和过期时间。存储需要支持key-value,并可能需要记录最后刷新时间或已调用次数。

    它们的共性是:都是key-value结构,且value通常是一个可序列化的对象(字符串、JSON对象等);都具有强烈的时效性。

  2. “轻量级”与“无依赖”的权衡

    • “无依赖”的边界:我们指的是不依赖外部服务(Redis)或需要额外启动的嵌入式服务。但我们可以充分利用JDK自带的标准库,这是“零成本”的依赖。
    • “轻量级”的体现:内存占用小、GC友好、API简洁、线程安全开销低。这意味着我们要谨慎选择JDK中的数据结构,避免不必要的对象创建和锁竞争。

2.2 技术方案选型:为什么是ConcurrentHashMap + ScheduledExecutorService?

面对这个需求,JDK给我们提供了几个候选工具,我们需要逐一分析其优劣:

  1. 方案一:纯HashMap/ConcurrentHashMap

    • 优点:绝对的简单、速度最快。ConcurrentHashMap提供了高效的并发读写。
    • 致命缺点无法自动处理过期数据。过期条目会永远占据内存,导致内存泄漏。我们需要自己实现一个“清扫”逻辑。
  2. 方案二:WeakHashMap

    • 原理:以键(Key)为弱引用,当键对象不再被其他地方引用时,条目会被自动GC。这听起来很适合存Token?
    • 现实骨感:Token的Key(如token字符串本身)只要还在被外部请求引用,就不会被GC。而我们希望的是基于时间的过期,而非基于引用。因此WeakHashMap不适用。
  3. 方案三:Guava Cache / Caffeine

    • 优点:功能强大,支持基于时间和大小的过期、异步刷新、统计等,是生产级选择。
    • 缺点引入了第三方库依赖。虽然Guava/Caffeine非常优秀且广泛使用,但这违背了我们“无第三方依赖”的硬性约束。对于追求极致纯净的单机工具,这个依赖可能不被接受。
  4. 我们的选择:ConcurrentHashMap+ScheduledExecutorService

    • 核心思想:用ConcurrentHashMap提供高性能并发存储,用一个后台定时调度线程,定期扫描并清理过期的条目。
    • 为什么是它
      • 完全满足“无依赖”:两者都是java.util.concurrent包下的标准JDK组件。
      • 可控性强:我们可以精确控制清理策略(如每隔30秒扫描一次)、过期判断逻辑,甚至实现更复杂的策略(如惰性删除)。
      • 性能平衡ConcurrentHashMap的读写在大多数情况下是常数时间复杂度。定期的清理任务在数据量不大时开销极低。
      • 资源消耗清晰:只有一个额外的后台线程,内存中只有一份Map结构,非常透明。

    注意:这个方案不是银弹。它的一个潜在问题是,如果过期数据量巨大,单次扫描清理可能会引起短暂的延迟。但对于我们设定的“数据量小”的场景(几千到几万条),这个影响微乎其微。如果数据量真的增长到十万级以上,那可能意味着这个场景已经超出了“轻量级临时存储”的范畴,需要重新评估架构。

3. 核心设计与实现细节

确定了技术方案,我们来设计这个本地缓存的核心组件。我们将它命名为SimpleLocalCache

3.1 数据结构设计:存储的不只是值

我们不能只存value,还必须存下它的“出生时间”和“寿命”,这样才能判断是否过期。

import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; /** * 轻量级本地缓存,适用于存储Token、验证码等临时数据。 * @param <K> 键类型 * @param <V> 值类型 */ public class SimpleLocalCache<K, V> { // 核心存储结构:键 -> 缓存条目(值 + 过期时间戳) private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>(); // 后台清理线程池(单线程即可) private final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "LocalCache-CleanupThread"); t.setDaemon(true); // 设置为守护线程,防止阻止JVM关闭 return t; }); // 缓存条目内部类 private static class CacheEntry<V> { final V value; final long expireAt; // 过期时间点(毫秒时间戳) CacheEntry(V value, long expireAt) { this.value = value; this.expireAt = expireAt; } boolean isExpired() { return System.currentTimeMillis() > expireAt; } } // ... 后续方法 }

设计要点解析

  1. CacheEntry封装:将值和过期时间打包。expireAt是绝对时间戳,比存储“存活时长”更直观,且避免了每次访问都要计算。
  2. 守护线程:清理线程设置为Daemon线程,这样当主线程退出时,JVM不会因为这个线程而等待,可以正常关闭。这对于单机工具非常重要。
  3. ConcurrentHashMap:选择它是因为我们的读写操作很可能来自多个线程(如Web容器的线程池)。它提供了最好的并发性能。

3.2 初始化与清理策略的实现

缓存需要启动清理任务,并提供优雅关闭的接口。

public class SimpleLocalCache<K, V> { // ... 接上文字段定义 /** * 创建缓存实例 * @param cleanupIntervalSeconds 后台清理任务执行间隔(秒) */ public SimpleLocalCache(long cleanupIntervalSeconds) { // 启动定时清理任务 cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredEntries, cleanupIntervalSeconds, cleanupIntervalSeconds, TimeUnit.SECONDS); } /** * 清理过期条目 */ private void cleanupExpiredEntries() { long now = System.currentTimeMillis(); cache.entrySet().removeIf(entry -> { CacheEntry<V> cacheEntry = entry.getValue(); // 如果条目已过期,则移除 return cacheEntry != null && cacheEntry.expireAt <= now; }); } /** * 手动触发一次清理(可用于特殊场景,如内存紧张时) */ public void cleanupNow() { cleanupExpiredEntries(); } /** * 关闭缓存,释放资源(非常重要!) */ public void shutdown() { cleanupScheduler.shutdown(); try { // 等待现有任务完成,最多等5秒 if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) { cleanupScheduler.shutdownNow(); // 强制关闭 } } catch (InterruptedException e) { cleanupScheduler.shutdownNow(); Thread.currentThread().interrupt(); // 恢复中断状态 } cache.clear(); } // ... 后续的put/get方法 }

实操心得

  • 清理间隔cleanupIntervalSeconds不宜过短(如1秒),会造成无意义的CPU空转;也不宜过长(如10分钟),会导致过期数据长时间占据内存。根据业务场景,30秒到5分钟是一个合理的范围。对于验证码这种60秒过期的数据,间隔设为30秒可以保证数据在过期后最多残留30秒。
  • shutdown()方法必须调用:特别是在Web应用(如Spring Boot)中,当应用关闭时,务必在@PreDestroyDisposableBean中调用shutdown(),以关闭后台线程,避免线程泄漏。这是一个容易被忽略但至关重要的步骤。

3.3 核心API:put、get与删除

这是缓存对外的核心接口,设计时要考虑线程安全和性能。

public class SimpleLocalCache<K, V> { // ... 接上文 /** * 存入缓存 * @param key 键 * @param value 值 * @param ttl 存活时间,单位毫秒 */ public void put(K key, V value, long ttl) { if (ttl <= 0) { // TTL小于等于0,视为不缓存或立即过期,直接返回或可选择存入一个立即过期的条目 // 这里选择直接返回,不存储 return; } long expireAt = System.currentTimeMillis() + ttl; CacheEntry<V> entry = new CacheEntry<>(value, expireAt); cache.put(key, entry); } /** * 获取缓存值 * @param key 键 * @return 值,如果不存在或已过期则返回null */ public V get(K key) { CacheEntry<V> entry = cache.get(key); if (entry == null) { return null; // 键不存在 } // 惰性过期检查:在获取时也检查是否过期 if (entry.isExpired()) { // 如果发现已过期,立即移除(惰性删除) cache.remove(key, entry); // 使用remove(key, oldValue)保证原子性 return null; } return entry.value; } /** * 删除指定键的缓存 * @param key 键 */ public void remove(K key) { cache.remove(key); } /** * 清空所有缓存 */ public void clear() { cache.clear(); } /** * 获取当前缓存中的有效条目数量(近似值,因为可能有已过期但未被清理的) */ public int size() { // 注意:此size可能包含已过期的条目,直到下次清理或get时被惰性删除 return cache.size(); } }

关键细节与避坑指南

  1. TTL的处理put方法接收ttl(存活时间),我们内部将其转换为绝对的expireAt时间戳。这样在检查过期时,只需要和当前时间比较一次,效率更高。一定要处理ttl <= 0的情况,避免存入一个“过去”的过期时间。
  2. 惰性删除(Lazy Eviction):在get方法中,我们不仅从Map里取数据,还检查是否过期。如果过期,我们立即移除它。这是一种惰性删除策略。它结合了后台定时清理,构成了双重过期保障
    • 后台定时清理:定期扫描,清理“僵尸”条目(那些再也不会被访问的过期数据)。
    • 惰性删除:在每次访问时检查并清理。这保证了当你获取一个key时,拿到的绝对是未过期的数据,即使它刚好在两次定时清理之间过期。
  3. 原子性移除cache.remove(key, entry)使用了ConcurrentHashMapremove(Object key, Object value)方法。这个方法只有在当前Map中key对应的值恰好是entry时才移除。这可以防止一种极端情况:在检查过期和移除之间,另一个线程刚好用新的值更新了这个key。使用这个方法可以避免误删新数据。
  4. size()方法的准确性:由于惰性删除的存在,size()方法返回的数量可能包含已过期但未被触发的条目。因此,它只是一个近似值。如果业务需要精确的有效数量,可以遍历所有条目进行计数,但性能损耗大,一般不推荐。

4. 高级特性与生产环境考量

一个基础的缓存已经完成,但要用于实际生产,我们还需要考虑更多边界情况和增强功能。

4.1 容量限制与淘汰策略

我们的场景是“数据量小”,但为了防止意外(如验证码遭受攻击被刷入大量垃圾数据),最好加入简单的容量限制。

public class SimpleLocalCache<K, V> { private final int maxCapacity; private final AtomicInteger currentSize = new AtomicInteger(0); // 近似大小 public SimpleLocalCache(long cleanupIntervalSeconds, int maxCapacity) { // ... 初始化清理任务 this.maxCapacity = maxCapacity; } public void put(K key, V value, long ttl) { if (ttl <= 0) return; // 容量检查(简易版) if (currentSize.get() >= maxCapacity) { // 触发清理,尝试腾出空间 cleanupExpiredEntries(); // 清理后再次检查 if (currentSize.get() >= maxCapacity) { // 如果仍然满,可以采取简单策略:拒绝写入或淘汰一个(如随机淘汰) // 这里选择拒绝写入并记录日志 System.err.println("[LocalCache] Capacity exceeded, reject put for key: " + key); return; } } long expireAt = System.currentTimeMillis() + ttl; CacheEntry<V> oldEntry = cache.put(key, new CacheEntry<>(value, expireAt)); // 更新大小计数器 if (oldEntry == null) { // 如果是新增,计数器+1 currentSize.incrementAndGet(); } // 如果是覆盖,大小不变 } private void cleanupExpiredEntries() { long now = System.currentTimeMillis(); int removedCount = 0; Iterator<Map.Entry<K, CacheEntry<V>>> iterator = cache.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<K, CacheEntry<V>> entry = iterator.next(); if (entry.getValue().expireAt <= now) { iterator.remove(); removedCount++; } } if (removedCount > 0) { currentSize.addAndGet(-removedCount); } } // 在get方法中惰性删除时,也需要更新计数器 public V get(K key) { CacheEntry<V> entry = cache.get(key); if (entry == null) return null; if (entry.isExpired()) { if (cache.remove(key, entry)) { currentSize.decrementAndGet(); // 原子递减 } return null; } return entry.value; } // remove和clear方法也需要同步更新currentSize }

注意事项

  • currentSize是一个近似值,因为在并发环境下,精确维护大小代价很高。我们用它做一个快速的容量预判。
  • 淘汰策略这里实现的是容量满则拒绝写入。更复杂的策略如LRU(最近最少使用)需要维护额外的数据结构(如LinkedHashMap),会引入复杂性和性能开销,与“轻量级”初衷相悖。对于临时数据,拒绝写入通常是可接受的,因为客户端会收到错误,可以重试或等待缓存空间释放。

4.2 监听器与事件通知(可选)

在某些场景下,我们可能想知道缓存条目何时被移除(过期或手动删除),以便执行一些后续逻辑(如记录日志、通知其他组件)。

public class SimpleLocalCache<K, V> { // 监听器接口 public interface RemovalListener<K, V> { void onRemoval(K key, V value, RemovalCause cause); } public enum RemovalCause { EXPLICIT, // 显式调用remove EXPIRED, // 过期 REPLACED, // 被新值覆盖 CAPACITY // 因容量限制被淘汰(如果实现了的话) } private final List<RemovalListener<K, V>> listeners = new CopyOnWriteArrayList<>(); public void addRemovalListener(RemovalListener<K, V> listener) { listeners.add(listener); } // 在删除条目时触发监听器 private void notifyRemoval(K key, V value, RemovalCause cause) { for (RemovalListener<K, V> listener : listeners) { try { listener.onRemoval(key, value, cause); } catch (Exception e) { // 避免一个监听器的异常影响其他监听器和主流程 System.err.println("[LocalCache] Error in removal listener: " + e.getMessage()); } } } // 修改remove方法和清理逻辑,在真正删除前调用notifyRemoval private boolean removeEntry(K key, CacheEntry<V> entry, RemovalCause cause) { if (entry == null) return false; V value = entry.value; if (cache.remove(key, entry)) { currentSize.decrementAndGet(); notifyRemoval(key, value, cause); return true; } return false; } // 在get中触发过期删除 public V get(K key) { CacheEntry<V> entry = cache.get(key); if (entry == null) return null; if (entry.isExpired()) { removeEntry(key, entry, RemovalCause.EXPIRED); return null; } return entry.value; } // 在put中,如果覆盖了旧值,也需要通知 public void put(K key, V value, long ttl) { // ... 容量检查等逻辑 CacheEntry<V> newEntry = new CacheEntry<>(value, expireAt); CacheEntry<V> oldEntry = cache.put(key, newEntry); if (oldEntry == null) { currentSize.incrementAndGet(); } else { // 覆盖旧值,触发监听 notifyRemoval(key, oldEntry.value, RemovalCause.REPLACED); } } }

使用场景:例如,你可以添加一个监听器来打印日志,监控缓存的淘汰情况;或者在Token过期被移除时,尝试异步刷新(但注意,在单机缓存中做刷新逻辑要谨慎,避免循环依赖)。

4.3 序列化与持久化思考(非必需)

标题要求“不需要持久化”,所以我们默认所有数据都在内存中。但在某些边缘场景,比如希望服务重启后某些凭证能恢复(尽管可能已过期),我们可以提供一个可选的、简单的持久化层。

重要提示:这违背了“轻量级”的初衷,仅作为思路扩展。

public class SimpleLocalCache<K, V> { // 假设K和V都是可序列化的 public void saveToFile(String filePath) throws IOException { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(filePath))) { // 只保存未过期的条目 Map<K, CacheEntry<V>> snapshot = new HashMap<>(); long now = System.currentTimeMillis(); for (Map.Entry<K, CacheEntry<V>> entry : cache.entrySet()) { if (!entry.getValue().isExpired(now)) { snapshot.put(entry.getKey(), entry.getValue()); } } oos.writeObject(snapshot); } } @SuppressWarnings("unchecked") public void loadFromFile(String filePath) throws IOException, ClassNotFoundException { try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filePath))) { Map<K, CacheEntry<V>> loaded = (Map<K, CacheEntry<V>>) ois.readObject(); cache.clear(); cache.putAll(loaded); // 需要重新计算currentSize currentSize.set(cache.size()); } } }

强烈建议:除非有非常强烈的理由,否则不要为这种临时缓存添加持久化。它极大地增加了复杂性(序列化兼容性、文件锁、数据一致性),并且与数据的“临时”性质相悖。Token、验证码这类数据,丢了就重新生成,这才是最简洁的设计。

5. 在典型场景中的集成与应用示例

现在,我们有了一个功能相对完善的SimpleLocalCache,来看看如何在文章开头提到的几个典型场景中使用它。

5.1 场景一:存储与验证JWT Token

假设我们有一个简单的用户认证服务,用户登录后生成JWT Token,我们需要在服务端缓存一部分Token信息(如用户ID与Token的映射),以便快速实现Token吊销或单点登录踢出。

public class TokenService { // 缓存实例:键为Token字符串,值为用户ID private final SimpleLocalCache<String, String> tokenCache = new SimpleLocalCache<>(60, 10000); // 60秒清理一次,最大容量10000 /** * 用户登录成功,缓存Token * @param token JWT Token * @param userId 用户ID * @param expiresInSeconds Token有效期(秒) */ public void cacheUserToken(String token, String userId, long expiresInSeconds) { // 将有效期转换为毫秒存入缓存 tokenCache.put(token, userId, expiresInSeconds * 1000); } /** * 验证Token是否有效(是否在缓存中且未过期) * @param token * @return 对应的用户ID,如果无效则返回null */ public String validateToken(String token) { return tokenCache.get(token); // get方法已包含过期检查 } /** * 用户登出或管理员吊销Token * @param token */ public void invalidateToken(String token) { tokenCache.remove(token); } // 应用关闭时清理资源 @PreDestroy public void destroy() { tokenCache.shutdown(); } }

实操要点

  • 缓存什么?这里缓存的是Token -> UserId的映射。你不需要缓存完整的JWT payload,因为JWT本身是无状态的,验证其签名即可。缓存映射的目的是为了支持服务端的主动吊销。
  • TTL设置:缓存的有效期应该略短于JWT Token本身的过期时间(例如,JWT过期是3600秒,缓存设置3500秒)。这样可以确保缓存条目先过期,避免出现缓存里还有,但JWT已过期的情况。
  • 容量规划maxCapacity应根据你的最大并发用户数来设置。对于后台管理系统,10000可能足够;对于高并发C端服务,这个方案可能就不适用了。

5.2 场景二:短信验证码的生成与校验

这是本地缓存最经典的应用场景之一。

public class SmsCodeService { // 缓存实例:键为手机号,值为验证码和生成时间(封装在一个对象里) private final SimpleLocalCache<String, SmsCodeInfo> smsCache = new SimpleLocalCache<>(30, 50000); // 30秒清理,容量5万 static class SmsCodeInfo { String code; // 验证码 long generateTime; // 生成时间戳 // 还可以加尝试次数等字段 } /** * 生成并发送验证码 * @param phoneNumber 手机号 * @return 生成的验证码(仅用于演示,实际应调用短信服务商接口) */ public String generateAndSendCode(String phoneNumber) { // 1. 生成随机6位数字码 String code = String.format("%06d", ThreadLocalRandom.current().nextInt(1000000)); // 2. 封装信息 SmsCodeInfo info = new SmsCodeInfo(); info.code = code; info.generateTime = System.currentTimeMillis(); // 3. 存入缓存,有效期120秒 smsCache.put(phoneNumber, info, 120 * 1000); // 4. 模拟发送短信(实际应调用短信网关) System.out.println("发送验证码 " + code + " 至手机 " + phoneNumber); return code; } /** * 校验验证码 * @param phoneNumber 手机号 * @param userInputCode 用户输入的验证码 * @return 是否验证成功 */ public boolean verifyCode(String phoneNumber, String userInputCode) { SmsCodeInfo info = smsCache.get(phoneNumber); if (info == null) { return false; // 验证码不存在或已过期 } // 可选:增加额外校验,比如验证码是否在1分钟内使用(防重放) long currentTime = System.currentTimeMillis(); if (currentTime - info.generateTime > 60 * 1000) { smsCache.remove(phoneNumber); // 超过1分钟,即使未到120秒也强制失效 return false; } // 核心校验:验证码是否匹配 boolean success = info.code.equals(userInputCode); if (success) { // 验证成功,立即移除验证码,防止重复使用 smsCache.remove(phoneNumber); } else { // 验证失败,可以记录失败次数,达到阈值后使验证码失效 // ... 此处可扩展 } return success; } }

避坑指南

  • 防重放攻击:上面代码中有一个校验:验证码生成后超过1分钟即失效,即使总有效期是2分钟。这是为了防止用户慢速攻击。更完善的机制可以引入“尝试次数”,失败3次后验证码自动失效。
  • 原子性校验:在超高并发下,可能存在同一手机号连续发送两次验证码,后一个覆盖前一个的情况。SimpleLocalCacheput操作是原子的,可以保证最后一次存入的生效。如果业务要求“不允许频繁发送”,需要在generateAndSendCode方法外加分布式锁或使用更精确的限流器,这已超出本地缓存范畴。
  • 验证码移除时机:一定要在验证成功后立即移除remove),这是保证验证码一次性使用的关键。

5.3 场景三:缓存第三方API的AccessToken

调用微信、支付宝等第三方API时,通常需要先获取一个有过期时间的AccessToken,并在有效期内重复使用。

public class WechatApiClient { private final SimpleLocalCache<String, AccessToken> tokenCache = new SimpleLocalCache<>(300, 10); // 5分钟清理,容量10(通常只存1个) private final HttpClient httpClient = HttpClient.newHttpClient(); static class AccessToken { String token; long expiresIn; // 有效期,单位秒(从API返回) long fetchTime; // 获取时间戳 } /** * 获取AccessToken(带缓存) * @return 有效的token字符串 */ public String getAccessToken() throws Exception { String cacheKey = "wechat_access_token"; AccessToken cached = tokenCache.get(cacheKey); // 如果缓存中有且未过期(预留5分钟缓冲),直接返回 if (cached != null && !isTokenAboutToExpire(cached)) { return cached.token; } // 缓存无效,重新获取 synchronized (this) { // 简单的同步锁,防止多个线程同时刷新token // 双重检查锁定模式 cached = tokenCache.get(cacheKey); if (cached != null && !isTokenAboutToExpire(cached)) { return cached.token; } AccessToken newToken = fetchNewAccessTokenFromApi(); // 存入缓存,TTL设置为实际过期时间(毫秒) tokenCache.put(cacheKey, newToken, newToken.expiresIn * 1000); return newToken.token; } } private boolean isTokenAboutToExpire(AccessToken token) { // 判断token是否在5分钟内过期 long timeElapsed = System.currentTimeMillis() - token.fetchTime; long timeLeft = token.expiresIn * 1000 - timeElapsed; return timeLeft < 5 * 60 * 1000; // 剩余时间小于5分钟 } private AccessToken fetchNewAccessTokenFromApi() throws Exception { // 模拟调用微信API String response = ""; // 实际为HTTP请求结果 // 解析response,得到token和expires_in AccessToken token = new AccessToken(); token.token = "模拟的AccessToken"; token.expiresIn = 7200; // 微信通常是7200秒 token.fetchTime = System.currentTimeMillis(); return token; } }

核心技巧

  • 缓冲期isTokenAboutToExpire方法中,我们判断剩余时间是否小于5分钟。这意味着我们不会等到token完全过期才刷新,而是提前刷新。这避免了在token过期的瞬间,大量请求同时触发刷新,也避免了在临界点拿到一个刚过期的token去调用接口。
  • 同步锁:使用synchronized防止多个线程同时去刷新token,造成重复请求和资源浪费。这是一个简单的单机锁。在分布式环境下,需要更复杂的分布式锁机制,但这又超出了本地缓存的范畴。
  • 容量设置:这种全局唯一的token,缓存容量设置为10都绰绰有余。

6. 性能测试、监控与常见问题排查

即使是一个简单的组件,我们也需要了解它的表现和可能遇到的问题。

6.1 简易性能压测思路

我们可以写一个简单的测试,模拟并发读写。

public class SimpleLocalCacheBenchmark { public static void main(String[] args) throws InterruptedException { SimpleLocalCache<String, String> cache = new SimpleLocalCache<>(60, 100000); int threadCount = 10; int operationsPerThread = 10000; ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(threadCount); long start = System.currentTimeMillis(); for (int i = 0; i < threadCount; i++) { final int threadId = i; executor.submit(() -> { try { for (int j = 0; j < operationsPerThread; j++) { String key = "key-" + threadId + "-" + j; String value = "value-" + j; // 80%读,20%写 if (ThreadLocalRandom.current().nextDouble() < 0.8) { cache.get(key); } else { cache.put(key, value, 60000); // 1分钟过期 } } } finally { latch.countDown(); } }); } latch.await(); long end = System.currentTimeMillis(); executor.shutdown(); System.out.println("总操作数: " + (threadCount * operationsPerThread)); System.out.println("总耗时(ms): " + (end - start)); System.out.println("吞吐量(ops/ms): " + (threadCount * operationsPerThread * 1.0 / (end - start))); System.out.println("最终缓存大小: " + cache.size()); cache.shutdown(); } }

在我的开发机(8核)上,这个简单的测试能达到每秒数十万次的操作吞吐量,对于单机临时存储场景完全够用。性能瓶颈主要在于ConcurrentHashMap本身,而它是高度优化的。

6.2 监控与日志

在生产环境中,我们可能需要知道缓存的使用情况。

  1. 添加统计信息:可以在SimpleLocalCache内部增加计数器。

    private final AtomicLong hitCount = new AtomicLong(0); private final AtomicLong missCount = new AtomicLong(0); private final AtomicLong putCount = new AtomicLong(0); public V get(K key) { CacheEntry<V> entry = cache.get(key); if (entry == null) { missCount.incrementAndGet(); return null; } if (entry.isExpired()) { removeEntry(key, entry, RemovalCause.EXPIRED); missCount.incrementAndGet(); return null; } hitCount.incrementAndGet(); return entry.value; } // 在put、remove等方法中也更新对应计数器 public CacheStats getStats() { return new CacheStats(hitCount.get(), missCount.get(), putCount.get(), currentSize.get()); }
  2. 定期打印状态:可以注册一个监听器,或者通过JMX暴露这些统计信息,方便监控。

6.3 常见问题与排查清单

问题现象可能原因排查与解决方案
内存持续增长(OOM)1. 过期数据未正确清理。
2. 缓存容量maxCapacity设置过大或未生效,导致数据无限堆积。
3. 存入的value对象本身很大(如大字符串、大对象)。
1. 检查清理线程是否正常启动(日志)。检查cleanupExpiredEntries逻辑。
2. 确认maxCapacity参数已传入并生效。检查容量满时的拒绝策略是否工作。
3. 使用Profiler工具(如VisualVM)分析堆内存,确认大对象来源。确保只存必要的小数据。
获取到的数据为null,但确信刚存入1. 数据已过期(TTL设置过短或时间计算错误)。
2. 并发情况下被其他线程移除。
3. Key的hashCodeequals方法实现有问题,导致存入和获取时定位不到同一个桶。
1. 打印expireAt和当前时间戳,核对TTL计算逻辑。检查服务器时间是否同步。
2. 检查业务逻辑中是否有其他地方调用了remove
3. 确保作为Key的对象是不可变的,并且正确重写了hashCodeequals方法。
后台清理线程未关闭,导致应用无法正常退出忘记调用shutdown()方法,或者shutdown()被异常中断。1. 确保在应用关闭的钩子(如Spring的@PreDestroy、Servlet的contextDestroyed)中调用shutdown()
2. 将清理线程设置为守护线程(我们在构造函数中已设置t.setDaemon(true)),这样即使未调用shutdown,也不会阻止JVM退出。但显式关闭仍是好习惯
高并发下,size()不准或出现奇怪行为ConcurrentHashMapsize()本身是近似值,且我们的currentSize在并发更新下也是近似值。惰性删除和定时清理之间存在时间窗口。理解并接受size()不精确性。对于临时缓存,我们通常只关心其是否在正常工作,而不需要精确的条目数。如果业务强依赖精确计数,这个方案可能不适用。
缓存穿透(大量请求查询不存在的key)恶意攻击或业务bug,频繁查询一个不存在或已过期的key,请求直达底层(虽然我们这里没底层)。对于本地缓存,穿透问题影响较小,但会浪费CPU。可在get方法内,对查询不到的key,短暂地存入一个表示“空值”的条目(TTL很短,如5秒),防止瞬间重复穿透。

6.4 与Spring框架集成

在Spring Boot项目中,我们可以很方便地将SimpleLocalCache作为一个Bean来管理其生命周期。

@Configuration public class CacheConfig { @Bean public SimpleLocalCache<String, Object> localCache() { // 配置清理间隔和最大容量 return new SimpleLocalCache<>(60, 5000); } } @Service public class MyService { @Autowired private SimpleLocalCache<String, Object> localCache; public void businessMethod() { // 使用localCache localCache.put("key", someObject, 300000); } // Spring容器关闭时,会自动调用@PreDestroy方法 @PreDestroy public void cleanup() { if (localCache != null) { localCache.shutdown(); } } }

集成要点:通过@Bean创建单例,通过@PreDestroy确保资源释放,这是最优雅的集成方式。

经过以上从设计到实现,再到应用和问题排查的完整拆解,我们得到了一个真正贴合“单机、轻量、无依赖”场景的Java本地临时存储方案。它没有Redis强大,但正因为其简单,所以在特定场景下显得更加锋利和趁手。记住,没有最好的工具,只有最合适的工具。当你下次面对需要暂存几个Token或验证码的场景时,不妨考虑一下这个自研的轻量级方案,它可能会让你的项目部署包更清爽,架构更简洁。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/28 9:01:26

Wider Person数据集解析与YOLOv8密集行人检测实战指南

简介&#xff1a;在计算机视觉领域&#xff0c;目标检测是感知和理解图像内容的核心技术&#xff0c;其原理是通过算法定位并识别图像中的特定对象。这项技术的核心价值在于将像素数据转化为结构化信息&#xff0c;为高层应用提供基础。在工程实践中&#xff0c;数据集的构建与…

作者头像 李华
网站建设 2026/8/28 8:58:51

大脑+小脑协同:人形机器人具身智能架构设计与仿真实现

这次我们来看一个在具身智能与人形机器人领域反复被强调的判断&#xff1a;“最强大脑”和“最强小脑”相互需要。它说的是大模型负责“动脑”&#xff0c;运动控制负责“动手”。没有运动控制&#xff0c;大模型只能在屏幕里给出建议&#xff0c;机器人动不起来&#xff1b;没…

作者头像 李华
网站建设 2026/8/28 8:58:33

多语言推理迁移难?RP-OPSD在线自蒸馏训练范式详解

在 LLM 推理能力研究中&#xff0c;多语言推理迁移&#xff08;Multilingual Reasoning Transfer&#xff09;是一个既关键又棘手的问题。简单来说&#xff0c;我们希望模型在使用英文推理数据训练之后&#xff0c;不仅能在英文上做数学、逻辑或代码推理&#xff0c;也能在中文…

作者头像 李华
网站建设 2026/8/28 8:57:50

FancyZones 窗口管理完整指南:5 步重建你的多屏工作流

FancyZones 窗口管理完整指南&#xff1a;5 步重建你的多屏工作流 【免费下载链接】PowerToys Microsoft PowerToys is a collection of utilities that supercharge productivity and customization on Windows 项目地址: https://gitcode.com/GitHub_Trending/po/PowerToys…

作者头像 李华
网站建设 2026/8/28 8:55:18

职业院校技能大赛特色赛,获奖很容易

学生选好选拔具备潜力的学生是获奖的基础。优先选择学习能力强、动手实践能力突出、对比赛项目有浓厚兴趣的学生。关注学生的抗压能力和团队协作能力&#xff0c;确保在备赛过程中能高效配合。通过校内选拔或模拟赛筛选出综合能力突出的选手。资源找好优质资源是备赛的关键。收…

作者头像 李华