本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接http://item.jd.com/12299018.html


本节介绍一个常用的并发容器 - ConcurrentHashMap,它是HashMap的并发版本,与HashMap相比,它有如下特点:

  • 并发安全
  • 直接支持一些原子复合操作
  • 支持高并发、读操作完全并行、写操作支持一定程度的并行
  • 与同步容器Collections.synchronizedMap相比,迭代不用加锁,不会抛出ConcurrentModificationException
  • 弱一致性

我们分别来看下。

并发安全

我们知道,HashMap不是并发安全的,在并发更新的情况下,HashMap的链表结构可能形成环,出现死循环,占满CPU,我们看个例子:

public static void unsafeConcurrentUpdate() {
final Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < 100; i++) {
Thread t = new Thread() {
Random rnd = new Random(); @Override
public void run() {
for (int i = 0; i < 100; i++) {
map.put(rnd.nextInt(), 1);
}
}
};
t.start();
}
}

运行上面的代码,在我的机器上,每次都会出现死循环,占满CPU。

为什么会出现死循环呢?死循环出现在多个线程同时扩容哈希表的时候,不是同时更新一个链表的时候,那种情况可能会出现更新丢失,但不会死循环,具体过程比较复杂,我们就不解释了,感兴趣的读者可以参考这篇文章,http://coolshell.cn/articles/9606.html。

使用Collections.synchronizedMap方法可以生成一个同步容器,避免该问题,替换第一行代码即可:

final Map<Integer, Integer> map = Collections.synchronizedMap(new HashMap<Integer, Integer>());

在Java中,HashMap还有一个同步版本Hashtable,它与使用synchronizedMap生成的Map基本是一样的,也是在每个方法调用上加了synchronized,我们就不赘述了。

同步容器有几个问题:

  • 每个方法都需要同步,支持的并发度比较低
  • 对于迭代和复合操作,需要调用方加锁,使用比较麻烦,且容易忘记

ConcurrentHashMap没有这些问题,它同样实现了Map接口,也是基于哈希表实现的,上面的代码替换第一行即可:

final Map<Integer, Integer> map = new ConcurrentHashMap<>();

原子复合操作

除了Map接口,ConcurrentHashMap还实现了一个接口ConcurrentMap,接口定义了一些条件更新操作,具体定义为:

public interface ConcurrentMap<K, V> extends Map<K, V> {
//条件更新,如果Map中没有key,设置key为value,返回原来key对应的值,如果没有,返回null
V putIfAbsent(K key, V value);
//条件删除,如果Map中有key,且对应的值为value,则删除,如果删除了,返回true,否则false
boolean remove(Object key, Object value);
//条件替换,如果Map中有key,且对应的值为oldValue,则替换为newValue,如果替换了,返回ture,否则false
boolean replace(K key, V oldValue, V newValue);
//条件替换,如果Map中有key,则替换值为value,返回原来key对应的值,如果原来没有,返回null
V replace(K key, V value);
}

如果使用同步容器,调用方必须加锁,而ConcurrentMap将它们实现为了原子操作。实际上,使用ConcurrentMap,调用方也没有办法进行加锁,它没有暴露锁接口,也不使用synchronized。

高并发

ConcurrentHashMap是为高并发设计的,它是怎么做的呢?具体实现比较复杂,我们简要介绍其思路,主要有两点:

  • 分段锁
  • 读不需要锁

同步容器使用synchronized,所有方法,竞争同一个锁,而ConcurrentHashMap采用分段锁技术,将数据分为多个段,而每个段有一个独立的锁,每一个段相当于一个独立的哈希表,分段的依据也是哈希值,无论是保存键值对还是根据键查找,都先根据键的哈希值映射到段,再在段对应的哈希表上进行操作。

采用分段锁,可以大大提高并发度,多个段之间可以并行读写。默认情况下,段是16个,不过,这个数字可以通过构造方法进行设置,如下所示:

public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel)

concurrencyLevel表示估计的并行更新的线程个数,ConcurrentHashMap会将该数转换为2的整数次幂,比如14转换为16,25转换为32。

在对每个段的数据进行读写时,ConcurrentHashMap也不是简单的使用锁进行同步,内部使用了CAS、对一些写采用原子方式,实现比较复杂,我们就不介绍了,实现的效果是,对于写操作,需要获取锁,不能并行,但是读操作可以,多个读可以并行,写的同时也可以读,这使得ConcurrentHashMap的并行度远远大于同步容器。

迭代

我们在66节介绍过,使用同步容器,在迭代中需要加锁,否则可能会抛出ConcurrentModificationException。ConcurrentHashMap没有这个问题,在迭代器创建后,在迭代过程中,如果另一个线程对容器进行了修改,迭代会继续,不会抛出异常。

问题是,迭代会反映别的线程的修改?还是像上节介绍的CopyOnWriteArrayList一样,反映的是创建时的副本?答案是,都不是!我们看个例子:

public class ConcurrentHashMapIteratorDemo {
public static void test() {
final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.put("a", "abstract");
map.put("b", "basic"); Thread t1 = new Thread() {
@Override
public void run() {
for (Entry<String, String> entry : map.entrySet()) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println(entry.getKey() + "," + entry.getValue());
}
}
};
t1.start();
// 确保线程t1启动
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
map.put("c", "call");
} public static void main(String[] args) {
test();
}
}

t1启动后,创建迭代器,但在迭代输出每个元素前,先睡眠1秒钟,主线程启动t1后,先睡眠一下,确保t1先运行,然后给map增加了一个元素,程序输出为:

a,abstract
b,basic
c,call

说明,迭代器反映了最新的更新,但我们将添加语句更改为:

map.put("g", "call");

你会发现,程序输出为:

a,abstract
b,basic

这说明,迭代器没有反映最新的更新,这是怎么回事呢?我们需要理解ConcurrentHashMap的弱一致性。

弱一致性

ConcurrentHashMap的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。

类似的情况还会出现在ConcurrentHashMap的另一个方法:

//批量添加m中的键值对到当前Map
public void putAll(Map<? extends K, ? extends V> m)

该方法并非原子操作,而是调用put方法逐个元素进行添加的,在该方法没有结束的时候,部分修改效果就会体现出来。

小结

本节介绍了ConcurrentHashMap,它是并发版的HashMap,通过分段锁和其他技术实现了高并发,支持原子条件更新操作,不会抛出ConcurrentModificationException,实现了弱一致性。

Java中没有并发版的HashSet,但可以通过Collections.newSetFromMap方法基于ConcurrentHashMap构建一个。

我们知道HashMap/HashSet基于哈希,不能对元素排序,对应的可排序的容器类是TreeMap/TreeSet,并发包中可排序的对应版本不是基于树,而是基于Skip List(跳跃表)的,类分别是ConcurrentSkipListMap和ConcurrentSkipListSet,它们到底是什么呢?

(与其他章节一样,本节所有代码位于 https://github.com/swiftma/program-logic)

----------------

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。

计算机程序的思维逻辑 (74) - 并发容器 - ConcurrentHashMap的更多相关文章

  1. 计算机程序的思维逻辑 (73) - 并发容器 - 写时拷贝的List和Set

    本节以及接下来的几节,我们探讨Java并发包中的容器类.本节先介绍两个简单的类CopyOnWriteArrayList和CopyOnWriteArraySet,讨论它们的用法和实现原理.它们的用法比较 ...

  2. Java编程的逻辑 (74) - 并发容器 - ConcurrentHashMap

    ​本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http: ...

  3. 计算机程序的思维逻辑 (75) - 并发容器 - 基于SkipList的Map和Set

    上节我们介绍了ConcurrentHashMap,ConcurrentHashMap不能排序,容器类中可以排序的Map和Set是TreeMap和TreeSet,但它们不是线程安全的.Java并发包中与 ...

  4. Java编程的逻辑 (73) - 并发容器 - 写时拷贝的List和Set

    ​本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http: ...

  5. 计算机程序的思维逻辑 (64) - 常见文件类型处理: 属性文件/CSV/EXCEL/HTML/压缩文件

    对于处理文件,我们介绍了流的方式,57节介绍了字节流,58节介绍了字符流,同时,也介绍了比较底层的操作文件的方式,60节介绍了随机读写文件,61节介绍了内存映射文件,我们也介绍了对象的序列化/反序列化 ...

  6. 计算机程序的思维逻辑 (8) - char的真正含义

    看似简单的char 通过前两节,我们应该对字符和文本的编码和乱码有了一个清晰的认识,但前两节都是与编程语言无关的,我们还是不知道怎么在程序中处理字符和文本. 本节讨论在Java中进行字符处理的基础 - ...

  7. 计算机程序的思维逻辑 (29) - 剖析String

    上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比 ...

  8. 计算机程序的思维逻辑 (66) - 理解synchronized

    上节我们提到了多线程共享内存的两个问题,一个是竞态条件,另一个是内存可见性,我们提到,解决这两个问题的一个方案是使用synchronized关键字,本节就来讨论这个关键字. 用法 synchroniz ...

  9. JAVA 多线程随笔 (三) 多线程用到的并发容器 (ConcurrentHashMap,CopyOnWriteArrayList, CopyOnWriteArraySet)

    1.引言 在多线程的环境中,如果想要使用容器类,就需要注意所使用的容器类是否是线程安全的.在最早开始,人们一般都在使用同步容器(Vector,HashTable),其基本的原理,就是针对容器的每一个操 ...

随机推荐

  1. C++ 头文件系列(unordered_map、unordered_set)

    简介 很明显,这两个头文件分别是map.set头文件对应的unordered版本. 所以它们有一个重要的性质就是: 乱序 如何乱序 这个unorder暗示着,这两个头文件中类的底层实现----Hash ...

  2. Scala入门笔记二

    [TOC] 标识符 可用的字符 处理括号类字符,分隔符之外,其他所有的可打印的ASCII字符,如字母,数字,下划线和美元符号($)均可出现在Scala标识符中 插入符包括了(,) [,] {,and} ...

  3. Git学习之路(4)- 撤销操作、删除文件和恢复文件

    ▓▓▓▓▓▓ 大致介绍 经过前面的学习(小白学Git)已经建立了版本库,并上传了文件,这次来学习对这些文件进行基本的操作,即: ◆ 撤销操作 ◆ 删除文件 ◆ 恢复文件 我在此之前,已经将三个文件提交 ...

  4. CardboardSDK-iOS 源码简单分析

    该项目地址: 地址 克隆地址为 https://github.com/rsanchezsaez/CardboardSDK-iOS.git 目前如果想在iOS设备上实现双目VR的功能,Google 已经 ...

  5. C++编程练习(6)----“实现简单的队列的链式存储结构“

    队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出.简称链队列. 实现代码如下: /* LinkQueue.h 头文件 */ #include<iostream> #defi ...

  6. WebAppBuilder自定义主题

    WebAppBuilder自定义主题 by 李远祥 基本步骤: 创建新主题的文件夹 注册新的主题到manifest.json 文件 覆盖HeaderController 部件的颜色. 覆盖panel的 ...

  7. JAVA构造函数的继承

    1.子类中无参构造函数,可直接继承父类中无参构造函数,前提是所有变量均为public 如下:父类Student中有空构造函数Student(),子类Pupil中有空构造函数Pupil(),后者会继承前 ...

  8. PHP编码规范实例

    <?php   /**   * 符合psr-1,2的编程实例   *   * @author 作者 描述   */       namespace Standard; // 顶部命名空间   / ...

  9. 手动搭建apache james邮件服务器,实现邮件功能

    最近一直在搞邮件这块,本来我们邮件发送是用的腾讯免费的企业邮箱,邮件功能没有问题,但是由于邮件的限制,如下: 这些限制导致我们的部分客户是收不到邮件的,哪怕付费,这样的固定频率限制也是无法解决的,可以 ...

  10. Ceph BlueStore 解析:Object IO到磁盘的映射

    作者:吴香伟 发表于 2017/02/19 版权声明:可以任意转载,转载时务必以超链接形式标明文章原始出处和作者信息以及版权声明 简单回顾下Ceph OSD后端存储引擎的历史. 为解决事务原子性问题, ...