并发读写缓存实现机制:高并发下数据写入与过期

  • 时间:
  • 浏览:2
  • 来源:5分排列5_5分排列3

很糙说明:尊重作者的劳动成果,转载请注明出处哦~~~http://blog.yemou.net/article/query/info/tytfjhfascvhzxcyt207

一般来说并发的读取和写入是一对矛盾体,而缓存的过期移除和持久化则是另一对矛盾体。你你本身节,你们你们你们 歌词 着重来了解下高并发清况 下缓存的写入、过期控制及附过相关功能。系列文章目录:并发读写缓存实现机制(零):缓存操作指南并发读写缓存实现机制(一):为哪些ConcurrentHashMap都还还都可以 太难快?并发读写缓存实现机制(二):高并发下数据写入与过期并发读写缓存实现机制(三):API封装和冗杂

1.高效的数据写入(put)    在研究写入机制之后,你们你们你们 歌词 先来回顾下上一节的内容。ConcurrentHashMap并非 读取比较慢了 了 ,很大一次责原因着归功于它的数据分割设计,就像是把书的内容划分为所以 章,章下面又分了某些小节。同样的原理,写入过程也都还还都可以 按你你本身规则把数据分为所以 独立的块,也之后前一节提到的Segment。我本人面为了防止并发问题,加锁是另一一兩个多不错的选用。再回头看看Segment类图(清单1),Segment嘴笨 是继承了ReentrantLock,我本人你本身之后另一一兩个多锁。清单1:Segment类图愿意最大限度的支持并发,之后读写操作不会加锁,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:缓存数据的移除123456789101112131415161718192021222324252627282980313233343536void removeEntry(HashEntry entry, int hash) {int c = count - 1;    AtomicReferenceArray<HashEntry> tab = table;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内的元素个数-1return;        }    }}HashEntry removeEntryFromChain(HashEntry first, HashEntry entry) {    HashEntry newFirst = entry.next;// 从链条1的头节点first之后开始迭代到还还都可以 删除的节点entryfor (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)    缓存中构造函数有一兩个参数与容量相关:initialCapacity代表缓存的初始容量、loadFactor代表负载因子、concurrencyLevel字面上的意思是并发等级,嘴笨 之后segment的数量(结构会转变为2的n次方)。既然有初始容量,则自然有容量缺乏的清况 ,你你本身清况 就还还都可以 对系统扩容,随之而来的之后另一一兩个多问题:哪年扩容以及何如扩容?    第另一一兩个多问题:哪年扩容,缓存就使用到了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 扩容后数组下标,比扩容前下标大8000,转换为十进制之后16,中括号内代表的是扩容后的数组下标。    假设你们你们你们 歌词 有链表A[5] -> B[21] -> C[5] -> D[21] -> E[21] -> F[21]基于上面的原理,ConcurrentHashMap扩容是先把链表上面的一整段连续相同下标的元素链(D[21] -> E[21] -> F[21])找出来,直接复用你你本身链,之后克隆qq好友好友你你本身链之后的元素(A[5] -> B[21] -> C[5]),另一一兩个多多就防止了所有元素的克隆qq好友好友。事实上,根据JDK的描述:Statistically, at the default threshold, only about one-sixth of them need cloning when a table double,翻译过来之后:据统计,使用默认的阈值,扩容时仅有1/6的数据还还都可以 克隆qq好友好友。清单5:缓存数据的扩容12345678910111213141516171819202122232425262728298031323334353637383940414243444546void 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);// 在这之后的元素下标有可能性一样,不会可能性不一样,所以 把前面的元素重新克隆qq好友好友一遍中放新数组中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负载;我本人面,缓存的过期时间不精准,可能性缓存的过期是基于定期检测的,只有定期检测时间,缓存就会地处于内存中。时间轴    在平时,你们你们你们 歌词 可能性会了解到另一一兩个多名词:timeline,翻译过来之后“时间轴”。请看下面另一一兩个多简单的示例清单6:时间轴TimeLine----进入时间最短-----Enter-->--D-->--C-->--B-->--A-->--进入时间最久-----你们你们你们 歌词 的缓存数据就地处于你你本身时间轴上,如上例所示:数据A产生或变化后你们你们你们 歌词 都还还都可以 把它中放时间轴的Enter点,随着时间的推移,B、C、D就是会依次中放Enter点,最终就形成了上面的另一一兩个多时间轴。当有时间轴上的数据地处变更,你们你们你们 歌词 再把它从时间轴上移除,当做新数据重新加入Enter点。很简单了,另一一兩个多多们何如检测数据有太难过期?可能性在时间轴上的数据不会有序的,问题就缓存的产生和改变不会有先后顺序的,你们你们你们 歌词 之后找到第另一一兩个多没过期的元素,则比它进入时间短的数据不会没过期的。整个流程进一步看来之后另一一兩个多都还还都可以 删除指定元素的先进先出队列,基于你你本身原理可实现缓存的过期。删除指定元素的先进先出队列AccessQueue    目前地处你本身常见做法-双向链表形式的环状队列,在你你本身队列中的元素提供了获取前另一一兩个多和后另一一兩个多元素的引用,新的元素插入到链表的尾端,过期元素从头部过期,而双向链表另一一兩个多很糙要的特点之后它都还还都可以 很方便的从队列上面移除元素。队列AccessQueue继承了AbstractQueue,拥有了队列的基本功能,队列内的元素不会ReferenceEntry,它有一兩个子类: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:都还还都可以 移除元素的先进先出队列1234567891011121314151617181920212223242526272829803132333435363738394041424344454647484980staticfinalclass AccessQueue extends AbstractQueue<ReferenceEntry> {// head代码省略final ReferenceEntry head = XXX;// 某些次责代码省略    @Overridepublicboolean offer(ReferenceEntry entry) {// 将上另一一兩个多节点与下另一一兩个多节点链接,也之后把entry从链表中移除        connectAccessOrder(entry.getPreviousInAccessQueue(), entry.getNextInAccessQueue());// 再加到链表tail        connectAccessOrder(head.getPreviousInAccessQueue(), entry);        connectAccessOrder(entry, head);return true;    }    @Overridepublic ReferenceEntry peek() {// 从head之后开始获取        ReferenceEntry next = head.getNextInAccessQueue();return (next == head) ? null : next;    }    @Overridepublicboolean 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链接起来staticvoid connectAccessOrder(ReferenceEntry previous, ReferenceEntry next) {    previous.setNextInAccessQueue(next);    next.setPreviousInAccessQueue(previous);}// 将nulled的previousAccess和nextAccess都设为nullEntry,方便GC回收nulledstaticvoid nullifyAccessOrder(ReferenceEntry nulled) {    ReferenceEntry nullEntry = nullEntry();    nulled.setNextInAccessQueue(nullEntry);    nulled.setPreviousInAccessQueue(nullEntry);}哪年进行过期移除?    在拥有了定制版的先进先出队列,缓存过期就相对比较简单了,你们你们你们 歌词 之后把新增和修改的数据中放队列尾部,之后从队列首部依次判断数据否是过期就都还还都可以 了。那哪些之后去执行你你本身操作呢?google的缓存是中放每次写入操作可能性每64次读操作执行一次清理操作。一方面,可能性缓存是不停在使用的,这就决定了过期的缓存可能性性次责太久;我本人面,缓存的过期仅仅是时间点的判断,强度非常快。所以 另一一兩个多多操作性能并太难带来性能的降低,之后却带来了缓存过期的准确性。清单10:读操作执行清理1234567void postReadCleanup() {// 作为位操作的mask(DRAIN_THRESHOLD),还还都可以 是(2^n)-1,也之后1111的二进制格式if ((readCount.incrementAndGet() & DRAIN_THRESHOLD) == 0) {// 代表每2^n执行一次        cleanUp();    }}清单11:缓存过期移除1234567891011void 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节的内容,至此你们你们你们 歌词 就拥有了另一一兩个多强大的缓存,它都还还都可以 并发且高效的数据读写、数据加载和数据移除,支持数据过期控制和持久化的平衡。在下一节中你们你们你们 歌词 会在此基础上冗杂缓存的使用,以方便日常的调用。