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源码解读的更多相关文章

  1. Java集合系列[4]----LinkedHashMap源码分析

    这篇文章我们开始分析LinkedHashMap的源码,LinkedHashMap继承了HashMap,也就是说LinkedHashMap是在HashMap的基础上扩展而来的,因此在看LinkedHas ...

  2. java集合系列之HashMap源码

    java集合系列之HashMap源码 HashMap的源码可真不好消化!!! 首先简单介绍一下HashMap集合的特点.HashMap存放键值对,键值对封装在Node(代码如下,比较简单,不再介绍)节 ...

  3. java集合系列之LinkedList源码分析

    java集合系列之LinkedList源码分析 LinkedList数据结构简介 LinkedList底层是通过双端双向链表实现的,其基本数据结构如下,每一个节点类为Node对象,每个Node节点包含 ...

  4. java集合系列之ArrayList源码分析

    java集合系列之ArrayList源码分析(基于jdk1.8) ArrayList简介 ArrayList时List接口的一个非常重要的实现子类,它的底层是通过动态数组实现的,因此它具备查询速度快, ...

  5. java基础系列之ConcurrentHashMap源码分析(基于jdk1.8)

    1.前提 在阅读这篇博客之前,希望你对HashMap已经是有所理解的,否则可以参考这篇博客: jdk1.8源码分析-hashMap:另外你对java的cas操作也是有一定了解的,因为在这个类中大量使用 ...

  6. Java集合系列:-----------03ArrayList源码分析

    上一章,我们学习了Collection的架构.这一章开始,我们对Collection的具体实现类进行讲解:首先,讲解List,而List中ArrayList又最为常用.因此,本章我们讲解ArrayLi ...

  7. Java集合系列[3]----HashMap源码分析

    前面我们已经分析了ArrayList和LinkedList这两个集合,我们知道ArrayList是基于数组实现的,LinkedList是基于链表实现的.它们各自有自己的优劣势,例如ArrayList在 ...

  8. 【源码阅读】Java集合之二 - LinkedList源码深度解读

    Java 源码阅读的第一步是Collection框架源码,这也是面试基础中的基础: 针对Collection的源码阅读写一个系列的文章; 本文是第二篇LinkedList. ---@pdai JDK版 ...

  9. Java集合系列[1]----ArrayList源码分析

    本篇分析ArrayList的源码,在分析之前先跟大家谈一谈数组.数组可能是我们最早接触到的数据结构之一,它是在内存中划分出一块连续的地址空间用来进行元素的存储,由于它直接操作内存,所以数组的性能要比集 ...

随机推荐

  1. 为eclipse添加源代码

    看到这个页面,直接点击 红色区域 attach source 关联源代码,进入到如下页面: 点击第二个选择外部的路径,点击导入文件夹,也就是解压出来的src文件夹(不建议直接导整个jar包,虽然也可以 ...

  2. mysql是否区分大小写

    1.是否区分 库名.表名.列名.别名 的大小写? ------------------------------------------------------------------ [ Linux] ...

  3. codechef FEB19 Manhattan Rectangle

    Manhattan Rectangle 链接 题意: 交互题,询问小于7次,确定一个矩形的位置,每次询问一个点到矩形的曼哈顿距离. 分析: 询问三个顶点,然后解一下方程,求出一个边界,就好办了. 用s ...

  4. 对RedisTemplate接口二次封装成自定义工具接口

    开发过程中,经常使用redis数据库存储. 一般都是依赖注入redisTemplate,然后调用redisTemplate的api进行接口功能实现. 但是,如果对redisTemplate自带的API ...

  5. 关于自动化测试框架,所需代码技能,Java篇——参数配置与读取.

    前言: 说在前边.像我这种假期不出去浪,在这里乖乖写文章研究代码的人,绝壁不是因为爱学习,而是自己不知道去哪玩好,而且也不想玩游戏,看电视剧什么的,结果就无聊到看代码了…… 至于如何解读代码,请把它当 ...

  6. 《SLAM十四讲》个人学习知识点梳理

    0.引言 从六月末到八月初大概一个月时间一直在啃SLAM十四讲[1]这本书,这本书把SLAM中涉及的基本知识点都涵盖了,所以在这里做一个复习,对这本书自己学到的东西做一个梳理. 书本地址:http:/ ...

  7. [Lua] 尾调用消除(tail-call elimination)

    <Lua程序设计(第2版)> 6.3 正确的尾调用(proper tail call) Lua是支持尾调用消除(tail-call elimination)的,如下面对函数g的调用就是尾调 ...

  8. spring cloud 入门系列八:使用spring cloud sleuth整合zipkin进行服务链路追踪

    好久没有写博客了,主要是最近有些忙,今天忙里偷闲来一篇. =======我是华丽的分割线========== 微服务架构是一种分布式架构,微服务系统按照业务划分服务单元,一个微服务往往会有很多个服务单 ...

  9. 解决网速慢时maven仓库访问慢

    构建maven项目时会下载很多依赖,会从官网地址下载是个外国网站,访问速度会很慢,但可以通过修改maven的settings.xml文件换成国内的镜像地址就可以加快访问速度: 一.找到settings ...

  10. web小结

    一.ajax 1.用于前端向服务器异步获取数据 json数组:可以直接通过数组下标获取到值 json对象:可以用“data.xx”获取到值 2.注意事项 同时请求两个ajax时,容易出现异常,第一个a ...