本文主要是介绍ConcurrentHashMap实现原理总结--下,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
主要研究ConcurrentHashMap的get、put和remove 这3个操作。对于哈希表,Java中采用链表的方式来解决hash冲突的。实现了同步的HashTable也是这样的结构,它的同步使用锁来保证的,并且所有同步操作使用的是同一个锁对象。这样若有n个线程同时在get时,这n个线程要串行的等待来获取锁。
ConcurrentHashMap中对这个数据结构,针对并发稍微做了一点调整。它把区间按照并发级别(concurrentLevel),分成了若干个segment段。默认情况下内部按并发级别为16来创建。对于每个segment的容量,默认情况也是16。当然并发级别(concurrentLevel)和每个段(segment)的初始容量都是可以通过构造函数设定的。
从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长,但是带来的好处是写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上),所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
- segment的定义:
Segment继承了ReentrantLock,表明每个segment都可以当做一个锁。这样对每个segment中的数据需要同步操作的话都是使用每个segment容器对象自身的锁来实现。只有对全局需要改变时锁定的是所有的segment。这种做法,就称之为“分离锁”。
由此可见,ConcurrentHashMap的实现使用了一个包含 16 个锁的数组,每一个锁都守护 HashMap 的 1/16 。假设 Hash 值均匀分布,这将会把对于锁的请求减少到约为原来的 1/16 。这项技术使得 ConcurrentHashMap 能够支持 16 个的并发 Writer 。当多处理器系统的大负荷访问需要更好的并发性时,锁的数量还可以增加。
看上去,单是这样就已经能大大提高多线程并发的性能了。还没完,继续看我们关注的get,put,remove这三个函数怎么保证数据同步的。
- get
这个实现很微妙,没有锁同步的话,靠什么保证同步呢?我们一步步分析。
第一步,先判断一下 count != 0;count变量表示segment中存在entry的个数。如果为0就不用找了。
假设这个时候恰好另一个线程put或者remove了这个segment中的一个entry,会不会导致两个线程看到的count值不一致呢?
看一下count变量的定义: transient volatile int count;
它使用了volatile来修改。我们前文说过,Java5之后,JMM实现了对volatile的保证:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。所以,每次判断count变量的时候,即使恰好其他线程改变了segment也会体现出来。
第二步,获取到要该key所在segment中的索引地址,调用了getFirst()来取得链表的头部:同样,这里也是用位操作来确定链表的头部,hash值和HashTable的长度减一做与操作,最后的结果就是hash值的低n位,其中n是HashTable的长度以2为底的结果。
这是为何呢?
Segment中的元素是以HashEntry的形式存放在链表数组中的,先看一下HashEntry类结构。
回想一下我们之前讨论的双重检测的问题,这里也一样,没有锁同步的话,new 一个对象对于多线程看到这个对象的状态是没有保障的,这里同样有可能一个线程new这个对象的时候还没有执行完构造函数就被另一个线程得到这个对象引用。所以才需要判断一下:if (v != null) 如果确实是一个不完整的对象,则使用锁的方式再次get一次。
有没有可能会put进一个value为null的entry? 不会的,已经做了检查,这种情况会抛出异常,所以 ②处的判断完全是出于对多线程下访问一个new出来的对象的状态检测。
value是用volitale修饰的,可以保证读取时获取到的是修改后的值。
假设我们的链表元素是:e1-> e2 -> e3 -> e4 我们要删除 e3这个entry。 如果我们get的也恰巧是e3, 这里没有办法实时保证了。
①之后到②之间,如果再次发生了其他线程再删除了entry节点,就没法保证看到最新的了。
不过这也没什么关系,即使我们返回e3的时候,它被其他线程删除了,暴漏出去的e3也不会对我们新的链表造成影响。
这其实是一种乐观设计,设计者假设 ①之后到②之间 发生被其它线程增、删、改的操作可能性很小,所以不采用同步设计,而是采用了事后(其它线程这期间也来操作,并且可能发生非安全事件)弥补的方式。
而因为其他线程的“改”和“删”对我们的数据都不会造成影响,所以只有对“新增”操作进行了安全检查,就是②处的非null检查,如果确认不安全事件发生,则采用加锁的方式再次get。
这样做减少了使用互斥锁对并发性能的影响。可能有人怀疑remove操作中复制链表的方式是否代价太大,这里我没有深入比较,不过既然Java5中这么实现,我想new一个对象的代价应该已经没有早期认为的那么严重。
我们基本分析完了get操作。对于put和remove操作,是使用锁同步来进行的,不过是用的ReentrantLock而不是synchronized,性能上要更高一些。
ConcurrentHashMap的迭代器不是Fast-Fail的方式,所以在迭代的过程中别其他线程添加/删除了元素,不会抛出异常,也不能体现出元素的改动。但也没有关系,因为每个entry的成员除了value都是final修饰的,暴漏出去也不会对其他元素造成影响。
这篇关于ConcurrentHashMap实现原理总结--下的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!