技术头条 - 一个快速在微博传播文章的方式     搜索本站
您现在的位置首页 --> 算法 --> ConcurrentHaspLRUHashMap实现初探

ConcurrentHaspLRUHashMap实现初探

浏览:2804次  出处信息

ConcurrentHaspLRUHashMap实现初探

一、 关于LRU。

    LRU 即 Least Rencetly Used(最近最少使用)缓存替换策略。在任何LRU算法中,它必定有以下两个策略组成:

    1、 退化 策略。根据访问情况,对节点按热度进行排序(hot->cold),以便决定哪些节点是热节点(hot)的,哪些节点是冷节点(cold)的。这个退化的策略,一般按以下两种方式去处理:

    l 非集中式。即每命中一次就进行退化操作。

    非集中式的退化操作,往往由双向链表的方式去实现。每次命中之后就移动命中节点在链表中的位置。(位置靠前的就是hot的数据)。当然,复杂的策略中,有用queue数组进行hot分级等。

    l 集中式。定期去进行退化操作。

    在集中式的退化操作,常用的策略是:每次命中之后,记录一个时间戳、定时器时间点等等参数。由一个线程去扫描,定期清除老数据。

    2、 清除 策略。即去掉那些cold的数据。

    l 替换。这个在操作系统缓存中应该是一个常用的做法。

    l 删除。删除掉数据,以腾出空间放新的数据。(因为内存是有限的)

二、 ConcurrentHashMap与LinkedHashMap

    在JAVA中,LRU的原生实现是JDK中LinkedHashMap。LinkedHashMap继承自HashMap

    【实现原理】 简单说就是HashMap的每个节点做一个双向链表。每次访问这个节点,就把该节点移动到双向链表的头部。满了以后,就从链表的尾部删除。但是LinkedHashMap并是非线程安全(其实现中,双向链表的操作是没有任何线程安全的措施的)。

    对于线程安全的HashMap,在JDK中有ConcurrentHashMap原生支持。

    【实现原理】采用锁分离机制,把一个HashMap分成多个segement,对每个segement的写操作上锁。同时,他的get()操作是没有锁的,具体思想就是把每个hash槽中的链表的头节点置成final的。对hash槽中链表操作,只能从头部去处理。这样就不会有读不一致的情况出现。这个原理,最好还是看源码,比较清晰。

三、 ConcurrentLRUHashMap的实现方式一:直接包装LinkedHashMap。

    即,在LinkedHashMap外层全部加锁。

    典型代码:

public V get(Object key) {
 lock.lock();
 try {
 return super.get(key);
 }
 finally {
 lock.unlock();
 }
 }

    对LinkedHashMap做包装,所有访问都是带锁委托给LinkedHashMap。这样虽然解决了多线程安全问题。但是,是以严重的性能消耗为代价代价。

四、 ConcurrentLRUHashMap实现方式二:直接改造ConcurrentHashMap

    该方案主要是重写ConcurrentHashMap。

    1、 给每个Entry加一个timestamp。

    2、 每次get命中的话,修改时间戳。

    3、 定时统计整个map的总量,如果总量大于某个阈值,则deadline往后推。同时,在put的时候,检查hash槽里面每个节点的时间戳,如果已经过期,就删除掉过期节点。

    上述做法,删除操作分布在每次put操作中。所以,删除效率比较高。但是,由于时间片不可控,最终将导致内存爆炸的情况出现。

    请看下面一种场景:

    

    

    横坐标表示一个时间片。面积表示这个时间片里面节点数量。

    假定节点命中率为50%(命中后,更新到命中时刻的时间片),每个时间片写入10条新数据。

    我们可以在运行过程中,每个时间片定义一个更新一次deadline。在put数据的时候,我们可以检查hash槽中Entry是否过期,如果已经过期,则删掉过期数据。

    

    对于deadline的计算,我们可以设置三个阈值(a

    a) totalCount

    b) a

    c) b

    d) totalCount>c deadline=currentTime

    上述看似非常优雅的方案,却隐藏几个严重的问题:

    1、 时间片的选择问题。

    这个方案中,时间片的选择是一个比较困难的问题。因为,如果系统在一个时间片之内爆掉内存的话,系统将直接崩溃。

    当然,这个问题,我们可以加外部限制得方式去控制

    2、 deadline 之前的数据,不能很快删除。导致deaddata滞留,浪费大量的内存

    假定 deadline之前的数据,约为总数据量的10%。因为删数据操作,只在put的时候。假定每个时间点的put操作,能覆盖20%的hash槽。这个10%*20%=2%,每个时间点,只能删除2%的过期数据。然后,随着时间的推移。这个过程必将趋于稳定。而这个趋于稳定后,内存消耗,至少是capacity的4-5倍。这样的消耗和浪费。是难以承受的。

    这个方案,从实际测试来看,情况非常糟糕。所以最终还是放弃了。

五、 ConcurrentLRUHashMap实现方式三:分段实现锁分离+每个段内维护一份退化链表

    【实现策略】:

    1、锁分离机制。内部分成了多个segement,每个segement是独立加锁,相互不干扰。

    2、每个segement内部维护一个双向链表(退化链表)。每次命中/添加,就把节点移动到退化链表头部。

    3、每次put操作,通过hash,散到每个segement中,判断segment的容量是否到达阈值。 如果到达阈值,则删除退化链表中最末尾的节点。

    【实现】

    1、重新定义HashEntry

static class HashEntry {
/**
* 键
*/
final K key;
/**
* hash值
*/
final int hash;
/**
* 值
*/
volatile V value;
/**
* hash链指针
*/
final HashEntry next;
/**
* 双向链表的下一个节点
*/
HashEntry linknext;
/**
* 双向链表的下一个节点
*/
HashEntry linkpref;
/**
* 死亡标记
*/
AtomicBoolean dead;
}

    2、定义segment

static final class Segment extends ReentrantLock implements

			Serializable {

		private static final long serialVersionUID = 1L;

		transient int threshold;

		transient volatile int count;

		transient int modCount;

		transient volatile HashEntry[] table;

		transient final HashEntry header;// 头节点

}

    3、 put操作

    代码太长了,见附件吧

    4、 get操作

		V get(Object key, int hash) {
			HashEntry e = getFirst(hash);
			// 遍历查找
			while (e != null) {
				if (e.hash == hash && key.equals(e.key)) {
					V v = e.value;
					// 把节点移动到头部。
					moveNodeToHeader(e);
					if (v != null)
						return v;
					// 在锁的情况读,必定能读到。
					// tab[index] = new HashEntry(key, hash, first, value),
					// value赋值和tab[index]赋值可能会重新排序,重新排序之后,可能会读空值
					// 读到空值的话,在有锁的情况在再读一遍,一定能读!
					return readValueUnderLock(e); // recheck
				}
				e = e.next;
			}
			return null;

六、 ConcurrentLRUHashMap实现方式四:

    具体的做法是:

    1、 对concurrentHashMap 每个节点加时间戳,每次命中只修改该节点的时间戳。

    2、 集中式退化操作,每次命中并不进行退化操作。而是集中式进行退化操作(满的时候,或者时间到了)。

    代码:

private static class CountableKey implements Comparable> {

		public CountableKey(K key,V value) {

			if (value == null) {

				throw new NullPointerException("should not be null");

			}

			this.value = value;

			this.key = key;

			refreshTimeStamp();

		}

		

		public void refreshTimeStamp(){

			timestamp.set(System.currentTimeMillis());

		}

		final V value;

		final K key;

		AtomicLong timestamp = new AtomicLong();

		

		@Override

		public int compareTo(CountableKey o) {

			long thisval = this.timestamp.get();

			long anotherVal = o.timestamp.get();

			return (thisval < anotherVal?-1:(thisval == anotherVal?0:1));

		}

	}

    该方案的好处:

    1、 快速执行get操作。get操作的时间是“concurrentHashMap的get时间+更新时间戳”的时间。

    2、 put操作,一般的put操作的时间是“concurrentHashMap的put时间”,只要还未到达容量限制。而到达容量限制以后的,需要进行“退化,清理操作”+put的时间

    该方案的 可能存在的问题:

    1、 命中率,该算法的命中率同linkedHashMap

    2、 清除 策略:

    l 满了,执行清楚。缺点:1、会出现某个时刻,写操作卡死(如果正在等待清理的话)

    l 定时执行。缺点:1、性能耗费。2、读不一致仍然无法避免。

七、 ConcurrentLRUHashMap实现方式的比较

    本文只是抛砖引玉,希望能看到更多好多ConcurrentLRUHashMap的实现方式。由于能力有限。上文提到的第二种实现方式,在实际实现中并不能很好的退化,最终可能导致内存溢出。具体分析如下表

方式 方式一 方式二 方式三 方式四
性能
线程安全 绝对安全 安全 安全 安全
内存消耗 一般 很多 一般 一般
稳定性 稳定 不稳定 稳定 不稳定

    总体来说,第三者性较好。

    比较方式一和方式三:

    

    

    

    

建议继续学习:

  1. MySQL数据库InnoDB存储引擎 Buffer pool LRU List Flush策略详解    (阅读:3791)
  2. Memcached的LRU算法    (阅读:2407)
  3. 缓存算法–LRU    (阅读:2341)
QQ技术交流群:445447336,欢迎加入!
扫一扫订阅我的微信号:IT技术博客大学习
© 2009 - 2024 by blogread.cn 微博:@IT技术博客大学习

京ICP备15002552号-1