秋招面试的问题,给作为菜鸟应届生的我难住了,回答的面试官并不满意。题目如下:
背景:高并发,并且用户的消息很少变动。
ConcurrentMap<String, Object> cache = new ConcurrentHashMap<>();
public Object getUserConfig(String key){
Object value = cache.get(key);
if (value == null){
value = getInDataBase(key); // 这是一个高耗时的数据库读取操作
cache.put(key, value);
}
return value;
}
- 该代码会导致哪些问题?如何优化?
- ConcurrentHashMap 在这里真的合适吗?如果是你你会选择什么组件,更加适合该场景?
经过一晚上的查询,和同门讨论,我觉得有以下几点,欢迎大家补充和指正。
问题
- concurrentHashMap 没有淘汰机制,最坏的情况会将所有数据加载到内存,可能造成缓存溢出;
- 数据库操作没有同步机制,大量的请求会造成缓存击穿;
- 用户消息很少变动,那么写入写出缓存都是幂等的,是否一定要用到大量针对 cache的锁同步机制?
这里我们参考 guava cache的本地实现。
guava 本地缓存实现
可以使用 guava 的本地缓存方案,其既是线程安全的缓存,架构设计类似于ConcurrentHashMap,在简单场景中可以通过HashMap实现简单数据缓存;也提供了缓存淘汰策略、缓存过期策略等。 guava cache 可以通过CacheBuilder进行创建:
Cache<Integer, String> cache = CacheBuilder.newBuilder()
//设置并发级别设备核心数,并发级别是指可以同时写缓存的线程数
.concurrencyLevel(Runtime.getRuntime().availableProcessors())
//设置缓存容器的初始容量为10
.initialCapacity(10)
//设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项
.maximumSize(100)
//是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
.recordStats()
//设置写缓存后n秒钟过期
.expireAfterWrite(60, TimeUnit.SECONDS)
//设置读写缓存后n秒钟过期,实际很少用到,类似于expireAfterWrite
//.expireAfterAccess(17, TimeUnit.SECONDS)
//只阻塞当前数据加载线程,其他线程返回旧值
//.refreshAfterWrite(13, TimeUnit.SECONDS)
//设置缓存的移除通知
.removalListener(notification -> {
System.out.println(notification.getKey() + " " + notification.getValue() + " 被移除,原因:" + notification.getCause());
})
//build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存
.build();
public String getUserConfig(int key){
try {
String data = cache.get(key, new Callable<String>() {
public String call() throws Exception {
// 线程休眠两秒,模拟数据库高耗时操作
String value = UUID.randomUUID().toString();
TimeUnit.SECONDS.sleep(2);
return value;
}
});
return data;
} catch (ExecutionException e) {
throw new RuntimeException(e.getMessage());
}
}
让我们来看看get源码(guava 33.4.8-jre),我就不用文字表述了,直接加注释
// valueLoader 即为当 localcache中缓存未命中时,通过valueload加载数据
// final确保valueloader在方法内部不会被重新赋值,java要求匿名内部类使用的局部变量必须是final或effective final
public V get(K key, final Callable<? extends V> valueLoader) throws ExecutionException {
// 判断valueloader是否为空
Preconditions.checkNotNull(valueLoader);
// 根据key获取value
return this.localCache.get(key, new CacheLoader<Object, V>() {
public V load(Object key) throws Exception {
return valueLoader.call();
}
});
}
接下来我们进到this.localcache 的get方法中
// 调用该方法时可以安全的忽略返回值
@CanIgnoreReturnValue
V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {
// 类似于concurrentHashMap 根据键 key定位缓存段,同时 checkNotNull确保key不为null
int hash = this.hash(Preconditions.checkNotNull(key));
// 通过 segmentFor找到对应的缓存段,并调用get方法调用该缓存段上的 get方法
// 即其将单个 segment视为一个对象,进行并发安全性控制等操作
return this.segmentFor(hash).get(key, hash, loader);
}
接下来我们进入segment的get方法中
// LocalCache级别变量,用于获取时间戳
final Ticker ticker;
// segment级别变量,segment中 key-value数量
volatile int count;
@CanIgnoreReturnValue
// 核心加载方法,键 key,键的哈希 hash,自定义加载器 loader
V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
// 判空操作
Preconditions.checkNotNull(key);
Preconditions.checkNotNull(loader);
try {
if (this.count != 0) {
// 如果当前 segment不为空,执行 getEntry查找对应条目,这里就不提供源码了
// getEntry方法就是先获取桶的第一个对象,然后通过 for循环和 getNext方法进行遍历,并进行 hash匹配,如果成功匹配则返回,否则返回 null
// getEntry方法还提供了当获得的 entry为 null时,自动清理逻辑
ReferenceEntry<K, V> e = this.getEntry(key, hash);
if (e != null) {
// 缓存命中,先进行值的有效性检查
// 从 ticker中获取当前时间,后续用该值判断 value是否过期
// ticker调用的 System.nanoTime(),封装成对象更灵活
long now = this.map.ticker.read();
// getLiveValue先判断 key和 value是否为 null,为 null则调用清理方法进行清理
// 然后再调用 tryExpireEntries方法检查是否过期,该方法需要先获取锁,未获取锁直接返回不等待……
V value = this.getLiveValue(e, now);
if (value != null) {
// 记录操作,记录一次命中,同时触发可能的缓存刷新策略
this.recordRead(e, now);
this.statsCounter.recordHits(1);
Object var17 = this.scheduleRefresh(e, key, hash, value, now, loader);
return var17;
}
// 缓存命中,存在 key,但是 value为 null,isLoading检查是否有其他线程正在异步加载,如果有,等待,避免重复加载,避免缓存穿透
// ValueReference提供对缓存值的间接访问,主要用于对值的加载状态管理、生命周期控制等
ValueReference<K, V> valueReference = e.getValueReference();
if (valueReference.isLoading()) {
// 该等待机制通过 Future.get() 实现,而非重试或者订阅通知
Object var9 = this.waitForLoadingValue(e, key, valueReference);
return var9;
}
}
}
// 未命中,则调用 lockedGetOrLoad进行加锁的加载操作,避免缓存穿透
Object var15 = this.lockedGetOrLoad(key, hash, loader);
return var15;
} catch (ExecutionException var13) {
// 异常处理
Throwable cause = var13.getCause();
if (cause instanceof Error) {
throw new ExecutionError((Error)cause);
} else if (cause instanceof RuntimeException) {
throw new UncheckedExecutionException(cause);
} else {
throw var13;
}
} finally {
// 清理工作
this.postReadCleanup();
}
}
那么接下来,聚焦于以下三部分:
scheduleRefresh 缓存刷新策略
V scheduleRefresh(ReferenceEntry<K, V> entry, K key, int hash, V oldValue, long now, CacheLoader<? super K, V> loader) {
// 判断缓存是否启用了刷新功能,距离上次写入时间是否超过刷新间隔,确保值当前不在加载中
if (this.map.refreshes() && now - entry.getWriteTime() > this.map.refreshNanos && !entry.getValueReference().isLoading()) {
// 执行 refresh加载方法
V newValue = this.refresh(key, hash, loader, true);
if (newValue != null) {
return newValue;
}
}
return oldValue;
}
@CanIgnoreReturnValue
// @Nullable表示返回值可能为 null
@Nullable V refresh(K key, int hash, CacheLoader<? super K, V> loader, boolean checkTime) {
// LoadingValueReference 是 Guava Cache 实现高并发缓存加载的核心组件,在数据真正加载完成前,负责协调和管理所有并发请求。
// 检查并创建该 key加载中的标记,原子操作,只有一个线程可以抢到锁,返回 LoadingValueReference 对象,进行异步刷新
// 其他线程未抢到锁返回 null,直接 return,同时 LoadingValueReference会持有对旧值的引用,未获得锁的线程仍旧使用旧值
LoadingValueReference<K, V> loadingValueReference = this.insertLoadingValueReference(key, hash, checkTime);
if (loadingValueReference == null) {
return null;
} else {
// 调用 loadAsync进行异步加载,返回一个 ListenableFuture对象
ListenableFuture<V> result = this.loadAsync(key, hash, loadingValueReference, loader);
if (result.isDone()) {
try {
// 如果获取成功,使用 Uninterruptibles.getUninterruptibly 获取结果
return Uninterruptibles.getUninterruptibly(result);
} catch (Throwable var8) {
// 异常时静默处理,返回 null
}
}
return null;
}
}
ListenableFuture<V> loadAsync(K key, int hash, LoadingValueReference<K, V> loadingValueReference, CacheLoader<? super K, V> loader) {
// LoadingValueReference.loadFuture方法启动异步加载
// 通过自定义的匿名内部类中的 call方法进行缓存刷新
ListenableFuture<V> loadingFuture = loadingValueReference.loadFuture(key, loader);
// 通过完成监听器实现回调机制
loadingFuture.addListener(() -> {
try {
// 处理加载结果等相关信息
this.getAndRecordStats(key, hash, loadingValueReference, loadingFuture);
} catch (Throwable var6) {
LocalCache.logger.log(Level.WARNING, "Exception thrown during refresh", var6);
loadingValueReference.setException(var6);
}
}, MoreExecutors.directExecutor());
return loadingFuture;
}
waitForLoadingValue 等待加载
V waitForLoadingValue(ReferenceEntry<K, V> e, K key, ValueReference<K, V> valueReference) throws ExecutionException {
if (!valueReference.isLoading()) {
// 如果当前 key不在加载中状态,那么程序出现错误,抛出异常
throw new AssertionError();
} else {
// 检查当前线程是否已持有该 ReferenceEntry的锁,防止递归加载,如果持有了还等待加载就会形成死锁
Preconditions.checkState(!Thread.holdsLock(e), "Recursive load of: %s", key);
Object var7;
try {
// 调用 waitForValue
V value = valueReference.waitForValue();
if (value == null) {
throw new CacheLoader.InvalidCacheLoadException("CacheLoader returned null for key " + key + ".");
}
long now = this.map.ticker.read();
this.recordRead(e, now);
var7 = value;
} finally {
this.statsCounter.recordMisses(1);
}
return var7;
}
}
public V waitForValue() throws ExecutionException {
// 工具类
return Uninterruptibles.getUninterruptibly(this.futureValue);
}
@CanIgnoreReturnValue
@ParametricNullness
// 以不可中断的方式获取 Future执行结果
public static <V> V getUninterruptibly(Future<V> future) throws ExecutionException {
// 用于中断状态跟踪
boolean interrupted = false;
try {
while(true) {
// 循环等待结果
// 这种循环等待的状态不会浪费cpu资源,因为 future.get()是阻塞调用,本地线程阻塞等待
// 而 Redisson是分布式环境,需要考虑网络延迟,服务端压力,客户端压力,所以采用订阅通知机制
try {
Object var2 = future.get();
return var2;
} catch (InterruptedException var6) {
// 如果捕获到 InterruptedException,设置 interrupted = true并继续循环
interrupted = true;
}
}
} finally {
if (interrupted) {
// 在方法结束前,如果曾经被中断过,恢复当前线程的中断状态
Thread.currentThread().interrupt();
}
}
}
lockedGetOrLoad 加锁获取
V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
// 缓存值引用
ValueReference<K, V> valueReference = null;
// 值加载引用
LoadingValueReference<K, V> loadingValueReference = null;
// 标记是否需要创建新 key-value对象
boolean createNewEntry = true;
// 获取分段锁,确保同一 segment内的操作线程安全
this.lock();
ReferenceEntry e;
try {
long now = this.map.ticker.read();
// 写操作前清理过期的 key-value
this.preWriteCleanup(now);
int newCount = this.count - 1;
// 获取哈希表和相关索引 table,同时计算哈希桶索引 index,获取链表头节点 first
AtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;
int index = hash & table.length() - 1;
ReferenceEntry<K, V> first = (ReferenceEntry)table.get(index);
// 通过 for循环遍历哈希桶的链表,寻找匹配的键,guava cache没有使用红黑树优化
for(e = first; e != null; e = e.getNext()) {
K entryKey = e.getKey();
if (e.getHash() == hash && entryKey != null && this.map.keyEquivalence.equivalent(key, entryKey)) {
// 当哈希相等,键非空,并且键值都相等时,执行以下逻辑
// 获取该键值的值引用
valueReference = e.getValueReference();
if (valueReference.isLoading()) {
// 如果该值正在被其他线程加载,则不需要创建 key-value对象,等待现有加载完成
createNewEntry = false;
} else {
// 检查已存在的值
V value = valueReference.get();
if (value == null) {
// 值已被垃圾回收
this.enqueueNotification(entryKey, hash, value, valueReference.getWeight(), RemovalCause.COLLECTED);
} else {
if (!this.map.isExpired(e, now)) {
// 缓存命中,且值未过期,则记录相关信息,直接返回缓存值
this.recordLockedRead(e, now);
this.statsCounter.recordHits(1);
Object var16 = value;
return var16;
}
// 值已过期
this.enqueueNotification(entryKey, hash, value, valueReference.getWeight(), RemovalCause.EXPIRED);
}
// 清理无效键值对并更新计数器
this.writeQueue.remove(e);
this.accessQueue.remove(e);
this.count = newCount;
}
// 找到匹配项,退出循环
break;
}
}
// 判断是否需要创建新加载的键值对象
if (createNewEntry) {
// 创建加载标记
loadingValueReference = new LoadingValueReference();
if (e == null) {
// 哈希桶中不存在该键,创建全新条目,并将该对象状态设置成加载中
e = this.newEntry(key, hash, first);
e.setValueReference(loadingValueReference);
table.set(index, e);
} else {
// 替换现有的无效条目的引用
e.setValueReference(loadingValueReference);
}
}
} finally {
// 释放分段锁,写操作后清理
this.unlock();
this.postWriteCleanup();
}
// 根据是否创建新键值对象选择不同的路径
if (createNewEntry) {
Object var9;
try {
synchronized(e) {
// 对键值对象加锁,防止同一对象并发加载
var9 = this.loadSync(key, hash, loadingValueReference, loader);
}
} finally {
// 记录未命中统计
this.statsCounter.recordMisses(1);
}
return var9;
} else {
// 其他线程正在加载等待其结果
return this.waitForLoadingValue(e, key, valueReference);
}
}
总结
一方面,建议就用现成的 cache策略,然后我们再讨论 guava cache是如何解决缓存溢出,缓存击穿以及并发安全性的。
- 缓存溢出:可以自行配置容量,当超出容量时默认使用 LRU(最近最少使用)算法进行淘汰;同时支持过期时间,避免不常用数据长时间占用缓存空间。
- 缓存击穿:当数据过期或者缓存未命中时,通过悲观锁机制,有且仅有一个线程负责执行数据库读取操作;如果数据过期,则其他线程暂时使用旧值进行计算;如果是正在加载,则其他线程执行等待加载操作,即通过 future.get()方法获取数据,然后通过 while循环轮询 future.get()的结果。
- 并发安全性:类似于 concurrentHashMap,操作时对桶进行加锁。
因为前提条件中提到,用户的消息很少变动,那么在高并发场景下,即使对缓存的操作不是原子性的,乱序操作导致的结果也是具有幂等性的,无非就是多写入了几次而已,那么用 hashmap也是一个可以选择的方式。