多线程访问的简易定时过期缓存技术问题咨询
背景代码
public class ExpirationCache<T> { private final Supplier<T> computable; private final long validTime; private final Lock lock = new ReentrantLock(); private LocalDate lastAccess; private Future<T> data; public ExpirationCache(Supplier<T> computable, long validTime) { this.computable = computable; this.validTime = validTime; } public boolean hasExpired() { if( lastAccess == null ){ return true; } return LocalDateTime.now().isAfter(lastAccess.plus(validTime, ChronoUnit.MILLIS)); } public T getData() throws InterruptedException { while (true) { if (hasExpired()) { FutureTask<T> ft = new FutureTask<T>(computable::get); try { lock.lock(); if (hasExpired()) { data.cancel(true); data = ft; } } finally { lastAccess = LocalDate.now(); lock.unlock(); } } try { return data.get(); } catch (CancellationException e) { throw new RuntimeException(e); } catch (ExecutionException e) { throw new RuntimeException(e); } } } }
核心疑问
围绕data和lastAccess变量的线程安全问题:
- 是否存在场景:线程A调用
hasExpired()返回false,但随后线程B调用getData()时hasExpired()返回true,进入同步块修改data引用,导致线程A出现未定义行为? - 加锁能否防止指令重排?会不会出现线程设置了
data和lastAccess的新值,但其他线程只看到lastAccess新值、data旧值的情况?
如果上述场景存在,正确的修复方式是什么?是否需要将lastAccess或data改为AtomicReference?对hasExpired()加锁会让整个类变成同步实现,有没有更优方案?
另外,当数据计算耗时超过validTime时,代码会在设置新的Future引用前取消原有操作,这是否意味着computable需要监听中断标志以正确退出计算?
问题解答
1. 第一个场景确实存在风险
线程A调用hasExpired()返回false后,线程B刚好触发缓存过期并进入同步块修改data引用。线程A后续执行data.get()时,可能拿到刚被取消的Future,或是新的Future,但核心问题是:线程A读取data时无锁保护,无法保证看到最新值,甚至可能因缓存一致性或指令重排问题,读取到过期的data引用,导致get()调用出现预期外异常。
2. 指令重排与可见性问题
- 加锁(
ReentrantLock)本身能防止指令重排:lock()和unlock()会建立内存屏障,保证锁内操作不会被重排到锁外,且解锁后变量修改对后续加锁线程可见。 - 但当前代码存在逻辑漏洞:
lastAccess在finally块中无条件更新,即使同步块内判断缓存未过期,也会刷新有效期,不符合预期。同时,锁外读取lastAccess和data时无同步机制,可能出现“看到lastAccess新值、但data还是旧引用”的情况——因为没有内存屏障保证两个变量修改的原子性可见性。
修复方案
方案一:保证可见性与逻辑正确性
无需使用AtomicReference,通过volatile和逻辑调整即可解决:
private volatile LocalDateTime lastAccess; // 替换LocalDate:LocalDate无时分秒,过期判断不准确 private volatile Future<T> data; public boolean hasExpired() { if (lastAccess == null) { return true; } return LocalDateTime.now().isAfter(lastAccess.plus(validTime, ChronoUnit.MILLIS)); } public T getData() throws InterruptedException { while (true) { lock.lock(); try { if (hasExpired()) { if (data != null) { data.cancel(true); } FutureTask<T> ft = new FutureTask<>(computable::get); data = ft; lastAccess = LocalDateTime.now(); ft.run(); // 锁内启动任务,避免后续get()阻塞 } } finally { lock.unlock(); } try { return data.get(); } catch (CancellationException e) { // 任务被取消,循环重试获取新数据 continue; } catch (ExecutionException e) { throw new RuntimeException(e); } } }
关键调整点:
- 用
LocalDateTime替换LocalDate,保证过期时间计算精确到毫秒。 - 给
lastAccess和data加volatile修饰,确保锁外读取能获取最新值。 - 将
lastAccess更新移到锁内,仅在缓存过期时刷新,修正原逻辑错误。 - 锁内启动Future任务,避免多线程等待同一任务执行。
方案二:全锁同步(简单直接)
若不介意性能损耗,可将所有对data和lastAccess的访问(包括hasExpired()和data.get())都放入锁内。这种方式完全避免可见性问题,但data.get()的阻塞会占用锁,导致其他线程无法获取缓存,性能不如方案一。
关于computable的中断处理
是的,调用data.cancel(true)后,computable的get()方法必须监听中断标志才能正确退出。如果computable不响应中断,cancel(true)仅会标记Future为取消状态,无法真正停止后台计算,会造成资源浪费。
示例响应中断的computable实现:
Supplier<BigData> computable = () -> { while (!Thread.currentThread().isInterrupted()) { // 执行分段计算逻辑 if (计算完成) { return result; } } throw new InterruptedException("任务被中断"); };
内容的提问来源于stack exchange,提问作者Johnyb

