ConcurrentHashMap的size方法是线程安全的吗?
前言
之前在面试的过程中有被问到,ConcurrentHashMap的size方法是线程安全的吗?
这个问题,确实没有答好。这次来根据源码来了解一下,具体是怎么一个实现过程。
ConcurrentHashMap的原理与结构
我们都知道Hash表的结构是数组加链表,就是一个数组中,每一个元素都是一个链表,有时候也把会形象的把数组中的每个元素称为一个“桶”。在插入元素的时候,首先通过对传入的键(key),进行一个哈希函数的处理,来确定元素应该存放于数组中哪个一个元素的链表中。
这种数据结构在很多计算机语言中都能找到其身影,在Java中如HashMap,ConcurrentHashMap等都是这种数据结构。
但是这中数据结构在实现HashMap的时候并不是线程安全的,因为在HashMap扩容的时候,是会将原先的链表迁移至新的链表数组中,在迁移过程中多线程情况下会有造成链表的死循环情况(JDK1.7之前的头插法);还有就是在多线程插入的时候也会造成链表中数据的覆盖导致数据丢失。
所以就出现了线程安全的HashMap类似的hash表集合,典型的就是HashTable和ConcurrentHashMap。
Hashtable实现线程安全的代价比较大,那就是在所有可能产生竞争方法里都加上了synchronized,这样就会导致,当出现竞争的时候只有一个线程能对整个Hashtable进行操作,其他所有线程都需要阻塞等待当前获取到锁的线程执行完成。
这样效率是非常低的。
而ConcurrentHashMap解决线程安全的方式就不一样了,它避免了对整个Map进行加锁,从而提高了并发的效率。
下面将具体介绍一下JDK1.7和1.8的实现。
JDK1.7中的ConcurrentHashMap
JDK1.7中的ConcurrentHashMap采用了分段锁的形式,每一段为一个Segment类,它内部类似HashMap的结构,内部有一个Entry数组,数组的每个元素是一个链表。同时Segment类继承自ReentrantLock
。
结构如下:
在HashEntry中采用了volatile来修饰了HashEntry的当前值和next元素的值。所以get方法在获取数据的时候是不需要加锁的,这样就大大的提供了执行效率。
在执行put()
方法的时候会先尝试获取锁(tryLock()
),如果获取锁失败,说明存在竞争,那么将通过scanAndLockForPut()
方法执行自旋,当自旋次数达到MAX_SCAN_RETRIES
时会执行阻塞锁,直到获取锁成功。
源码如下:
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 首先尝试获取锁,获取失败则执行自旋,自旋次数超过最大长度,后改为阻塞锁,直到获取锁成功。
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
JDK1.8后的ConcurrentHashMap
在JDK1.8中,放弃了Segment这种分段锁的形式,而是采用了CAS+Synchronized
的方式来保证并发操作的,采用了和HashMap一样的结构,直接用数组加链表,在链表长度大于8的时候为了提高查询效率会将链表转为红黑树(链表定位数据的时间复杂度为O(N),红黑树定位数据的时间复杂度为O(logN))。
在代码上也和JDK1.8的HashMap很像,也是将原先的HashEntry改为了Node类,但还是使用了volatile修饰了当前值和next的值。从而保证了在获取数据时候的高效。
JDK1.8中的ConcurrentHashMap在执行put()
方法的时候还是有些复杂的,主要是为了保证线程安全才做了一系列的措施。
源码如下:
- 第一步通过key进行hash。
- 第二步判断是否需要初始化数据结构。
- 第三步根据key定位到当前
Node
,如果当前位置为空,则可以写入数据,利用CAS
机制尝试写入数据,如果写入失败,说明存在竞争,将会通过自旋来保证成功。 - 第四步如果当前的
hashcode
值等于MOVED
则需要进行扩容(扩容时也使用了CAS
来保证了线程安全)。 - 第五步如果上面四步都不满足,那么则通过
synchronized
阻塞锁将数据写入。 - 第六步如果数据量大于
TREEIFY_THRESHOLD
时需要转换成红黑树(默认为8)。
JDK1.8的ConcurrentHashMap的get()
方法就还是比较简单:
- 根据
key
的hashcode
寻址到具体的桶上。 - 如果是红黑树则按照红黑树的方式去查找数据。
- 如果是链表就按照遍历链表的方式去查找数据。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
ConcurrentHashMap的size方法
JDK1.7中的ConcurrentHashMap的size方法,计算size的时候会先不加锁获取一次数据长度,然后再获取一次,最多三次。比较前后两次的值,如果相同的话说明不存在竞争的编辑操作,就直接把值返回就可以了。
但是如果前后获取的值不一样,那么会将每个Segment都加上锁,然后计算ConcurrentHashMap的size值。
JDK1.8中的ConcurrentHashMap的size()
方法的源码如下:
/**
* {@inheritDoc}
*/
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
这个方法最大会返回int的最大值,但是ConcurrentHashMap的长度有可能超过int的最大值。
在JDK1.8中增加了mappingCount()
方法,这个方法的返回值是long类型的,所以JDK1.8以后更推荐用这个方法获取Map中数据的数量。
/**
* @return the number of mappings
* @since 1.8
*/
public long mappingCount() {
long n = sumCount();
return (n < 0L) ? 0L : n; // ignore transient negative values
}
无论是size()
方法还是mappingCount()
方法,核心方法都是sumCount()
方法。
源码如下:
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
在上面sumCount()方法中我们看到,当counterCells
为空时直接返回baseCount
,当counterCells
不为空时遍历它并垒加到baseCount
中。
先看baseCount
/**
* Base counter value, used mainly when there is no contention,
* but also as a fallback during table initialization
* races. Updated via CAS.
*/
private transient volatile long baseCount;
baseCount
是一个volatile
变量,那么我们来看在put()
方法执行时是如何使用baseCount的,在put方法的最后一段代码中会调用addCount()
方法,而addCount()
方法的源码如下:
首先对baseCount做CAS自增操作。
如果并发导致了baseCount的CAS失败了,则使用counterCells进行CAS。
如果counterCells的CAS也失败了,那么则进入fullAddCount()
方法,fullAddCount()
方法中会进入死循环,直到成功为止。
那么CountCell到底是个什么呢?
源码如下:
/**
* A padded cell for distributing counts. Adapted from LongAdder
* and Striped64. See their internal docs for explanation.
*/
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
一个使用了 @sun.misc.Contended
标记的类,内部一个 volatile
变量。
@sun.misc.Contended
这个注解是为了防止“伪共享”。
那么什么是伪共享呢?
缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
所以伪共享对性能危害极大。
JDK 8 版本之前没有这个注解,JDK1.8之后使用拼接来解决这个问题,把缓存行加满,让缓存之间的修改互不影响。
总结
无论是JDK1.7还是JDK1.8中,ConcurrentHashMap的size()
方法都是线程安全的,都是准确的计算出实际的数量,但是这个数据在并发场景下是随时都在变的。
ConcurrentHashMap的size方法是线程安全的吗?的更多相关文章
- ConcurrentHashMap的size()方法(1.7和1.8)
在1.7和1.8版本中,计算size()方法有写不同.先介绍1.7版本的实现. 1.7版本 在1.7版本中,有一个重要的类Segment,利用它来实现分段锁 static final class Se ...
- ConcurrentHashmap中的size()方法简单解释
本文所有的源码都是基于JDK1.8 ConcurrentHashmap中的size()方法源码: public int size() { long n = sumCount(); return ((n ...
- 并发编程 —— ConcurrentHashMap size 方法原理分析
前言 ConcurrentHashMap 博大精深,从他的 50 多个内部类就能看出来,似乎 JDK 的并发精髓都在里面了.但他依然拥有体验良好的 API 给我们使用,程序员根本感觉不到他内部的复杂. ...
- 使用size()方法输出列表中的元素数量。需要注意的是,这个方法返回的值可能不是真实的,尤其当有线程在添加数据或者移除数据时,这个方法需要遍历整个列表来计算元素数量,而遍历过的数据可能已经改变。仅当没有任何线程修改列表时,才能保证返回的结果是准确的。
使用size()方法输出列表中的元素数量.需要注意的是,这个方法返回的值可能不是真实的,尤其当有线程在添加数据或者移除数据时,这个方法需要遍历整个列表来计算元素数量,而遍历过的数据可能已经改变.仅当没 ...
- Java小知识--length,length(),size()方法详细介绍
Java中length,length(),size()区别 length属性:用于获取数组长度. eg: int ar[] = new int{1,2,3} /** * 数组用length属性取得长度 ...
- 在Java如何保证方法是线程安全的
废话开篇 都说Java程序好些,但是我觉得Java编程这东西,没个十年八年的真不敢说自己精通Java编程,由于工作原因,开始转战Java多线程,以前没怎么接触过,所以想留点脚印在这两条路上. 切入正题 ...
- spring项目中service方法开启线程处理业务的事务问题
1.前段时间在维护项目的时候碰到一个问题,具体业务就是更新已有角色的资源,数据库已更新,但是权限控制不起效果,还是保留原来的权限. 2.排查发现原有的代码在一个service方法里有进行资源权限表的更 ...
- concurrentHashMap求size
在 JDK1.7 中,首先会使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的.如果不符 ...
- [C#]一个简易的、轻量级的方法并行执行线程辅助类
一个简易的.轻量级的方法并行执行线程辅助类 在实际应用中,经常要让多个方法并行执行以节约运行时间,线程就是必不可少的了,而多线程的管理经常又是一件头疼的事情,比如方法并行执行异步的返回问题,方法并 ...
随机推荐
- CI4框架应用三 - app目录
我们再来看一下项目的app目录结构,这个目录就是我们开发的主目录,项目的配置,代码的编写都在这个目录中. Administrator@PC- MINGW64 /c/wamp64/www/ci4/app ...
- 008_用go语言实现简单的冒泡排序
冒泡排序是各个语言中的基本排序算法,本次我们用go语言实现简单的冒泡排序 package main import "fmt" // [13,10,5,7,2] // [10,13, ...
- 003_go语言中的变量
代码演示: package main import "fmt" func main() { var a = "initial" fmt.Println(a) v ...
- C语言学习笔记之一个程序弄清&&、||、i++、++i
由此程序可以看出, ++a是先执行自加,再把值赋值给c,所以c就是a+1=10+1=11 b++是先做赋值运算,也就是先d=b,再b自加,所以d=b(原先)=5 a和b都执行自加,所以a=11,b= ...
- 【模式识别与机器学习】——PCA与Kernel PCA介绍与对比
PCA与Kernel PCA介绍与对比 1. 理论介绍 PCA:是常用的提取数据的手段,其功能为提取主成分(主要信息),摒弃冗余信息(次要信息),从而得到压缩后的数据,实现维度的下降.其设想通过投影矩 ...
- c++萌新到大牛,要看哪些书?
基础语法 <c++primer> 语法进阶 <c++primer plus> 专为c++编著.支持c++14国际标准. 数据结构和算法 <大话数据结构> 编程规范 ...
- nginx进程模型解析
nginx进程模型解析 概念 master会发送请求给worker,用于处理用户的请求,模型图如下 nginx进程分类 master进程(只有1个) 接受信号传递给worker wo ...
- C#LeetCode刷题之#459-重复的子字符串(Repeated Substring Pattern)
问题 该文章的最新版本已迁移至个人博客[比特飞],单击链接 https://www.byteflying.com/archives/3945 访问. 给定一个非空的字符串,判断它是否可以由它的一个子串 ...
- JavaScript 严格模式(strict mode)
概述 除了正常运行模式,ECMAscript 5添加了第二种运行模式:'严格模式'.顾名思义,这种模式使得Javascript在更严格的条件下运行. 目的 1: 消除Javascript语法的一些不合 ...
- node mssql 无法连接sql server
mssql无法连接sql server主要有两种原因: Sql server使用的是Windows身份验证 Sql server并没有打开网络连接功能 1.打开Sql Server身份验证 参考这篇文 ...