concurrenthashmap线程安全(concurrenthashmap为什么线程安全)

http://www.itjxue.com  2023-01-25 16:32  来源:未知  点击次数: 

线程安全的 Map对比

线程安全的本质:

ConcurrentHashMap:本质上是创建一个HashEntry数组去存储元素。分段去锁数据

Collections.synchronizedMap(): 本质上是返回一个 SynchronizedMap

主要的不同:

ConcurrentHashMap:更新的时候,它会锁住部分数据 ,其他的数据能够被其他线程调用

Collections.synchronizedMap():更新的时候锁住所有数据,其他线程无法介入,除非这个锁释放

注意:如果有许多更新操作,或者相对来说较多的读取操作,应该选择ConcurrentHashMap

另一个不同:

ConcurrentHashMap不会保留元素传入的顺序,和HashMap差不多

进一步说,ConcurrentHashMap能保证不会出现ConcurrentModificationException 这个异常,当一个线程在更新,

另一个线程也去访问的时候。

但是Collections.synchronizedMap()无法保证。

拓展:ConcurrentSkipListMap (since 1.6版本以后)兼顾上边两个的有点,有序和不报异常

ConcurrentHashMap如何实现高效地线程安全?

1)相对于HashTable 整个加锁的形式,采用了分段加锁的方式,只有hash值在同一个段中的数据才会发生竞争,如果分n段,理论上最高的并发度也是n。2)分析get操作比较一下HashTable的get操作,是通过synchronized加了锁的public synchronized V get(Object key) {

Entry tab[] = table;

int hash = hash(key);

int index = (hash 0x7FFFFFFF) % tab.length;

for (EntryK,V e = tab[index] ; e != null ; e = e.next) {

if ((e.hash == hash) e.key.equals(key)) {

return e.value;

}

}

return null;

}

而在ConcurrentHashMap当中,是通过Segment的get操作获取的-------------------------------------------------①问:ConcurrentHashMap的get操作有没有加锁?答:并没有问:那怎么实现的并发安全准确的读取数据呢?答:当出现有key,但是没有value的情况时,将加lock锁,等待value值写入,再读取,防止读不到最新的值。②问:ConcurrentHashMap有哪些参数可以在构造方法中设置?答:concurrencyLevel 并发度,也就是segment的段数 loadfactor 加载因子,进行扩容的阀值因子 initialCapacity 初始化总的线性链的个数,平均分配到每个segment当中问:为什么concurrencyLevel 和每个segment当中的链表数都设置成2的n次方形式?为了根据hash值得前缀或者后缀快速定位所属的segment和线性链③static final class HashEntryK,V {

final K key;

final int hash;

volatile V value;

final HashEntryK,V next;

}

HashEntry节点key hash next 都定义成final为什么? volatile修饰value为什么?为了防止链表结构被破坏,出现ConcurrentModification的情况。-------------------------------//返回键值key映射的value值,当不包含key键时返回null

public V get(Object key) {

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

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

}

/* Specialized implementations of map methods */

V get(Object key, int hash) {

if (count != 0) { // read-volatile

HashEntryK,V e = getFirst(hash);

while (e != null) {

if (e.hash == hash key.equals(e.key)) {

V v = e.value;

if (v != null)

return v;

return readValueUnderLock(e); // recheck

}

e = e.next;

}

}

return null;

}

Segment的get操作没有加锁又是怎么实现并发的呢?ConcurrentHashMap的get操作是直接委托给Segment的get方法get操作不需要锁。第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count变量,通过这种机制保证get操作能够得到最新的结构更新。下面都是Segment内部的变量和方法/**

* The number of elements in this segment's region.

*/

transient volatile int count;

/**

* Returns properly casted first entry of bin for given hash.

*/

HashEntryK,V getFirst(int hash) {

HashEntryK,V[] tab = table;

return tab[hash (tab.length - 1)];

}

链表节点static final class HashEntryK,V {

final K key;

final int hash;

volatile V value;

final HashEntryK,V next;

HashEntry(K key, int hash, HashEntryK,V next, V value) {

this.key = key;

this.hash = hash;

this.next = next;

this.value = value;

}

@SuppressWarnings("unchecked")

static final K,V HashEntryK,V[] newArray(int i) {

return new HashEntry[i];

}

}

对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是volatile的,也能保证读取到最新的值。接下来就是对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null。对hash链进行遍历不需要加锁的原因在于链指针next是final的。但是头指针却不是final的,这是通过getFirst(hash)方法返回,也就是存在table数组中的值。这使得getFirst(hash)可能返回过时的头结点,例如,当执行get方法时,刚执行完getFirst(hash)之后,另一个线程执行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。这是可以允许,通过对count变量的协调机制,get能读取到几乎最新的数据,虽然可能不是最新的。要得到最新的数据,只有采用完全的同步。最后,如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次。这似乎有些费解,理论上结点的值不可能为空,这是因为put的时候就进行了判断,如果为空就要抛NullPointerException。空值的唯一源头就是HashEntry中的默认值,因为HashEntry中的value不是final的,非同步读取有可能读取到空值。仔细看下put操作的语句:tab[index] = new HashEntryK,V(key, hash, first, value),在这条语句中,HashEntry构造函数中对value的赋值以及对tab[index]的赋值可能被重新排序,这就可能导致结点的值为空。这种情况应当很罕见,一旦发生这种情况,ConcurrentHashMap采取的方式是在持有锁的情况下再读一遍,这能够保证读到最新的值,并且一定不会为空值。参考于中的实现原理与Java7已经有很大不同了

ConcurrentHashMap原理和使用

ConcurrentHashMap是线程安全的,使用环境大多在多线程环境下,在高并发情况下保证数据的可见性和一致性。

HashMap是一种键值对的数据存储容器,在JDK1.7中使用的是数组+链表的存储结构,在JDK1.8使用的是数组+链表+红黑树的存储结构,关于HashMap的实现原理可以查看 《hashMap实现原理》 。

HashMap是线程不安全的,主要体现在多线程环境下,容器扩容的时候会造成环形链表的情况,关于hashMap线程不安全原因可以查看 《HashMap线程不安全原因》

HashTable也是一种线程安全的键值对容器,我们一般都是听说过,具体使用较少,为什么线程安全的HashTable在多线程环境下使用较少,主要原因在于其效率较低(虽然效率低,但是很安全在多线程环境下)。

下面来分析一下HashTable为什么线程安全,但是效率较低的原因?

查看HashTable类,我们发现在源码中所有的方法都加上了synchronized同步关键字,这也就保证的在多线程环境下线程安全,由于所有的方法都加上了synchronized,这也就导致在一个线程获取到HashTable对象锁的时候,其他线程是不能访问HashTable中的其他方法的,比如A线程在使用HashTable的get()方法时,当B线程想使用HashTable的put()方法的时候必须等到A线程使用完get()方法并释放锁,而且B线程正好能够获取到HashTable的锁的时候才行,这样在多线程环境下就会造成线程长时间的阻塞。使其效率底下的原因所在。

在HashTable中由于在方法上设置synchronized,导致虽然是线程安全的,但是只有一个HashTable的对象锁,也就是说同一时刻只能有一个线程可以访问HashTable,线程都必须竞争同一把锁。假如容器中里面有多把锁,每把锁用于锁住容器的一部分数据,那么当多线程访问容器里面不同的数据时,多线程之间就不会存在锁的竞争,从而提高了并发访问的效率,这就是ConcurrentHashMap的 锁分段技术 。

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁

ConcurrentHashMap的初始化

在ConcurrentHashMap中如何将数据均匀的散列到每一个Segment中?如果数据不能均匀的散列到各个Segment中,那么ConcurrentHashMap的并行性就会下降,好比,所有的数据都在一个Segment中,那么一个ConcurrentHashMap中就相当于只有一个锁,这和HashTable没有什么区别,所以通过一个hash()方法,将数据均匀的散列到不同的Segment中去(注意,虽然经过散列数据会均匀分散的不同的Segment中去,但是,也会有可能出现一个Segment中数据过多的问题,如果数据过多,多线程访问到的概率就会增加,导致并行度下降,如何优化解决ConcurrentHashMap中锁的加入时机和位置,这就是JDK1.8对ConcurrentHashMap所要做的事情)

查看ConcurrentHashMap的put()方法

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

(1)是否需要扩容

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

(2)如何扩容

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

get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空才会加锁重读。我们知道HashTable容器的get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不加锁的呢?原因是它的get方法里将要使用的共享变量都定义成volatile类型(关于volatile可以查看 《volatile synchronized final的内存语义》 ),如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁。

锁的优化

在前面我们提到,在JDK1.7中ConcurrentHashMap使用锁分段的技术,提高了ConcurrentHashMap的并行度,虽然经过散列数据会均匀分散的不同的Segment中去,但是也会有可能出现一个Segment中数据过多的问题,如果数据过多,多线程访问到的概率就会增加,导致并行度下降。

在JDK1.8中ConcurrentHashMap细化了锁的粒度,缩小了公共资源的范围。采用synchronized+CAS的方式实现对共享资源的安全访问,只锁定当前链表或红黑二叉树的 首节点 ,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

结构优化

在JDK1.7中ConcurrentHashMap的结构是数组+链表,我们知道链表随机插入和删除较快,但是查询和修改则会很慢,在ConcurrentHashMap中如果存在一个数组下标下的链表过长,查找某个value的复杂度为O(n),

在JDK1.8中ConcurrentHashMap的结构是数组+链表+红黑树,在链表的长度不超过8时,使用链表,在链表长度超过8时,将链表转换为红黑树复杂度变成O(logN)。效率提高

下面是JDK1.8中ConcurrentHashMap的数据结构

TreeBin:红黑树数节点? ? ?Node:链表节点

在JDK1.8中,hash()方法得到了简化,提高了效率,但是增加了碰撞的概率,不过碰撞的概率虽然增加了,但是通过红黑树可以优化,总的来说还是相较JDK1.7有很大的优化。

下面来分析put(),来观察JDK1.8中如何采用synchronized+CAS的方式细化锁的粒度,只锁定当前链表或红黑二叉树的 首节点。

jdk8中的ConcurrentHashMap究竟为什么高效?

从源码来窥其一斑!

我们都知道hashMap不是线程安全的,因为在扩容方法中很容易出现死循环,hashTable使用锁的方式比较简单暴力,几乎在所有操作方法上都加了synchronized锁,导致总体性能很差,concurrentHashmap凭借线程安全且性能优异一直都是高并发中的首选key-value型数据结构;

concurrentHashmap的高性能有以下原因:

一,分段锁:jdk8中对concurrentHashmap进行了改进,抛弃了jdk7中新建segment作为分段锁的过程,jdk8中虽沿用了这种分段锁的思想,却直接使用数组中的数据作为 分段锁保证concurrentHashmap在上锁的时候只针对数组下标下的数据进行上锁 (比如如果数组长度为256,那么每次put平均只有1/256的数据被锁),而大多数其他的数据还是能进行正常的增删改操作,无需阻塞等待,这无疑极大的 降低了锁的粒度,提升了性能。

二,红黑树 :jdk8中引入了红黑树结构,在单个数组下标内的数据达到8以后,会自动转换为红黑树进行存储, 使用大O表示法表示效率的话,红黑树的查找效率为O(log(n)),而链表的效率为O(n) ,当数据量越来越大的时候,红黑树的效率明显好于链表,所以concurrentHashmap性能得到很大提升;

现在我们主要从put方法中的主要方法来分析性能的提升:

spread(key.hashCode());//作用是再次哈希,减少冲突 ,源码如下

其中涉及到的位运算有

16:无符号右移16位,空位以0补齐 。

^:异或运算符--相同为0,不同为1; :与运算符--全1得1,否则0;

(h ^ (h 16)) HASH_BITS; 所以这句代码的意思就是不仅消除高16位的影响,同时获得正整数的hash值

再来看后面的方法, 如上图:

1,就是判断当这个hash表还是空的时候,调用initTable进行初始化; 2,使用(n - 1) hash)计算数组下标,如果数据指定下标处为null,则直接插入,注: cas是java8中的concurrentHashmap引入的线程安全判断,CAS算法做为乐观锁 ;

3,(fh = f.hash) == MOVED,走到此处说明下标内有node,且该node的值为-1(MODED=-1),搜索全类发现MODED是在调用有参构造器ForwardingNode中默认写入的,而这个调用处刚好在transfer方法中,所以我们推断,扩容的时候先将数组下标内的node.hash置为-1! 同时在3这一步中调用helpTransfer(tab, f)参与扩容,并把数据写入;

4,走到这说明node不是空的,也没在扩容,那么锁住该下标下的node,并把新value插入链表中; 5,如果锁住的这个node能实例化为TreeBin,则代表已经转化为红黑树进行存储,将数据插入红黑树中; 6,判断在4,5中计算得到的数组下标内所有节点总数, 如果满足转化为红黑树的条件(节点数大于8),则自动转化为红黑树进行存储!

总的来说,concurrentHashmap之所以性能高就是因为使用了分段锁和红黑树!

至于conrrentHashmap其他的方法的源码分析,后期会补上的,更多的技术分享,敬请关注!

(责任编辑:IT教学网)

更多

推荐新手入门文章