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. Scala_运算符

    Scala运算符与操作数的位置关系,可分为  前缀运算符.中缀运算符.后缀运算符 算术运算符 + - * / % ++ -- 关系运算符 == != < > >= <= 逻辑运 ...

  2. JavaWeb总结(十四)

    无脚本JSP表达式语言EL JSP页面中显示结果 jsp:useBean和jsp:getProperty两个元素冗长而笨拙 jsp:getProperty只支持对简单Bean属性的访问 public ...

  3. 19-[模块]-json/pickle、shelve

    1.序列化? 序列化是指把内存里的数据类型转变成字符串,以使其能存储到硬盘或通过网络传输到远程,因为硬盘或网络传输时只能接受bytes (1)把字典保存到文件 data = { 'roles': [ ...

  4. Asp.net中使用缓存(cache)

    做了一个时间优化的项目,目的就是缩短程序过程中的时间花费,最后发现了asp.net和asp.net core 中都有缓存工具来进行缓存,以加快访问速度. 找了官方demo来进行分析: ObjectCa ...

  5. c3p0 ComboPooledDataSource无法识别的问题

    maven项目下,基本就是导错包了的问题. 下面那个才是连接池的.

  6. python爬虫之图片懒加载、selenium和phantomJS

    一.什么是图片懒加载 在网页中,常常需要用到图片,而图片需要消耗较大的流量.正常情况下,浏览器会解析整个HTML代码,然后从上到下依次加载<img src="xxx"> ...

  7. APP性能测试中的几个重要概念

    转载一篇文章,关于app性能测试的几个概念,对于想要接触app测试的朋友或许有些帮助. 我们在使用各种 App 的时候基本会关注到:这款软件挺耗流量的?运行起来设备掉电有点快嘛?切换页面的时候还会有卡 ...

  8. MySQL数据库引擎、事务隔离级别、锁

    MySQL数据库引擎.事务隔离级别.锁 数据库引擎InnoDB和MyISAM有什么区别 大体区别为: MyISAM类型不支持事务处理等高级处理,而InnoDB类型支持.MyISAM类型的表强调的是性能 ...

  9. MYSQL 数据库结构优化

    数据库结构优化 优化数据大小 使表占用尽量少的磁盘空间.减少磁盘I/O次数及读取数据量是提升性能的基础原则.表越小,数据读写处理时则需要更少的内存,同时,小表的索引占用也相对小,索引处理也更加快速. ...

  10. SQL Server存储过程用法介绍

    存储过程其实就是已预编译为可执行过程的一个或多个SQL语句. 通过调用和传递参数即可完成该存储过程的功能. 前面有介绍过存储过程的一些语法,但是没有详细示例,今天我们来一起研究一下存储过程. 提高性能 ...