深入理解JAVA集合系列二:ConcurrentHashMap源码解读
HashMap和Hashtable的区别
在正式开始这篇文章的主题之前,我们先来比较下HashMap和Hashtable之间的差异点:
1、Hashtable是线程安全的,它对外提供的所有方法都是都使用了synchronized,是同步的,而HashMap是非线程安全的。
2、Hashtable不允许value为空,否则会抛出空指针异常; 而HashMap中key、value都可以为空。
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
在Map家族中,同样都是线程安全的,下面来比较下Hashtable和ConcurrentHashMap的差异
Hashtable和ConcurrentHashMap的区别
1、Hashtable实现线程安全的方式是锁住整张Hash表,即每次锁住整张表让线程独占。以下是Hashtable的put和get方法的实现:
public synchronized V get(Object key) {
Entry tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return e.value;
}
}
return null;
}
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
} // Makes sure the key is not already in the hashtable.
Entry tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
V old = e.value;
e.value = value;
return old;
}
}
2、ConcurrentHashMap采用的是锁分离技术,其内部是由段(Segment)来组成,每个段就是一个小的Hashtable,每个段由一把锁来控制,所以允许多个修改操作并发进行。
3、从本质上来说,两者之间的区别在于锁的粒度不一样,ConcurrentHashMap的粒度更小,更灵活,这样在多线程情况下性能更高。
下面我们从ConcurrentHashMap的数据结构开始这篇文章的主题:
ConcurrentHashMap的数据结构
我们可以做这样一个比喻,把ConcurrentHashMap看成一本书,其中的Segment看做书的卷,table数组中的元素当成章节的标题。
1、 其中segments是整张Hash表,然后里面有16个段(Segment,这里的16是默认值),每个段是一个table数组,数组中每个元素是一个桶,桶中存放的是HashEntry。
2、ConcurrentHashMap的这个数据结构,针对并发做了些调整,它把区间按照并发级别(concurrentLevel),分成了若干个segment,默认的并发级别是16;对于每个segment的容量,默认也是16。当然并发级别和每个segment的初始容量都是可以通过构造函数设定的。
3、继续看每个segment是怎么定义的:
static final class Segment<K,V> extends ReentrantLock implements Serializable
Segment继承了ReentrantLock,表明每个Segment都可以当成一个锁来使用(如果对ReentrantLock不理解的话,就把它认为是Synchronized)。这样对每个segment中的数据需要进行同步操作的话,都是使用每个segment容器对象自身的锁来实现。这种做法,就称之为“分离锁”。
4、HashEntry的数据结构定义如下:
static final class HashEntry<K,V> {
final K key;
final int hash;
volatile V value;
final HashEntry<K,V> next;
我们在介绍HashMap时,其中的Entry并没有使用final、volatile来修饰元素。而ConcurrentHashMap中除了value不是用final修饰的。这就意味着不能从hash链的中间或者尾部添加或删除节点,因为如果这样做,就必须要修改next的引用值。对于put操作,可以一律添加到Hash链的头部,即新增的元素都是放在Header位置。对于remove操作,可能需要从中间删除一个节点,这就需要将被删除的节点的前面所有节点复制一遍,最后一个节点指向要删除节点的下一个节点。
另外为了确保读操作能够看到最新的的值,且不采用加锁的方式,所以将value设置为volatile。
ConcurrentHashMap中数据的定位
先来看下元素定位的代码:
public V put(K key, V value) {
if (value == null)
throw new NullPointerException();
int hash = hash(key.hashCode());
return segmentFor(hash).put(key, hash, value, false);
}
final Segment<K,V> segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask];
}
1、首先对key的hashcode码做hash运算,主要是为了减少hash冲突。
2、现在来看segmentFor()方法,这个方法主要是返回segments数组中的元素,即现在已经可以定位到具体的某一个段。
3、在put方法中第8行的index,其实就是元素在table数组中的下标了。然后通过单向链表中的next去遍历就可以找到具体的Entry了。
V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock();
try {
int c = count;
if (c++ > threshold) // ensure capacity
rehash();
HashEntry<K,V>[] tab = table;
int index = hash & (tab.length - 1);
HashEntry<K,V> first = tab[index];
4、上面的3个步骤大致可以了解数据查找的过程,总结来说就是一次hash运算,2次位运算就定位到数据所在的数据块中。接着链式查找的效率也是比较高的。到现在我们大致可以理解缓存为什么会这么快了。
put方法
先来看put方法的源代码:
V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock();
try {
int c = count;
if (c++ > threshold) // ensure capacity
rehash();
HashEntry<K,V>[] tab = table;
int index = hash & (tab.length - 1);
HashEntry<K,V> first = tab[index];
HashEntry<K,V> e = first;
while (e != null && (e.hash != hash || !key.equals(e.key)))
e = e.next; V oldValue;
if (e != null) {
oldValue = e.value;
if (!onlyIfAbsent)
e.value = value;
}
else {
oldValue = null;
++modCount;
tab[index] = new HashEntry<K,V>(key, hash, first, value);
count = c; // write-volatile
}
return oldValue;
} finally {
unlock();
}
}
1、从第2行可以看到,该方法是在持有段锁的情况下执行的。这当然是为了并发的安全,毕竟修改数据是不能进行并发操作的。
2、在第4-6行,count表示该段的容量,先进行加1操作,然后判断是否需要进行扩容。扩容也是在原来容量的基础上扩大一倍。
3、我们直接从第10行开始解读:e取的是该位置上链表的头元素。
4、第11行是在链表中精确定位Entry,如果没有找到,则通过next继续遍历该单向链表。
5、第15-19行,是替换的操作,即该位置上原e不为空,那么把原来的value作为put方法的返回值,并且将value值替换成最新的(onlyIfAbsent==false)
6、第20-25行,是新增元素的操作,即通过key定位到的位置上并没有元素,则创建一个新的Entry放到该位置上。并将count值修改为最新。
get方法
先看下get方法的源代码:
V get(Object key, int hash) {
if (count != 0) { // read-volatile
HashEntry<K,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;
}
1、通过对比可以知道,get操作是不需要锁的。
2、第一步是访问count变量,这是一个volatile变量,由于所有修改操作在进行结构修改时都会在最后一步写count变量,通过这种机制保证get操作能够得到最新的结构更新。
3、后面就是遍历链表,根据hash值、key值来查询Entry,如果找到则返回。否则在有锁的情况下再读取一次。等会,这个是什么情况?
4、我们来认真分析下为什么查询value值为空的时候还要在有锁的情况下再读取一次。这有些让人费解,理论上节点的值是不可能为空的,因为在put操作的时候就进行了判断,如果为空会抛出空指针异常的。
V get(Object key, int hash) {
if (count != 0) { //1
HashEntry<K,V> e = getFirst(hash);
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
V v = e.value;
if (v != null)//2
return v;
return readValueUnderLock(e); // recheck
}
e = e.next;
}
}
return null;
}
在上面代码有两行代码中,我用红色的字体做了标记。
在get代码1和2之间,另一个线程新增了一个Entry
1、让人抓狂的是:恰好这个线程新增的Entry是我们要get的。先看下put方法新增一个entry的过程:
2、新增entry肯定是放在头结点位置,这个前面已经说明分析过了。
3、newEntry对象是通过new HashEntry<K,V>(key, hash, first, value)方式创建的。如果一个线程刚好new这个对象时,当前线程来get它。由于没有同步,就会出现当前线程得到的newEntry对象是一个没有完全构造好的对象引用。这个时候的value可能为空。所以才有了前面加锁重新get一次的动作。
4、另外在讨论DCL的问题时跟这个类似,在没有锁同步的情况下,new一个对象对于多线程看到这个对象的状态是没有保障的,这里同样有可能一个线程new这个对象的时候还没有执行完构造函数就被另一个线程得到这个对象的引用。
在get代码1和2之间,另一个线程修改了一个entry的value值
value是用volatile修饰的,可以保证读取的时候得到的是修改后的值。
在get代码1和2之间,另一个线程删除了一个Entry
假设我们的链表元素是:e1 -> e2 -> e3 -> e4。另一个线程删除的entry是e3。由于hashEntry中的next不可改变,我们无法直接把e2的next直接指向e4,而是需要将删除节点之前的节点复制一份,形成新的链表。
大致实现如图所示:
如果我们get的也恰好是e3,可能我们顺着链表刚找到e1,这是另一个线程就删除了e3,而当前线程还会继续沿着旧的链表去查找e3,这里没有办法实时保证了。
在代码1的地方判断了count变量,它保障了在1位置能看到其他线程修改后的。在1到2之间再次发生了其他线程删除了entry节点,就没有办法保证看到最新的。
不过这里也没有什么关系,即使我们返回e3的时候,他被其他线程删除了,暴露出去的e3也不会对我们新的链表造成影响。
这其实是一种乐观设计,因为其他线程的“删”、“改”对我们的数据不会造成影响,所以只有“新增”操作做了安全检查,就是位置2的非null检查。
remove方法
先直接上源代码
V remove(Object key, int hash, Object value) {
lock();
try {
int c = count - 1;
HashEntry<K,V>[] tab = table;
int index = hash & (tab.length - 1);
HashEntry<K,V> first = tab[index];
HashEntry<K,V> e = first;
while (e != null && (e.hash != hash || !key.equals(e.key)))
e = e.next; V oldValue = null;
if (e != null) {
V v = e.value;
if (value == null || value.equals(v)) {
oldValue = v;
// All entries following removed node can stay
// in list, but all preceding ones need to be
// cloned.
++modCount;
HashEntry<K,V> newFirst = e.next;
for (HashEntry<K,V> p = first; p != e; p = p.next)
newFirst = new HashEntry<K,V>(p.key, p.hash,
newFirst, p.value);
tab[index] = newFirst;
count = c; // write-volatile
}
}
return oldValue;
} finally {
unlock();
}
}
1、整个定位的过程和put操作类似,先定位到段,然后委托给段的remove操作。当多个删除操作并发进行时,只要它们所在的段不相同,就可以同时进行。
2、前面的过程比较类似,我们直接从第21行开始分析。首先取该位置上的头结点。然后进行for循环操作,为的就是将待删除元素之前的Entry重新复制一次。这个是由entry中next不变性来控制的。下面我们来看下示意图:
删除元素3之后:
3、remove操作有两个地方需要注意下,一个是删除节点存在时,删除的最后一步操作要将count的值减1。另外一个是remove执行开始的时候就将table赋值给了一个局部变量tab,这是因为table是volatile变量,读写volatile变量的开销很大。编译器不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量则没有多大影响,编译器会做响应的优化。
深入理解JAVA集合系列二:ConcurrentHashMap源码解读的更多相关文章
- Java集合系列[4]----LinkedHashMap源码分析
这篇文章我们开始分析LinkedHashMap的源码,LinkedHashMap继承了HashMap,也就是说LinkedHashMap是在HashMap的基础上扩展而来的,因此在看LinkedHas ...
- java集合系列之HashMap源码
java集合系列之HashMap源码 HashMap的源码可真不好消化!!! 首先简单介绍一下HashMap集合的特点.HashMap存放键值对,键值对封装在Node(代码如下,比较简单,不再介绍)节 ...
- java集合系列之LinkedList源码分析
java集合系列之LinkedList源码分析 LinkedList数据结构简介 LinkedList底层是通过双端双向链表实现的,其基本数据结构如下,每一个节点类为Node对象,每个Node节点包含 ...
- java集合系列之ArrayList源码分析
java集合系列之ArrayList源码分析(基于jdk1.8) ArrayList简介 ArrayList时List接口的一个非常重要的实现子类,它的底层是通过动态数组实现的,因此它具备查询速度快, ...
- java基础系列之ConcurrentHashMap源码分析(基于jdk1.8)
1.前提 在阅读这篇博客之前,希望你对HashMap已经是有所理解的,否则可以参考这篇博客: jdk1.8源码分析-hashMap:另外你对java的cas操作也是有一定了解的,因为在这个类中大量使用 ...
- Java集合系列:-----------03ArrayList源码分析
上一章,我们学习了Collection的架构.这一章开始,我们对Collection的具体实现类进行讲解:首先,讲解List,而List中ArrayList又最为常用.因此,本章我们讲解ArrayLi ...
- Java集合系列[3]----HashMap源码分析
前面我们已经分析了ArrayList和LinkedList这两个集合,我们知道ArrayList是基于数组实现的,LinkedList是基于链表实现的.它们各自有自己的优劣势,例如ArrayList在 ...
- 【源码阅读】Java集合之二 - LinkedList源码深度解读
Java 源码阅读的第一步是Collection框架源码,这也是面试基础中的基础: 针对Collection的源码阅读写一个系列的文章; 本文是第二篇LinkedList. ---@pdai JDK版 ...
- Java集合系列[1]----ArrayList源码分析
本篇分析ArrayList的源码,在分析之前先跟大家谈一谈数组.数组可能是我们最早接触到的数据结构之一,它是在内存中划分出一块连续的地址空间用来进行元素的存储,由于它直接操作内存,所以数组的性能要比集 ...
随机推荐
- zlib库的编译及使用
* 打开网址http://zlib.net/ 下载zlib源码, * 解压压缩包,进入目录:C:\Users\Administrator\Desktop\zlib-1.2.11\zlib-1.2.11 ...
- [收藏转]由于CredSSP加密Oracle修正 导致远程桌面报错处理
原文:https://blog.csdn.net/lanwilliam/article/details/80346792 由于win103月份的一个更新,导致mstsc突然无法连接服务器了,报错如标题 ...
- 二维码PDF417简介及其解码实现(zxing-cpp)
二维码PDF417是一种堆叠式二维条码.PDF417条码是由美国SYMBOL公司发明的,PDF(Portable Data File)意思是"便携数据文件".组成条码的每一个条码字 ...
- zabbix监控DELL服务器硬件状态
zabbix监控DELL服务器硬件状态 登录dell服务的管理页面 默认用户名:root 密码:calvin 服务器开放snmp信息,开启完应用 Zabbix服务器导入dell监控硬件模板 验证 sn ...
- hdu1754 I Hate It(线段树单点更新,区间查询)
传送门 有更新单个学生成绩和查询某个区间内学生成绩最大值两种操作 线段树代码 #include<bits/stdc++.h> using namespace std; +; using n ...
- 通俗理解BFS和DFS,附基本模板
1.BFS(宽度优先搜索):使用队列来保存未被检测的节点,按照宽度优先的顺序被访问和进出队列 打个比方:(1)类似于树的按层次遍历 (2)你的眼镜掉在了地上,你趴在地上,你总是先摸离你最近的地方,如果 ...
- Babylon.js官方性能优化文档中文翻译
在这里列出Babylon.js官方性能优化文档的中英文对照,并在CardSimulate项目里对其中的一些优化方法进行实践. How To 如何 Optimize your scene 优化你的场景 ...
- 信息提示 - bootStrap4常用CSS笔记
.alert 基类 .alert-{success.info.warning.danger.primary.secondary.light.dark} 各种类型的配色样式 .fade..show 设置 ...
- 如何在unix系统中用别的用户运行一个程序?
1.问题的缘由 实际开发系统的时候,经常需要用别的用户运行一个程序.比如,有些系统为保证系统安全,不允许使用root来运行.这里,我们总结了unix系统下如何解决这个问题的一些方法.同时,我们还讨论如 ...
- Ubuntu下LimeSDR Mini使用说明
本文内容.开发板及配件仅限用于学校或科研院所开展科研实验! 淘宝店铺名称:开源SDR实验室 LimeSDR链接:https://item.taobao.com/item.htm?spm=a230r.1 ...