緩存(Cache) 是指將程序或系統中常用的數據對象存儲在像內存這樣特定的介質中,以避免在每次程序調用時,重新創建或組織數據所帶來的性能損耗,從而提高了系統的整體運行速度。
以目前的系統架構來說,用戶的請求一般會先經過緩存系統,如果緩存中沒有相關的數據,就會在其他系統中查詢到相應的數據并保存在緩存中,最后返回給調用方。
緩存既然如此重要,那本課時我們就來重點看一下,應該如何實現本地緩存和分布式緩存?
#### 典型回答
本地緩存是指程序級別的緩存組件,它的特點是本地緩存和應用程序會運行在同一個進程中,所以本地緩存的操作會非常快,因為在同一個進程內也意味著不會有網絡上的延遲和開銷。
本地緩存適用于單節點非集群的應用場景,它的優點是快,缺點是多程序無法共享緩存,比如分布式用戶 Session 會話信息保存,由于每次用戶訪問的服務器可能是不同的,如果不能共享緩存,那么就意味著每次的請求操作都有可能被系統阻止,因為會話信息只保存在某一個服務器上,當請求沒有被轉發到這臺存儲了用戶信息的服務器時,就會被認為是非登錄的違規操作。
除此之外,無法共享緩存可能會造成系統資源的浪費,這是因為每個系統都單獨維護了一份屬于自己的緩存,而同一份緩存有可能被多個系統單獨進行存儲,從而浪費了系統資源。
分布式緩存是指將應用系統和緩存組件進行分離的緩存機制,這樣多個應用系統就可以共享一套緩存數據了,它的特點是共享緩存服務和可集群部署,為緩存系統提供了高可用的運行環境,以及緩存共享的程序運行機制。
本地緩存可以使用 EhCache 和 Google 的 Guava 來實現,而分布式緩存可以使用 Redis 或 Memcached 來實現。
由于 Redis 本身就是獨立的緩存系統,因此可以作為第三方來提供共享的數據緩存,而 Redis 的分布式支持主從、哨兵和集群的模式,所以它就可以支持分布式的緩存,而 Memcached 的情況也是類似的。
#### 考點分析
本課時的面試題顯然不只是為了問你如何實現本地緩存和分布式緩存這么簡單,主要考察的是你對緩存系統的理解,以及對緩存本質原理的洞察,和緩存相關的面試題還有這些:
* 更加深入的談談 EhCache 和 Guava。
* 如何自己手動實現一個緩存系統?
#### 知識擴展
* [ ] 1. EhCache 和 Guava 的使用及特點分析
EhCache 是目前比較流行的開源緩存框架,是用純 Java 語言實現的簡單、快速的 Cache 組件。EhCache 支持內存緩存和磁盤緩存,支持 LRU(Least Recently Used,最近很少使用)、LFU(Least Frequently Used,最近不常被使用)和 FIFO(First In First Out,先進先出)等多種淘汰算法,并且支持分布式的緩存系統。
EhCache 最初是獨立的本地緩存框架組件,在后期的發展中(從 1.2 版)開始支持分布式緩存,分布式緩存主要支持 RMI、JGroups、EhCache Server 等方式。
* LRU 和 LFU 的區別
LRU 算法有一個缺點,比如說很久沒有使用的一個鍵值,如果最近被訪問了一次,那么即使它是使用次數最少的緩存,它也不會被淘汰;而 LFU 算法解決了偶爾被訪問一次之后,數據就不會被淘汰的問題,它是根據總訪問次數來淘汰數據的,其核心思想是“如果數據過去被訪問多次,那么將來它被訪問次數也會比較多”。因此 LFU 可以理解為比 LRU 更加合理的淘汰算法。
* EhCache 基礎使用
首先,需要在項目中添加 EhCache 框架,如果為 Maven 項目,則需要在 pom.xml 中添加如下配置:
```
<!-- https://mvnrepository.com/artifact/org.ehcache/ehcache -->
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.8.1</version>
</dependency>
```
無配置參數的 EhCache 3.x 使用代碼如下:
```
import org.ehcache.Cache;
import org.ehcache.CacheManager;
import org.ehcache.config.builders.CacheConfigurationBuilder;
import org.ehcache.config.builders.CacheManagerBuilder;
import org.ehcache.config.builders.ResourcePoolsBuilder;
public class EhCacheExample {
public static void main(String[] args) {
// 創建緩存管理器
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build();
// 初始化 EhCache
cacheManager.init();
// 創建緩存(存儲器)
Cache<String, String> myCache = cacheManager.createCache("MYCACHE",
CacheConfigurationBuilder.newCacheConfigurationBuilder(
String.class, String.class,
ResourcePoolsBuilder.heap(10))); // 設置緩存的最大容量
// 設置緩存
myCache.put("key", "Hello,Java.");
// 讀取緩存
String value = myCache.get("key");
// 輸出緩存
System.out.println(value);
// 關閉緩存
cacheManager.close();
}
}
```
其中:
* CacheManager:是緩存管理器,可以通過單例或者多例的方式創建,也是 Ehcache 的入口類;
* Cache:每個 CacheManager 可以管理多個 Cache,每個 Cache 可以采用 hash 的方式存儲多個元素。
它們的關系如下圖所示:

更多使用方法,請參考[官方文檔](http://www.ehcache.org/documentation/3.8/getting-started.html)。
EhCache 的特點是,它使用起來比較簡單,并且本身的 jar 包不是不大,簡單的配置之后就可以正常使用了。EhCache 的使用比較靈活,它支持多種緩存策略的配置,它同時支持內存和磁盤緩存兩種方式,在 EhCache 1.2 之后也開始支持分布式緩存了。
Guava Cache 是 Google 開源的 Guava 里的一個子功能,它是一個內存型的本地緩存實現方案,提供了線程安全的緩存操作機制。
Guava Cache 的架構設計靈感來源于 ConcurrentHashMap,它使用了多個 segments 方式的細粒度鎖,在保證線程安全的同時,支持了高并發的使用場景。Guava Cache 類似于 Map 集合的方式對鍵值對進行操作,只不過多了過期淘汰等處理邏輯。
在使用 Guava Cache 之前,我們需要先在 pom.xml 中添加 Guava 框架,配置如下:
```
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
```
Guava Cache 的創建有兩種方式,一種是 LoadingCache,另一種是 Callable,代碼示例如下:
```
import com.google.common.cache.*;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class GuavaExample {
public static void main(String[] args) throws ExecutionException {
// 創建方式一:LoadingCache
LoadingCache<String, String> loadCache = CacheBuilder.newBuilder()
// 并發級別設置為 5,是指可以同時寫緩存的線程數
.concurrencyLevel(5)
// 設置 8 秒鐘過期
.expireAfterWrite(8, TimeUnit.SECONDS)
//設置緩存容器的初始容量為 10
.initialCapacity(10)
// 設置緩存最大容量為 100,超過之后就會按照 LRU 算法移除緩存項
.maximumSize(100)
// 設置要統計緩存的命中率
.recordStats()
// 設置緩存的移除通知
.removalListener(new RemovalListener<Object, Object>() {
public void onRemoval(RemovalNotification<Object, Object> notification) {
System.out.println(notification.getKey() + " was removed, cause is " + notification.getCause());
}
})
// 指定 CacheLoader,緩存不存在時,可自動加載緩存
.build(
new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 自動加載緩存的業務
return "cache-value:" + key;
}
}
);
// 設置緩存
loadCache.put("c1", "Hello, c1.");
// 查詢緩存
String val = loadCache.get("c1");
System.out.println(val);
// 查詢不存在的緩存
String noval = loadCache.get("noval");
System.out.println(noval);
// 創建方式二:Callable
Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(2) // 設置緩存最大長度
.build();
// 設置緩存
cache.put("k1", "Hello, k1.");
// 查詢緩存
String value = cache.get("k1", new Callable<String>() {
@Override
public String call() {
// 緩存不存在時,執行
return "nil";
}
});
// 輸出緩存值
System.out.println(value);
// 查詢緩存
String nokey = cache.get("nokey", new Callable<String>() {
@Override
public String call() {
// 緩存不存在時,執行
return "nil";
}
});
// 輸出緩存值
System.out.println(nokey);
}
}
```
以上程序的執行結果為:
```
Hello, c1.
cache-value:noval
Hello, k1.
nil
```
可以看出 Guava Cache 使用了編程式的 build 生成器進行創建和管理,讓使用者可以更加靈活地操縱代碼,并且 Guava Cache 提供了靈活多樣的個性化配置,以適應各種使用場景。
* [ ] 2. 手動實現一個緩存系統
上面我們講了通過 EhCache 和 Guava 實現緩存的方式,接下來我們來看看自己如何自定義一個緩存系統,當然這里說的是自己手動實現一個本地緩存。
要自定義一個緩存,首先要考慮的是數據類型,我們可以使用 Map 集合中的 HashMap、Hashtable 或 ConcurrentHashMap 來實現,非并發情況下我們可以使用 HashMap,并發情況下可以使用 Hashtable 或 ConcurrentHashMap,由于 ConcurrentHashMap 的性能比 Hashtable 的高,因此在高并發環境下我們可以傾向于選擇 ConcurrentHashMap,不過它們對元素的操作都是類似的。
選定了數據類型之后,我們還需要考慮緩存過期和緩存淘汰等問題,在這里我們可以借鑒 Redis 對待過期鍵的處理策略。
目前比較常見的過期策略有以下三種:
* 定時刪除
* 惰性刪除
* 定期刪除
**定時刪除**是指在設置鍵值的過期時間時,創建一個定時事件,當到達過期時間后,事件處理器會執行刪除過期鍵的操作。它的優點是可以及時的釋放內存空間,缺點是需要開啟多個延遲執行事件來處理清除任務,這樣就會造成大量任務事件堆積,占用了很多系統資源。
**惰性刪除**不會主動刪除過期鍵,而是在每次請求時才會判斷此值是否過期,如果過期則刪除鍵值,否則就返回 null。它的優點是只會占用少量的系統資源,缺點是清除不夠及時,會造成一定的空間浪費。
**定期刪除**是指每隔一段時間檢查一次數據庫,隨機刪除一些過期鍵值。
Redis 使用的是定期刪除和惰性刪除這兩種策略,我們本課時也會參照這兩種策略。
先來說一下自定義緩存的實現思路,首先需要定義一個存放緩存值的實體類,這個類里包含了緩存的相關信息,比如緩存的 key 和 value,緩存的存入時間、最后使用時間和命中次數(預留字段,用于支持 LFU 緩存淘汰),再使用 ConcurrentHashMap 保存緩存的 key 和 value 對象(緩存值的實體類),然后再新增一個緩存操作的工具類,用于添加和刪除緩存,最后再緩存啟動時,開啟一個無限循環的線程用于檢測并刪除過期的緩存,實現代碼如下。
首先,定義一個緩存值實體類,代碼如下:
```
import lombok.Getter;
import lombok.Setter;
/**
* 緩存實體類
*/
@Getter
@Setter
public class CacheValue implements Comparable<CacheValue> {
// 緩存鍵
private Object key;
// 緩存值
private Object value;
// 最后訪問時間
private long lastTime;
// 創建時間
private long writeTime;
// 存活時間
private long expireTime;
// 命中次數
private Integer hitCount;
@Override
public int compareTo(CacheValue o) {
return hitCount.compareTo(o.hitCount);
}
}
```
然后定義一個全局緩存對象,代碼如下:
```
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Cache 全局類
*/
public class CacheGlobal {
// 全局緩存對象
public static ConcurrentMap<String, MyCache> concurrentMap = new ConcurrentHashMap<>();
}
```
定義過期緩存檢測類的代碼如下:
```
import java.util.concurrent.TimeUnit;
/**
* 過期緩存檢測線程
*/
public class ExpireThread implements Runnable {
@Override
public void run() {
while (true) {
try {
// 每十秒檢測一次
TimeUnit.SECONDS.sleep(10);
// 緩存檢測和清除的方法
expireCache();
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 緩存檢測和清除的方法
*/
private void expireCache() {
System.out.println("檢測緩存是否過期緩存");
for (String key : CacheGlobal.concurrentMap.keySet()) {
MyCache cache = CacheGlobal.concurrentMap.get(key);
// 當前時間 - 寫入時間
long timoutTime = System.currentTimeMillis() - cache.getWriteTime();
if (cache.getExpireTime() > timoutTime) {
// 沒過期
continue;
}
// 清除過期緩存
CacheGlobal.concurrentMap.remove(key);
}
}
}
```
接著,我們要新增一個緩存操作的工具類,用于查詢和存入緩存,實現代碼如下:
```
import org.apache.commons.lang3.StringUtils;
import java.util.concurrent.TimeUnit;
/**
* 緩存操作工具類
*/
public class CacheUtils {
/**
* 添加緩存
* @param key
* @param value
* @param expire
*/
public void put(String key, Object value, long expire) {
// 非空判斷,借助 commons-lang3
if (StringUtils.isBlank(key)) return;
// 當緩存存在時,更新緩存
if (CacheGlobal.concurrentMap.containsKey(key)) {
MyCache cache = CacheGlobal.concurrentMap.get(key);
cache.setHitCount(cache.getHitCount() + 1);
cache.setWriteTime(System.currentTimeMillis());
cache.setLastTime(System.currentTimeMillis());
cache.setExpireTime(expire);
cache.setValue(value);
return;
}
// 創建緩存
MyCache cache = new MyCache();
cache.setKey(key);
cache.setValue(value);
cache.setWriteTime(System.currentTimeMillis());
cache.setLastTime(System.currentTimeMillis());
cache.setHitCount(1);
cache.setExpireTime(expire);
CacheGlobal.concurrentMap.put(key, cache);
}
/**
* 獲取緩存
* @param key
* @return
*/
public Object get(String key) {
// 非空判斷
if (StringUtils.isBlank(key)) return null;
// 字典中不存在
if (CacheGlobal.concurrentMap.isEmpty()) return null;
if (!CacheGlobal.concurrentMap.containsKey(key)) return null;
MyCache cache = CacheGlobal.concurrentMap.get(key);
if (cache == null) return null;
// 惰性刪除,判斷緩存是否過期
long timoutTime = System.currentTimeMillis() - cache.getWriteTime();
// 緩存過期
if (cache.getExpireTime() <= timoutTime) {
// 清除過期緩存
CacheGlobal.concurrentMap.remove(k
return null;
}
cache.setHitCount(cache.getHitCount() + 1);
cache.setLastTime(System.currentTimeMillis());
return cache.getValue();
}
}
```
最后是調用緩存的測試代碼:
```
public class MyCacheTest {
public static void main(String[] args) {
CacheUtils cache = new CacheUtils();
// 存入緩存
cache.put("key", "老王", 10);
// 查詢緩存
String val = (String) cache.get("key");
System.out.println(val);
// 查詢不存在的緩存
String noval = (String) cache.get("noval");
System.out.println(noval);
}
}
```
以上程序的執行結果如下:
```
老王
null
```
到目前為止,自定義緩存系統就已經實現完了。
#### 小結
本課時講解了本地緩存和分布式緩存這兩個概念和實現的具體方式,其中本地緩存可以通過自己手動編碼或借助 Guava Cache 來實現,而分布式緩存可以使用 Redis 或 EhCache 來實現。此外,本課時重點演示了手動實現緩存代碼的方式和實現思路,并使用定期刪除和惰性刪除策略來實現緩存的清除,希望學完本課時后能對你有所幫助。
#### 課后問答
* 1、定期刪除,遍歷keyset再get是不是換成直接遍歷entry要高效些?
講師回復: 可以看下我的這篇《HashMap 的 7 種遍歷方式與性能分析!》有專門的性能分析哈 https://mp.weixin.qq.com/s?__biz=MzU1NTkwODE4Mw==&mid=2247485208&idx=1&sn=62be917b1431243b898354d17112b06f&scene=21#wechat_redirect
* 2、CacheUtils里的put和get方法,在并發情況下,會出現問題。比如,在put方法中執行到MyCache cache = CacheGlobal.concurrentMap.get(key);之后,進行設置值,還沒有設置完,另一個線程就調用get方法,這時候,get到的MyCache值中的一些屬性是錯亂的。2.get方法只是返回的value值,1中的問題會導致value獲取不到另一個線程中設置的值。所以CacheValue的value值是不是設置成valitale比較好。
講師回復: 很棒,這是一個簡易版的 Cache 類只是為了說明過期策略的。
* 3、老師,我想問下文章中的本地緩存和 spring cache 有什么關聯么?Spring cache 也可以緩存到本地的
講師回復: spring 內置 cache 屬于本地緩存的一種,它的本質是使用 map 進行數據存儲的。
* 4、">// 當緩存存在時,更新緩存 if (CacheGlobal.concurrentMap.containsKey(key)) {// 如果兩個線程同時執行到這個位置,都已經得到了cache對象,這個時候cache的hitcount = 11// 這個時候兩個線程交替或者并發對這個hitcount加1,hitcount就會=12.其實應該是13的// 這個時候每個線程都保存有cache這個副本,相互獨立,感覺這個地方有問題 }
講師回復: 這是一個簡易版的 Cache 類,其實只是為了演示過期策略的哈。
* 5、惰性刪除 清除過期緩存是不是寫錯分支了
講師回復: 嗯?么有寫錯啊
* 6、老師講的定時或定期刪除搞亂了。。
講師回復: 是比較繞,可以多看兩遍就清楚了。
* 7、獲取緩存值方法里,清除過期緩存的位置不正確
講師回復: 您指的是哪一行代碼,麻煩復制一下。
* 8、雖然基于并發map,但手寫的緩存沒有考慮并發問題。而且contain方法有性能問題
講師回復: 自定義緩存使用的是 ConcurrentHashMap 不會有并發問題,HashMap 會有并發問題。containsKey 在 JDK 8 時因為數據結構在必要時會轉成紅黑樹,所以不會有性能問題。如果是鏈表的話,最差的情況可以查詢效率是 O(n),可能會出現問題,但 JDK 8 之后不會了。
- 前言
- 開篇詞
- 開篇詞:大廠技術面試“潛規則”
- 模塊一:Java 基礎
- 第01講:String 的特點是什么?它有哪些重要的方法?
- 第02講:HashMap 底層實現原理是什么?JDK8 做了哪些優化?
- 第03講:線程的狀態有哪些?它是如何工作的?
- 第04講:詳解 ThreadPoolExecutor 的參數含義及源碼執行流程?
- 第05講:synchronized 和 ReentrantLock 的實現原理是什么?它們有什么區別?
- 第06講:談談你對鎖的理解?如何手動模擬一個死鎖?
- 第07講:深克隆和淺克隆有什么區別?它的實現方式有哪些?
- 第08講:動態代理是如何實現的?JDK Proxy 和 CGLib 有什么區別?
- 第09講:如何實現本地緩存和分布式緩存?
- 第10講:如何手寫一個消息隊列和延遲消息隊列?
- 模塊二:熱門框架
- 第11講:底層源碼分析 Spring 的核心功能和執行流程?(上)
- 第12講:底層源碼分析 Spring 的核心功能和執行流程?(下)
- 第13講:MyBatis 使用了哪些設計模式?在源碼中是如何體現的?
- 第14講:SpringBoot 有哪些優點?它和 Spring 有什么區別?
- 第15講:MQ 有什么作用?你都用過哪些 MQ 中間件?
- 模塊三:數據庫相關
- 第16講:MySQL 的運行機制是什么?它有哪些引擎?
- 第17講:MySQL 的優化方案有哪些?
- 第18講:關系型數據和文檔型數據庫有什么區別?
- 第19講:Redis 的過期策略和內存淘汰機制有什么區別?
- 第20講:Redis 怎樣實現的分布式鎖?
- 第21講:Redis 中如何實現的消息隊列?實現的方式有幾種?
- 第22講:Redis 是如何實現高可用的?
- 模塊四:Java 進階
- 第23講:說一下 JVM 的內存布局和運行原理?
- 第24講:垃圾回收算法有哪些?
- 第25講:你用過哪些垃圾回收器?它們有什么區別?
- 第26講:生產環境如何排除和優化 JVM?
- 第27講:單例的實現方式有幾種?它們有什么優缺點?
- 第28講:你知道哪些設計模式?分別對應的應用場景有哪些?
- 第29講:紅黑樹和平衡二叉樹有什么區別?
- 第30講:你知道哪些算法?講一下它的內部實現過程?
- 模塊五:加分項
- 第31講:如何保證接口的冪等性?常見的實現方案有哪些?
- 第32講:TCP 為什么需要三次握手?
- 第33講:Nginx 的負載均衡模式有哪些?它的實現原理是什么?
- 第34講:Docker 有什么優點?使用時需要注意什么問題?
- 彩蛋
- 彩蛋:如何提高面試成功率?