记一次源码分析过程,顺便给MyBatis修个Bug
1 问题背景
事情是这样的,一天,有一位小伙伴 知乎用户@阳光沙滩裤 私信我问我书中的一个问题,如下:
哎,不对啊。我都AT他了,他的头像还打什么码啊!
打了个寂寞啊!
不管了,继续讨论问题。
说实话,这个问题我确实没有注意到。因为我只读了其中的一个分支……
提问的小伙伴很有心,细致地对比了两个分支的区别。然后提出了这个问题。
那当然,我要去给出这个问题的解答!
尽量!
开始。
2 相关知识
开始之前,我们需要先介绍下这个问题的相关知识。
主要的背景知识分为两块,一块是MyBatis缓存的实现机制,一块是软引用和弱引用。
2.1 MyBatis的缓存
装饰器模式在编程开发中经常使用。通常的使用场景是在一个核心基本类的基础上,提供大量的装饰器,从而使得核心基本类经过不同的装饰器修饰后获得不同的功能。
装饰器还有一个优点就是可以叠加使用,即一个核心基本类可以被多个装饰器修饰,从而同时具有这多个装饰器的功能。
MyBatis便使用了大量的装饰器实现了不同类型的缓存,它们都在cache包中。在imple子包中存放了实现类,在decorators子包中存放了众多装饰器类。而Cache接口是实现类和装饰器类的共同接口。
下面给出了Cache接口及其子类的类图。Cache接口的子类中,只有一个实现类,但却有十个装饰器类。通过使用不同的装饰器装饰实现类可以让实现类有着不同的功能。
Cache接口的源码如下所示,在接口中定义了实现类和装饰器类中必须实现的方法。
public interface Cache {
/**
* 获取缓存id
* @return 缓存id
*/
String getId();
/**
* 向缓存写入一条数据
* @param key 数据的键
* @param value 数据的值
*/
void putObject(Object key, Object value);
/**
* 从缓存中读取一条数据
* @param key 数据的键
* @return 数据的值
*/
Object getObject(Object key);
/**
* 从缓存中删除一条数据
* @param key 数据的键
* @return 原来的数据值
*/
Object removeObject(Object key);
/**
* 清空缓存
*/
void clear();
/**
* 读取缓存中数据的数目
* @return 数据的数目
*/
int getSize();
/**
* 获取读写锁,该方法已经废弃
* @return 读写锁
*/
default ReadWriteLock getReadWriteLock() {
return null;
}
}
缓存实现类PerpetualCache
的实现非常简单,但可以通过装饰器来为其增加更多的功能。decorators
子包中存在许多装饰器,根据装饰器的功能可以将它们可以分为以下几个大类:
-
同步装饰器:为缓存增加同步功能,如SynchronizedCache类。 -
日志装饰器:为缓存增加日志功能,如LoggingCache类。 -
清理策略装饰器:为缓存中的数据增加清理功能,如FifoCache类、LruCache类、WeakCache类、SoftCache类。 -
阻塞装饰器:为缓存增加阻塞功能,如BlockingCache类。 -
刷新装饰器:为缓存增加定时刷新功能,如ScheduledCache类。 -
序列化装饰器:为缓存增加序列化功能,如SerializedCache类。 -
事务装饰器:用于支持事务操作的装饰器,如TransactionalCache类。
FifoCache类是一个装饰器,经过它装饰的缓存会采用先进先出的策略来清理缓存,它内部使用了keyList属性存储了缓存数据的写入顺序,并且使用size属性存储了缓存数据的数量限制。当缓存中的数据达到限制时,FifoCache装饰器会将最先放入缓存中的数据删除。下面展示了FifoCache类的属性。
// 被装饰对象
private final Cache delegate;
// 按照写入顺序保存了缓存数据的键
private final Deque<Object> keyList;
// 缓存空间的大小
private int size;
当向缓存中存入数据时,FifoCache类会判断数据数量是否已经超过限制。如果超过,则会将最先写入缓存的数据删除,下面展示了相关操作的源码。
/**
* 向缓存写入一条数据
* @param key 数据的键
* @param value 数据的值
*/
@Override
public void putObject(Object key, Object value) {
cycleKeyList(key);
delegate.putObject(key, value);
}
/**
* 记录当前放入的数据的键,同时根据空间设置清除超出的数据
* @param key 当前放入的数据的键
*/
private void cycleKeyList(Object key) {
keyList.addLast(key);
if (keyList.size() > size) {
Object oldestKey = keyList.removeFirst();
delegate.removeObject(oldestKey);
}
}
其中的delegate引用的就是缓存最终的实现类,当然也可能是被装饰器装饰后的实现类。毕竟,我们也说过,装饰器可以叠加使用。
OK,以上就是简要介绍,为了让大家了解背景。如果大家要详细了解,可以阅读《通用源码阅读指导书——MyBatis源码详解》。
2.2 软引用和弱引用
在Java程序的运行过程中,JVM会自动地帮我们进行垃圾回收操作以避免无用的对象占用内存空间。这个过程主要分为两步:
-
找出所有的垃圾对象 -
清理找出的垃圾对象
我们这里重点关注第一步,即如何找出垃圾对象。这里的关键问题在于如何判断一个对象是否为垃圾对象。
判断一个对象是否为垃圾对象的方法主要有引用计数法和可达性分析法,JVM采用的是可达性分析法。
可达性分析是指JVM会从垃圾回收的根对象(Garbage Collection Root, 简称GC Root)为起点,沿着对象之间的引用关系不断遍历。最终能够遍历到的对象都是有用的对象,而遍历结束后也无法遍历到的应用便是垃圾对象。
根对象不止一个,例如栈中引用的对象、方法区中的静态成员等都是常见的根对象。
我们举一个例子。如果下图中的对象c不再引用对象d,则通过GC Root便无法到达对象d和对象f,那么对象d和f便成了垃圾对象。
有一点要说明,在上图中我们只绘制了一个GC Root,实际在JVM中有多个GC Root。当一个对象无法通过任何一个GC Root遍历到时,它才是垃圾对象。
不过上图展示的这种引用关系是有局限性的。试想存在一个非必须的大对象,我们希望系统在内存不紧张时可以保留它,而在内存紧张时释放它以为更重要的对象让渡内存空间。这时应该怎么做呢?
Java已经考虑到了这种情况,Java的引用中并不是只有“引用”、“不引用”这两种情况,而是有四种情况。
-
强引用(StrongReference):即我们平时所说的引用。只要一个对象能够被GC Root强引用到,那它就不是垃圾对象。当内存不足时,JVM会抛出OutOfMemoryError错误而不是清除被强引用的对象。 -
软引用(SoftReference):如果一个对象只能被GC Root软引用到,则说明它是非必须的。当内存空间不足时,JVM会回收该对象。 -
弱引用(WeakReference):如果一个对象只能被GC Root弱引用到,则说明它是多余的。JVM只要发现它,不管内存空间是否充足都会回收该对象。与软引用相比,弱引用的引用强度更低,被弱引用的对象存在时间相对更短。 -
虚引用(PhantomReference):如果一个对象只能被GC Root虚引用到,则和无法被GC Root引用到时一样。因此,就垃圾回收过程而言,虚引用就像不存在一样,并不会决定对象的生命周期。虚引用主要用来跟踪对象被垃圾回收器回收的活动。
下面给出了强引用、软引用、弱引用的示例。
// 通过等号直接建立的引用都是强引用
User user = new User();
// 通过SoftReference建立的引用是软引用
SoftReference<User> softRefUser =new SoftReference<>(new User());
// 通过WeakReference建立的引用是弱引用
WeakReference<User> weakRefUser = new WeakReference<>(new User());
3 问题介绍
那这位小伙伴所述的问题是什么的?
在MyBatis的缓存的WeakCache修饰器中,存在下面的方法:
public Object getObject(Object key) {
Object result = null;
WeakReference<Object> weakReference = (WeakReference<Object>) delegate.getObject(key);
if (weakReference != null) {
result = weakReference.get();
if (result == null) {
delegate.removeObject(key);
} else {
hardLinksToAvoidGarbageCollection.addFirst(result);
if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {
hardLinksToAvoidGarbageCollection.removeLast();
}
}
}
return result;
}
其具体逻辑不难理解,即尝试从弱引用中取出缓存结果。如果取到了,则将结果返回,并且将结果写入到强引用中备用。
同样的,在SoftCache修饰器中,也有一段类似的代码。其逻辑一样。
public Object getObject(Object key) {
Object result = null;
@SuppressWarnings("unchecked") // assumed delegate cache is totally managed by this cache
SoftReference<Object> softReference = (SoftReference<Object>) delegate.getObject(key);
if (softReference != null) {
result = softReference.get();
if (result == null) {
delegate.removeObject(key);
} else {
// See #586 (and #335) modifications need more than a read lock
synchronized (hardLinksToAvoidGarbageCollection) {
hardLinksToAvoidGarbageCollection.addFirst(result);
if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {
hardLinksToAvoidGarbageCollection.removeLast();
}
}
}
}
return result;
}
问题就在于,SoftCache和WeakCache中的这段代码,略有区别。多了一个锁操作:
synchronized (hardLinksToAvoidGarbageCollection) {
// 内部略
}
这就是小伙伴的疑问。
好,问题我们也清楚了。
开始研究这一点。
4 疑问解答
4.1 从源码作者处找答案
首先,我们看源码作者有没有给出答案。
还真有,至少是线索。
就是这段注释:
// See #586 (and #335) modifications need more than a read lock
好,那我们去找对应的提交记录。
这是335号issue。
这是586号issue。
问题来了,这两个issue讨论的问题,和我们寻找的问题,驴唇不对马嘴啊!
不要灰心,我们直接去看代码的提交记录。
代码总有记录吧!
先看WeakCache的提交记录:
这段代码写于8年前,老代码啊!
在看SoftCache的提交记录:
这段代码写于9年前和11年前,更老啊!
到这,我们可以得出一个结论:
-
从时间上看,我们截图中的#586 (and #335)的时间更晚,因此确实和这段注释不对应。不是注释中反映的问题。 -
SoftCache的代码更早,WeakCache应该是参照它写的。可是,WeakCache中却没有加锁。
然后,我们可以看到提交记录中有一行注释,如下。
而且,我们找到这次的提交:
看,就是在这里加的锁啊!
这就是答案啊!
http://code.google.com/p/mybatis/issues/detail?id=586
走起!
然后,悲剧了!
哈哈!
当你以为追踪到了答案,
才发现
追踪了个寂寞!
哎!
对了,会不是咱们上网不够科学啊!
我梯子呢?我梯子呢?
我们搭起梯子,翻出保护我们的小矮墙,
成功!
结果,还是不行。链接真的失效了。
不过,作者注释里到是留下了一句话:
Reads do modify internal structures. —— 读取确实会修改内部结构。
不过,接下来,我们有了点新发现。
在11年的版本b12a58ce中。我们有了新发现:
-
「WeakCache和SoftCache这两个类都是存在的。」 -
「WeakCache和SoftCache这两个类中都没有synchronized (hardLinksToAvoidGarbageCollection)。」
然后,唯一的线索就是:
Reads do modify internal structures. —— 读取确实会修改内部结构。
说到这,Youtube还是挺好看的,我先去看会儿。
4.2 自行探索答案
好了,源码注释中的线索已经断了,我们开始自行探索。
不过要说明一下,其实自己分析也很快的,但是为什么根据源码中的线索找了那么久,实在不行才自己分析呢?
-
相比于自己分析,源码作者人数众多,考虑的更全面。很可能给出你想不到的一个点,给你带来巨大的收获。 -
分别源代码是知识输入,而自己分析,就是属于知识运用。知识运用不如知识输入的收获大。
但是,实在找不到也没办法。
开始自己分析。
刚才不是打好了翻墙的梯子么。
我发现一个问题:
——Youtube上的视频真好玩!
等我看会Youtube先,一会就回来。
首先看被加锁的对象:
private final Deque<Object> hardLinksToAvoidGarbageCollection;
它是SoftCache的私有变量。其他对象不会访问到。
因此,探究可以局限在该类内部。
那它究竟是什么呢?
public SoftCache(Cache delegate) {
this.delegate = delegate;
this.numberOfHardLinks = 256;
this.hardLinksToAvoidGarbageCollection = new LinkedList<>();
this.queueOfGarbageCollectedEntries = new ReferenceQueue<>();
}
构造方法里写了,是一个LinkedList。
再看加锁的地方:
synchronized (hardLinksToAvoidGarbageCollection) {
hardLinksToAvoidGarbageCollection.addFirst(result);
if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {
hardLinksToAvoidGarbageCollection.removeLast();
}
}
「那LinkedList的addFirst方法需要加锁么?」
按照MyBatis作者留下的注释:Reads do modify internal structures. —— 读取确实会修改内部结构。
注意,这里的“Reads”指的是SoftCache中所述的getObject方法。但是,作者的意思还是说这里的addFirst确实需要加锁。
我们还是去看LinkedList本身。
我们在LinkedList中找到注释如下:
它说明了两点:
-
增删均会改变列表结构 -
需要外部加锁
是的,因为LinkedList作为一个链表,确实会在增删时候修改结构。
「那在并发读缓存时,会涉及到这个私有变量LinkedList的写,确实可能出问题。」
因此,「对hardLinksToAvoidGarbageCollection加锁后再addFirst是有必要的。」
也就是说,「SoftCache的做法是对的。」
那我们再回到WeakCache。它需要加锁么?
软引用和弱引用确实有些不同,但是,他们的不同没那么大,关键在于这些不同对WeakCache中的队列hardLinksToAvoidGarbageCollection没有影响。
因此,「WeakCache应该也要加锁才对。」
但是,WeakCache没有加!
为什么呢?
答案变的极为简单:开发者忘记了。
哈哈,忘记了……
所以,最终结案的原因是:
-
起初,SoftCache和WeakCache一样,都没有给hardLinksToAvoidGarbageCollection加锁。 -
后来,发生了已经无法访问的586号Bug。 -
在3a7cf3c0提交中,修复了586号Bug,即在SoftCache中加了锁。但是, 「忘记了给WeakCache加。」
就是这样子。
所以,这是一个存在了10年的Bug。
那这个Bug什么时候会触发呢?
使用弱引用缓存,然后,多线程读取缓存或者清除缓存。则很可能会命中Bug,进而导致读取到错误值、缓存数据混乱、越界崩溃等问题。
当然了,发现问题也别只是自己知道,毕竟开源项目就是人人为我、我为人人。所以,发现开源项目的Bug可以帮忙改正下。
然后就可以合并进MyBatis的主分支,新版本就不会有这个bug了。
以后,其他人也不会有这个疑问了。
最后,再次感谢 知乎用户@阳光沙滩裤 发现这个Bug。
看,「读源码,如探案。」
好玩不!
最后,我是高级架构师「易哥」,这里是「架构研究所」。真心希望本文能让大家有所收获。
欢迎「关注」我们,我会偶尔出没分享「软件架构」和「编程」相关的干货知识。