ConcurrentHashMap 的实现原理与使用

1.为什么要使用ConcurrentHashMap?

1.1 因为HashMap是线程不安全的。

我曾经在工作中因为年少无知没有注意并发的情况,使用了HashMap,最终导致cpu全部吃满(3000%的使用率,32核的机器)。为啥呢?因为HashMap在多线程同时进行put操作的时候容易引起环形链表,扩容这个Map的时候就变成了一个一直吃CPU和死循环。

这个过程为,先将A复制到新的Hash表中,然后接着复制B到链头(A的前边:B.next=A),本来B.next=null,到此也就结束了(跟线程二一样的过程),但是,由于线程二扩容的原因,将B.next=A,所以,这里继续复制A,让A.next=B,由此,环形链表出现:B.next=A; A.next=B


1.2 HashTable的效率非常低

原因很简单,HashTable使用synchronized来保证线程安全。同一时刻只允许一个线程去方案HashTable的同步方法,其他线程只能阻塞等待。竞争越激烈效率越低。


1.3 ConcurrentHashMap分段锁可以提升并发效率

HashTable效率低是因为所有的线程只能竞争同一把锁。那就很好提高效率了,ConcurrentHashMap通过使用多把锁的方式来提高并发时候的效率。假如容器里有很多把锁,每一把锁用于其中的一部分数据,那么多线程方案不同地方的数据的时候线程之间不会存在竞争的关系。



2.ConcurrentHashMap的结构

我们可以借鉴HashMap的结构这么理解:一个ConcurrentHashMap里包含了多个Segment,一个Segment里包含多个HashEntry,其中Segment可以看做是数组,HashEntry可以看做是链表。这样就好理解一些,如果有线程想要去修改这个HashEntry,那么只需要获取对应的Segment的锁即可。如图所示:


3.定位Segment

既然一个ConcurrentHashMap里包含有多个Segment,那么在获取和插入数据的时候该如何定位到具体的Segment呢?

第一步,先对所要查找的元素进行一次hashCode运算;第二步,对第一步得到的结果进行下面的hash算法再散列运算。

private static int hash(int h) {

        h += (h << 15) ^ 0xffffcd7d;

        h ^= (h >>> 10);

        h += (h << 3);

        h ^= (h >>> 6);

        h += (h << 2) + (h << 14);

        return h ^ (h >>> 16);

    }

之所以进行再哈希,其目的是为了减少哈希冲突,使元素能够均匀的分布在不同的Segment上,从而提高容器的存取效率。假如哈希的质量差到极点,那么所有的元素都在一个Segment中,不仅存取元素缓慢,分段锁也会失去意义。


4.操作

4.1 get

Segment的get操作实现非常简单和高效。先经过一次再哈希,然后使用这个哈希值通过哈希运算定位到segment,再通过哈希算法定位到元素,代码如下:

public V get(Object key) {

    int hash = hash(key.hashCode());

    return segmentFor(hash).get(key, hash);

}

get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空的才会加锁重读,我们知道HashTable容器的get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不加锁的呢?

原因是它的get方法里将要使用的共享变量都定义成volatile,如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁。之所以不会读到过期的值,是根据java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景。


4.2 put操作

由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须得加锁。Put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置然后放在HashEntry数组里。

4.2.1 是否需要扩容

在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阀值,数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容。

4.2.2 如何扩容

扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。


4.3  size操作

这个操作是我一开始没有想到的,原以为直接统计一下就能知道结果的size,但是忽略了现在是多线程的场景,size随时随地都可能在发生变化。

如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?

不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。

所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。 因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小(这也是一个性能提升的办法,有时候我们经常会为了1%的性能付出99%的耗时,但其实很多时候先用“不准”的方法判断一下就可以节省大量的时间)。

本文标题: ConcurrentHashMap 的实现原理与使用

本文链接: http://generalcode.cn/archives/427

你可能感兴趣的:(ConcurrentHashMap 的实现原理与使用)