前言
上一篇文章中我们从整体出发,对mybatis
的配置、启动流程进行了简单的解析说明
这次我们准备深入学习mybaits
的缓存原理以及使用
目录
为了提升一丢丢阅读体验,在文章最前面显示这鸡肋的目录(吐槽下微信公众号文章)
1. 缓存
2. mybatis实现的缓存
2.1 Cache接口
2.2 永久缓存实现PerpetualCache
2.3 缓存的装饰器实现
2.3.1 缓存实现列表
2.3.2 BlockingCache 阻塞缓存
2.3.3 FifoCache 先进先出缓存
2.3.4 LoggingCache 日志缓存
2.3.5 LruCache 最近最少使用缓存
2.3.6 ScheduledCache 定时缓存
2.3.7 SerializedCache同步缓存
2.3.8 SoftCache 软引用缓存
2.3.9 TransactionalCache 事务缓存
2.3.10 SerializedCache序列化缓存
2.3.11 WeakCache 软引用缓存
1. 缓存
说起缓存,不知道你们想到的是什么
我理解的缓存就是把数据(info)放在离应用程序或用户(user)更近的地方
缓存都用处非常多
像cdn文件分发、 maven仓库、浏览器缓存、分布式缓存、单机缓存(堆内、堆外)、cpu缓存等等都是缓存使用的场景
可以说,缓存是高并发、高吞吐的保障跟基础
,用好缓存,对于任何一个系统都是非常重要
的
那么,开始我们今天的maybatis缓存之旅吧
2. mybatis实现的缓存
mybatis中为了减少依赖,几乎什么都自己来实现了一份(缓存,事务,连接池)
这样使得mybaits整体上更加和谐(没有多余功能或多余的依赖,不臃肿)
先来看看cache
包
从上一篇文章中我们可以知道,cache
包的代码量在910行,占mybatis总代码量4%的篇幅
可想而知,mybatis缓存的实现了一定非常简洁,这对开发者无疑是非常友好的,阅读体验将会大大提升
Cache.java
cache
包里面几个类,其中Cache
是mybaits的缓存接口
CacheException.java
CacheException
缓存异常,继承了PersistenceException
持久化异常,提供几种构造器方法
CacheKey.java
CacheKey
是mybatis
缓存的键实体,采用stementId + offset + limit + sql + queryParams + environment
规则生成key
NullCacheKey.java
NullCacheKey
继承了CacheKey
, 用于Null值的key, 但是3.5.5
后弃用了
package-info.java
package-info.java
是包注释
实现了Cloneable
和Serializable
接口,使用Object
的clone方法(浅拷贝)
TransactionalCacheManager.java
TransactionalCacheManager
是mybatis的事务缓存管理器(即二级缓存),管理所有的事务缓存TransactionalCache
, 并提供TransactionalCache
的提交、回滚(不是数据库的事务)等方法
decorators 和 impl
还有两个包分别是decorators
(装饰器)和impl
(实现)
impl
是对Cache
接口的实现
decorators
包下的类也有对Cache
实现,但是将Cache
的操作委托给内部的Cache
实现去执行(使用装饰器模式)
2.1 Cache接口
1public interface Cache {
2 // 获取缓存id
3 String getId();
4
5 // 设置缓存
6 void putObject(Object key, Object value);
7
8 // 获取缓存
9 Object getObject(Object key);
10
11 // 移除缓存
12 Object removeObject(Object key);
13
14 // 清除所有缓存
15 void clear();
16
17 // 获取缓存数量
18 int getSize();
19
20 // 获取读写锁 3.2.6版本后未再使用
21 default ReadWriteLock getReadWriteLock() {
22 return null;
23 }
24}
可以看到,Cache
的键值对都是Object
对象,在mybatis
中,key为CacheKey
,value则为sql查询的结果集
其他几个方法都很常规,不过多说明了
2.2 永久缓存实现PerpetualCache
PerpetualCache
是Cache
的一个实现,这里的永久是指存储在PerpetualCache
中的数据会一直保存,直到手动移除
内部使用一个HashMap
来存储数据(真的很简单)
1public class PerpetualCache implements Cache {
2 // 每个PerpetualCache都有一个唯一的id
3 private final String id;
4 // cache是真正存储缓存的地方
5 private final Map cache = new HashMap<>(); 6 7 public PerpetualCache(String id) { 8 this.id = id; 9 }10 @Override11 public void putObject(Object key, Object value) {12 cache.put(key, value);13 }14 @Override15 public Object getObject(Object key) {16 return cache.get(key);17 }18 @Override19 public Object removeObject(Object key) {20 return cache.remove(key);21 }22// 省略部分Cache接口方法23}
可以看到PerpetualCache
内部通过一个hashmap
来存储和操作数据,
需要注意的是cache
才是真正存储缓存的地方
2.3 缓存的装饰器实现
mybatis
为了扩展缓存的实现,以及可以实现多种不同实现效果的叠加,使用了装饰器模式来设计
简单说下mybatis的其他缓存实现
2.3.1 缓存实现列表
类 | 描述 |
---|---|
BlockingCache | 阻塞缓存,当获取不到缓存时,其他线程会被阻塞直到当前线程填充缓存并释放锁 |
FifoCache | 先进先出缓存,当队列满时,最先进队的缓存会出队 |
LoggingCache | 实现打印缓存命中率日志的功能,mybatis默认会使用 |
LruCache | 最近最少使用缓存,不常用的缓存将被自动移除 |
ScheduledCache | 定时清理缓存 |
SerializedCache | 序列化缓存 |
SoftCache | 软引用缓存,使用SoftReference, 方便GC回收 |
SynchronizedCache | 同步缓存,缓存的crud操作都是同步的 |
TransactionalCache | 事务缓存 |
WeakCache | 弱应用缓存 |
由于decorators包下所有类都使用了装饰者模式,所有有多一样的代码
在这里展示一次,后续只展示不同代码的地方
由于同步缓存的代码量最少,所以这里展示同步缓存的所有代码
1public class SynchronizedCache implements Cache {
2 // 缓存的操作委派给delegate, 典型的装饰者模式
3 private final Cache delegate;
4
5 public SynchronizedCache(Cache delegate) {
6 this.delegate = delegate;
7 }
8
9 @Override
10 public String getId() {
11 return delegate.getId();
12 }
13
14 @Override
15 public synchronized int getSize() {
16 return delegate.getSize();
17 }
18
19 @Override
20 public synchronized void putObject(Object key, Object object) {
21 delegate.putObject(key, object);
22 }
23
24 @Override
25 public synchronized Object getObject(Object key) {
26 return delegate.getObject(key);
27 }
28
29 @Override
30 public synchronized Object removeObject(Object key) {
31 return delegate.removeObject(key);
32 }
33
34 @Override
35 public synchronized void clear() {
36 delegate.clear();
37 }
38
39 @Override
40 public int hashCode() {
41 return delegate.hashCode();
42 }
43
44 @Override
45 public boolean equals(Object obj) {
46 return delegate.equals(obj);
47 }
48
49}
SynchronizedCache
同步缓存,就是在通过给缓存操作加上同步锁synchronized
来实现
可以看到处理方法加锁外,方法体都是直接调用delegate
委派缓存的对应方法
下面介绍其他缓存,由于很多方法与此相同,所以只展示部分特别的代码、方法块
2.3.2 BlockingCache 阻塞缓存
BlockingCache
有三个属性
1// 请求锁的超时时间,毫秒,如果设置了时间,则会尝试请求锁,否则直接加锁
2private long timeout;
3
4// Cache接口方法委托给delegate去执行
5private final Cache delegate;
6
7// 内部使用线程安全的map来管理锁
8private final ConcurrentHashMap locks;
看看阻塞缓存的getObject
方法
1@Override
2public Object getObject(Object key) {
3 // 尝试获取锁
4 acquireLock(key);
5 Object value = delegate.getObject(key);
6 // 如果值存在,直接释放锁
7 if (value != null) {
8 releaseLock(key);
9 }
10 return value;
11}
12
13private void acquireLock(Object key) {
14 Lock lock = getLockForKey(key);
15 // 如果设置了超时时间, 尝试去获取锁, 否则直接加锁
16 if (timeout > 0) {
17 try {
18 boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
19 if (!acquired) {
20 throw new CacheException("Couldn't get a lock in " + timeout + " for the key " + key + " at the cache " + delegate.getId());
21 }
22 } catch (InterruptedException e) {
23 throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
24 }
25 } else {
26 lock.lock();
27 }
28 }
29
30// 获取锁,如果不存在,new一个
31private ReentrantLock getLockForKey(Object key) {
32 return locks.computeIfAbsent(key, k -> new ReentrantLock());
33}
34// 释放锁
35private void releaseLock(Object key) {
36 ReentrantLock lock = locks.get(key);
37 if (lock.isHeldByCurrentThread()) {
38 lock.unlock();
39 }
40}
当第一个线程调用getObject
方法时, BlockingCache
会阻塞其他线程的getObject
(相同key, 不同的key不会阻塞)
如果此时key
没有对应的缓存,mybatis
则会去数据库查找,然后填充进去
我们用个小例子试试
我们在bookMapper.xml
开启二级缓存, 并使用二级缓存
1<cache2 blocking="true"3 />
使用一个简单的测试方法来验证
1@Test
2public void getDataByDifferentThread() throws IOException {
3 // 线程1
4 new Thread(() -> {
5 SqlSession sqlSession = sqlSessionFactory.openSession(true);
6 BookMapper mapper = sqlSession.getMapper(BookMapper.class);
7 Book byId = mapper.getById(1);
8 System.out.println("thread1 " + byId);
9 // 为防止线程退出, 主线程休眠5秒钟
10 try {
11 TimeUnit.SECONDS.sleep(5);
12 sqlSession.close();
13 System.out.println("关闭会话");
14 } catch (InterruptedException e) {
15 e.printStackTrace();
16 }
17 }).start();
18
19 // 线程2
20 new Thread(() -> {
21 // 为了保证先执行,这需要休眠1秒钟
22 try {
23 TimeUnit.SECONDS.sleep(1);
24 } catch (InterruptedException e) {
25 e.printStackTrace();
26 }
27 SqlSession sqlSession = sqlSessionFactory.openSession(true);
28 BookMapper mapper = sqlSession.getMapper(BookMapper.class);
29 Book byId = mapper.getById(1);
30 System.out.println("thread2 " + byId);
31// sqlSession.close();
32 }).start();
33
34 // 阻塞主线程
35 System.in.read();
36}
测试代码开启两个线程执行同样的SQL查询语句,由于我们开启二级缓存,并配置了使用阻塞缓存
所以第一个线程进入之后,获取锁,发现没有缓存,到数据库查找,然后回来填充缓存,但是此时由于我们还没有执行sqlSession.close()
,第一个线程并未释放锁, 所以第二个线程会一直阻塞,直到第一个线程释放锁,再获取缓存(此时有值,会直接释放锁)
2.3.3 FifoCache 先进先出缓存
FifoCache
是先进先出缓存,在其内部维护一个LinkedList
, 结构如下
1//委派给delegate操作cache
2private final Cache delegate
3// 双向队列来维护队列
4private final Deque keyList; 5// 缓存数量 6private int size; 7 8public FifoCache(Cache delegate) { 9 this.delegate = delegate;10 this.keyList = new LinkedList<>(); // 使用链表的实现11 this.size = 1024; // 默认102412}
主要来看看putObject
设置缓存的操作
1@Override
2public void putObject(Object key, Object value) {
3 cycleKeyList(key);
4 delegate.putObject(key, value);
5}
6// 维护队列
7private void cycleKeyList(Object key) {
8 // 添加到队尾
9 keyList.addLast(key);
10 // 队列长度对于设定值
11 if (keyList.size() > size) {
12 // 从队头移除
13 Object oldestKey = keyList.removeFirst();
14 // 委派cache也一并删除
15 delegate.removeObject(oldestKey);
16 }
17}
就两个字, 优雅
而且还兼具性能
在putObject
中添加cycleKeyList
操作,要去维护一个队列,需要添加、判断、移除等操作,给人感觉性能不好的亚子
其实不然
由于使用LinkedList
实现双向链表, 所以addLast(key)
直接在将key
连上尾元素,时间复杂度为O(1)
removeFirst()
同理,将头指针往后一个元素,时间复杂度也是O(1)
,至于removeObject(oldestKey)
,我们知道Cache
底层是一个HashMap
,所以时间复杂度还是O(1)
当然我们需要付出一个O(N)
大小的空间复杂度,默认设置为1024,所以开销并不大
2.3.4 LoggingCache 日志缓存
LoggingCache
的目的很明确,就是为了打印日志,记录缓存命中的比率
计算方式如下
缓存命中率 = 请求总数 / 命中缓存的请求数
内部结构如下
1//mybatis封装的Log接口
2private final Log log;
3//cache操作委派
4private final Cache delegate;
5// 请求总数
6protected int requests = 0;
7// 命中数
8protected int hits = 0;
9
10public LoggingCache(Cache delegate) {
11 this.delegate = delegate;
12 // 通过日志工厂获取日志实现类
13 this.log = LogFactory.getLog(getId());
14}
看看获取缓存的方法
1@Override
2public Object getObject(Object key) {
3 // 请求数累加
4 requests++;
5 final Object value = delegate.getObject(key);
6 // 如果命中,则累加命中数
7 if (value != null) {
8 hits++;
9 }
10 if (log.isDebugEnabled()) {
11 // 输出缓存命中情况
12 log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
13 }
14 return value;
15}
16// 计算缓存命中率
17private double getHitRatio() {
18 return (double) hits / (double) requests;
19}
LoggingCache
本身并没什么好说的
不过有一个点可以说一下,就是只要我们的日志配置(无论是什么),只要设置输出DEBUG
日志,就会有缓存命中的信息出现
因为mybatis
的二级缓存默认是带LoggingCache
的
1private Cache setStandardDecorators(Cache cache) {
2 try {
3 //省略部分代码
4 //包装一层 日志
5 cache = new LoggingCache(cache);
6 //包装为 同步缓存
7 cache = new SynchronizedCache(cache);
8 // 如果设置为阻塞缓存,则包装一层
9 if (blocking) {
10 cache = new BlockingCache(cache);
11 }
12 return cache;
13 } catch (Exception e) {
14 throw new CacheException("Error building standard cache decorators. Cause: " + e, e);
15 }
16}
2.3.5 LruCache 最近最少使用缓存
LruCache
与 FifoCache
有些类似,都需要在内部维护一个固定大小的容器
但是使用场景不太一样,LruCache
主要使用在那些热点数据访问量较高的场景,如果没有热点数据,也就是数据访问比较平均,那么使用LruCache
意义也不大,FifoCache
暂时想不出有什么比较好的使用(或许这就是佛性?),只知道某些情况下会比lru
好一丢丢
LruCache
内部通过维护一个LinkedHashMap
并通过改写removeEldestEntry
实现移除内部最老的一个cache
1// 委派
2private final Cache delegate;
3// map接口
4private Map keyMap; 5// 对接的cache key 6private Object eldestKey; 7 8public LruCache(Cache delegate) { 9 this.delegate = delegate;10 setSize(1024);11}12// 设置大小, 并重写removeEldestEntry13public void setSize(final int size) {14 keyMap = new LinkedHashMap(size, .75F, true) {15 private static final long serialVersionUID = 4267176411845948333L;1617 @Override18 protected boolean removeEldestEntry(Map.Entry eldest) {19 //当map大小超过size, 返回true, 让LinkedHashMap移除最老元素20 //同时设置eldestKey为最老元素21 boolean tooBig = size() > size;22 if (tooBig) {23 eldestKey = eldest.getKey();24 }25 return tooBig;26 }27 };28}
因为LinkedHashMap
在每次访问或插入元素,都是将元素插入到链表尾部,这样链表头部就是最近最少使用的元素啦
而LinkedHashMap
也会在每次新增元素调用removeEldestEntry
判断是否移除最老元素
接下来
1@Override
2public void putObject(Object key, Object value) {
3 delegate.putObject(key, value);
4 cycleKeyList(key);
5}
6
7private void cycleKeyList(Object key) {
8 // 先插入元素, 如果溢出,则会移除并设置eldestKey
9 keyMap.put(key, key);
10 // 如果eldestKey不为空,说明keyMap已经移除了最老元素,cache也要一起移除
11 if (eldestKey != null) {
12 delegate.removeObject(eldestKey);
13 eldestKey = null;
14 }
15}
只要在添加元素之后,判断eldestKey
的情况即可
这里的设计的确十分巧妙
不过也要感叹jdk
十分强大
2.3.6 ScheduledCache 定时缓存
顾名思义,就是定时清理一下缓存
由于实现比较简单,说明一下了(不上代码了)
内部维护了两个变量,一个clearInterval
表示清理间隔时间(毫秒单位),一个是lastClear
记录上一次清理缓存的时间,那么System.currentTimeMillis() - lastClear > clearInterval
便可以得出是否到了清缓存的时间了
在每个操作之间都是计算一下(只是一个判断,消耗不大),如果到时间了,就调用clear
方法清理一下缓存
2.3.7 SerializedCache同步缓存
在缓存列表里有简单说明,这里不再说明
2.3.8 SoftCache 软引用缓存
看到软引用,第一个想到的就是GC
, 一想到GC
,我就想到了垃圾
, 一想到垃圾
,我就想到了我自己
SoftReference
表示某个引用在发生GC
垃圾回收时,会被自动回收机制处理的类
所以在发生OOM
之前,SoftReference
的引用值并不会被回收,生命周期还算比较长的,适合拿来做缓存
看看SoftReference
的构造
1// 强引用, 链表, 用于防止垃圾回收
2private final Deque hardLinksToAvoidGarbageCollection; 3// 软应用队列, 用于垃圾回收 4private final ReferenceQueue queueOfGarbageCollectedEntries; 5// cache操作委派 6private final Cache delegate; 7// 强引用个数 默认 256 8private int numberOfHardLinks; 910public SoftCache(Cache delegate) {11 this.delegate = delegate;12 this.numberOfHardLinks = 256;13 this.hardLinksToAvoidGarbageCollection = new LinkedList<>();14 this.queueOfGarbageCollectedEntries = new ReferenceQueue<>();15}
SoftReference
对SoftReference
简单包装了一层
1private static class SoftEntry extends SoftReference<Object> {
2 //保存 cache 的 key
3 private final Object key;
4
5 SoftEntry(Object key, Object value, ReferenceQueue garbageCollectionQueue) { 6 // 调用SoftReference的构造器, 设置value为软引用 7 // 并将此软引用与garbageCollectionQueue软引用队列关联 8 // 如果value被GC回收,JVM会把这个软引用加入到garbageCollectionQueue中 9 super(value, garbageCollectionQueue);10 this.key = key;11 }12}
在设置操作putObject
, 获取大小getSize
, 移除缓存removeObject
都调用了removeGarbageCollectedItems
清理被GC回收的缓存
1private void removeGarbageCollectedItems() {
2 SoftEntry sv;
3 while ((sv = (SoftEntry) queueOfGarbageCollectedEntries.poll()) != null) {
4 delegate.removeObject(sv.key);
5 }
6}
遍历软引用队列(此时队列中所有的元素已经被GC回收,即value为空,但key还在)
再根据key移除缓存
那之前的硬引用hardLinksToAvoidGarbageCollection
又有什么作用呢
答案在getObject
1@Override
2public Object getObject(Object key) {
3 Object result = null;
4 @SuppressWarnings("unchecked") // assumed delegate cache is totally managed by this cache
5 SoftReference softReference = (SoftReference) delegate.getObject(key); 6 if (softReference != null) { 7 result = softReference.get(); 8 if (result == null) { 9 delegate.removeObject(key);10 } else {11 // See #586 (and #335) modifications need more than a read lock12 synchronized (hardLinksToAvoidGarbageCollection) {13 hardLinksToAvoidGarbageCollection.addFirst(result);14 if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {15 hardLinksToAvoidGarbageCollection.removeLast();16 }17 }18 }19 }20 return result;21}
由于缓存中保存是一个继承的SoftReference
的SoftEntry
所有需要判断值是否为空(被GC回收)
如果被回收了,则将key也移除掉
如果没有被回收,说明访问次数较多,移动到hardLinksToAvoidGarbageCollection
链表中
并维护其固定大小
2.3.9TransactionalCache 事务缓存
TransactionalCache
是一个具有事务性的缓存,除了有Cache
接口的方法外,还增加了commit
,rollback
等事务方法
而且在mybatis
中,二级缓存(也就是事务性查询缓存)就是通过TransactionalCache
实现的
1private final Cache delegate;
2// 是否在commit时清除缓存
3private boolean clearOnCommit;
4// 先保存一份数据,等commit时再提交到缓存
5private final Map entriesToAddOnCommit; 6// 保存没有命中缓存的key 7private final Set entriesMissedInCache; 8 9public TransactionalCache(Cache delegate) {10 this.delegate = delegate;11 this.clearOnCommit = false; // 默认commit不清缓存12 this.entriesToAddOnCommit = new HashMap<>();13 this.entriesMissedInCache = new HashSet<>();14}
mybatis
通过在缓存之前加了一层(entriesToAddOnCommit), 以此来实现了事务
果然计算机中没有什么事不能通过加一层来解决的,如果有,那就加两层
如果当前事务没提交,则对于其他事务来说,当前事务的操作对于其他事务是不可见的
即缓存不会被其他事务获取到
1@Override
2public void putObject(Object key, Object object) {
3 entriesToAddOnCommit.put(key, object);
4}
所以原先的设置缓存操作,只是将值保存到entriesToAddOnCommit
上面
等到commit时
1public void commit() {
2 if (clearOnCommit) {
3 delegate.clear();
4 }
5 flushPendingEntries();
6 reset();
7}
8
9//刷新待处理的缓存
10private void flushPendingEntries() {
11 for (Map.Entry entry : entriesToAddOnCommit.entrySet()) {12 delegate.putObject(entry.getKey(), entry.getValue());13 }14 // 未命中缓存也要保存一份空对象15 for (Object entry : entriesMissedInCache) {16 if (!entriesToAddOnCommit.containsKey(entry)) {17 delegate.putObject(entry, null);18 }19 }20}21// 重置事务22private void reset() {23 clearOnCommit = false;24 entriesToAddOnCommit.clear();25 entriesMissedInCache.clear();26}
再将值提交到缓存,并将事务重置
2.3.10 SerializedCache序列化缓存
在设置缓存的时候,将对象序列化
等到获取缓存时,将值反序列化成对象,达到节省内存的目的
典型的时间换取空间,但是Java的原生序列化性能并不理想(很差就是了)
简单了解一下设置缓存的序列化过程即可
1@Override
2public void putObject(Object key, Object object) {
3 // 判断是否可序列化,否则抛出异常
4 if (object == null || object instanceof Serializable) {
5 delegate.putObject(key, serialize((Serializable) object));
6 } else {
7 throw new CacheException("SharedCache failed to make a copy of a non-serializable object: " + object);
8 }
9}
10// 对象序列化成字节数组
11private byte[] serialize(Serializable value) {
12 try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
13 ObjectOutputStream oos = new ObjectOutputStream(bos)) {
14 oos.writeObject(value);
15 oos.flush();
16 return bos.toByteArray();
17 } catch (Exception e) {
18 throw new CacheException("Error serializing object. Cause: " + e, e);
19 }
20}
2.3.11 WeakCache 软引用缓存
弱引用跟软引用的实现几乎一毛一样
只是把SoftReference
换成WeakReference
而已
弱引用的生命周期要再短一点,GC的时候,不管会不会发生OOM
, WeakReference
都会被回收
1// 只是换成WeakReference, 其他几乎同SoftCache
2private static class WeakEntry extends WeakReference<Object> {
3 private final Object key;
4
5
6 private WeakEntry(Object key, Object value, ReferenceQueue garbageCollectionQueue) {
7 super(value, garbageCollectionQueue);
8 this.key = key;
9 }
10}
最后
微信公众号文章的篇幅实在有限,因此本篇只写到mybatis
中cache
的实现,对于一级缓存,二级缓存
的使用以及原理留到下一篇中说明把