JDK源码学习——ConcurrentHashMap

注:内容主要参考《java并发编程的艺术》一书

为什么要使用ConcurrentHashMap?

在并发编程的时候使用HashMap容易导致程序死循环,而使用HashTable效率比较低

(1)线程不安全的Hashmap

为什么线程不安全?

a) hashmap是采用链地址法解决hash冲突的,底层由数组和链表构成。当多个线程对同一个哈希映射进行操作的时候,也就是对同一个数组位置进行修改,一个线程代用addEntry()方法添加一个节点,然后第二个线程也添加一个节点,这时第二个线程的操作就会覆盖掉第一个线程的操作,造成数据丢失。

b) 除了数据可能丢失,Hashmap在并发执行put操作的时候还可能造成死循环。因为当多线程执行put操作的时候,Entry数组容量可能不够,就会进行扩容。假设其中一个哈希地址上有A和B两个元素。一个线程发现不够,准备扩容,就在这个时候,第二个线程介入了,第二个线程也发现容量不够开始扩容,扩大为原来的两倍,然后把A元素复制到新hash表中,接着把B元素查到链表的头部。这些完成之后,第一个线程又开始操作,导致A的next节点指向B元素,B的next节点指向A,形成了一个环形数据结构。一旦形成环形数据结构,Entry元素的的next节点永远不为空,就会出现死循环获取Entry元素的情况。

2)效率低下的hashtable

HashTable使用synchronized来保证线程安全,在线程竞争激烈的情况下HashTable的效率很低。

为什么效率低下?

因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,就会进入阻塞或者轮询状态。比如,当线程1使用put方法添加元素的时候,线程2不仅不能使用put方法来添加元素,也不能使用get方法获取元素,于是竞争越激烈效率越低。

3)ConcurrentHashMap如何提高并发访问效率

HashTable在并发条件下访问效率低的原因是所有访问hashtable的线程都去竞争同意把锁。假如容器中有多把锁,每一把锁用来锁住容器里的一部分数据,当多个线程访问不同的数据段的数据时,线程之间就不会发生锁竞争的现象了,可以有效地提升并发访问效率。ConcurrentHashMap就是这么做的,叫做锁分段技术。首先把数据分为一段一段的进行存储,然后给以一个数据段配一把锁,当一个线程访问一个数据段的数据时,其他线程可以访问其他数据段的数据。

4)底层实现

ConcurrentHashMap的底层由Segment数组和HashEntry数组构成的。

static final class Segment<K,V> extends ReentrantLock implements Serializable

Segment继承了RetrantLock,所以每一个segment都是一个可重入锁。每一个segment对应一个HashEntry数组,每一个HashEntry对应着一个链表结构的元素。这样每一个segment就守护者一个HashEntry数组中的元素,想要更改这些元素,首先要获得它对应的Segment锁。

4.1)定位segment

既然使用分段锁segment来保护不同数据段,那么在插入和获取元素的时候,要首先通过散列算法定位到Segment。在ConcurrentHashMap中会首先对元素的哈希值进行一次再散列。再散列的目的就是为了减少散列冲突,是元素能均匀地分布到不同的Segment上,提升容器的存取的效率。如果没有再散列,假如散列的质量很差,所有的元素都在一个Segment上,那么所有的线程都会竞争同一把Segment锁,不仅存取元素的速度很慢,分段锁也失去了意义。

在ConcurrentHashMap中我们主要关注get和put操作。

4.2)get操作

get操作的过程是:先经过一次再散列操作,使用这个散列值经过散列运算定位到Segment,在通过散列运算定位到需要的元素。

get操作比较高效的地方在于:在整个get过程不需要加锁,除非读到的值是空,才需要加锁重新读取。在HashTable中get操作是需要加锁的所以效率比较低。

如何实现不加锁的?

不加锁的原因是在get方法里把将要使用的共享变量定义成volatile变量,比如用来统计segment大小的count和HashEntry里用来存储值的value。定义成volatile的变量,能够在线程之间保持可见性,当多线程同时读的时候,保证不会拿到过期的值。

为什么不会读到过期的值?

因为根据java内存模型的happen-before原则,volatile变量的写操作优先于读操作,即使两个两个线程同时修改和读取volatile变量,get操作也能拿到最新的值。这是用volatile来替换锁的一个经典场景。

4.3)put操作

在put方法里需要对共享变量进行写入操作,为了保证线程安全,需要进行加锁。put方法首先定位到Segment,然后在Segment里记性插入操作。插入操作主要有两个步骤:

第一步:判断Segment里的HashEntry数组是否需要进行扩容

是否需要扩容?

Segment的扩容判断相比Hashmap更合适,因为Hashmap中是先插入元素再判断元素是否达到容量,如果扩容之后没有元素插入,这次扩容就没什么用了。 而Segment扩容是先判断容量是否超过阈值,再进行插入操作,可以避免这个问题

如何扩容?

扩容的时候,先创建一个大小是原来两倍的数组,然后把原数组的元素经过再散列之后复制过来。为了提高效率,并不会对整个容器进行扩容,而是只针对某个Segment进行扩容。

第二步:定位元素需要插入的位置,然后把元素放到HashEntry数组里面 。

猜你喜欢

转载自blog.csdn.net/shida_hu/article/details/80263370