这两天学习锁时,明白了一个道理:
除了重量级锁以及数据库基本的读写锁之外,其他所有锁的出现都是为了优化这两种基本的锁,提高这两种锁的效率
也不知道总结的对不对,我们暂且按照这个思路进行分析
我们知道HashTable是使用重量级锁sync来保证线程安全的,性能过于低下,所以就出现了分段锁
分段锁就是把一整个锁分成若干份,让多线程竞争锁时,可以同时在不同分段竞争,相当于对线程分流,从而提高并发效率,这就是currentHashMap使用的锁机制
1,将数据分成一段一段地存储
2,给每一段数据配一把锁
3,当一个线程占用锁访问其中一段数据时,其他段数据也能被其他线程访问
CurrentHashMap锁是通过继承Segment类实现(Segment是通过继承ReentrantLock类实现的),也就是Segment是一种可重入锁,
CurrentHashMap是一种双数组结构,首先是一个Segment数组(默认大小为16,也就是初始并发度为16),然后每个Segment数组中包含一个HashEntry数组,然后每个HashEntry后面是一个链表结构,如图:
get()操作
Segment的get操作实现非常简单和高效.
- 先经过一次再散列
- 然后使用这个散列值通过散列运算定位到Segment
- 再通过散列算法定位到元素.
整个get方法不需要加锁,只需要计算两次hash值,然后遍历一个单向链表
之所以不需要加锁是因为:get方法将要使用的共享变量都定义成了volatile类型, 如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value.定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写
在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁.
之所以不会读到过期的值,是因为根据Java内存模型的happen before原则,对volatile字段的写操作先于读操作,即使两个线程同时修改和获取 volatile变量,get操作也能拿到最新的值, 这是用volatile替换锁的经典应用场景.
transient volatile int count;
volatile V value;
在定位元素的代码里可以发现,定位HashEntry和定位Segment的散列算法虽然一样,都与数组的长度减去1再相“与”,但是相“与”的值不一样
定位Segment使用的是元素的hashcode再散列后得到的值的高位
定位HashEntry直接使用再散列后的值.
其目的是避免两次散列后的值一样,虽然元素在Segment里散列开了,但是却没有在HashEntry里散列开.
hash >>> segmentShift & segmentMask // 定位Segment所使用的hash算法
int index = hash & (tab.length - 1); // 定位HashEntry所使用的hash算法
put()操作
由于需要对共享变量进行写操作,所以为了线程安全,在操作共享变量时必须加锁.
put方法首先定位到Segment,然后在Segment里进行插入操作.
插入操作需要经历两个步骤
判断是否需要对Segment里的HashEntry数组进行扩容
定位添加元素的位置,然后将其放在HashEntry数组里
1,是否需要扩容
在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阈值,则对数组进行扩容.
值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容.
2,如何扩容
在扩容的时候,首先会创建一个容量是原来两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里.
为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment扩容.
put方法的第一步,计算segment数组的索引,并找到该segment,然后调用该segment的put方法。
put方法第二步,在Segment的put方法中进行操作。
size()操作
每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。
/**
* The number of elements. Accessed only either within locks
* or among other volatile reads that maintain visibility.
*/
transient int count;
在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。
ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。
尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。
如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。
/**
* Number of unsynchronized retries in size and containsValue
* methods before resorting to locking. This is used to avoid
* unbounded retries if tables undergo continuous modification
* which would make it impossible to obtain an accurate result.
*/
static final int RETRIES_BEFORE_LOCK = 2;
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// 超过尝试次数,则对每个 Segment 加锁
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 连续两次得到的结果一致,则认为这个结果是正确的
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
JDK1.8的改动:
JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与 Segment 数量相等。
JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。
并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。
参考链接:https://blog.csdn.net/qq_33589510/article/details/79962152