缓存在日常开发中举足轻重,如果你的应用对某类数据有着较高的读取频次,并且改动较小时那就非常适合利用缓存来提高性能。
谈谈 Java 中所用到的缓存,
JVM 缓存
首先是 JVM 缓存,也可以认为是堆缓存。其实就是创建一些全局变量,如 Map、List 之类的容器用于存放数据。这样的优势是使用简单但是也有以下问题:
- 只能显式的写入,清除数据。
- 不能按照一定的规则淘汰数据,如 LRU,LFU,FIFO 等。
- 清除数据时的回调通知。
- 其他一些定制功能等。
Ehcache、Guava Cache
所以出现了一些专门用作 JVM 缓存的开源工具出现了,如本文提到的 Guava Cache。
它具有上文 JVM 缓存不具有的功能,如自动清除数据、多种清除算法、清除回调等。但也正因为有了这些功能,这样的缓存必然会多出许多东西需要额外维护,自然也就增加了系统的消耗。
分布式缓存
刚才提到的两种缓存其实都是堆内缓存,只能在单个节点中使用,这样在分布式场景下就招架不住了。
于是也有了一些缓存中间件,如 Redis、Memcached,在分布式环境下可以共享内存。
GuavaCache
GuavaCache 提供了一般我们使用缓存所需要的几乎所有的功能,主要有:
- 自动将entry节点加载进缓存结构中;
- 当缓存的数据已经超过预先设置的最大值时,使用LRU算法移除一些数据;
- 具备根据entry节点上次被访问或者写入的时间来计算过期机制;
- 缓存的key被封装在WeakReference引用内;
- 缓存的value被封装在WeakReference或者SoftReference引用内;
- 移除entry节点,可以触发监听器通知事件;
- 统计缓存使用过程中命中率/异常率/未命中率等数据。
Guava Cache其核心数据结构大体上和ConcurrentHashMap一致,具体细节上会有些区别。功能上,ConcurrentMap会一直保存所有添加的元素,直到显式地移除.相对地,Guava Cache为了限制内存占用,通常都设定为自动回收元素.在某些场景下,尽管它不回收元素,也是很有用的,因为它会自动加载缓存.
Guava Cache 官方推荐的使用场景:
- 愿意消耗一些内存空间来提升速度;
- 能够预计某些key会被查询一次以上;
- 缓存中存放的数据总量不会超出内存容量(Guava Cache是单个应用运行时的本地缓存)。
不管性能,还是可用性来说,Guava Cache绝对是本地缓存类库中首要推荐的工具类。其提供的Builder模式的CacheBuilder生成器来创建缓存的方式,十分方便,并且各个缓存参数的配置设置,类似于函数式编程的写法.
Guava Cache 三种方式加载
- LoadingCache在构建缓存的时候,使用build方法内部调用CacheLoader方法加载数据
- 在使用get方法的时候,如果缓存不存在该key或者key过期等,则调用get(K, Callable
)方式加载数据; - 使用粗暴直接的方式,直接想缓存中put数据。
demo
import java.util.Date;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.cache.RemovalListener;
import com.google.common.cache.RemovalNotification;
/**
* @author tao.ke Date: 14-12-20 Time: 下午1:55
* @version \$Id$
*/
public class CacheSample {
private static final Logger logger = LoggerFactory.getLogger(CacheSample.class);
// Callable形式的Cache
private static final Cache CALLABLE_CACHE = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS).maximumSize(1000).recordStats()
.removalListener(new RemovalListener
上述代码,简单的介绍了Guava Cache 的使用,给了两种加载构建Cache的方式。在Guava Cache对外提供的方法中, recordStats和removalListener是两个很有趣的接口,可以很好的帮我们完成统计功能和Entry移除引起的监听触发功能。
虽然在Guava Cache对外方法接口中提供了丰富的特性,但是如果我们在实际的代码中不是很有需要的话,建议不要设置这些属性,因为会额外占用内存并且会多一些处理计算工作,不值得。
Guava Cache 前置知识
Guava Cache就是借鉴Java的ConcurrentHashMap的思想来实现一个本地缓存,但是它内部代码实现的时候,还是有很多非常精彩的设计实现,
Builder模式
设计模式之 Builder模式 在Guava中很多地方得到的使用。Builder模式是将一个复杂对象的构造与其对应配置属性表示的分离,也就是可以使用基本相同的构造过程去创建不同的具体对象。
具体参照笔者的这篇Builder模式
java 对象引用
当虚拟机执行时,遇到一条new指令时,首先会去检查这个指令在常量池中是否已经存在该类对应的符号引用,并且检查这个符号引用对应的类是否已经被加载,解析和初始化。如果没有,则执行相应的类加载过程。
然后虚拟机为新的对象分配内存。虚拟机根据我们配置的垃圾收集器规则采取不同的分配方式,包括:指针碰撞分配方式和空闲列表分配方式。
内存分配完成之后,开始执行init方法。init方法会按照代码的指定过程来初始化,对一些类属性进行赋值。
然后,我们需要访问这个对象,怎么办?在Java运行时内存区域模型中,线程拥有一个虚拟机栈,这个栈会有一个本地方法表,这个表内部就会存放一个引用地址,如下图所示(HotSpot虚拟机采用这种方式,还有另外一种形式这里不做介绍):
在JDK 1.2之前,Java中关于引用的定义是:如果reference类型的数据中存储的数值表示的是另外一块内存的起始地址,就说明这块内存称为引用。
这种定义表明对象只有两种:被引用的对象和没有被引用的对象。这种方式对于垃圾收集GC来说,效果并不是很好,因为很多对象划为为被引用和非被引用都不是很重要,这种现象就无法划分。垃圾收集的时候,就无法更好更精准的划为可GC的对象。
因此,在JDK 1.2之后,Java对引用的概念进行扩展,有如下四种类型的引用(按强度排序):
* 强引用(Strong Reference)
* 软引用(SoftReference)
* 弱引用(WeakReference)
* 虚引用(PhantomReference)
- 强引用:强引用在程序代码中随处可见,十分普遍。比如: Object object = new Object() ,这类引用只要还存在,垃圾收集器就永远不会回收掉这类引用的对象。
- 软引用:软引用用来描述一些虽然有用但是并不是必须的对象。对于软引用关联的对象,在系统将可能发生内存溢出异常之前,垃圾收集器将会把这些引用的对象进行第二次回收。只有这次垃圾回收还没有足够的内存的时候,才会抛出内存溢出异常。
- 弱引用:弱引用是一种比软引用强度还要弱的引用,因此这些引用的对象也是非必须的。但是,对于弱引用的对象只能生存到下一次垃圾回收发生之前。当垃圾收集工作开始后,无论当前的内存是否够用,都会把这些弱引用的对象回收掉。
- 虚引用:虚引用是最弱的一种引用。一个对象是否被虚引用关联,完全不会对其生存时间构成影响,也无法通过虚引用获得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
关于引用,最典型的使用就是对HashMap的自定义开发,包括JDK内部也存在。
Strong Reference—> HashMap:默认情况下,HashMap使用的引用就是强引用,也就是说垃圾收集的时候,Map中引用的对象不会被GC掉。
Weak Reference—> WeakHashMap:JDK中还有一种基于引用类型实现的HashMap,WeakHashMap。当节点的key不在被使用的时候,该entry就会被自动回收掉。因此,对于一个mapping映射,不能保证接下来的GC不会把这个entry回收掉。
Soft Reference—> SoftHashMap:在JDK中没有提供基于软引用实现的HashMap,原因可能是一般大家都不能期待出现内存溢出,而当出现内存溢出,一点点的软引用GC余下的内存空间,肯定不会起到关键作用。但是,虽然不广泛,在aspectj提供的ClassLoaderRepository类中实现了SoftHashMap,作为一个基于ClassLoader字节码实现的方法,在OOM的时候,显然需要考虑通过GC释放内存空间,并且SoftHashMap在内部是作为缓存使用。
具体可参照笔者这篇java四种引用
JMM可见性
什么叫可见性?
可见性就是,当程序中一个线程修改了某个全局共享变量的值之后,其他使用该值的线程都可以获知,在随后他们读该共享变量的时候,查询的都是最新的改改修改的值。
一个线程上修改共享变量,这个变量的最新的值不会立刻写入到共享内存中,还是暂时存放在线程本地缓存,然后某一时刻触发写入到共享内存中。可见性就是,当我们对共享变量修改的时候,立刻把新值同步到主内存中,然后该变量被读的时候从主内存获取最新的值确保所有对该变量的读取操作,总是获取最新最近修改的值。
为什么会有可见性问题?
学过计算机组成原理的同学都知道,在现代CPU结构中,存在多级缓存架构,如下图所示:
同样,在Java虚拟机中分为两种内存:
- 主内存(Main Memory):所有线程共享的内存区域,虚拟机内存的一部分。
- 工作内存(Working Memory):线程自己操作的内存区域,线程直接无法访问对方的工作内存区域。
之所以分为两部分内存区域,原因和CPU很类似。为了线程可以快速访问操作变量,当线程全部直接操作共享内存,则会导致大量线程之间竞争等问题出现,影响效率。
关于Java中线程,工作内存,主内存之间的交互关系如下图
为了保证共享变量可见性,除了上篇博文中介绍的volatile之外,还有synchronized和final关键字。
synchronized:执行synchronized代码块时,在对变量执行unlock操作之前,一定会把此变量写入到主内存中。final:该关键字修饰的变量在构造函数中初始化完成之后(不考虑指针逃逸,变量初始化一半的问题),其他线程就可以看到这个final变量的值,并且由于变量不能修改,所以能确保可见性。
指令重排序
重排序通常是编译器或者运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。
在运行时不同的微指令在不同的执行单元中同时执行,而且每个执行单元都全速运行。只要当前微指令所需要的数据就绪,而且有空闲的执行单元,微指令就可以立即执行,有时甚至可以跳过前面还未就绪的微指令。通过这种方式,需要长时间运行的操作不会阻塞后面的操作,流水线阻塞带来的损失被极大的减小了。
运行期JVM会对指令进行重排序以提高程序性能,当然其会通过happens-before原则保证顺序执行语义,也就是不会随便对代码指令进行重排序。
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
上述的代码会造成很多的不同结果,由于数据的可见性问题,或者就是重排序。比如重排序后执行顺序如下,则会存在问题。
可参考volatile
锁细化
这两年十分火的用于线程间通信的高性能消息组件,其虽然有很多创新的设计,但是很多优化的基本就是,锁细化,此外,在Linux内核2.6之后采用的RCU锁机制,本质上也是锁粒度细化。 在Java语言中,最经典的锁细化提高多线程并发性能的案例,就是ConcurrentHashMap,其采用多个segment,每个segment对应一个锁,来分散全局锁带来的性能损失。从而,当我们put某一个entry的时候,在实现的时候,一般只需要拥有某一个segment锁就可以完成。
关于普通的HashTable结构和ConcurrentHashMap结构,借用一张图来说明:
从结构上,可以很显而易见的看出两者的区别。所以,就锁这个层面上,concurrentHashMap就会比HashTable性能好。
Guava ListenableFuture接口
可参照ListenableFuture
CacheBuilder实现
写过Cache的,或者其他一些工具类的同学知道,为了让工具类更灵活,我们需要对外提供大量的参数配置给使用者设置,虽然这带有一些好处,但是由于参数太多,使用者开发构造对象的时候过于繁杂。
上面提到过参数配置过多,可以使用Builder模式。Guava Cache也一样,它为我们提供了CacheBuilder工具类来构造不同配置的Cache实例。但是,和本文上面提到的构造器实现有点不一样,它构造器返回的是另外一个对象,因此,这意味着在实现的时候,对象构造函数需要有Builder参数提供配置属性。
CacheBuilder构造LocalCache实现
首先,我们先看看Cache的构造函数:
/**
* 从builder中获取相应的配置参数。
*/
LocalCache(CacheBuilder super K, ? super V> builder, @Nullable CacheLoader super K, V> loader) {
concurrencyLevel = Math.min(builder.getConcurrencyLevel(), MAX_SEGMENTS);
keyStrength = builder.getKeyStrength();
valueStrength = builder.getValueStrength();
keyEquivalence = builder.getKeyEquivalence();
valueEquivalence = builder.getValueEquivalence();
maxWeight = builder.getMaximumWeight();
weigher = builder.getWeigher();
expireAfterAccessNanos = builder.getExpireAfterAccessNanos();
expireAfterWriteNanos = builder.getExpireAfterWriteNanos();
refreshNanos = builder.getRefreshNanos();
removalListener = builder.getRemovalListener();
removalNotificationQueue = (removalListener == NullListener.INSTANCE) ? LocalCache
.> discardingQueue() : new ConcurrentLinkedQueue>();
ticker = builder.getTicker(recordsTime());
entryFactory = EntryFactory.getFactory(keyStrength, usesAccessEntries(), usesWriteEntries());
globalStatsCounter = builder.getStatsCounterSupplier().get();
defaultLoader = loader;
int initialCapacity = Math.min(builder.getInitialCapacity(), MAXIMUM_CAPACITY);
if (evictsBySize() && !customWeigher()) {
initialCapacity = Math.min(initialCapacity, (int) maxWeight);
}
//.......
}
从构造函数可以看到,Cache的所有参数配置都是从Builder对象中获取的,Builder完成了作为该模式最典型的应用,多配置参数构建对象。
在Cache中只提供一个构造函数,但是在上面代码示例中,我们演示了两种构建缓存的方式:自动加载;手动加载。那么,一般会存在一个完成两者之间的过渡adapter组件,接下来看看Builder在内部是如何完成创建缓存对象过程的。
在LocalCache中确实提供了两种过渡类,一个是支持自动加载value的LocalLoadingCache 和只能在键值找不到的时候手动调用获取值方法的LocalManualCache。
LocalManualCache实现
static class LocalManualCache implements Cache, Serializable {
final LocalCache localCache;
LocalManualCache(CacheBuilder super K, ? super V> builder) {
this(new LocalCache(builder, null));
}
private LocalManualCache(LocalCache localCache) {
this.localCache = localCache;
}
// Cache methods
@Override
@Nullable
public V getIfPresent(Object key) {
return localCache.getIfPresent(key);
}
@Override
public V get(K key, final Callable extends V> valueLoader) throws ExecutionException {
checkNotNull(valueLoader);
return localCache.get(key, new CacheLoader() {
@Override
public V load(Object key) throws Exception {
return valueLoader.call();
}
});
}
//......
@Override
public CacheStats stats() {
SimpleStatsCounter aggregator = new SimpleStatsCounter();
aggregator.incrementBy(localCache.globalStatsCounter);
for (Segment segment : localCache.segments) {
aggregator.incrementBy(segment.statsCounter);
}
return aggregator.snapshot();
}
// Serialization Support
private static final long serialVersionUID = 1;
Object writeReplace() {
return new ManualSerializationProxy(localCache);
}
}
从代码实现看出实际上是一个adapter组件,并且绝大部分实现都是直接调用LocalCache的方法,或者加一些参数判断和聚合。在它核心的构造函数中,就是直接调用LocalCache构造函数,对于loader对象直接设null值。
LocalLoadingCache实现
LocalLoadingCache实现继承了LocalManualCache 类,其主要对get相关方法做了重写。
static class LocalLoadingCache extends LocalManualCache implements LoadingCache {
LocalLoadingCache(CacheBuilder super K, ? super V> builder, CacheLoader super K, V> loader) {
super(new LocalCache(builder, checkNotNull(loader)));
}
// LoadingCache methods
@Override
public V get(K key) throws ExecutionException {
return localCache.getOrLoad(key);
}
@Override
public V getUnchecked(K key) {
try {
return get(key);
} catch (ExecutionException e) {
throw new UncheckedExecutionException(e.getCause());
}
}
@Override
public ImmutableMap getAll(Iterable extends K> keys) throws ExecutionException {
return localCache.getAll(keys);
}
@Override
public void refresh(K key) {
localCache.refresh(key);
}
@Override
public final V apply(K key) {
return getUnchecked(key);
}
// Serialization Support
private static final long serialVersionUID = 1;
@Override
Object writeReplace() {
return new LoadingSerializationProxy(localCache);
}
}
}
提供了这些adapter类之后,builder类就可以创建LocalCache,如下:
// 获取value可以通过key计算出
public LoadingCache build(CacheLoader super K1, V1> loader) {
checkWeightWithWeigher();
return new LocalCache.LocalLoadingCache(this, loader);
}
// 手动加载
public Cache build() {
checkWeightWithWeigher();
checkNonLoadingCache();
return new LocalCache.LocalManualCache(this);
}
CacheBuilder参数设置
CacheBuilder在为我们提供了构造一个Cache对象时,会构造各个成员对象的初始值(默认值)。了解这些默认值,对于我们分析Cache源码实现时,一些判断条件的设置原因,还是很有用的。
初始参数值设置
在ConcurrentHashMap中,我们知道有个并发水平(CONCURRENCY_LEVEL),这个参数决定了其允许多少个线程并发操作修改该数据结构。这是因为这个参数是最后map使用的segment个数,而每个segment对应一个锁,因此,对于一个map来说,并发环境下,理论上最大可以有segment个数的线程同时安全地操作修改数据结构。那么是不是segment的值可以设置很大呢?显然不是,要记住维护一个锁的成本还是挺高的,此外如果涉及全表操作,那么性能就会非常不好了。
其他一些初始参数值的设置如下所示:
private static final int DEFAULT_INITIAL_CAPACITY = 16; // 默认的初始化Map大小
private static final int DEFAULT_CONCURRENCY_LEVEL = 4; // 默认并发水平
private static final int DEFAULT_EXPIRATION_NANOS = 0; // 默认超时
private static final int DEFAULT_REFRESH_NANOS = 0; // 默认刷新时间
static final int UNSET_INT = -1;
boolean strictParsing = true;
int initialCapacity = UNSET_INT;
int concurrencyLevel = UNSET_INT;
long maximumSize = UNSET_INT;
long maximumWeight = UNSET_INT;
long expireAfterWriteNanos = UNSET_INT;
long expireAfterAccessNanos = UNSET_INT;
long refreshNanos = UNSET_INT;
初始对象引用设置
在Cache中,我们除了超时时间,键值引用属性等设置外,还关注命中统计情况,这就需要统计对象来工作。CacheBuilder提供了初始的null 统计对象和空统计对象。
/**
* 默认空的缓存命中统计类
*/
static final Supplier extends StatsCounter> NULL_STATS_COUNTER = Suppliers.ofInstance(new StatsCounter() {
//......省略空override
@Override
public CacheStats snapshot() {
return EMPTY_STATS;
}
});
static final CacheStats EMPTY_STATS = new CacheStats(0, 0, 0, 0, 0, 0);// 初始状态的统计对象
/**
* 系统实现的简单的缓存状态统计类
*/
static final Supplier CACHE_STATS_COUNTER = new Supplier() {
@Override
public StatsCounter get() {
return new SimpleStatsCounter();//这里构造简单地统计类实现
}
};
/**
* 自定义的空RemovalListener,监听到移除通知,默认空处理。
*/
enum NullListener implements RemovalListener {
INSTANCE;
@Override
public void onRemoval(RemovalNotification notification) {
}
}
/**
* 默认权重类,任何对象的权重均为1
*/
enum OneWeigher implements Weigher {
INSTANCE;
@Override
public int weigh(Object key, Object value) {
return 1;
}
}
static final Ticker NULL_TICKER = new Ticker() {
@Override
public long read() {
return 0;
}
};
/**
* 默认的key等同判断
* @return
*/
Equivalence getKeyEquivalence() {
return firstNonNull(keyEquivalence, getKeyStrength().defaultEquivalence());
}
/**
* 默认value的等同判断
* @return
*/
Equivalence getValueEquivalence() {
return firstNonNull(valueEquivalence, getValueStrength().defaultEquivalence());
}
/**
* 默认的key引用
* @return
*/
Strength getKeyStrength() {
return firstNonNull(keyStrength, Strength.STRONG);
}
/**
* 默认为Strong 属性的引用
* @return
*/
Strength getValueStrength() {
return firstNonNull(valueStrength, Strength.STRONG);
}
Weigher getWeigher() {
return (Weigher) Objects.firstNonNull(weigher, OneWeigher.INSTANCE);
}
其中,在我们不设置缓存中键值引用的情况下,默认都是采用强引用及相对应的属性策略来初始化的。此外,在上面代码中还可以看到,统计类SimpleStatsCounter是一个简单的实现。里面主要是简单地缓存累加,此外由于多线程下Long类型的线程非安全性,所以也进行了一下封装,下面给出命中率的实现:
public static final class SimpleStatsCounter implements StatsCounter {
private final LongAddable hitCount = LongAddables.create();
private final LongAddable missCount = LongAddables.create();
private final LongAddable loadSuccessCount = LongAddables.create();
private final LongAddable loadExceptionCount = LongAddables.create();
private final LongAddable totalLoadTime = LongAddables.create();
private final LongAddable evictionCount = LongAddables.create();
public SimpleStatsCounter() {}
@Override
public void recordHits(int count) {
hitCount.add(count);
}
@Override
public CacheStats snapshot() {
return new CacheStats(
hitCount.sum());
}
/**
* Increments all counters by the values in {@code other}.
*/
public void incrementBy(StatsCounter other) {
CacheStats otherStats = other.snapshot();
hitCount.add(otherStats.hitCount());
}
}
LocalCache实现
在设计实现上,LocalCache的并发策略和concurrentHashMap的并发策略是一致的,也是根据分段锁来提高并发能力,分段锁可以很好的保证 并发读写的效率。因此,该map支持非阻塞读和不同段之间并发写
LoacalCache使用LRU页面替换算法,是因为该算法简单,并且有很高的命中率,以及O(1)的时间复杂度。需要说明的是, LRU算法是基于页面而不是全局实现的,所以可能在命中率上不如全局LRU算法,但是应该基本相似。,是因为在计算机专业课程上,CPU,操作系统,算法上,基本上都介绍过分页导致优化效果的提升。
总体数据结构
LocalCache的数据结构和ConcurrentHashMap一样,都是采用分segment来细化管理HashMap中的节点Entry。借用ConcurrentHashMap的数据结构图来说明Cache的实现:
从图中可以直观看到cache是以segment粒度来控制并发get和put等操作的,接下来首先看我们的LocalCache是如何构造这些segment段的,继续上面初始化localCache构造函数的代码:
// 找到大于并发水平的最小2的次方的值,作为segment数量
int segmentShift = 0;
int segmentCount = 1;
while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20 <= maxWeight)) {
++segmentShift;
segmentCount <<= 1;
}
this.segmentShift = 32 - segmentShift;//位 偏移数
segmentMask = segmentCount - 1;//mask码
this.segments = newSegmentArray(segmentCount);// 构造数据数组,如上图所示
//获取每个segment初始化容量,并且保证大于等于map初始容量
int segmentCapacity = initialCapacity / segmentCount;
if (segmentCapacity * segmentCount < initialCapacity) {
++segmentCapacity;
}
//段Size 必须为2的次数,并且刚刚大于段初始容量
int segmentSize = 1;
while (segmentSize < segmentCapacity) {
segmentSize <<= 1;
}
// 权重设置,确保权重和==map权重
if (evictsBySize()) {
// Ensure sum of segment max weights = overall max weights
long maxSegmentWeight = maxWeight / segmentCount + 1;
long remainder = maxWeight % segmentCount;
for (int i = 0; i < this.segments.length; ++i) {
if (i == remainder) {
maxSegmentWeight--;
}
//构造每个段结构
this.segments[i] = createSegment(segmentSize, maxSegmentWeight, builder.getStatsCounterSupplier().get());
}
} else {
for (int i = 0; i < this.segments.length; ++i) {
//构造每个段结构
this.segments[i] = createSegment(segmentSize, UNSET_INT, builder.getStatsCounterSupplier().get());
}
}
基本上都是基于2的次数来设置大小的,显然基于移位操作比普通计算操作速度要快。此外,对于最大权重分配到段权重的设计上,很特殊。为什么呢?为了保证两者能够相等(maxWeight==sumAll(maxSegmentWeight)),对于remainder前面的segment maxSegmentWeight的值比remainder后面的权重值大1,这样保证最后值相等。
map get 方法
@Override
@Nullable
public V get(@Nullable Object key) {
if (key == null) {
return null;
}
int hash = hash(key);
return segmentFor(hash).get(key, hash);
}
首先check key是否为null,然后计算hash值,定位到对应的segment,执行segment实例拥有的get方法获取对应的value值
map put 方法
@Override
public V put(K key, V value) {
checkNotNull(key);
checkNotNull(value);
int hash = hash(key);
return segmentFor(hash).put(key, hash, value, false);
}
和get方法一样,也是先check值,然后计算key的hash值,然后定位到对应的segment段,执行段实例的put方法,将键值存入map中。
map isEmpty 方法
@Override
public boolean isEmpty() {
long sum = 0L;
Segment[] segments = this.segments;
for (int i = 0; i < segments.length; ++i) {
if (segments[i].count != 0) {
return false;
}
sum += segments[i].modCount;
}
if (sum != 0L) { // recheck unless no modifications
for (int i = 0; i < segments.length; ++i) {
if (segments[i].count != 0) {
return false;
}
sum -= segments[i].modCount;
}
if (sum != 0L) {
return false;
}
}
return true;
}
判断Cache是否为空,就是分别判断每个段segment是否都为空,但是由于整体是在并发环境下进行的,也就是说存在对一个segment并发的增加和移除元素的时候,而我们此时正在check其他segment段。
上面这种情况,决定了我们不能够获得任何一个时间点真实段状态的情况。因此,上面的代码引入了sum变量来计算段modCount变更情况。modCount表示改变segment大小size的更新次数,这个在批量读取方法期间保证它们可以看到一致性的快照。需要注意,这里先获取count,该值是volatile,因此modCount通常都可以在不需要一致性控制下,获得当前segment最新的值.
在判断如果在第一次check的时候,发现segment发生了数据结构级别变更,则会进行recheck,就是在每个modCount下,段仍然是空的,则判断该map为空。如果发现这期间数据结构发生变化,则返回非空判断。
来看一张LocalCache的数据结构图:
LocalCache类似ConcurrentHashMap采用了分段策略,通过减小锁的粒度来提高并发,LocalCache中数据存储在Segment[]中,每个segment又包含5个队列和一个table,这个table是自定义的一种类数组的结构,每个元素都包含一个ReferenceEntry
这些队列,前2个是key、value引用队列用以加速GC回收,后3个队列记录用户的写记录、访问记录、高频访问顺序队列用以实现LRU算法。AtomicReferenceArray是JUC包下的Doug Lea老李头设计的类:一组对象引用,其中元素支持原子性更新。
LoadingCache内部采用分段锁控实现写并发,每个段(Segment)内有如下几个主要的数据结构:
hash表: 最基本的数据结构,hash数组,采用AtomicReferenceArray来实现,这里主要是利用atmoic array的写操作对读的可见性 (unsafe.putObjectVolatile/ unsafe.getObjectVolatile)。hash冲突采用最基本的单链表来解决,所以数组中元素ReferenceEntry有个指向链表下一个元素的指针。
key引用队列和value引用队列 如果在build缓存时启用了key/value的引用封装,那么在创建segment时就会初始化对应的这两个队列,用于接收gc回收key/value对象的通知,队列中元素是引用key/value的Reference对象。显然ReferenceQueue是线程安全的,队列的生成者是jvm的gc线程,消费者是LoadingCache自身,消费的时机前面也提过了。消费做的事情就是清理:即hash表中对应key/value相关的entry从hash表中删除(具体代码见drainReferenceQueues方法)。
最近写队列 如果build缓存时设置了元素写后过期时间,那么创建segment时就会初始化这个WriteQueue,WriteQueue的实现非常简单,就是一个带头节点的双向循环链表,而且没有考虑任何并发访问。节点对象就是ReferenceEntry本身。注意到hash表里的散列链表节点也是ReferenceEntry,这是一个很常见的技巧:即一个节点对象可能会同时属于多个链表中,不同的链表使用不同的前后节点指针,这个技巧的好处在于给定一个节点entry对象,所有链表都可以做到常数时间的查找和删除(jdk里的LinkedHashMap实现也采用了这个技巧)。由于写操作都会在锁保护进行,因此WriteQueue无需是线程安全的。
最近读队列(recencyQueue) 和 最近访问队列(accessQueue/最近LRU队列) 之所以把这二者放在一起,是因为他们密切相关。如果在build缓存时设置了缓存的最大容量或者是为缓存元素设置了访问后过期时间,那么在初始化segment时这两个队列就会被初始化。其中最近读队列是采用的是jdk中线程安全、支持高并发读写的ConcurrentLinkedQueue,队列中存储的元素是ReferenceEntry(注意这和节点是ReferentEntry本身的WriteQueue的区别);最近访问队列采用的则是LoadingCache自己实现的AccessQueue,AccessQueue的实现和前面的WriteQueue基本一致,节点是ReferenceEntry、非线程安全。那么问题来了:为啥需要两个队列来实现LRU功能。要回答这个问题首先得明确在LoadingCache场景下一个LRU队列需求有哪些:1)缓存的场景基本都是读多写少,LoadingCache的读操作要做到的是高性能、lock-free的读,这样就会有多个线程同时读缓存,意味着LRU队列支持多线程高并发写入(调整元素的LRU队列中的访问顺序) 2)LoadingCache中元素可能会因为过期、容量限制、被gc回收等原因被淘汰出缓存,意味着需要从LRU队列中高效删除元素。因此我们需要的是一个支持多线程并发访问的、常数时间删除元素的队列实现。显然一个ConcurrentLinkedQueue不能同时满足这两个需求,Guava给的解就是再增加一个简单的AccessQueue做到常数时间删除元素。具体来说:
每次无锁的读操作都会去写而且只会写最近读队列(将entry无脑入队,见方法:recordRead)
每次锁保护下写操作都会涉及到最近访问队列的读写,比如每次向缓存新增元素都会做几次清理工作,清理就需要读accessQueue(淘汰掉队头的元素,见方法expireEntries、evictEntries);每次向缓存新增元素成功后记录元素写操作,记录会写accessQueue(加到队尾,见方法recordWrite)。每次访问accessQueue前都需要先排干最近读队列至accessQueue中(按先进先出顺序,相当于批量调整accessQueue中元素顺序),然后再去进行accessQueue的读或者写操作,以尽量保证accessQueue中元素顺序和真实的最近访问顺序一致(见方法:drainRecencyQueue)
最后是ReferenceEntry:引用数据存储接口,默认强引用,类图如下:
引用数据结构
在介绍Segment数据结构之前,先讲讲Cache中引用的设计。
在Guava Cache 中,主要使用三种引用类型,分别是:STRONG引用,SOFT引用 ,WEAK引用。和Map不同,在Cache中,使用ReferenceEntry来封装键值对,并且对于值来说,还额外实现了ValueReference引用对象来封装对应Value对象。
ReferenceEntry节点结构
为了支持各种不同类型的引用,以及不同过期策略,这里构造了一个ReferenceEntry节点结构。通过下面的节点数据结构,可以清晰的看到缓存大致操作流程。
/**
* 引用map中一个entry节点。
*
* 在map中得entries节点有下面几种状态:
* valid:-live:设置了有效的key/value;-loading:加载正在处理中....
* invalid:-expired:时间过期(但是key/value可能仍然设置了);Collected:key/value部分被垃圾收集了,但是还没有被清除;
* -unset:标记为unset,表示等待清除或者重新使用。
*
*/
interface ReferenceEntry {
/**
* 从entry中返回value引用
*/
ValueReference getValueReference();
/**
* 为entry设置value引用
*/
void setValueReference(ValueReference valueReference);
/**
* 返回链中下一个entry(解决hash碰撞存在链表)
*/
@Nullable
ReferenceEntry getNext();
/**
* 返回entry的hash
*/
int getHash();
/**
* 返回entry的key
*/
@Nullable
K getKey();
/*
* Used by entries that use access order. Access entries are maintained in a doubly-linked list. New entries are
* added at the tail of the list at write time; stale entries are expired from the head of the list.
*/
/**
* 返回该entry最近一次被访问的时间ns
*/
long getAccessTime();
/**
* 设置entry访问时间ns.
*/
void setAccessTime(long time);
/**
* 返回访问队列中下一个entry
*/
ReferenceEntry getNextInAccessQueue();
/**
* Sets the next entry in the access queue.
*/
void setNextInAccessQueue(ReferenceEntry next);
/**
* Returns the previous entry in the access queue.
*/
ReferenceEntry getPreviousInAccessQueue();
/**
* Sets the previous entry in the access queue.
*/
void setPreviousInAccessQueue(ReferenceEntry previous);
// ...... 省略write队列相关方法,和access一样
}
从上面代码可以看到除了和Map一样,有key、value、hash和next四个属性之外,还有访问和写更新两个双向链表队列,以及entry的最近访问时间和最近更新时间。显然,多出来的属性就是为了支持缓存必须要有的过期机制。
从上面的代码可以看出cache支持的LRU机制实际上是建立在segment上的,也就是基于页的替换机制。
关于访问队列数据结构,其实质就是一个双向的链表。当节点被访问的时候,这个节点将会移除,然后把这个节点添加到链表的结尾。创建不同类型的ReferenceEntry由其枚举工厂类EntryFactory来实现,它根据key的Strength类型、是否使用accessQueue、是否使用writeQueue来决定不同的EntryFactry实例,并通过它创建相应的ReferenceEntry实例
ValueReference结构
同样为了支持Cache中各个不同类型的引用,其对Value类型进行再封装,支持引用。看看其内部数据属性:
/**
* A reference to a value.
*/
interface ValueReference {
/**
* Returns the value. Does not block or throw exceptions.
*/
@Nullable
V get();
/**
* Waits for a value that may still be loading. Unlike get(), this method can block (in the case of
* FutureValueReference).
*
* @throws ExecutionException if the loading thread throws an exception
* @throws ExecutionError if the loading thread throws an error
*/
V waitForValue() throws ExecutionException;
/**
* Returns the weight of this entry. This is assumed to be static between calls to setValue.
*/
int getWeight();
/**
* Returns the entry associated with this value reference, or {@code null} if this value reference is
* independent of any entry.
*/
@Nullable
ReferenceEntry getEntry();
/**
* 为一个指定的entry创建一个该引用的副本
*
* {@code value} may be null only for a loading reference.
*/
ValueReference copyFor(ReferenceQueue queue, @Nullable V value, ReferenceEntry entry);
/**
* 告知一个新的值正在加载中。这个只会关联到加载值引用。
*/
void notifyNewValue(@Nullable V newValue);
/**
* 当一个新的value正在被加载的时候,返回true。不管是否已经有存在的值。这里加锁方法返回的值对于给定的ValueReference实例来说是常量。
*
*/
boolean isLoading();
/**
* 返回true,如果该reference包含一个活跃的值,意味着在cache里仍然有一个值存在。活跃的值包含:cache查找返回的,等待被移除的要被驱赶的值; 非激活的包含:正在加载的值,
*/
boolean isActive();
}
介绍下ReferenceQueue引用队列,这个队列是JDK提供的,在检测到适当的可到达性更改后,垃圾回收器将已注册的引用对象添加到该队列中。因为Cache使用了各种引用,而通过ReferenceQueue这个“监听器”就可以优雅的实现自动删除那些引用不可达的key了
Segment 数据结构
Segment数据结构,是ConcurrentHashMap的核心实现,也是该结构保证了其算法的高效性。在Guava Cache 中也一样,segment数据结构保证了缓存在线程安全的前提下可以高效地更新,插入,获取对应value。
实际上,segment就是一个特殊版本的hash table实现。其内部也是对应一个锁,不同的是,对于get和put操作做了一些优化处理。因此,在代码实现的时候,为了快速开发和利用已有锁特性,直接extends ReentrantLock.
在segment中,其主要的类属性就是一个LoacalCache类型的map变量。关于segment实现说明,如下:
/**
* segments 维护一个entry列表的table,确保一致性状态。所以可以不加锁去读。节点的next field是不可修改的final,因为所有list的增加操作
* 是执行在每个容器的头部。因此,这样子很容易去检查变化,也可以快速遍历。此外,当节点被改变的时候,新的节点将被创建然后替换它们。 由于容器的list一般都比较短(平均长度小于2),所以对于hash
* tables来说,可以工作的很好。虽然说读操作因此可以不需要锁进行,但是是依赖
* 使用volatile确保其他线程完成写操作。对于绝大多数目的而言,count变量,跟踪元素的数量,其作为一个volatile变量确保可见性(其内部原理可以参考其他相关博文)。
* 这样一下子变得方便的很多,因为这个变量在很多读操作的时候都会被获取:所有非同步的(unsynchronized)读操作必须首先读取这个count值,并且如果count为0则不会 查找table
* 的entries元素;所有的同步(synchronized)操作必须在结构性的改变任务bin容器之后,才会写操作这个count值。
* 这些操作必须在并发读操作看到不一致的数据的时候,不采取任务动作。在map中读操作性质可以更容易实现这个限制。例如:没有操作可以显示出 当table
* 增长了,但是threshold值没有更新,所以考虑读的时候不要求原子性。作为一个原则,所有危险的volatile读和写count变量都必须在代码中标记。
*/
final LocalCache map;
/**
* 该segment区域内所有存活的元素个数
*/
volatile int count;
/**
* 改变table大小size的更新次数。这个在批量读取方法期间保证它们可以看到一致性的快照:
* 如果modCount在我们遍历段加载大小或者核对containsValue期间被改变了,然后我们会看到一个不一致的状态视图,以至于必须去重试。
* count+modCount 保证内存一致性
*
* 感觉这里有点像是版本控制,比如数据库里的version字段来控制数据一致性
*/
int modCount;
/**
* 每个段表,使用乐观锁的Array来保存entry The per-segment table.
*/
volatile AtomicReferenceArray> table; // 这里和concurrentHashMap不一致,原因是这边元素是引用,直接使用不会线程安全
/**
* A queue of elements currently in the map, ordered by write time. Elements are added to the tail of the queue
* on write.
*/
@GuardedBy("Segment.this")
final Queue> writeQueue;
/**
* A queue of elements currently in the map, ordered by access time. Elements are added to the tail of the queue
* on access (note that writes count as accesses).
*/
@GuardedBy("Segment.this")
final Queue> accessQueue;
在segment实现中,很多地方使用count变量和modCount变量来保持线程安全,从而省掉lock开销。
LocalCache作为一个缓存,其必须具有访问和写超时特性,因为其内部维护了访问队列和写队列,队列中的元素按照访问或者写时间排序,新的元素会被添加到队列尾部。如果,在队列中已经存在了该元素,则会先delete掉,然后再尾部add该节点,新的时间。这也就是为什么,对于LocalCache而言,其LRU是针对segment的,而不是全Cache范围的。
get put方法源码分析请参照笔者的get put源码分析