张开涛:Java应用缓存示例

开发 开发工具
缓存的工作机制是先从缓存中读取数据,如果没有,则再从慢速设备上读取实际数据并同步到缓存。本文以Java应用缓存为示例进行讲解。

一、缓存简介

缓存,笔者的理解是让数据更接近于使用者,目的是让访问速度更多。工作机制是先从缓存中读取数据,如果没有,则再从慢速设备上读取实际数据并同步到缓存。那些经常读取的数据、频繁访问的数据、热点数据、IO瓶颈数据、计算昂贵的数据、符合五分钟法则和局部性原理的数据都可以进行缓存。如CPU→L1/L2/L3→内存→磁盘就是一个典型的例子,CPU需要数据时先从L1读取,如果没有找到,则查找L2/L3读取,如果没有,则到内存中查找,如果还没有,则会到磁盘中查找。还有比如用过Maven的朋友都应该知道,加载依赖的时候,先从本机仓库找,再从本地服务器仓库找,最后到远程仓库服务器找。还有如京东的物流为什么那么快?他们在各地都有分仓库,如果该仓库有货物,那么送货的速度是非常快的。

本文以Java应用缓存为示例进行讲解。

二、缓存命中率

缓存命中率是从缓存中读取数据的次数与总读取次数的比率,命中率越高越好。缓存命中率 = 从缓存中读取次数/〔总读取次数(从缓存中读取次数 + 从慢速设备上读取的次数)〕。这是一个非常重要的监控指标,如果做缓存,则应通过监控这个指标来看缓存是否工作良好。

三、缓存回收策略

1. 基于空间

即设置缓存的存储空间,如设置为10MB,当达到存储空间时,按照一定的策略移除数据。

2. 基于容量

基于容量指缓存设置了最大大小,当缓存的条目超过最大大小,则按照一定的策略将旧数据移除。

3. 基于时间

TTL(Time To Live ):存活期,即缓存数据从缓存中创建时间开始直到它到期的一个时间段(不管在这个时间段内有没有访问都将过期)。

TTI(Time To Idle):空闲期,即缓存数据多久没被访问过将从缓存中移除的时间。

4. 基于Java对象引用

软引用:如果一个对象是软引用,那么当JVM堆内存不足时,垃圾回收器可以回收这些对象。软引用适合用来做缓存,从而当JVM堆内存不足时,可以回收这些对象腾出一些空间供强引用对象使用,从而避免OOM。

弱引用:当垃圾回收器回收内存时,如果发现弱引用,则将立即回收它。相对于软引用有更短的生命周期。

注意:弱引用/软引用对象只有当没有其他强引用对象引用它时,垃圾回收时才回收该引用。即如果有一个对象(不是弱引用/软引用)引用了弱引用/软引用对象,那么垃圾回收时不会回收该引用对象。

5. 回收算法

使用基于空间和基于容量的会使用一定的策略移除旧数据,常见的如下。

  • FIFO(First In First Out):先进先出算法,即先放入缓存的先被移除。
  • LRU(Least Recently Used):最近最少使用算法,使用时间距离现在最久的那个被移除。
  • LFU(Least Frequently Used):最不常用算法,一定时间段内使用次数(频率)最少的那个被移除。

实际应用中基于LRU的缓存居多,如Guava Cache、Ehcache支持LRU。

四、Java缓存类型

  • 堆缓存:使用Java堆内存来存储缓存对象。使用堆缓存的好处是没有序列化/反序列化,是最快的缓存。缺点也很明显,当缓存的数据量很大时, GC暂停时间会变长,存储容量受限于堆空间大小。一般通过软引用/弱引用来存储缓存对象,即当堆内存不足时,可以强制回收这部分内存释放堆内存空间。一般使用堆缓存存储较热的数据。可以使用Guava Cache、Ehcache 3.x、MapDB实现。
  • 堆外缓存:即缓存数据存储在堆外内存,可以减少GC暂停时间(堆对象转移到堆外,GC扫描和移动的对象变少了),可以支持更大的缓存空间(只受机器内存大小限制,不受堆空间的影响)。但是,读取数据时需要序列化/反序列化,因此,会比堆缓存慢很多。可以使用Ehcache 3.x、MapDB实现。
  • 磁盘缓存:即缓存数据的存储在磁盘上,当JVM重启时数据还是在的。而堆缓存/堆外缓存重启时数据会丢失,需要重新加载。可以使用Ehcache 3.x、MapDB实现。
  • 分布式缓存:上文提到的缓存是进程内缓存和磁盘缓存,在多JVM实例的情况时,会存在两个问题:1.单机容量问题;2.数据一致性问题(多台JVM实例的缓存数据不一致怎么办),不过,这个问题不用太纠结,既然数据允许缓存,则表示允许一定时间内的不一致,因此,可以设置缓存数据的过期时间来定期更新数据;3.缓存不命中时,需要回源到DB/服务查询变多:每个实例在缓存不命中情况下都会回源到DB加载数据,因此,多实例后DB整体的访问量就变多了,解决办法可以使用如一致性哈希分片算法来解决。因此,这些情况可以考虑使用分布式缓存来解决。可以使用ehcache-clustered(配合Terracotta server)实现Java进程间分布式缓存。当然也可以使用如Redis实现分布式缓存。

Redis实现分布式缓存

两种模式如下。

● 单机时:存储最热的数据到堆缓存,相对热的数据到堆外缓存,不热的数据存到磁盘缓存。

● 集群时:存储最热的数据到堆缓存,相对热的数据到堆外缓存,全量数据存到分布式缓存。

接下来,我们看看如何在Java中使用堆缓存、堆外缓存、磁盘缓存、分布式缓存,是不是感觉像L1、L2、L3级缓存架构。

Guava Cache只提供堆缓存,小巧灵活,性能最好,如果只使用堆缓存,那么使用它就够了。

EhCache3.x提供了堆缓存、堆外缓存、磁盘缓存、分布式缓存。但是,其代码注释比较少,API还不完善(比如,2.x支持LRU、LFU、FIFO,而3.x目前还没有API设置),功能还不完善(比如,集群情况个人测试其暂时不可以生产环境使用),如果需要较稳定的API和功能,则请考虑使用EhCache2.x(不支持堆外缓存)。

MapDB是一款嵌入式Java数据库引擎和集合框架。提供了Maps、Sets、Lists、Queues、Bitmaps的支持,还支持ACID事务,增量备份。支持堆缓存、堆外缓存、磁盘缓存。

1. 堆缓存

Gauva Cache实现

  1. Cache<String, String> myCache
  2.         CacheBuilder.newBuilder() 
  3.                 .concurrencyLevel(4) 
  4.                 .expireAfterWrite(10, TimeUnit.SECONDS) 
  5.                 .maximumSize(10000) 
  6.                 .build(); 

然后可以通过put、getIfPresent来读写缓存。CacheBuilder有几类参数:缓存回收策略、并发设置、统计命中率等。

(1) 缓存回收策略/基于容量

maximumSize:设置缓存的容量,当超出maximumSize时,按照LRU进行缓存回收。

(2) 缓存回收策略/基于时间

  • expireAfterWrite:设置TTL,缓存数据在给定的时间内没有写(创建/覆盖)时,则被回收,即定期的会回收缓存数据。
  • expireAfterAccess:设置TTI,缓存数据在给定的时间内没有读/写时,则被回收。每次访问时,都会更新它的TTI,从而如果该缓存是非常热的数据,则将一直不过期,可能会导致脏数据存在很长时间(因此,建议设置expireAfterWrite)。

(3) 缓存回收策略/基于Java对象引用

  • weakKeys/weakValues:设置弱引用缓存。
  • softValues:设置软引用缓存。

(4) 缓存回收策略/主动失效

invalidate(Object key)/ invalidateAll(Iterablekeys)/invalidateAll():主动失效某些缓存数据。

什么时候触发失效呢?Guava Cache不会在缓存数据失效时立即触发回收操作(如果要这么做,则需要有额外的线程来进行清理),是在PUT时会主动进行一次清理缓存,当然读者也可以根据实际业务通过自己设计线程来调用cleanUp方法进行清理。

(5) 并发级别

concurrencyLevel:Guava Cache重写了ConcurrentHashMap,concurrencyLevel用来设置Segment数量,concurrencyLevel越大并发能力越强。

(6) 统计命中率

recordStats:启动记录统计信息,比如命中率等。

(7) EhCache 3.x实现

本文使用最新的Ehcache3.1.2,目前Ehcache3.x版本还比较新,一些文档还不是很全。

  1. CacheManager cacheManager = CacheManagerBuilder. newCacheManagerBuilder(). build(true); 
  2. CacheConfigurationBuilder<String, String> cacheConfigCacheConfigurationBuilder.newCacheConfigurationBuilder( 
  3.        String.class, 
  4.        String.class, 
  5.        ResourcePoolsBuilder.newResourcePoolsBuilder() 
  6.                .heap(100, EntryUnit.ENTRIES)) 
  7.        .withDispatcherConcurrency(4) 
  8.        .withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS))); 
  9.   
  10. Cache<String, String> myCache = cacheManager.createCache("myCache",cacheConfig); 

CacheManager在JVM关闭时请调用CacheManager.close()方法。 可以通过PUT、GET来读写缓存。CacheConfigurationBuilder也有几类参数:缓存回收策略、并发设置、统计命中率等。

(8) 缓存回收策略/基于容量

heap(100, EntryUnit.ENTRIES):设置缓存的条目数量,当超出此数量时按照LRU进行缓存回收。

(9) 缓存回收策略/基于空间

heap(100, MemoryUnit.MB):设置缓存的内存空间,当超出此空间时按照LRU进行缓存回收。另外,应该设置withSizeOfMaxObjectGraph(2):统计对象大小时对象图遍历深度和withSizeOfMaxObjectSize(1, MemoryUnit.KB):可缓存的最大对象大小。

(10) 缓存回收策略/基于时间

  • withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS))):设置TTL,没有TTI。
  • withExpiry(Expirations.timeToIdleExpiration(Duration.of(10,TimeUnit.SECONDS))):同时设置TTL和TTI,且TTL和TTI值一样。

(11) 缓存回收策略/主动失效

remove(K key)/ removeAll(Set keys)/clear():主动失效某些缓存数据。

什么时候触发失效呢?EhCache使用了类似于Guava Cache同样的机制。

(12) 并发级别

目前还没有提供API来设置,EhCache内部使用ConcurrentHashMap作为缓存存储,默认并发级别16。withDispatcherConcurrency是用来设置事件分发时的并发级别。

(13) 统计命中率

目前还没有开放API来统计。

MapDB 3.x实现

  1. HTreeMap myCache = 
  2.        DBMaker.heapDB().concurrencyScale(16).make().hashMap("myCache") 
  3.        .expireMaxSize(10000) 
  4.        .expireAfterCreate(10, TimeUnit.SECONDS) 
  5.         .expireAfterUpdate(10,TimeUnit.SECONDS) 
  6.        .expireAfterGet(10, TimeUnit.SECONDS) 
  7.        .create(); 

然后可以通过PUT、GET来读写缓存。其有几类参数:缓存回收策略、并发设置、统计命中率等。

a. 缓存回收策略/基于容量

expireMaxSize:设置缓存的容量,当超出expireMaxSize时,按照LRU进行缓存回收。

b. 缓存回收策略/基于时间

expireAfterCreate/expireAfterUpdate:设置TTL,缓存数据在给定的时间内没有写(创建/覆盖)时,则被回收。即定期的会回收缓存数据。

expireAfterGet:设置TTI, 缓存数据在给定的时间内没有读/写时,则被回收。每次访问时都会更新它的TTI,从而如果该缓存是非常热的数据,则将一直不过期,可能会导致脏数据存在很长的时间(因此,建议要设置expireAfterCreate/expireAfterUpdate)。

c. 缓存回收策略/主动失效

remove(Object key) /clear():主动失效某些缓存数据。

什么时候触发失效呢?MapDB默认使用类似于Guava Cache的机制。不过,也支持可以通过如下配置使用线程池定期进行缓存失效。

  1. .expireExecutor(scheduledExecutorService) 
  2. .expireExecutorPeriod(3000) 

d. 并发级别

concurrencyScale:类似于Guava Cache配置。

e. 统计命中率

暂无。

还可以使用DBMaker.memoryDB()创建堆缓存,它将数据序列化并存储到1MB大小的byte[]数组中,从而减少垃圾回收的影响。

2. 堆外缓存

EhCache 3.x实现

  1. CacheConfigurationBuilder<String, String> cacheConfigCacheConfigurationBuilder.newCacheConfigurationBuilder( 
  2.        String.class, 
  3.        String.class, 
  4.        ResourcePoolsBuilder.newResourcePoolsBuilder() 
  5.                .offheap(100, MemoryUnit.MB)) 
  6.        .withDispatcherConcurrency(4) 
  7.        .withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS))) 
  8.        .withSizeOfMaxObjectGraph(3) 
  9.        .withSizeOfMaxObjectSize(1, MemoryUnit.KB); 

堆外缓存不支持基于容量的缓存过期策略。

MapDB 3.x实现

  1. HTreeMap myCache = 
  2.        DBMaker.memoryDirectDB().concurrencyScale(16).make().hashMap("myCache") 
  3.        .expireStoreSize(64 * 1024 * 1024) //指定堆外缓存大小64MB 
  4.        .expireMaxSize(10000) 
  5.        .expireAfterCreate(10, TimeUnit.SECONDS) 
  6.        .expireAfterUpdate(10, TimeUnit.SECONDS) 
  7.        .expireAfterGet(10, TimeUnit.SECONDS) 
  8.        .create(); 

在使用堆外缓存时,请记得添加JVM启动参数,如-XX:MaxDirectMemorySize=10G。

3. 磁盘缓存

EhCache 3.x实现

  1. CacheManager cacheManager = CacheManagerBuilder. newCacheManagerBuilder() 
  2.         //默认线程池 
  3.         .using(PooledExecutionServiceConfigurationBuilder.newPooledExecutionServiceConfigurationBuilder().defaultPool("default",1, 10).build()) 
  4.         //磁盘文件存储位置 
  5.         .with(new CacheManagerPersistenceConfiguration(newFile("D:\\bak"))) 
  6.        .build(true); 
  7.   
  8. CacheConfigurationBuilder<String, String> cacheConfigCacheConfigurationBuilder. newCacheConfigurationBuilder( 
  9.        String.class, 
  10.        String.class, 
  11.        ResourcePoolsBuilder.newResourcePoolsBuilder() 
  12.                 .disk(100, MemoryUnit.MB,true)) //磁盘缓存 
  13.         .withDiskStoreThreadPool("default", 5) //使用"default"线程池进行dump文件到磁盘 
  14.         .withExpiry(Expirations.timeToLiveExpiration(Duration.of(50,TimeUnit.SECONDS))) 
  15.        .withSizeOfMaxObjectGraph(3) 
  16.        .withSizeOfMaxObjectSize(1, MemoryUnit.KB); 

在JVM停止时,记得调用cacheManager.close(),从而保证内存数据能dump到磁盘。

MapDB 3.x实现

  1. DB db = DBMaker 
  2.         .fileDB("D:\\bak\\a.data")//数据存哪里 
  3.         .fileMmapEnable() //启用mmap 
  4.         .fileMmapEnableIfSupported() //在支持的平台上启用mmap 
  5.         .fileMmapPreclearDisable() //让mmap文件更快 
  6.         .cleanerHackEnable() //一些BUG处理 
  7.         .transactionEnable() //启用事务 
  8.         .closeOnJvmShutdown() 
  9.        .concurrencyScale(16) 
  10.        .make(); 
  11.   
  12. HTreeMap myCache = db.hashMap("myCache") 
  13.        .expireMaxSize(10000) 
  14.        .expireAfterCreate(10, TimeUnit.SECONDS) 
  15.        .expireAfterUpdate(10, TimeUnit.SECONDS) 
  16.        .expireAfterGet(10, TimeUnit.SECONDS) 
  17.        .createOrOpen(); 

因为开启了事务,MapDB则开启了WAL。另外,操作完缓存后记得调用db.commit方法提交事务。

  1. myCache.put("key" + counterWriter,"value" + counterWriter); 
  2. db.commit(); 

4. 分布式缓存

本文使用Ehcache 3.1+Terracottaserver实现,Ehcache 3.1引入了一个下载套件,其包含了Terracotta Server。

Ehcache 3.1+Terracottaserver

调用start-tc-server脚本启动tc server。

(1) 架构

Terracotta Server配置

Terracotta Server配置

  1. <?xml version="1.0"encoding="UTF-8"?> 
  2. <tc-configxmlnstc-configxmlns="http://www.terracotta.org/config" 
  3.           xmlns:ohr="http://www.terracotta.org/config/offheap-resource"> 
  4.   
  5.  <servers> 
  6.     <server host="192.168.147.50" name="s1"> 
  7.      <tsa-port>9510</tsa-port> 
  8.      <tsa-group-port>9530</tsa-group-port> 
  9.    </server> 
  10.   
  11.     <server host="192.168.147.52" name="s2"> 
  12.      <tsa-port>9510</tsa-port> 
  13.      <tsa-group-port>9530</tsa-group-port> 
  14.    </server> 
  15.   
  16.   <client-reconnect-window>30</client-reconnect-window> 
  17.   <restartable enabled="true"/> 
  18.    </servers> 
  19.   
  20.    <services> 
  21.        <service id="resources"> 
  22.            <ohr:offheap-resources> 
  23.                <ohr:resource name="cache"unit="MB">64</ohr:resource> 
  24.            </ohr:offheap-resources> 
  25.        </service> 
  26.    </services> 
  27. </tc-config> 

配置了两个tc server,其中一主一备。在两台服务器中分别调用如下脚本启动两台tc server。

  1. ./start-tc-server.sh -f tc-config.xml  -n s1 
  2. ./start-tc-server.sh -f tc-config.xml  -n s2 

(2) EhCache代码片段

  1. CacheManagerBuilder<PersistentCacheManager> clusteredCacheManagerBuilder
  2.        CacheManagerBuilder.newCacheManagerBuilder() 
  3.        .with(ClusteringServiceConfigurationBuilder.cluster(URI.create("terracotta://192.168.147.50:9510")).readOperationTimeout(500,TimeUnit.MILLISECONDS).autoCreate()); 
  4.   
  5. final PersistentCacheManager cacheManager =clusteredCacheManagerBuilder. build(true); 
  6.   
  7.   
  8. Cache<String, String> myCache = cacheManager.createCache("myCache", 
  9.        CacheConfigurationBuilder.newCacheConfigurationBuilder( 
  10.                String.class, 
  11.                String.class, 
  12.                ResourcePoolsBuilder.newResourcePoolsBuilder().with(ClusteredResourcePoolBuilder.clusteredDedicated("cache",32, MemoryUnit.MB))) 
  13.                .withDispatcherConcurrency(4).withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)))); 

可以看到一个问题,此处只指定了IP为192.168.147.50这台机器的tc-server,那么当50这台机器挂了,目前是不能自动连接到52机器的。不知道未来是否会支持。或者考虑使用其主打产品BigMemory(付费)。

对于分布式缓存个人还是喜欢使用Redis之类的,性能也非常好,有主从模式、集群模式。目前不建议使用Ehcache3.1+Terracottaserver组合。

5. 多级缓存

如先查找堆缓存,如果没有查找磁盘缓存,则使用MapDB可以通过如下配置实现。

  1. HTreeMap diskCache = db.hashMap("myCache") 
  2.        .expireStoreSize(8 * 1024 * 1024 * 1024) 
  3.        .expireMaxSize(10000) 
  4.        .expireAfterCreate(10, TimeUnit.SECONDS) 
  5.        .expireAfterUpdate(10, TimeUnit.SECONDS) 
  6.        .expireAfterGet(10, TimeUnit.SECONDS) 
  7.        .createOrOpen(); 
  8.   
  9. HTreeMap heapCache = db.hashMap("myCache") 
  10.        .expireMaxSize(100) 
  11.        .expireAfterCreate(10, TimeUnit.SECONDS) 
  12.        .expireAfterUpdate(10, TimeUnit.SECONDS) 
  13.        .expireAfterGet(10, TimeUnit.SECONDS) 
  14.        .expireOverflow(diskCache) //当缓存溢出时存储到disk 
  15.        .createOrOpen(); 

使用JMH时首先进行JVM预热,然后进行度量,产生测试结果(本文使用吞吐量)。建议读者按照需求进行基准性能测试来选择适合自己的缓存框架。

【本文是51CTO专栏作者张开涛的原创文章,作者微信公众号:开涛的博客( kaitao-1234567)】

戳这里,看该作者更多好文

责任编辑:赵宁宁 来源: 51CTO专栏
相关推荐

2017-04-21 08:51:42

API缓存分布式

2017-05-05 10:13:03

应用级缓存缓存代码

2017-05-10 11:40:29

缓存Nginx HTTP

2017-05-18 16:07:23

回滚数据库代码

2017-04-18 14:49:38

应用层API代码

2017-06-16 15:16:15

2017-07-02 16:50:21

2017-06-04 16:24:27

线程线程池中断

2012-12-13 17:38:48

2012年度IT博客大IT博客大赛博客

2010-06-02 17:46:54

MySQL 查询缓存

2009-09-21 16:14:50

2016-01-04 15:16:01

京东详情页实践

2009-09-02 09:12:55

Google云计算

2018-02-08 18:00:49

Spark文件测试

2009-07-09 16:22:12

WebWork配置

2010-11-29 10:53:14

Sybase日期函数

2020-09-24 11:38:52

Java开发代码

2015-01-04 13:56:44

DockerPostgreSQL

2021-07-20 10:59:22

云计算架构示例云应用

2022-02-28 15:44:05

鸿蒙系统鸿蒙API加载网络图片
点赞
收藏

51CTO技术栈公众号