一个与 Ehcache 相关的死锁案例

最近朋友分享一个与 Ehcache 相关的死锁案例,记录下。

0. 一个思考题

缓存击穿是指高并发的请求访问同一个 key,当这个 key 失效后,所有的请求都会传递到数据库,数据库因而崩溃。

为了防止数据库崩溃,我们需要在应用层面拦截这些请求,比如控制一个应用加载一个 key 时只有一个请求到数据库。

如果用加锁的方法,每个 key 一个锁,那么 100 万个 key 就会有 100 万个锁实例在 JVM 里。如果 key 过期了,锁实例可能还得过期,特麻烦。

如果所有的 key 共享同一个锁,那么这个锁就会成为瓶颈。

该如何控制是好?

1. 背景说明

一个面向海量用户的 api 服务,需要提供用户信息查询的功能。

缓存有两层,第一层是 JVM 内基于 Ehcache 的,第二层是 memcached ,查找缓存时首先从本机 Ehcache 查询,找不到再去 memcached 查找,还找不到则从 Loader 接口加载数据并存入相应的缓存。

用户信息由 会员信息、个人信息、家庭信息 等组成,这些信息位于不同的模块,存储在不同的表或库里。

在 Ehcache 里,用户信息有一层缓存,会员信息、个人信息、家庭信息 也有对应的缓存。

以 ID 为 “007” 的用户举例,用户信息缓存对应的 key 为 “User:007″,会员信息对应的缓存 key 为 “member:007″,个人信息对应的缓存 key 为 “personal:007″,家庭信息对应的缓存 key 为 “family:007″。
其中用户信息对应的缓存的过期时间是比较短的。

当用户信息在缓存里都查找不到时,它的 Loader 实现会调用 MemerbService 加载会员信息、调用 PersonalService 加载个人信息,FamilyService 加载家庭信息,然后合并成用户信息保存在缓存里。MemerbService/PersonalService/FamilyService加载信息时也是先从缓存查找,两级缓存都查找不到时也调用对应的 Loader 从表里加载数据。

使用的 Ehcache 的版本是 ehcache-core-2.6.9 。

2. 故障现象

Tomcat 进程还在,内存正常,新请求都超时,从 Java 线程栈来看,出现了死锁,没有空闲的线程可处理请求。

下面是 Java 线程栈里的死锁信息,脱敏后的,每个方法栈里补充了已持有的锁。

Full thread dump Java HotSpot(TM) 64-Bit Server VM (24.45-b08 mixed mode):

Found one Java-level deadlock:
=============================
"http-nio-8082-exec-13":
  waiting for ownable synchronizer 0x0000000710d41508, (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync),
  which is held by "http-nio-8082-exec-195"
"http-nio-8082-exec-195":
  waiting for ownable synchronizer 0x0000000710d3ffa8, (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync),
  which is held by "http-nio-8082-exec-13"

Java stack information for the threads listed above:
===================================================

"http-nio-8082-exec-13":
    at sun.misc.Unsafe.park(Native Method)
    - parking to wait for  <0x0000000710d41508> (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync)
    at java.util.concurrent.locks.LockSupport.park(LockSupport.java:186)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:834)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireShared(AbstractQueuedSynchronizer.java:964)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireShared(AbstractQueuedSynchronizer.java:1282)
    at java.util.concurrent.locks.ReentrantReadWriteLock$ReadLock.lock(ReentrantReadWriteLock.java:731)
    at net.sf.ehcache.store.MemoryOnlyStore.get(MemoryOnlyStore.java:105)
    at net.sf.ehcache.Cache.searchInStoreWithoutStats(Cache.java:2070)
    at net.sf.ehcache.Cache.get(Cache.java:1590)
    at net.coderbee.api.cache.EhcacheService.get(EhcacheService.java:198)
    at net.coderbee.api.cache.EhcacheService.getOrLoad(EhcacheService.java:84)
    at net.coderbee.api.service.TemplateService.findOne(TemplateService.java:41)
    at net.coderbee.api.service.ContentService.getContentVO(ContentService.java:255)
    at net.coderbee.api.service.ContentService$2.load(ContentService.java:234)
    at net.coderbee.cache.MemcachedCache.getOrLoad(MemcachedCache.java:43)
    at net.coderbee.api.cache.EhcacheService.getOrLoad(EhcacheService.java:70)

   Locked ownable synchronizers:
    - <0x000000070fda8c80> (a java.util.concurrent.ThreadPoolExecutor$Worker)
    - <0x0000000710d3ffa8> (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync)

"http-nio-8082-exec-195":
    at sun.misc.Unsafe.park(Native Method)
    - parking to wait for  <0x0000000710d3ffa8> (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync)
    at java.util.concurrent.locks.LockSupport.park(LockSupport.java:186)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:834)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireShared(AbstractQueuedSynchronizer.java:964)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireShared(AbstractQueuedSynchronizer.java:1282)
    at java.util.concurrent.locks.ReentrantReadWriteLock$ReadLock.lock(ReentrantReadWriteLock.java:731)
    at net.sf.ehcache.store.MemoryOnlyStore.get(MemoryOnlyStore.java:105)
    at net.sf.ehcache.Cache.searchInStoreWithoutStats(Cache.java:2070)
    at net.sf.ehcache.Cache.get(Cache.java:1590)
    at net.coderbee.api.cache.EhcacheService.get(EhcacheService.java:198)
    at net.coderbee.api.cache.EhcacheService.getOrLoad(EhcacheService.java:62)
    at net.coderbee.api.service.TemplateService.findOne(TemplateService.java:41)
    at net.coderbee.api.service.ContentService.getContentVO(ContentService.java:255)
    at net.coderbee.api.service.ContentService$2.load(ContentService.java:234)
    at net.coderbee.cache.MemcachedCache.getOrLoad(MemcachedCache.java:43)
    at net.coderbee.api.cache.EhcacheService.getOrLoad(EhcacheService.java:70)

   Locked ownable synchronizers:
    - <0x000000070ead0cb8> (a java.util.concurrent.ThreadPoolExecutor$Worker)
    - <0x0000000710d41508> (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync)

Found 1 deadlock.

其他的 HTTP 处理线程都阻塞在获取锁 0x0000000710d415080x0000000710d3ffa8 上。

死锁是实实在在的,上面的两个线程在互相等待对方持有的锁。

3. 死锁代码示例

public interface Loader {
    Object load(String key);
}

public class MemcachedService {
    public Object get(String key, Loader loader) {
        Object val = getFromRemote(key);
        if (val == null) {
            // 通过 loader 加载数据
            val = loader.load(key);
            setToRemote(key, val);
        }

        return val;
    }

    private Object getFromRemote(String key) {
        return null;
    }
    private void setToRemote(String key, Object val) {
    }
}

public class EhcacheService {
    // 本机 Ehcache 缓存
    private Cache cache = new Cache(null);
    // 远程 memcached 缓存
    private MemcachedService memcachedService = new MemcachedService(); 

    public Object get(String key, Loader loader) throws Exception {
        String ehkey = "eh:" + key; // 与 memcached 的是一致的

        Object ehvalue = cache.get(ehkey);
        if (ehvalue == null) {
            // 获取 ehkey 对应的 Ehcache 缓存的写锁
            if (cache.tryWriteLockOnKey(ehkey, 0)) {
                try {
                    if ((ehvalue = cache.get(ehkey)) == null) {
                        // 从 memcached 加载
                        ehvalue = memcachedService.get(key, loader);
                        if (ehvalue != null) {
                            cache.put(new Element(ehkey, ehvalue));
                        }
                    }
                } finally {
                    cache.releaseWriteLockOnKey(ehkey);
                }
            }
        } else {
            // 省略
        }
        return ehvalue;
    }
}

请结合背景信息思考死锁原因?

4. 原因分析

我们来看看发生死锁等待的那一行代码:

public Element get(Object key) {
    if (key == null) {
        return null;
    }

    Lock lock = getLockFor(key).readLock();
    lock.lock();
    try {
        return copyElementForReadIfNeeded(authority.get(key));
    } finally {
        lock.unlock();
    }
}

public ReadWriteLock getLockFor(Object key) {
    return masterLocks.getLockForKey(key);
}

既然发生了死锁,肯定要知道线程要等待的是哪把锁,继续看 masterLocks.getLockForKey 的实现:

public class StripedReadWriteLockSync implements StripedReadWriteLock {
    public static final int DEFAULT_NUMBER_OF_MUTEXES = 2048;

    private final ReadWriteLockSync[] mutexes;
    private final List<ReadWriteLockSync> mutexesAsList;

    public ReadWriteLock getLockForKey(final Object key) {
        int lockNumber = ConcurrencyUtil.selectLock(key, mutexes.length);
        return mutexes[lockNumber].getReadWriteLock();
    }
}

public final class ConcurrencyUtil {
    public static int selectLock(final Object key, int numberOfLocks) throws CacheException {
        int number = numberOfLocks & (numberOfLocks - 1);
        if (number != 0) {
            throw new CacheException("Lock number must be a power of two: " + numberOfLocks);
        }
        if (key == null) {
            return 0;
        } else {
            int hash = hash(key) & (numberOfLocks - 1);
            return hash;
        }
    }
}

从上面可以知道,Ehcache 并没有为每一个 key 维护一个锁,而是默认维护 2048 个锁实例的数组,获取锁时通过 key 计算出哈希值,然后映射到数组下标,取对应的锁实例。

用了哈希算法,肯定会出现哈希冲突,这样就会出现多个 key 竞争同一个锁。

从上面的代码也可以知道,Ehcache 每次读取 key 对应的值时,都会先加 读锁。

那个思考题也可以参考 Ehcache 的这个思路。

结合背景,是时候解开谜底了。

当查找用户信息时,如果 Ehcache 里不存在,则会调用 loader 加载子信息,而加载子信息也会访问缓存,也存在加锁的可能。

Object ehvalue = cache.get(ehkey);  // pos1:加读锁、释放读锁
if (ehvalue == null) {
    if (cache.tryWriteLockOnKey(ehkey, 0)) { // pos2:加写锁
        try {
            if ((ehvalue = cache.get(ehkey)) == null) {
                ehvalue = memcachedService.get(key, loader);// pos3:可能调用 loader 加载
                if (ehvalue != null) {
                    cache.put(new Element(ehkey, ehvalue));
                }
            }
        } finally {
            cache.releaseWriteLockOnKey(ehkey); // pos3: 释放写锁
        }
    }
}

public class MemcachedService {
    public Object get(String key, Loader loader) {
        Object val = getFromRemote(key);
        if (val == null) {
            val = loader.load(key); // pos4:
            setToRemote(key, val);
        }

        return val;
    }

线程 A 执行到 pos2 对锁 1 进行加锁,线程 B 也执行到 pos2 对锁 2 进行加锁。

线程 A 执行到 pos3 再到 pos4,pos4 的 loader 加载会员信息,此时加载的 key 变了,继续调回到 pos1,对锁 2 加读锁,由于锁 2 已经被线程 B 持有,线程 A 只能等待。

线程 B 继续执行 pos3、pos4 也没加载到用户信息,换一个 key 去加载会员信息,然后也调回到 pos1,此时这个会员信息的 key 刚好映射到锁 1,尝试加载锁 1 的读锁,而锁 1 已经被线程 A 持有,进入等待。

至此,循环等待形成,死锁诞生。

5. 小结

这个问题的根本原因是:一次调用存在两次加锁,具体加哪个锁、加锁的顺序是由参数的哈希值决定的。

要解决这个问题也很简单,Ehcache 为了维护有限的锁数量,底层的逻辑不好去改,可以消除一次加锁的行为、变为只有一次加锁,这样就消除了死锁形成的一个必要条件:持有一个锁,尝试对另一个锁加锁。

因此把 pos2 处的加锁行为去掉就行。

有没有觉得 Ehcache 这个基于 key 的哈希值来决定加哪个锁的逻辑跟 JDK 1.7 里 ConcurrentHashMap 的分段加锁很像,^_^。


欢迎关注我的微信公众号: coderbee笔记

一个与 Ehcache 相关的死锁案例》有一个想法

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据