天天看點

Java本地高性能緩存實踐

作者:Java架構學習指南

Java緩存技術可分為遠端緩存和本地緩存,遠端緩存常用的方案有著名的redis和memcache,而本地緩存的代表技術主要有HashMap,Guava Cache,Caffeine和Encahche。遠端緩存将在後面的博文中進行深入探讨,此處挖個坑,是以本篇博文僅覆寫了本地緩存,且突出探讨高性能的本地緩存。

本篇博文将首先介紹常見的本地緩存技術,對本地緩存有個大概的了解;其次介紹本地緩存中号稱性能最好的Cache,可以探讨看看到底有多好?怎麼做到這麼好?最後通過幾個實戰樣例,在日常工作中應用高性能的本地緩存。

一、 Java本地緩存技術介紹

1.1 HashMap

通過Map的底層方式,直接将需要緩存的對象放在記憶體中。

  • 優點:簡單粗暴,不需要引入第三方包,比較适合一些比較簡單的場景。
  • 缺點:沒有緩存淘汰政策,定制化開發成本高。
public class LRUCache extends LinkedHashMap {


    /**
     * 可重入讀寫鎖,保證并發讀寫安全性
     */
    private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private Lock readLock = readWriteLock.readLock();
    private Lock writeLock = readWriteLock.writeLock();


    /**
     * 緩存大小限制
     */
    private int maxSize;


    public LRUCache(int maxSize) {
        super(maxSize + 1, 1.0f, true);
        this.maxSize = maxSize;
    }


    @Override
    public Object get(Object key) {
        readLock.lock();
        try {
            return super.get(key);
        } finally {
            readLock.unlock();
        }
    }


    @Override
    public Object put(Object key, Object value) {
        writeLock.lock();
        try {
            return super.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }


    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return this.size() > maxSize;
    }
}           

1.2 Guava Cache

Guava Cache是由Google開源的基于LRU替換算法的緩存技術。但Guava Cache由于被下面即将介紹的Caffeine全面超越而被取代,是以不特意編寫示例代碼了,有興趣的讀者可以通路Guava Cache首頁。

  • 優點:支援最大容量限制,兩種過期删除政策(插入時間和通路時間),支援簡單的統計功能。
  • 缺點:springboot2和spring5都放棄了對Guava Cache的支援。

1.3 Caffeine

Caffeine采用了W-TinyLFU(LUR和LFU的優點結合)開源的緩存技術。緩存性能接近理論最優,屬于是Guava Cache的增強版。

public class CaffeineCacheTest {


    public static void main(String[] args) throws Exception {
        //建立guava cache
        Cache<String, String> loadingCache = Caffeine.newBuilder()
                //cache的初始容量
                .initialCapacity(5)
                //cache最大緩存數
                .maximumSize(10)
                //設定寫緩存後n秒鐘過期
                .expireAfterWrite(17, TimeUnit.SECONDS)
                //設定讀寫緩存後n秒鐘過期,實際很少用到,類似于expireAfterWrite
                //.expireAfterAccess(17, TimeUnit.SECONDS)
                .build();
        String key = "key";
        // 往緩存寫資料
        loadingCache.put(key, "v");


        // 擷取value的值,如果key不存在,擷取value後再傳回
        String value = loadingCache.get(key, CaffeineCacheTest::getValueFromDB);


        // 删除key
        loadingCache.invalidate(key);
    }


    private static String getValueFromDB(String key) {
        return "v";
    }
}           

1.4 Encache

Ehcache是一個純java的程序内緩存架構,具有快速、精幹的特點。是hibernate預設的cacheprovider。

  • 優點:支援多種緩存淘汰算法,包括LFU,LRU和FIFO;緩存支援堆内緩存,堆外緩存和磁盤緩存;支援多種叢集方案,解決資料共享問題。
  • 缺點:性能比Caffeine差
public class EncacheTest {




    public static void main(String[] args) throws Exception {

        // 聲明一個cacheBuilder

        CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder()

                .withCache("encacheInstance", CacheConfigurationBuilder

                        //聲明一個容量為20的堆内緩存

                        .newCacheConfigurationBuilder(String.class,String.class, ResourcePoolsBuilder.heap(20)))

                .build(true);

        // 擷取Cache執行個體

        Cache<String,String> myCache =  cacheManager.getCache("encacheInstance", String.class, String.class);

        // 寫緩存

        myCache.put("key","v");

        // 讀緩存

        String value = myCache.get("key");

        // 移除換粗

        cacheManager.removeCache("myCache");

        cacheManager.close();

    }

}           
Java本地高性能緩存實踐

在Caffeine的官網介紹中,Caffeine在性能和功能上都與其他幾種方案相比具有優勢,是以接下來主要探讨Caffeine的性能和實作原理。

二、高性能緩存Caffeine

2.1 緩存類型

2.1.1 Cache

Cache<Key, Graph> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(10_000)
    .build();


// 查找一個緩存元素, 沒有查找到的時候傳回null
Graph graph = cache.getIfPresent(key);
// 查找緩存,如果緩存不存在則生成緩存元素,  如果無法生成則傳回null
graph = cache.get(key, k -> createExpensiveGraph(key));
// 添加或者更新一個緩存元素
cache.put(key, graph);
// 移除一個緩存元素
cache.invalidate(key);           

Cache 接口提供了顯式搜尋查找、更新和移除緩存元素的能力。當緩存的元素無法生成或者在生成的過程中抛出異常而導緻生成元素失敗,cache.get 也許會傳回 null 。

2.1.2 Loading Cache

LoadingCache<Key, Graph> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));


// 查找緩存,如果緩存不存在則生成緩存元素,  如果無法生成則傳回null
Graph graph = cache.get(key);
// 批量查找緩存,如果緩存不存在則生成緩存元素
Map<Key, Graph> graphs = cache.getAll(keys);           

一個LoadingCache是一個Cache 附加上 CacheLoader能力之後的緩存實作。

如果緩存不錯在,則會通過CacheLoader.load來生成對應的緩存元素。

2.1.3 Async Cache

AsyncCache<Key, Graph> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(10_000)
    .buildAsync();


// 查找一個緩存元素, 沒有查找到的時候傳回null
CompletableFuture<Graph> graph = cache.getIfPresent(key);
// 查找緩存元素,如果不存在,則異步生成
graph = cache.get(key, k -> createExpensiveGraph(key));
// 添加或者更新一個緩存元素
cache.put(key, graph);
// 移除一個緩存元素
cache.synchronous().invalidate(key);           

AsyncCache就是Cache的異步形式,提供了Executor生成緩存元素并傳回CompletableFuture的能力。預設的線程池實作是 ForkJoinPool.commonPool() ,當然你也可以通過覆寫并實作 Caffeine.executor(Executor)方法來自定義你的線程池選擇。

2.1.4 Async Loading Cache

AsyncLoadingCache<Key, Graph> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    // 你可以選擇: 去異步的封裝一段同步操作來生成緩存元素
    .buildAsync(key -> createExpensiveGraph(key));
    // 你也可以選擇: 建構一個異步緩存元素操作并傳回一個future
    .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));


// 查找緩存元素,如果其不存在,将會異步進行生成
CompletableFuture<Graph> graph = cache.get(key);
// 批量查找緩存元素,如果其不存在,将會異步進行生成
CompletableFuture<Map<Key, Graph>> graphs = cache.getAll(keys);           

AsyncLoadingCache就是LoadingCache的異步形式,提供了異步load生成緩存元素的功能。

2.2 驅逐政策

  • 基于容量
// 基于緩存内的元素個數進行驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> createExpensiveGraph(key));


// 基于緩存内元素權重進行驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumWeight(10_000)
    .weigher((Key key, Graph graph) -> graph.vertices().size())
    .build(key -> createExpensiveGraph(key));           
  • 基于時間
// 基于固定的過期時間驅逐政策
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfterAccess(5, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));


// 基于不同的過期驅逐政策
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfter(new Expiry<Key, Graph>() {
      public long expireAfterCreate(Key key, Graph graph, long currentTime) {
        // Use wall clock time, rather than nanotime, if from an external resource
        long seconds = graph.creationDate().plusHours(5)
            .minus(System.currentTimeMillis(), MILLIS)
            .toEpochSecond();
        return TimeUnit.SECONDS.toNanos(seconds);
      }
      public long expireAfterUpdate(Key key, Graph graph, 
          long currentTime, long currentDuration) {
        return currentDuration;
      }
      public long expireAfterRead(Key key, Graph graph,
          long currentTime, long currentDuration) {
        return currentDuration;
      }
    })
    .build(key -> createExpensiveGraph(key));           
  • 基于引用
// 當key和緩存元素都不再存在其他強引用的時候驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> createExpensiveGraph(key));


// 當進行GC的時候進行驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .softValues()
    .build(key -> createExpensiveGraph(key));           

2.3 重新整理機制

LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .refreshAfterWrite(1, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));           

隻有在LoadingCache中可以使用重新整理政策,與驅逐不同的是,在重新整理的時候如果查詢緩存元素,其舊值将仍被傳回,直到該元素的重新整理完畢後結束後才會傳回重新整理後的新值。

2.4 統計

Cache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats()
    .build();           

通過使用Caffeine.recordStats()方法可以打開資料收集功能。Cache.stats()方法将會傳回一個CacheStats對象,其将會含有一些統計名額,比如:

  • hitRate(): 查詢緩存的命中率
  • evictionCount(): 被驅逐的緩存數量
  • averageLoadPenalty(): 新值被載入的平均耗時

配合SpringBoot提供的RESTful Controller,能很友善的查詢Cache的使用情況。

三、Caffeine在SpringBoot的實戰

按照Caffeine Github官網文檔的描述,Caffeine是基于Java8的高性能緩存庫。并且在Spring5(SpringBoot2.x)官方放棄了Guava,而使用了性能更優秀的Caffeine作為預設的緩存方案。

SpringBoot使用Caffeine有兩種方式:

  • 方式一:直接引入Caffeine依賴,然後使用Caffeine的函數實作緩存
  • 方式二:引入Caffeine和Spring Cache依賴,使用SpringCache注解方法實作緩存

    下面分别介紹兩種使用方式。

方式一:使用Caffeine依賴

首先引入maven相關依賴:

<dependency>  
  <groupId>com.github.ben-manes.caffeine</groupId>  
    <artifactId>caffeine</artifactId>  
</dependency>           

其次,設定緩存的配置選項

@Configuration
public class CacheConfig {


    @Bean
    public Cache<String, Object> caffeineCache() {
        return Caffeine.newBuilder()
                // 設定最後一次寫入或通路後經過固定時間過期
                .expireAfterWrite(60, TimeUnit.SECONDS)
                // 初始的緩存空間大小
                .initialCapacity(100)
                // 緩存的最大條數
                .maximumSize(1000)
                .build();
    }


}           
最後給服務添加緩存功能           
@Slf4j
@Service
public class UserInfoServiceImpl {


    /**
     * 模拟資料庫存儲資料
     */
    private HashMap<Integer, UserInfo> userInfoMap = new HashMap<>();


    @Autowired
    Cache<String, Object> caffeineCache;


    public void addUserInfo(UserInfo userInfo) {
        userInfoMap.put(userInfo.getId(), userInfo);
        // 加入緩存
        caffeineCache.put(String.valueOf(userInfo.getId()),userInfo);
    }


    public UserInfo getByName(Integer id) {
        // 先從緩存讀取
        caffeineCache.getIfPresent(id);
        UserInfo userInfo = (UserInfo) caffeineCache.asMap().get(String.valueOf(id));
        if (userInfo != null){
            return userInfo;
        }
        // 如果緩存中不存在,則從庫中查找
        userInfo = userInfoMap.get(id);
        // 如果使用者資訊不為空,則加入緩存
        if (userInfo != null){
            caffeineCache.put(String.valueOf(userInfo.getId()),userInfo);
        }
        return userInfo;
    }


    public UserInfo updateUserInfo(UserInfo userInfo) {
        if (!userInfoMap.containsKey(userInfo.getId())) {
            return null;
        }
        // 取舊的值
        UserInfo oldUserInfo = userInfoMap.get(userInfo.getId());
        // 替換内容
        if (!StringUtils.isEmpty(oldUserInfo.getAge())) {
            oldUserInfo.setAge(userInfo.getAge());
        }
        if (!StringUtils.isEmpty(oldUserInfo.getName())) {
            oldUserInfo.setName(userInfo.getName());
        }
        if (!StringUtils.isEmpty(oldUserInfo.getSex())) {
            oldUserInfo.setSex(userInfo.getSex());
        }
        // 将新的對象存儲,更新舊對象資訊
        userInfoMap.put(oldUserInfo.getId(), oldUserInfo);
        // 替換緩存中的值
        caffeineCache.put(String.valueOf(oldUserInfo.getId()),oldUserInfo);
        return oldUserInfo;
    }


    @Override
    public void deleteById(Integer id) {
        userInfoMap.remove(id);
        // 從緩存中删除
        caffeineCache.asMap().remove(String.valueOf(id));
    }


}           

方式二:使用Spring Cache注解

首先引入maven相關依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>           

其次,配置緩存管理類

@Configuration  
public class CacheConfig {  
  
    /**  
     * 配置緩存管理器  
     *  
     * @return 緩存管理器  
     */  
    @Bean("caffeineCacheManager")  
    public CacheManager cacheManager() {  
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();  
        cacheManager.setCaffeine(Caffeine.newBuilder()  
                // 設定最後一次寫入或通路後經過固定時間過期  
                .expireAfterAccess(60, TimeUnit.SECONDS)  
                // 初始的緩存空間大小  
                .initialCapacity(100)  
                // 緩存的最大條數  
                .maximumSize(1000));  
        return cacheManager;  
    }  
  
}           

最後給服務添加緩存功能

@Slf4j
@Service
@CacheConfig(cacheNames = "caffeineCacheManager")
public class UserInfoServiceImpl {


    /**
     * 模拟資料庫存儲資料
     */
    private HashMap<Integer, UserInfo> userInfoMap = new HashMap<>();


    @CachePut(key = "#userInfo.id")
    public void addUserInfo(UserInfo userInfo) {
        userInfoMap.put(userInfo.getId(), userInfo);
    }


    @Cacheable(key = "#id")
    public UserInfo getByName(Integer id) {
        return userInfoMap.get(id);
    }


    @CachePut(key = "#userInfo.id")
    public UserInfo updateUserInfo(UserInfo userInfo) {
        if (!userInfoMap.containsKey(userInfo.getId())) {
            return null;
        }
        // 取舊的值
        UserInfo oldUserInfo = userInfoMap.get(userInfo.getId());
        // 替換内容
        if (!StringUtils.isEmpty(oldUserInfo.getAge())) {
            oldUserInfo.setAge(userInfo.getAge());
        }
        if (!StringUtils.isEmpty(oldUserInfo.getName())) {
            oldUserInfo.setName(userInfo.getName());
        }
        if (!StringUtils.isEmpty(oldUserInfo.getSex())) {
            oldUserInfo.setSex(userInfo.getSex());
        }
        // 将新的對象存儲,更新舊對象資訊
        userInfoMap.put(oldUserInfo.getId(), oldUserInfo);
        // 傳回新對象資訊
        return oldUserInfo;
    }


    @CacheEvict(key = "#id")
    public void deleteById(Integer id) {
        userInfoMap.remove(id);
    }


}           

四、Caffeine在Reactor的實戰

Caffeine和Reactor的結合是通過CacheMono和CacheFlux來使用的,Caffine會存儲一個Flux或Mono作為緩存的結果。

首先定義Caffeine的緩存:

final Cache<String, String> caffeineCache = Caffeine.newBuilder()
      .expireAfterWrite(Duration.ofSeconds(30))
      .recordStats()
      .build();           

CacheMono

final Mono<String> cachedMonoCaffeine = CacheMono
      .lookup(
          k -> Mono.justOrEmpty(caffeineCache.getIfPresent(k)).map(Signal::next),
          key
      )
      .onCacheMissResume(this.handleCacheMiss(key))
      .andWriteWith((k, sig) -> Mono.fromRunnable(() ->
          caffeineCache.put(k, Objects.requireNonNull(sig.get()))
      ));           

lookup方法查詢cache中是否已存在,如果不存在,則通過onCacheMissResume重新生成一個Mono,并通過andWriteWith方法将結果存入緩存中。

CacheFlux

final Flux<Integer> cachedFluxCaffeine = CacheFlux
      .lookup(
          k -> {
            final List<Integer> cached = caffeineCache.getIfPresent(k);
 
            if (cached == null) {
              return Mono.empty();
            }
 
            return Mono.just(cached)
                .flatMapMany(Flux::fromIterable)
                .map(Signal::next)
                .collectList();
          },
          key
      )
      .onCacheMissResume(this.handleCacheMiss(key))
      .andWriteWith((k, sig) -> Mono.fromRunnable(() ->
          caffeineCache.put(
              k,
              sig.stream()
                  .filter(signal -> signal.getType() == SignalType.ON_NEXT)
                  .map(Signal::get)
                  .collect(Collectors.toList())
          )
      ));           

同理CacheFlux的用法也類似。