JDK1.7和JDK1.8中HashMap为什么是线程不安全的?

2024-03-02 03:58

本文主要是介绍JDK1.7和JDK1.8中HashMap为什么是线程不安全的?,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

前言

只要是对于集合有一定了解的一定都知道HashMap是线程不安全的,我们应该使用ConcurrentHashMap。但是为什么HashMap是线程不安全的呢,之前面试的时候也遇到到这样的问题,但是当时只停留在***知道是***的层面上,并没有深入理解***为什么是***。于是今天重温一个HashMap线程不安全的这个问题。

首先需要强调一点,HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中仍会有数据覆盖这样的问题。

扩容引发的线程不安全

HashMap的线程不安全主要是发生在扩容函数中,即根源是在transfer函数中,JDK1.7中HashMaptransfer函数如下:

void transfer(Entry[] newTable, boolean rehash) {int newCapacity = newTable.length;for (Entry<K,V> e : table) {while(null != e) {Entry<K,V> next = e.next;if (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}int i = indexFor(e.hash, newCapacity);e.next = newTable[i];newTable[i] = e;e = next;}}}

这段代码是HashMap的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。理解了头插法后再继续往下看是如何造成死循环以及数据丢失的。

扩容造成死循环和数据丢失的分析过程

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

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

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

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

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

重点来了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTabletable都是最新的,也就是说: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在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。

JDK1.8中的线程不安全

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

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

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;}

其中第六行代码是判断是否出现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,所有说还是由于数据覆盖又导致了线程不安全。

总结

HashMap的线程不安全主要体现在下面两个方面:
1.在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。

这篇关于JDK1.7和JDK1.8中HashMap为什么是线程不安全的?的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/764840

相关文章

jdk1.8的Jenkins安装配置实践

《jdk1.8的Jenkins安装配置实践》Jenkins是一款流行的开源持续集成工具,支持自动构建、测试和部署,通过Jenkins,开发团队可以实现代码提交后自动进行构建、测试,并将构建结果分发到测... 目录Jenkins介绍Jenkins环境搭建Jenkins安装配置Jenkins插件安装Git安装配

线程池ThreadPoolExecutor应用过程

《线程池ThreadPoolExecutor应用过程》:本文主要介绍如何使用ThreadPoolExecutor创建线程池,包括其构造方法、常用方法、参数校验以及如何选择合适的拒绝策略,文章还讨论... 目录ThreadPoolExecutor构造说明及常用方法为什么强制要求使用ThreadPoolExec

Java线程池核心参数原理及使用指南

《Java线程池核心参数原理及使用指南》本文详细介绍了Java线程池的基本概念、核心类、核心参数、工作原理、常见类型以及最佳实践,通过理解每个参数的含义和工作原理,可以更好地配置线程池,提高系统性能,... 目录一、线程池概述1.1 什么是线程池1.2 线程池的优势二、线程池核心类三、ThreadPoolE

input的accept属性让文件上传安全高效

《input的accept属性让文件上传安全高效》文章介绍了HTML的input文件上传`accept`属性在文件上传校验中的重要性和优势,通过使用`accept`属性,可以减少前端JavaScrip... 目录前言那个悄悄毁掉你上传体验的“常见写法”改变一切的 html 小特性:accept真正的魔法:让

JAVA线程的周期及调度机制详解

《JAVA线程的周期及调度机制详解》Java线程的生命周期包括NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING和TERMINATED,线程调度依赖操作系统,采用抢占... 目录Java线程的生命周期线程状态转换示例代码JAVA线程调度机制优先级设置示例注意事项JAVA线程

Redis的安全机制详细介绍及配置方法

《Redis的安全机制详细介绍及配置方法》本文介绍Redis安全机制的配置方法,包括绑定IP地址、设置密码、保护模式、禁用危险命令、防火墙限制、TLS加密、客户端连接限制、最大内存使用和日志审计等,通... 目录1. 绑定 IP 地址2. 设置密码3. 保护模式4. 禁用危险命令5. 通过防火墙限制访问6.

深入理解Redis线程模型的原理及使用

《深入理解Redis线程模型的原理及使用》Redis的线程模型整体还是多线程的,只是后台执行指令的核心线程是单线程的,整个线程模型可以理解为还是以单线程为主,基于这种单线程为主的线程模型,不同客户端的... 目录1 Redis是单线程www.chinasem.cn还是多线程2 Redis如何保证指令原子性2.

C++实现一个简易线程池的使用小结

《C++实现一个简易线程池的使用小结》在现代软件开发中,多线程编程已经成为提升程序性能的常见手段,本文主要介绍了C++实现一个简易线程池的使用小结,感兴趣的可以了解一下... 在现代软件开发中,多线程编程已经成为提升程序性能的常见手段。无论是处理大量 I/O 请求的服务器,还是进行 CPU 密集型计算的应用

JDK21对虚拟线程的几种用法实践指南

《JDK21对虚拟线程的几种用法实践指南》虚拟线程是Java中的一种轻量级线程,由JVM管理,特别适合于I/O密集型任务,:本文主要介绍JDK21对虚拟线程的几种用法,文中通过代码介绍的非常详细,... 目录一、参考官方文档二、什么是虚拟线程三、几种用法1、Thread.ofVirtual().start(

Java HashMap的底层实现原理深度解析

《JavaHashMap的底层实现原理深度解析》HashMap基于数组+链表+红黑树结构,通过哈希算法和扩容机制优化性能,负载因子与树化阈值平衡效率,是Java开发必备的高效数据结构,本文给大家介绍... 目录一、概述:HashMap的宏观结构二、核心数据结构解析1. 数组(桶数组)2. 链表节点(Node