并发读写缓存实现机制(二):高并发下数据写入与过期
在上一章中,我们讲解了ConcurrentHashMap的读取效率很高的原因,一般来说并发的读取和写入是一对矛盾体,而缓存的过期移除和持久化则是另一对矛盾体。这一节,我们着重来了解下高并发情况下缓存的写入、过期控制及周边相关功能。
系列文章目录:
并发读写缓存实现机制(零):缓存操作指南
并发读写缓存实现机制(一):为什么ConcurrentHashMap可以这么快?
并发读写缓存实现机制(二):高并发下数据写入与过期
并发读写缓存实现机制(三):API封装和简化
文中缓存源码请参考:https://github.com/cm4j/cm4j-all
1.高效的数据写入(put)
在研究写入机制之前,我们先来回顾下上一节的内容。ConcurrentHashMap之所以读取很快,很大一部分原因归功于它的数据分割设计,就像是把书的内容划分为很多章,章下面又分了许多小节。同样的原理,写入过程也可以按这个规则把数据分为很多独立的块,也就是前一节提到的Segment。另一方面为了解决并发问题,加锁是一个不错的选择。再回头看看Segment类图(清单1),Segment其实是继承了ReentrantLock,自己本身就是一个锁。
想要最大限度的支持并发,就是读写操作都不加锁,JDK8 就实现了无锁的并发HashMap,效率更高的同时复杂度也更大;而在锁机制中,一个很好的方法就是读操作不加锁,写操作加锁,对于竞争资源来说就需要定义为volatile类型的。volatile类型能够保证happens-before法则,所以volatile能够近似保证正确性的情况下最大程度的降低加锁带来的影响,同时还与写操作的锁不产生冲突。
“锁分离” 技术
在竞争激烈的情况下,如果写入时对缓存中所有数据都加锁,效率必然低下,HashTable的效率不高就是因为这个原因。为此ConcurrentHashMap默认把数据分为16个Segment,每个Segment就是一把锁,也就是一个独立的数据块,那么多线程读写不同Segment的数据时就不会存在锁竞争,从而可以有效的提高读写效率,这就是“锁分离”技术。缓存中几乎所有的操作都是基于独立的segment数据块,且在修改时必须对segment加锁。
缓存的put()操作与get()操作类似,得到元素修改就行了,更多的参考源码,这里有几点要注意:
a.如果对数据做了新增或移除,需要修改count的数值,这个要放到整个操作的最后,为什么?前面说过count是volatile类型,而读取操作没有加锁,所以只能把元素真正写回Segment中的时候才能修改count值,所以这个必须要放到整个操作的最后。
b.因为HashEntry的next属性是final的,所以后加入的元素都是加在链表的头部
c.为什么在put操作中首先建立一个临时变量tab指向Segment的table,而不是直接使用table?这是因为table变量是volatile类型,多次读取volatile类型的开销要比非volatile开销要大,而且编译器也无法优化,多次读写tab的效率要比volatile类型的table要高,JVM也能够对此进行优化。
2.巧妙的数据移除(remove)
上一节中,我们也提到,为了防止在遍历HashEntry的时候被破坏,HashEntry中除了value之外其他属性都是final常量,否则不可避免的会得到ConcurrentModificationException,这就意味着,不能把节点添加到链表的中间和尾部,也不能在链表的中间和尾部删除节点,只能在头部添加节点。这个特性可以保证:在访问某个节点时,这个节点之后的链表不会被改变。这样可以大大降低处理链表时的复杂性。既然不能改变链表,缓存到底是如何移除对象的呢?我们首先来看下面两幅图:
清单2. 执行删除之前的原链表1:
清单3. 执行删除之后的新链表2
假设现在有一元素C需要删除,根据上一节所讲,C必然存在某一链表1中,假设这个链表结构为A->B->C->D->E,那现在该如何从链表1中删除C元素?
根据上一节的内容,我们先要经过2次hash定位,即我们先要定位到C所在的Segment,然后再定位到Segment中table的下标(C所在的链表)。然后遍历链表找到C元素,找到之后就把C的next节点D作为临时头节点构成链表2,然后从现有头节点A开始向后迭代加入到链表2的头部,一直到需要删除的C节点结束,即A、B依次都作为临时头节点加入链表2,最后的情形就是A加入到D前面,B又加入到A前面,这样就构造出来一个新的链表B->A->D->E,然后将此链表的最新头节点B设置到Segment的table中。这样就完成了元素C的删除操作。
需要说明的是,尽管旧的链表仍然存在(A->B->C->D->E),但是由于没有引用指向此链表,所以此链表中无引用的(A->B->C)最终会被GC回收掉。这样做的一个好处是,如果某个读操作在删除时已经定位到了旧的链表上,那么此操作仍然将能读到数据,只不过读取到的是旧数据而已,这在多线程里面是没有问题的。
从上面的流程可以看出,缓存的数据移除不是通过更改节点的next属性,而是通过重新构造一条新的链表来实现的,这样即保证了链条的完整性,同时也保证了并发读取的正确性。源码如下:
清单4:缓存数据的移除
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
void removeEntry(HashEntry entry, int hash) {
int c = count - 1; AtomicReferenceArray int index = hash & (tab.length() - 1); HashEntry first = tab.get(index); for (HashEntry e = first; e != null; e = e.next) { if (e == entry) { ++modCount; // 从链表1中删除元素entry,且返回链表2的头节点 HashEntry newFirst = removeEntryFromChain(first, entry); // 将链表2的新的头节点设置到segment的table中 tab.set(index, newFirst); count = c; // write-volatile,segment内的元素个数-1 return; } } } HashEntry removeEntryFromChain(HashEntry first, HashEntry entry) { HashEntry newFirst = entry.next; // 从链条1的头节点first开始迭代到需要删除的节点entry for (HashEntry e = first; e != entry; e = e.next) { // 拷贝e的属性,并作为链条2的临时头节点 newFirst = copyEntry(e, newFirst); } accessQueue.remove(entry); return newFirst; } HashEntry copyEntry(HashEntry original, HashEntry newNext) { // 属性拷贝 HashEntry newEntry = new HashEntry(original.getKey(), original.getHash(), newNext, original.value); copyAccessEntry(original, newEntry); return newEntry; } |
3.按需扩容机制(rehash)
缓存中构造函数有3个参数与容量相关:initialCapacity代表缓存的初始容量、loadFactor代表负载因子、concurrencyLevel字面上的意思是并发等级,其实就是segment的数量(内部会转变为2的n次方)。既然有初始容量,则自然有容量不足的情况,这种情况就需要对系统扩容,随之而来的就是2个问题:何时扩容以及如何扩容?
第一个问题:何时扩容,缓存就使用到了loadFactor负载因子,在插入元素前会先判断Segment里的HashEntry数组是否超过阈值threshold = (int) (newTable.length() * loadFactor),如果超过阀值,则调用rehash方法进行扩容。
那如何扩容呢?扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。为了效率缓存不会对整个容器进行扩容,而只对某个segment进行扩容。这样对于其他segment的读写都不影响。扩容的本质就是把数据的key按照新的容量重新hash放到新组建的数组中,但是相较于HashMap的扩容,ConcurrentHashMap有了些许改进。
我们来看个小例子:假设原有数组长度为16,根据上一节知识,我们知道掩码为1111,扩容后新的数组长度为16*2=32,掩码为11111。由下图可以看出扩容前掩码15到扩容后掩码31,也就是粉色那一列的掩码值由0变为1,这样子如果hash蓝色那一列的值原来是0,则扩容后下标和扩容前一样,如果原来是1,则扩容后下标=扩容前下标+16,由此我们可以得出结论:扩容前的长度为length的数组下标为n的元素,映射到扩容后数组的下标为n或n+length。
0111|1110 hash二进制
0000|1111 掩码15
-------------‘与’运算-----------
0000|1110 扩容前数组下标
0111|1110 hash二进制
0001|1111 掩码31
-------------‘与’运算-----------
0001|1110 扩容后数组下标,比扩容前下标大10000,转换为十进制就是16
假设我们有链表A[5] -> B[21] -> C[5] -> D[21] -> E[21] -> F[21],中括号内代表的是扩容后的数组下标。基于上面的原理,ConcurrentHashMap扩容是先把链表后面的一整段连续相同下标的元素链(D[21] -> E[21] -> F[21])找出来,直接复用这个链,然后复制这个链之前的元素(A[5] -> B[21] -> C[5]),这样就避免了所有元素的复制。
事实上,根据JDK的描述:Statistically, at the default threshold, only about one-sixth of them need cloning when a table double,翻译过来就是:据统计,使用默认的阈值,扩容时仅有1/6的数据需要复制。
清单5:缓存数据的扩容
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
void rehash() {
/** * .... 部分代码省略 */ for (int oldIndex = 0; oldIndex < oldCapacity; ++oldIndex) { HashEntry head = oldTable.get(oldIndex); if (head != null) { HashEntry next = head.next; int headIndex = head.getHash() & newMask; // next为空代表这个链表就只有一个元素,直接把这个元素设置到新数组中 if (next == null) { newTable.set(headIndex, head); } else { // 有多个元素时 HashEntry tail = head; int tailIndex = headIndex; // 从head开始,一直到链条末尾,找到最后一个下标与head下标不一致的元素 for (HashEntry e = next; e != null; e = e.next) { int newIndex = e.getHash() & newMask; if (newIndex != tailIndex) { // 这里的找到后没有退出循环,继续找下一个不一致的下标 tailIndex = newIndex; tail = e; } } // 找到的是最后一个不一致的,所以tail往后的都是一致的下标 newTable.set(tailIndex, tail); // 在这之前的元素下标有可能一样,也有可能不一样,所以把前面的元素重新复制一遍放到新数组中 for (HashEntry e = head; e != tail; e = e.next) { int newIndex = e.getHash() & newMask; HashEntry newNext = newTable.get(newIndex); HashEntry newFirst = copyEntry(e, newNext); if (newFirst != null) { newTable.set(newIndex, newFirst); } else { accessQueue.remove(e); newCount--; } } } } } table = newTable; this.count = newCount; } |
4.过期机制(expire)
既然叫做缓存,则必定存在缓存过期的概念。为了提高性能,读写数据时需要自动延长缓存过期时间。又因为我们这里所讲的缓存有持久化操作,则要求数据写入DB之前缓存不能过期。
数据拥有生命周期,我们可设置缓存的accessTime解决;读写数据时自动延长周期,也就是读写的时候都需要修改缓存的accessTime;那如何保证数据写入DB前不能清除缓存呢?
一种方法就是定期遍历缓存中所有的元素,检测缓存中数据是否全部写入到库中,如果已写入且达到过期时间,则可移除此缓存。很明显这种方式最大的问题在于需要定期检测所有数据,也就是短期内会有高CPU负载;另一方面,缓存的过期时间不精准,因为缓存的过期是基于定期检测的,不到定期检测时间,缓存就会存在于内存中;还有一方面就是如果过期检测到数据未保存到DB,则需要再延长数据的生命周期。
时间轴
在平时,我们可能会了解到一个名词:timeline,翻译过来就是“时间轴”。请看下面一个简单的示例
清单6:时间轴TimeLine
----进入时间最短-----Enter-->--D-->--C-->--B-->--A-->--进入时间最久-----
我们的缓存数据就存在于这个时间轴上,如上例所示:数据A产生或变化后我们可以把它放到时间轴的Enter点,随着时间的推移,B、C、D也都会依次放在Enter点,最终就形成了上面的一个时间轴。当有时间轴上的数据发生变更,我们再把它从时间轴上移除,当做新数据重新加入Enter点。
那我们如何检测数据有没有过期?因为在时间轴上的数据都是有序的,问题就很简单了,缓存的产生和改变都是有先后顺序的,我们只要找到第一个没过期的元素,则比它进入时间短的数据都是没过期的。整个流程进一步看来就是一个可以删除指定元素的先进先出队列,基于这个原理可实现缓存的过期。
删除指定元素的先进先出队列AccessQueue
目前存在一种常见做法-双向链表形式的环状队列,在这种队列中的元素提供了获取前一个和后一个元素的引用,新的元素插入到链表的尾端,过期元素从头部过期,而双向链表一个很重要的特点就是它可以很方便的从队列中间移除元素。队列AccessQueue继承了AbstractQueue,拥有了队列的基本功能,队列内的元素都是ReferenceEntry,它有3个子类:Head(队列头)、HashEntry(队列内元素)、NullEntry(主要用于移除队列元素)。其中Head元素是一直存在的,默认其previousAccess和nextAccess都指向head自身。
清单7:接口ReferenceEntry
清单8:队列AccessQueue的结构
请看队列AccessQueue的结构图,这里我们假设队列是按照逆时针添加元素的,则元素0、1、2是依次添加到队列中的。
数据移除:假设需要移除节点1,需要先把节点1的上个节点0和下个节点2链接起来,然后把节点1的previousAccess和nextAccess都链接到NullEntry,以确保元素1在JVM中无法再被引用,方便被GC回收。
数据新增:假设需要增加节点1到tail,有可能节点1已经存在于链表中,则需要先把节点1的上个节点0和下个节点2链接起来,然后再添加到尾部。
清单9:可以移除元素的先进先出队列
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
static final class AccessQueue extends AbstractQueue // head代码省略 final ReferenceEntry head = XXX; // 其他部分代码省略 @Override public boolean offer(ReferenceEntry entry) { // 将上一个节点与下一个节点链接,也就是把entry从链表中移除 connectAccessOrder(entry.getPreviousInAccessQueue(), entry.getNextInAccessQueue()); // 添加到链表tail connectAccessOrder(head.getPreviousInAccessQueue(), entry); connectAccessOrder(entry, head); return true; } @Override public ReferenceEntry peek() { // 从head开始获取 ReferenceEntry next = head.getNextInAccessQueue(); return (next == head) ? null : next; } @Override public boolean remove(Object o) { ReferenceEntry e = (ReferenceEntry) o; ReferenceEntry previous = e.getPreviousInAccessQueue(); ReferenceEntry next = e.getNextInAccessQueue(); // 将上一个节点与下一个节点链接 connectAccessOrder(previous, next); // 方便GC回收 nullifyAccessOrder(e); return next != NullEntry.INSTANCE; } } // 将previous与next链接起来 static void connectAccessOrder(ReferenceEntry previous, ReferenceEntry next) { previous.setNextInAccessQueue(next); next.setPreviousInAccessQueue(previous); } // 将nulled的previousAccess和nextAccess都设为nullEntry,方便GC回收nulled static void nullifyAccessOrder(ReferenceEntry nulled) { ReferenceEntry nullEntry = nullEntry(); nulled.setNextInAccessQueue(nullEntry); nulled.setPreviousInAccessQueue(nullEntry); } |
何时进行过期移除?
在拥有了定制版的先进先出队列,缓存过期就相对比较简单了,我们只要把新增和修改的数据放到队列尾部,然后从队列首部依次判断数据是否过期就可以了。那什么时候去执行这个操作呢?google的缓存是放在每次写入操作或者每64次读操作执行一次清理操作。一方面,因为缓存是不停在使用的,这就决定了过期的缓存不可能累积太多;另一方面,缓存的过期仅仅是时间点的判断,速度非常快。所以这样操作性能并没有带来性能的降低,但是却带来了缓存过期的准确性。
清单10:读操作执行清理
1
2 3 4 5 6 7 |
void postReadCleanup() {
// 作为位操作的mask(DRAIN_THRESHOLD),必须是(2^n)-1,也就是1111的二进制格式 if ((readCount.incrementAndGet() & DRAIN_THRESHOLD) == 0) { // 代表每2^n执行一次 cleanUp(); } } |
清单11:缓存过期移除
1
2 3 4 5 6 7 8 9 10 11 |
void expireEntries(long now) {
drainRecencyQueue(); ReferenceEntry e; // 从头部获取,过期且已保存db则移除 while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) { if (e.getValue().isAllPersist()) { removeEntry((HashEntry) e, e.getHash()); } } } |
5.个数统计(size)
前面提到,缓存中数据是分为多个segment的,如果我们要统计缓存的大小,就要统计所有segment的大小后求和,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?答案当然是否定的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。那该如何解决这个问题?
一个方法就是把所有segment都锁定然后求和,很显然这种方法效率非常低下,不可取。因此ConcurrentHashMap里提供了另一种解决方法,就是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候segment是否发生了变化呢?答案是使用modCount变量。每个segment中都有一个modCount变量,代表的是对segment中元素的数量造成影响的操作的次数,这个值只增不减。有了这个它就可以判定segment是否变化了。
所以,size操作本质上就是两次循环尝试,失败了则锁定获取,这种类似无锁的操作方式对性能是有很大的提升,因为大部分情况下两次循环尝试就可以得到结果了。源码相对比较简单,有兴趣的朋友可以自己去了解下,这里就不贴出来了。
总结:
结合前2节的内容,至此我们就拥有了一个强大的缓存,它可以并发且高效的数据读写、数据加载和数据移除,支持数据过期控制和持久化的平衡。在下一节中我们会在此基础上简化缓存的使用,以方便日常的调用。
posted on
2013-11-14 20:12 CM4J 阅读(
...) 评论(
...) 编辑 收藏