从Guava Cache到ConcurrentHashMap到HashMap
1、简介
Guava是google的一款开源java工具集库,其中许多功能甚是好用。其中的cache缓存功能,如果抛开业界的分布式缓存软件不提,guava cache是非常优秀的缓存工具。下面来看看美团技术团队对它的评价。
总体来看,Guava Cache在高并发场景支持和线程安全上都有相应的改进策略,使用Reference引用命令,提升高并发下的数据……访问速度并保持了GC的可回收,有效节省空间;同时,write链和access链的设计,能更灵活、高效的实现多种类型的缓存清理策略,包括基于容量的清理、基于时间的清理、基于引用的清理等;编程式的build生成器管理,让使用者有更多的自由度,能够根据不同场景设置合适的模式。
2、Guava Cache
guava cache实际是针对单机内存的缓存工具,其实正如HashMap、ConcurrentHashMap一样,他们都是将缓存加载到内存中。但是HashMap和ConCurrentHashMap只能通过主动删除来进行缓存回收。而guava Cache则可以支持通过容量、时间和引用的缓存回收机制。
2.1 数据淘汰策略
常用的缓存淘汰策略有以下几种:
2.2.1 FIFO:First In First Out
1、核心思想:简单的将数据塞入队列中,push --> pop 。
2、优点:当队列中的容量超过阈值时,先进的先出,直接将最先进入队列的元素挤出队列。
3、缺点:没有考虑缓存命中率的问题,使用较少。
2.2.2 LRU:Least Recently Used
1、核心思想:按照缓存命中的时间排序,不考虑单个数据命中的次数。超过阈值时,淘汰最久没用的数据。
2、优点:适用于热点数据的存放,高频访问的保留。
3、缺点:即使某个数据命中率很高,但最近该数据没有使用,依然会被淘汰。周期性高频访问数据会被删掉。
2.2.3 LFU:Least Frequently Used
1、核心思想:淘汰最不常用的数据,对数据的命中次数加权重。
2、优点:LFU要优于LRU,避免周期性高频访问数据被删除。
3、缺点:LFU存在历史数据影响将来数据的“缓存污染”效用。
guava cache采用的是LRU策略。
3、ConcurrentHashMap 与 HashMap
guava cache的底层其实是对ConcurrentHashMap的优化重写,这边文章从guava localCache到ConcurrentHashMap再到HashMap的源码都好好梳理一遍。
3.0.1 ArrayList
- 数组的扩容为何是通过拷贝来完成的?
- 1、数组的内存地址需要是连续的,如果一味的在原有基础上扩容,则需要更大且连续的内存空间来完成,显然无法满足。
- 2、如果强行延展连续的内容空间来满足扩容的需要,将耗费更多的资源来完成其他内存空间的挪动。
3.0.2 LinkedList
3.1 JDK1.7 HashMap
3.1.1 初始化HashMap
new HashMap(),调用构造方法,将初始化HashMap数组长度(JDK1.7的HashMap由数组加链表组成,默认值1 << 4,二进制左移4位=16)和负载因子(影响扩容、get读取数据效率的一个参数,默认0.75即元素超过75%时就要考虑扩容啦)
3.1.2 put方法
HashMap.get()方法
/**
* Returns the entry associated with the specified key in the
* HashMap. Returns null if the HashMap contains no mapping
* for the key.
*/
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
hashMap.get(key)方法,先计算key的hash值,然后计算对应下标,在数组中找到对应位置,遍历整个链表找到所在key的value
我们都知道
1、数组查找快,插值慢(因为数组在物理内存中地址连续,对应下标容易找到具体位置,但是插值慢是因为需要将插值后续的元素挨个后移,所以插值慢)
2、而链表插值快,查找慢(插值只需要将next引用的地址修改即可,查找则需要遍历整个表数据)
JDK1.7 hashMap为了解决链表过长所做的努力有:
2.1 在扩容时,同一数组下的链表,重新计算下标,打乱原先过长的链表
2.2 看经典案例2所述,多次二进制右移,保证数据散列度
3.1.3 扩容
1.7HashMap扩容的源码比较简单,源码如下面代码,示意见下图。
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
3.2 JDK1.7 ConcurrentHashMap
众所周知,HashMap线程不安全,为了解决多线程下的并发使用,JDK1.7同时提供了HashTable和ConcurrentHashMap,HashTable看一眼源码就知道了,存在并发问题的方法都加上了synchronized,显然这样的效率比较低。那针对高并发的场景,全村人的希望都落在ConcurrentHashMap上了。
3.2.1 构造函数初始化
JDK1.7 CHashMap 经典案例一(初始化过程)
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
}
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
解读1:
ConcurrentHashMap的构造方法,与HashMap的区别只在于添加了concurrencyLevel(并发级别,实际含义是segment分区数),默认值为16。
其中,根据默认或输入的concurrencyLevel,来计算需要左移次数sshift和数组的初始化容量做法,和JDK1.7 HashMap如出一辙,只是实现略有不同。(将concurrencyLevel通过遍历判断,转化为2的次幂数ssize)
解读2:
初始化segment[0],在构造方法中初始化segemnt[0],这与HashMap中进入到put方法在初始化略有区别。个人理解这样做的优点在于,当每个线程进入到put方法后再重新根据初始化参数构造一个segment[]浪费资源,不如在ConcurrentHashMap一开始构造出proto方便后续调用。
总而言之,构造方法完成了类似下图的长度为16的segment[]数组初始化,其中segment[0]是长度为2的HashEntry<K,V>数组。并将在后续的添加数据过程中,以segment[0]为模型,直接复制segment。
JDK1.7 CHM经典案例2
创建新的segemnt。CHashMap中存在大量UNSAFE操作, UNSAFE借用C++中指针的概念,来操作内存中的值(而非线程中的值)。该段代码是根据下标获取segment[k]的值或创建一个新的segment[k]。
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
解读:
上述代码很有意思,if判断ss数组中,偏移量u的位置有没有数据,没有则往内存中插值。
while语句内的代码,是通过自旋锁,循环执行cas插值。直到cas抢占到锁,并且成功修改了内存值,才会返回true,此时break退出。
需注意:
此时如果有其他线程占用了锁。在自旋锁内,会不停的在执行cas操作,这将导致CPU飙升,如果其他其他线程长时间不释放锁,那CPU的压力可是很大的。
3.2.2 put方法
JDK1.7 CHM Put方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
解读1:
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
步骤:非阻塞方式尝试获取锁---> 获得null 或 自旋锁最终得到锁
tryLock(); ------不阻塞(可以获取到锁则true,获取不到则即可返false)
unlock(); ------解锁
Lock(); ------ 阻塞等待
当然获取锁的过程会赋值字段exclusiveOwnerThread线程和volatile int state; 用于判断是否被其他线程操作、锁状态
3.2.3 扩容见1.8
3.3 JDK1.8 HashMap
3.3.1 put方法流程
3.3.2 红黑树
红黑树特性:
1、红黑树是平衡二叉树的一种
2、整棵树黑色节点的高度差不大于1
2、增删查的复杂度都是log n
3、根节点黑色、所有叶子节点都是黑色(NULL LEAF)、节点是红色其子节点必是黑色
5、从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
新插入节点的规则:
1、父节点是黑色:不用进行调整
2、父节点是红色:
a. 叔叔节点是红色,将父亲和叔叔节点改为黑色,祖父节点改为红色
b. 叔叔节点是空或者黑色时,需要旋转+变色。
3.3.3 扩容
红黑树及单链表生成双链表的示意图
3.4 JDK1.8 ConcurrentHashMap
3.4.1 put方法流程
下图表示了整体ConcurrentHashMap在put方法的过程。另外的扩容方法比较有意思,需要专门拎出来说说。
3.4.2 Map内元素统计及LongAdder多线程计数
值得一提的是,ConcurrentHashMap在存储整体map元素个数的时候,完整的借鉴了LongAdder的代码,有幸把二者的源码都读了一遍,感受到了多线程计数的乐趣,这里再把LongAdder.add(1L)的流程梳理了一遍。
其中悟到点道理,实现业务能力首先得有思路,有了思路世界也会为你让步!
3.4.2 CHM扩容
其实1.8CHM的扩容并不难理解,看懂1.8HashMap的扩容即可,这里只是在HashMap的基础上,添加了多线程扩容的概念。
3.5 Doug Lea经典写法趣味分析
3.5.1 hashmap计算数组下标
1)初始化hashMap的数组(HashMap由数组加链表组成)
由于HahMap提供了3种构造方法,可以由开发者自定义Map表长度,所以作者首先重新计算了一遍表长度。
JDK 1.7 经典场景1
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
上方代码计算过程
使用默认16时,16-1 左移1位为30(|运算,有1则为1)
代码1049行(i |= (i >> 1);)
0001 1110
0000 1111
-----------
0001 1111 = 31
代码1050行(i |= (i >> 2);)
0001 1111
0000 0111
-----------
0001 1111 = 31
....
可见无论右移多少次,最终取|时,都是31,因为i本身有1
return 31-15 = 16
这样做的意思在于什么? 返回传入number二进制最高位的1(这里一定还是2的次幂数)
为什么要右移到16位,因为int类型数据是32位的,即使右移16位仍能取到高位1
3.5.2 如何提升散列度
为了提高HashMap哈希值得散列度,多次右移取^,
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
^异或运算符顾名思义,异就是不同,其运算规则为1^0 = 1 , 1^1 = 0 , 0^1 = 1 , 0^0 = 0
保证了key值散列度
3.5.3 单线程为什么会抛出并发修改异常
modCount++,modCount是由hashmap维护的修改次数,记录hashmap的修改次数
hashmap内部抽象类HashIterator遍历器同时维护了expectedModCount
因为hashmap是线程不安全的,所以hashmap提供了fast-fail的快速失败机制,如果在并发修改时
modCount!= expectedModCount,
则快速排出异常ConcurrentModificationException并发修改异常。
解决办法:
1)hashtable 把所有会出现冲突的方法加锁!
2)使用ConcurrentHashMap 分段加锁
private abstract class HashIterator<E> implements Iterator<E> {
Entry<K,V> next; // next entry to return
int expectedModCount; // For fast-fail
int index; // current slot
Entry<K,V> current; // current entry
HashIterator() {
expectedModCount = modCount;
if (size > 0) { // advance to first entry
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext() {
return next != null;
}
final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
if ((next = e.next) == null) {
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
current = e;
return e;
}
public void remove() {
if (current == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Object k = current.key;
current = null;
HashMap.this.removeEntryForKey(k);
expectedModCount = modCount;
}
}
3.5.4 ConCunrentHashMap 1.7和1.8线程安全方面的区别
1.7 使用ReentrantLock 的非阻塞的tryLock()及阻塞的Lock()锁结合CAS,伴有大量自旋锁。当线程等待较长时间时,或出现CPU很高的问题。
1.8 使用cas + synchronized 锁住某个桶资源,提高了资源利用率。