一、HashMap

HashMap 是线程不安全的。

JDK 1.7 HashMap 采用数组 + 链表的数据结构,多线程背景下,在数组扩容的时候,存在 Entry 链死循环和数据丢失问题。

JDK 1.8 HashMap 采用数组 + 链表 + 红黑二叉树的数据结构,优化了 1.7 中数组扩容的方案,解决了 Entry 链死循环和数据丢失问题。但是多线程背景下,put 方法存在数据覆盖的问题。

1.7 中扩容引发的线程不安全分析

  1. void transfer(Entry[] newTable, boolean rehash) {
  2. int newCapacity = newTable.length;
  3. for (Entry<K,V> e : table) {
  4. while(null != e) {
  5. Entry<K,V> next = e.next;
  6. if (rehash) {
  7. e.hash = null == e.key ? 0 : hash(e.key);
  8. }
  9. int i = indexFor(e.hash, newCapacity);
  10. e.next = newTable[i];
  11. newTable[i] = e;
  12. e = next;
  13. }
  14. }
  15. }

这段代码是 HashMap 的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。

假设现在有两个线程A、B同时对下面这个 HashMap 进行扩容操作:

正常扩容后的结果是下面这样的:

但是当线程A执行到上面 transfer 函数的第11行代码时,CPU 时间片耗尽,线程A被挂起。即如下图中位置所示:

此时线程A中:e=3、next=7、e.next=null

当线程A的时间片耗尽后,CPU 开始执行线程B,并在线程B中成功的完成了数据迁移:

重点来了,根据 Java 内存模式可知,线程B执行完数据迁移后,此时主内存中 newTable 和 table 都是最新的,也就是说:7.next=3、3.next=null

随后线程A获得CPU时间片继续执行 newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:

接着继续执行下一轮循环,此时 e=7,从主内存中读取 e.next 时发现主内存中 7.next=3,于是乎next=3,并将 7 采用头插法的方式放入新数组中,并继续执行完此轮循环,结果如下:

执行下一次循环可以发现,next=e.next=null,所以此轮循环将会是最后一轮循环。接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中,执行结果如下图所示:

上面说了此时 e.next=null 即 next=null,当执行完 e=null 后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap 中出现了环形结构,当在以后对该 HashMap 进行操作时会出现死循环。

并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。

1.8 中 put 方法数据覆盖问题分析

根据上面JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到 transfer 函数,因为 JDK1.8 直接在 resize 函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。

为什么说JDK1.8会出现数据覆盖的情况喃,我们来看一下下面这段JDK1.8中的put操作代码:

  1. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
  2. boolean evict) {
  3. Node<K,V>[] tab; Node<K,V> p; int n, i;
  4. if ((tab = table) == null || (n = tab.length) == 0)
  5. n = (tab = resize()).length;
  6. if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
  7. tab[i] = newNode(hash, key, value, null);
  8. else {
  9. Node<K,V> e; K k;
  10. if (p.hash == hash &&
  11. ((k = p.key) == key || (key != null && key.equals(k))))
  12. e = p;
  13. else if (p instanceof TreeNode)
  14. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  15. else {
  16. for (int binCount = 0; ; ++binCount) {
  17. if ((e = p.next) == null) {
  18. p.next = newNode(hash, key, value, null);
  19. if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
  20. treeifyBin(tab, hash);
  21. break;
  22. }
  23. if (e.hash == hash &&
  24. ((k = e.key) == key || (key != null && key.equals(k))))
  25. break;
  26. p = e;
  27. }
  28. }
  29. if (e != null) { // existing mapping for key
  30. V oldValue = e.value;
  31. if (!onlyIfAbsent || oldValue == null)
  32. e.value = value;
  33. afterNodeAccess(e);
  34. return oldValue;
  35. }
  36. }
  37. ++modCount;
  38. if (++size > threshold)
  39. resize();
  40. afterNodeInsertion(evict);
  41. return null;
  42. }

其中第六行代码是判断是否出现 hash 碰撞,假设两个线程A、B都在进行 put 操作,并且 hash 函数计算出的插入下标是相同的,当线程A 执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了 hash 碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

除此之前,还有就是代码的第38行处有个 ++size,我们这样想,还是线程A、B,这两个线程同时进行 put 操作时,假设当前 HashMap 的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。

二、HashTable

HashTable 是线程安全的。

HashTable 容器使用 synchronized 来保证线程安全,但在线程竞争激烈的情况下 HashTable 的效率非常低下。因为当一个线程访问 HashTable 的同步方法,其他线程也访问 HashTable 的同步方法时,会进入阻塞或轮询状态。如线程1使用 put 进行元素添加,线程2不但不能使用 put 方法添加元素,也不能使用 get 方法来获取元素,所以竞争越激烈效率越低。

  1. public synchronized V get(Object key) {
  2. Entry<?,?> tab[] = table;
  3. int hash = key.hashCode();
  4. int index = (hash & 0x7FFFFFFF) % tab.length;
  5. for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
  6. if ((e.hash == hash) && e.key.equals(key)) {
  7. return (V)e.value;
  8. }
  9. }
  10. return null;
  11. }

三、ConcurrentHashMap

ConcurrentHashMap 线程安全的。

JDK 1.7 ConcurrentHashMap 采用Segment数组 + HashEntry数组实现。 Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。

分段锁技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。

从上面的结构我们可以了解到,ConcurrentHashMap 定位一个元素的过程需要进行两次 Hash 操作。第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部。

这一种结构写操作的时候只对元素所在的Segment进行加锁即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。

这一种结构的带来的副作用是Hash的过程要比普通的 HashMap 要长。

JDK 1.8 ConcurrentHashMap 采用数组 + 链表 + 红黑树的方式实现,结构基本上和 1.8 中的 HashMap 一样,不过大量的利用了 volatile,final,CAS 等 lock-free 技术来减少锁竞争对于性能的影响,从而保证线程安全性。

附录

HashMap、HashTable 和 ConcurrentHashMap 线程安全问题的更多相关文章

  1. ConcurrentHashMap允许一边遍历一边更新,而用HashMap则会报线程安全问题

    ConcurrentHashMap线程安全的,允许一边更新.一边遍历,也就是说在对象遍历的时候,也可以进行remove,put操作,且遍历的数据会随着remove,put操作产出变化,而如果用Hash ...

  2. HashMap HashTable和ConcurrentHashMap的区别

    HashMap和Hashtable都实现了Map接口,其主要的区别有:线程安全性,同步(synchronization),以及效率. HashMap和Hashtable基本上没啥区别,除了HashMa ...

  3. Java Main Differences between HashMap HashTable and ConcurrentHashMap

    转自这篇帖子:http://www.importnew.com/7010.html HashMap和Hashtable的比较是Java面试中的常见问题,用来考验程序员是否能够正确使用集合类以及是否可以 ...

  4. 为什么HashMap线程不安全,Hashtable和ConcurrentHashMap线程安全

    HashMap源码 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final ...

  5. HashMap,HashTable,ConcorrentHashMap的线程方式

    1.HashMap不是线程安全的,put,resize 2.HashTable是线程安全的,synchronized,但是效率较低 3.ConcorrentHashMap 对HashMap的一种加线程 ...

  6. hashmap,hashTable concurrentHashMap 是否为线程安全,区别,如何实现的

    线程安全类 在集合框架中,有些类是线程安全的,这些都是jdk1.1中的出现的.在jdk1.2之后,就出现许许多多非线程安全的类. 下面是这些线程安全的同步的类: vector:就比arraylist多 ...

  7. HashMap、HashTable、ConcurrentHashMap、HashSet区别 线程安全类

    HashMap专题:HashMap的实现原理--链表散列 HashTable专题:Hashtable数据存储结构-遍历规则,Hash类型的复杂度为啥都是O(1)-源码分析 Hash,Tree数据结构时 ...

  8. 大杂烩 -- HashMap、HashTable、ConCurrentHashMap 联系与区别

    基础大杂烩 -- 目录 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 1. Hashtable 和 HashMap ⑴ ...

  9. HashMap HashTable ConcurrentHashMap

    1. Hashtable 和 HashMap (1)区别,这两个类主要有以下几方面的不同:Hashtable和HashMap都实现了Map接口,但是Hashtable的实现是基于Dictionary抽 ...

随机推荐

  1. 常见的RuntimeException报错原因

    对于RuntimeException 做java开发的朋友想必不会陌生,可以用于事物的回滚操作.异常类型也有很多种,写这篇文章主要是为了总结自己开发中遇到的一些异常类型 以便帮助大家遇到相应的报错找不 ...

  2. Spring Boot 如何自定义返回错误码错误信息

    说明 在实际的开发过程中,很多时候要定义符合自己业务的错误码和错误信息,而不是统一的而不是统一的下面这种格式返回到调用端 INTERNAL_SERVER_ERROR(500, "Intern ...

  3. 表删除时 Cannot delete or update a parent row: a foreign key constraint fails 异常处理

    有两张表,结构如下: t_item:                          t_bid: id        int                     id        int n ...

  4. 一款 Postman 的开源替代品: Postwoman

    1. 前言 大家都知道,Postman是一个非常受欢迎的API接口调试工具,提供有Chrome扩展插件版和独立的APP,不过它的很多高级功能都需要付费才能使用. 如果你连Postman都还没有用过,不 ...

  5. 一个非常美的FlutterUI组件扩展集:FLUI

    项目地址 FLUI 官网 下载 Demo APK 体验 这是一个群内的网友写的,感觉里面的组件风格非常美,封装的挺到位的,在此推荐给大家,具体可以参考学习. 可以学到的知识还是挺多的,组件UI封装可以 ...

  6. eclipse 导入别人拷贝过来的工作空间项目

    切换自己的工作空间 File --> Import --> Existing Project into Workspace --> 选择项目根目录 --> 确定 如果你的ecl ...

  7. EFCore的外键级联删除导致的【可能会导致循环或多重级联路径】

    之前也是经常遇到这个问题,但好在每次创建的实体不多,很容易就能找到是哪个外键导致级联循环删除问题 之前都是这么处理,因为创建的实体也不多,所以还处理得来 但最近跟别人合作写后端,别人写了好多实体,我一 ...

  8. linux(center OS7)安装JDK、tomcat、mysql 搭建java web项目运行环境

    一.安装JDK 1.卸载旧版本或者系统自带的JDK (1)列出所有已安装的JDK rpm -qa | grep jdk (2)卸载不需要的JDK yum -y remove 安装包名称 2.下载并解压 ...

  9. java基础 - 锁

    ------------------------ 参考: https://www.cnblogs.com/hustzzl/p/9343797.html https://blog.csdn.net/qq ...

  10. 两种最常用的 HTTP 操作方法是:GET 和 POST。

    什么是 HTTP? 超文本传输协议(HTTP)的设计目的是保证客户机与服务器之间的通信. HTTP 的工作方式是客户机与服务器之间的请求-应答协议. web 浏览器可能是客户端,而计算机上的网络应用程 ...