引入ConcurrentHashMap

模拟使用hashmap在多线程场景下发生线程不安全现象

import java.util.HashMap;
import java.util.Map;
import java.util.UUID; /**
* 模拟hashmap在多线程场景下的出现的不安全现象之一
* hashmap还有put丢失,jdk1.7扩容成环的问题
*/
public class Demo2 {
public static void main(String[] args) {
Map<String, String> hashmap = new HashMap<>();
//开30个线程去往hashmap中添加元素
for (int i = 1; i <= 30; i++) {
new Thread(() -> {
hashmap.put(Thread.currentThread().getName(), UUID.randomUUID().toString().substring(0, 5));
System.out.println(hashmap);
}, String.valueOf(i)).start();
}
}
}

运行结果如下,由于fail-fast机制的存在,出现了并发修改失败的错误

如何解决该问题呢?

方式一:使用hashtable

Map<String, String> hashmap = new Hashtable<>();

方式二:使用Collections.synchronizedMap

Map<String, String> hashmap = Collections.synchronizedMap(new HashMap<>());

方式三:使用并发集合容器ConcurrentHashMap

Map<String, String> hashmap = new ConcurrentHashMap<>();

浅析Java7中ConcurrentHashMap源码

数据结构

ConcurrentHashMap JDK1.7的数据结构是由Segment数组+HashEntry数组组成,其解决hash冲突的方式与jdk1.7中的hashmap方式差不多,解决线程安全是采用一种分段锁的思想,多个线程操作多个Segment是相互独立的,这样一来相比于传统的hashtable就大大提高了并发度。

我们在简单画个图来理解分段锁的思想:数组套数组,多个线程独立访问Segment,扩容嵌套数组

Segment与HashEntry

我们在来看下其Segment数组以及HashEntry数组在源码中是如何定义的。

先来看看Segment的定义:由以下我们可以看到每个Segment都是继承的ReentLock,且其内部嵌套的是HashEntry数组,Segment的数量相当于锁的数量,这些锁彼此之间福独立,即“分段锁”

//以内部类的形式定义,并且继承的ReentratLock
static final class Segment<K,V> extends ReentrantLock implements Serializable { private static final long serialVersionUID = 2249069246763182397L; static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1; //由此处也可以看出Segment内部嵌套的是HashEntry数组
transient volatile HashEntry<K,V>[] table; //Segment的个数
transient int count;
//modCount代表被修改的次数,每次Remove、put都相当于一次修改
transient int modCount;
//阈值
transient int threshold;
//负载因子
final float loadFactor; Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
} //以下是Segment内部的一些操作
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
.......
} private void rehash(HashEntry<K,V> node) {
......
} final V remove(Object key, int hash, Object value) {
....
} ......

在来看看HashEntry的定义

//以内部类的形式定义
static final class HashEntry<K,V> {
final int hash;
final K key;
//采用volatile修饰,保证其可见性和有序性
volatile V value;
volatile HashEntry<K,V> next; HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
} //在HashEntry数组后面链上HashEntry对象
final void setNext(HashEntry<K,V> n) {
UNSAFE.putOrderedObject(this, nextOffset, n);
} // Unsafe类是Java提供的操作内存的类,
static final sun.misc.Unsafe UNSAFE;
static final long nextOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class k = HashEntry.class;
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}

关于Unsafe类中的putOrderedObject方法,摘自Java魔法类:Unsafe应用解析

//存储变量的引用到对象的指定的偏移量处,使用volatile的存储语义
public native void putObjectVolatile(Object o, long offset, Object x);
//有序、延迟版本的putObjectVolatile方法,不保证值的改变被其他线程立即看到。只有在field被volatile修饰符修饰时有效,而我们的HashEntry就是被volatile修饰的
public native void putOrderedObject(Object o, long offset, Object x);

关于Unsafe类,是Java提供的操作内存的类,其内容博大精深。可参考美团技术团队写的:Java魔法类:Unsafe应用解析

构造函数

我们来看下ConcurrentHashMap的构造函数在源码中是如何定义的

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
private static final long serialVersionUID = 7249069246763182397L;
//默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认并发等级
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//最小Segment数量
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//最大Segment数量
static final int MAX_SEGMENTS = 1 << 16; //默认构造函数
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
} public ConcurrentHashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
} public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
} /**
* initialCapacity:初始参数
* loadFactor:加载因子
* concurrencyLevel:并发级别即Segment的数量
*/
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
//非法数校验
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// 用来记录向左按位移动的次数
int sshift = 0;
//用来记录Segment的数量
int ssize = 1;
//该段while循环保证Segment的数量是2的幂
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
//这里SegmentMask先提前减一了,
//在hashmap中计算数组下标索引是(table.length-1)&hash
//这里也可以推断出Segment数量一旦确定不能在变,扩容是扩Segment数组内的HashEntry数组
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//每个Segment数组内要放置多少个HashEntry数组
int c = initialCapacity / ssize;
//确保无余数
if (c * ssize < initialCapacity)
++c;
//确保每个Segment内部的HashEntry数组的大小一定为2的幂,当三个参数皆为默认值时,其Segment内部的table大小是2,
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
//初始化Segment数组,并填充Segment[0],阈值是(int)(cap * loadFactor),当参数皆为默认时,该值为1,当put第一个元素时不会扩容,在put就会触发扩容
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
} .....

由构造函数可以看出来

  • Segment数量默认是16,初始容量默认是16,负载因子默认是0.75,最小Segment是2
  • Segment的数量即为并发级别,且内部保证是2的幂,Segment内部的table大小也保证为2的幂
  • Segment数量一旦确定不会在更改,后续添加元素不会增加Segment的数量,而是增加Segment中链表数组的容量,这样的好处是扩容也不用针对整个ConcurrentHashMap来进行了,而是针对Segment里面的数组
  • 初始化了Segment[0],其他Segment还是null

put函数

来看看put函数

    public V put(K key, V value) {
Segment<K,V> s;
//value不能为空
if (value == null)
throw new NullPointerException();
//通过hash函数获取关于key的hash值
int hash = hash(key);
//计算要插入的Segment数组的下标,位运算提高计算速度,由于此处使用位运算,所以得保证是2的幂可以减少hash冲突,具体原因不详述
int j = (hash >>> segmentShift) & segmentMask;
//如果要插入的Segment为初始化,调用ensureSeggment函数进行初始化(初始化concurrentHashMap时只初始化了第一个Segment[0])
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
//调用Segment的put函数
return s.put(key, hash, value, false);
}

到现在我们还没有发现加锁,在接着看Segment中的put函数,可见是在该函数中加的锁,这又一次验证了是分段锁,计算完了Segment位置后,在针对某一个Segment内部进行插入的时候上锁。

        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//去获取独占锁,获取锁失败进入scanAndLockForPut函数
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
//到此处肯定已经获取到锁了
try {
//Segment内部的HashEntry数组
HashEntry<K,V>[] tab = table;
//计算元素插入的位置
int index = (tab.length - 1) & hash;
//定位到第index个HashEntry
HashEntry<K,V> first = entryAt(tab, index);
//该段for循环使用头插法将元素进行插入
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
//如果在链表中找到相同的key,则新值替换旧值,并退出函数
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
//onlyIfAbsent默认为false,!onlyIfAbsent表示替换旧值
if (!onlyIfAbsent) {
e.value = value;
//修改次数+1
++modCount;
}
break;
}
//如果没有key值相同的则遍历到链表尾部
e = e.next;
}
else {//已经遍历到链表尾部
if (node != null)//在scanAndLockForPut函数中已经建立好node
node.setNext(first); //把node插入链表的头部
else
//新建node,插入到链表头部
node = new HashEntry<K,V>(hash, key, value, first);
//该count代表元素的个数
int c = count + 1;
//判断是否超过阈值,超过调用rehash扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
//把node赋值给tab[index]
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//释放锁
unlock();
}
return oldValue;
}

Segment内部的put函数涉及到一个scanAndLockForPut函数,多个线程去进行put操作,去竞争锁,那那些没获取到锁的线程它是如何处理的呢,我们来看一下scanAndLockForPut函数

        private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
//自旋过程中遍历链表,若发现没有重复的key值,则提前先新建一个节点为后续的插入节约时间
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
e = e.next;
}
//自旋次数达到若干次后就调用lock()进行阻塞,阻塞后的线程由AQS进行管理入队列
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}

该函数简化简化下来的思想如下:

//线程竞争锁失败后进入该函数
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//tryLock函数与Lock函数的区别就是tryLock函数获取锁失败会返回false,而不是阻塞
while(!tryLock()){//自旋操作
......
System.out.println("干点自己的事情...")
}
}

所以scanAndLockForPut函数的策略就是拿不到锁的线程不让它直接阻塞,而是让其自旋,自旋达到一定次数之后在调用lock()进行阻塞,另外在自旋的过程中遍历了后面的HashEntry链表,如果没有发现重复的节点就提前先建立一个,为线程之后拿到锁插入节省时间。

ensureSegment函数

在ConcurrentHashMap初始化时,只初始化了Segment[0],其他的Segment数组都是null,多个线程可能同时调用ensureSegment去初始化Segment[j],所以在该函数内部应该避免重复初始化的问题,保证其线程安全。

    private Segment<K,V> ensureSegment(int k) {
//赋值ss=this.segments
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
//第一次判断segment[j]是否被初始化
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
//使用segment[0]为原型去初始化新的segment
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
//第二次判断segment[j]是否被初始化
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
//while循环+CAS操作,当前线程成功设值或其他线程成功设值后,退出
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {//第三次判断segment[j]是否被初始化
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}

可见UNSAFE.getObjectVolatile(ss, u)) == null出现了三次,多次去判断segment[j]是否被初始化了,即使如此也不能完全避免重复初始化,最后还采用CAS操作保证其只被初始化

rehash函数

我们在来看看具体是如何扩容的,在Segment内部的put函数我们看到,超过阈值后会进行扩容操作

        private void rehash(HashEntry<K,V> node) {
//获取旧数组和其容量
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
//扩容为旧容量的2倍、设置新的阈值
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
//创建新的数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
//sizeMask提前减1了
int sizeMask = newCapacity - 1;
//遍历原数组
for (int i = 0; i < oldCapacity ; i++) {
//获取旧数组中的元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
//计算插入的索引
int idx = e.hash & sizeMask;
if (next == null) // 链表中只有单个元素时,直接放入新数组中去
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
//寻找链表中最后一个hash值不等于lastIdx的元素
for (HashEntry<K,V> last = next;last != null;last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
//一个优化,把在lastRun之后的链表元素直接链到新hash表中的lastIdx位置
newTable[lastIdx] = lastRun;
//在lastrun之前的所有链表元素,需要在新的位置逐个拷贝
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 把新的节点加入Hash表
int nodeIndex = node.hash & sizeMask;
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}

可见扩容函数是扩容为原来数组的两倍大小,且扩容进行了一次优化,并没有对元素依次拷贝,而是先通过for循环找到lastRun位置。lastRun到链表末尾的所有元素,其hash值没有改变,所以不需要一次重新拷贝,只需要把这部分链表链到新hash表中所对应的位置即可。lastRun之前的节点则需要依次拷贝。

get函数

整个get函数相对来是实现思路不复杂,先找到在哪个Segment数组中,再去寻找具体在哪个table上,整个过程没加锁,因为Sigment中的HashEntry和HashEntry中的value都是由volatile修饰的,volatile保证了内存的可见性。

    public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
//先计算在哪个segment数组中
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
//计算在segment数组中的哪个HashEntry上
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
//key值和当前节点的key指向同一片地址,或者当前节点的hash等于key的hash并且equals比价后相同则说明是目标节点
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}

小结

ConcurrentHashMap内容颇多且有难度,以上为简单阅读,如有不对的恳请指正。

  • 在JDK1.7中,ConcurrentHashMap是基于分段锁的思想来提高并发能力,数据结构采用Segment数组+HashEntry数组+链表来实现,每个Segment都相当于一把锁(其继承自ReentrantLock),多个线程操作多个Segment是相互独立的,Segment有多少个即为并发级别有多大。
  • Segment在ConcurrentHashMap初始化后就不会改变了,其扩容是针对每个Segment内部的HashEntry数组扩容,扩容为原来的两倍大小且进行了优化。
  • 多个线程put操作时候,竞争锁失败的线程会进行自旋,自旋达到一定次数在直接调用lock进行阻塞。
  • 初始化ConcurrentHashMap的时候只会填充第一个Segment[0],需要在多线程情况下避免重复初始化Segment[j]
  • 读操作未上锁,Segment中的HashEntry数组和hashEntry对象中的value都是用volatile修饰的

浅析Java7中的ConcurrentHashMap的更多相关文章

  1. Java7 和 Java8 中的 ConcurrentHashMap 原理解析

    Java7 中 ConcurrentHashMap ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些. 整个 ConcurrentHash ...

  2. Java7/8 HashMap ConcurrentHashMap

    网上关于 HashMap 和 ConcurrentHashMap 的文章确实不少,不过缺斤少两的文章比较多,所以才想自己也写一篇,把细节说清楚说透,尤其像 Java8 中的 ConcurrentHas ...

  3. 浅析Java中的final关键字

    浅析Java中的final关键字 谈到final关键字,想必很多人都不陌生,在使用匿名内部类的时候可能会经常用到final关键字.另外,Java中的String类就是一个final类,那么今天我们就来 ...

  4. 浅析mongodb中group分组

    这篇文章主要介绍了浅析mongodb中group分组的实现方法及示例,非常的简单实用,有需要的小伙伴可以参考下. group做的聚合有些复杂.先选定分组所依据的键,此后MongoDB就会将集合依据选定 ...

  5. 浅析py-faster-rcnn中不同版本caffe的安装及其对应不同版本cudnn的解决方案

    浅析py-faster-rcnn中不同版本caffe的安装及其对应不同版本cudnn的解决方案 本文是截止目前为止最强攻略,按照本文方法基本可以无压力应对caffe和Ross B. Girshick的 ...

  6. 浅析JS中的模块规范(CommonJS,AMD,CMD)////////////////////////zzzzzz

    浅析JS中的模块规范(CommonJS,AMD,CMD)   如果你听过js模块化这个东西,那么你就应该听过或CommonJS或AMD甚至是CMD这些规范咯,我也听过,但之前也真的是听听而已.     ...

  7. 浅析Java中的访问权限控制

    浅析Java中的访问权限控制 今天我们来一起了解一下Java语言中的访问权限控制.在讨论访问权限控制之前,先来讨论一下为何需要访问权限控制.考虑两个场景: 场景1:工程师A编写了一个类ClassA,但 ...

  8. [转载]浅析Java中的final关键字

    浅析Java中的final关键字 谈到final关键字,想必很多人都不陌生,在使用匿名内部类的时候可能会经常用到final关键字.另外,Java中的String类就是一个final类,那么今天我们就来 ...

  9. 浅析 JavaScript 中的 函数 currying 柯里化

    原文:浅析 JavaScript 中的 函数 currying 柯里化 何为Curry化/柯里化? curry化来源与数学家 Haskell Curry的名字 (编程语言 Haskell也是以他的名字 ...

随机推荐

  1. jupyter notebook 中同时添加Python2和3,在conda下配置R语言运行的环境

    1.第一步,安装Python2的环境 首先,在安装anaconda的时候先选择一个Python安装,我先安装的是Python3 然后,在anaconda Prompt下创建Python2环境 现在,还 ...

  2. WordPress 迁移站点更换域名为新域名

    使用 wp-cli 工具搜索替换域名的方式更换 WordPress 域名 wp-cli 是一个命令行工具,可以让我们通过命令行安装.更新 WordPress,对 WordPress 执行一些批量操作, ...

  3. 成为视频分析专家:自动生成视频集锦(Python实现)

    介绍 我是个超级板球迷.从我记事起,我就迷上了这项运动,至今它仍在我的日常生活中起着重要的作用.我相信很多读到这篇文章的人都会点头! 但是自从我开始工作以来,要跟上所有的比赛就成了一件棘手的事.我不能 ...

  4. Math对象常用方法介绍

     <script>         /* 001-Math.abs()  绝对值 */         console.log(Math.abs(123), Math.abs(-998), ...

  5. coding++:JS数组去重的几种常见方法

    一.简单的去重方法 // 最简单数组去重法 /* * 新建一新数组,遍历传入数组,值不在新数组就push进该新数组中 * IE8以下不支持数组的indexOf方法 * */ function uniq ...

  6. nginx IF 指令

    变量名可以使用"="或"!="运算符 ~ 符号表示区分大小写字母的匹配 "~*"符号表示不区分大小写字母的匹配 "!"和 ...

  7. C语言中结构体内存存储方式

    C语言中结构体内存存储方式 结构体的默认存储方式采用以最大字节元素字节数对其方式进行对齐,例如一个结构体中定义有char.int类型元素,则结构体存储空间按照int类型占用字节,如果还有double类 ...

  8. 创建Windows10无人值守(自动应答文件)教程

    一.准备工作 系统要求: Windows10 1809版本 工具下载: 镜像:Windows10,任何一个版本都可以,我使用的是1909版本 ed2k://|file|cn_windows_10_bu ...

  9. Java 添加、读取和删除 Excel 批注

    批注是一种富文本注释,常用于为指定的Excel单元格添加提示或附加信息. Free Spire.XLS for Java 为开发人员免费提供了在Java应用程序中对Excel文件添加和操作批注的功能. ...

  10. Redis 笔记(四)—— SET 常用命令

    常用命令 命令 用例和描述 SADD SADD key item [item ...] —— 将一个或多个元素添加到集合中,返回添加的数量 SREM SREM key item [item ...] ...