Java 集合类有哪些
Java 中的集合类主要由 Collection 和 Map 这两个接口派生而出,其中 Collection 接口又派生出三个子接口,分别是 Set、List、Queue。所有的 Java 集合类,都是 Set、List、Queue、Map 这四个接口的实现类。
Collection 接口(单列集合)
- 定义 :
Collection是所有单列集合的根接口
用于存储一系列不重复或重复的单个元素。 - 分类 :
List接口 :
Δ 特点 :元素有序 (取出数据按插入顺序),元素可重复 。
Δ 常见的实现类 :
①ArrayList:底层基于动态数组 实现。特点是查询(随机访问)速度快 ,因为可以通过索引直接定位;但增删元素(特别是中间位置)效率相对较低 ,因为可能会涉及到大量元素的移动。ArrayList类是非线程安全的 。
②LinkedList:底层基于双向链表 实现。特点是查询(随机访问)效率较低 ,因为需要从头或尾遍历。但增删元素(特别是首尾和中间位置)效率高 ,因为只需修改结点指针。LinkedList也是非线程安全的 。
③Vector:Vector类与ArrayList类似,但它是线程安全 的(方法都加了synchronized关键字)。但由于同步开销 ,其性能通常低于ArrayList。Set接口 :
Δ 特点 :元素无序 (取出数据不保证插入顺序),元素不可重复 (通过hashCode()和equals()方法判断)。
Δ 常见的实现类 :
①HashSet:底层其实是HashMap。特点是增删改查效率高 ,但元素无序 。HashSet是非线程安全的 。
②LinkedHashSet:继承自HashSet,底层基于哈希表和链表 实现。它在HashSet的基础上,通过链表维护了元素的插入顺序 (有序)。LinkedHashSet也是非线程安全的 。
③TreeSet:底层基于红黑树 实现。TreeSet类的特点是元素有序 (按自然排序或自定义比较器排序),增删改查效率相对HashSet略低(对数时间复杂度)。TreeSet也是非线程安全的。Queue接口 :
Δ 特点 :遵循 先进先出(FIFO) 原则,常用于模拟队列数据结构。
Δ 常见的实现类 :
①PriorityQueue:优先级队列 ,队列元素根据其自然排序或自定义比较器进行排序,每次取出的是优先级最高的元素 。PriorityQueue是非线程安全的 。
注意: LinkedList 也是Queue的实现类,提供offer()、poll()、peek()等队列操作方法
Map 接口(双列集合)
- 特点 :
- 双列集合用于存储键值对 (Key-Value Pair)
- 键(Key)是唯一的,用于快速查找对应的值。
- 值(Value)可以重复。
- 一个键最多映射一个值。
- 常见的实现类 :
HashMap:底层基于哈希表 实现。它的特点是查询、增删效率高 ,但元素(键值对)无序 。HashMap是非线程安全的。LinkedHashMap:继承自HashMap,底层基于哈希表 和 双向链表 实现。它在HashMap的基础上,通过链表维护了键值对的插入顺序 ,因此有序。LinkedHashMap是非线程安全的。TreeMap:底层基于红黑树 实现。它的特点是键值对有序 (按键的自然排序或自定义比较器排序),增删改查效率相对HashMap略低。TreeMap是非线程安全的。Hashtable:与HashMap类似,但它是线程安全 的(方法都加了synchronized关键字),性能较低,是 Java 早期版本提供的集合类。Hashtable的键和值都不能为null。ConcurrentHashMap:在JDK1.5之后提供的线程安全且高性能 的双列集合实现。它通过分段锁(JDK7)或CAS + synchronized(JDK8)等机制,提供了比Hashtable更好的并发性能。
Array、ArrayList、LinkedList的区别是什么?
- ArrayList vs Array :
- 长度:
ArrayList是动态数组的实现,可以自动扩容。而Array是固定长度的数组。 - 功能:
ArrayList提供了更多的功能,比如自动扩容 、增加和删除元素等,Array只有length属性和下标访问,无内置增删方法 - 存储类型:
Array可以直接存储基本类型数据,也可以存储对象。ArrayList中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如Integer、Double等)
- 长度:
- ArrayList vs LinkedList :
- 底层结构:
ArrayList基于动态数组,LinkedList基于双向链表。 - 使用特点:
ArrayList 特点是查询(随机访问)速度快 ,因为可以通过索引直接定位;但增删元素(特别是中间位置)效率相对较低 ,因为可能会涉及到大量元素的移动。
Linked 特点是查询(随机访问)效率较低 ,因为需要从头或尾遍历。但增删元素(特别是首尾和中间位置)效率高,因为只需修改结点指针。 - 内存占用:ArrayList 只需存储元素本身,内存紧凑;LinkedList 每个节点额外存储前后指针,内存占用更大。
- 扩容:当容量不足以容纳更多元素时,
ArrayList会自动扩容,这个过程涉及创建新数组和复制旧数组的内容,有一定的开销。而LinkedList只需要创建一个新的节点对象,把它和前后讲点关联起来就行。 - 使用场景:
- 如果需要频繁随机访问,优先使用
ArrayList。 - 如果主要是头部或中间插入删除,且对访问性能要求不高,可考虑
LinkedList。 - 实际开发中,绝大多数场景下
ArrayList是首选,因为它综合性能好、内存更省,除非有明确的链表操作需求。
- 如果需要频繁随机访问,优先使用
- 底层结构:
ArrayList 扩容机制:
ArrayList 的底层是一个 Object[] 数组。扩容的本质就是创建一个新的、容量更大的数组,然后将原数组中的元素复制到新数组中,最后让 ArrayList 内部的数组引用指向这个新数组。
具体来说,
初始化容量:
- 如果使用无参构造器 (
new ArrayList<>())创建一个ArrayList 对象,在JDK 1.8及以后,初始容量是0,而不是10(JDK 1.7)。 - 只有在第一次调用
add()方法 时,会通过ArrayList中grow()方法将容量懒加载为默认的10。 - 当然,也可以使用带初始容量的构造器 (
new ArrayList<>(int initialCapacity))来指定初始大小。
扩容时机:
当调用add()方法添加元素 ,且当前元素个数(size) + 1 > 当前内部数组的长度时,就会触发扩容。
扩容计算:
ArrayList扩容的计算是在一个grow()方法里面,+先尝试将数组扩大为原数组的1.5倍。
边界处理:
- 如果 1.5 倍后的容量仍然小于所需的最小容量 (例如
addAll一次性加入大量元素),那么新容量直接取所需的最小容量。 - 如果新容量超过了定义的最大数组长度(
MAX_ARRAY_SIZE),则会进行hugeCapacity()处理,最终可能将容量设为Integer.MAX_VALUE或者 抛出OOM错误(所需最小是int数值溢出的值)
复制:
- 若新的容量满足需求,会调用一个
Arrays.copyof方法, 将所有的元素从旧数组复制到新数组中,最后让 ArrayList 内部的数组引用指向这个新数组
总结:
由于扩容涉及数组复制(时间复杂度O(n)),频繁扩容会影响性能 。所所以说建议使用ArrayList(int initialCapacity)构造器来指定初始容量,避免多次扩容。
为什么是1.5倍?
选择 1.5 倍 (即右移一位 oldCapacity >> 1)是为了在时间与空间之间做平衡。
-
如果增长倍数太小 (如 1.1 倍),扩容次数频繁,复制成本高。
-
如果增长倍数太大 (如 2 倍),可能会浪费较多内存空间 。
1.5 倍既能保证均摊下来
add操作的时间复杂度依然是 O(1),又能避免过度的内存浪费。
HashMap
-
底层实现
在JDK 1.8之前,
HashMap由数组和链表 组成,当发生哈希冲突时,多个元素会以链表的形式存储在同一个数组位置。JDK 1.8开始引入了红黑树 ,当链表长度大于等于8时,数组长度大于等于64时,链表会转换成红黑树,以提高搜索效率,链表大小小于等于6时就会变成链表,因为红黑树在节点数量较大时,优势才会明显体现出来,节点数少的时候红黑树的维护会引来额外开销。 -
为什么链表大小超过 8 会自动转为红黑树,小于 6 时重新变成链表
根据泊松分布 ,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分 之一,所以将7作为一个分水岭, 等于 7 的时候不转换,大于等于 8 的时候才转换成红黑树,小于等于 6 的时候转化为链表。
- 为什么引入红黑树,而不是其他树?
是因为红黑树具有以下几点性质
- 不追求绝对的平衡,插入或删除节点时,允许有一定的局部不平衡,相较于AVL树的绝对自平衡,减少了很多性能开销;
- 红黑树是一种自平衡的二叉搜索树,因此插入和删除操作的时间复杂度都是O(log n)
HashMap读和写的时间复杂度是多少?
- 读:
- 在最佳情况下:读操作的时间复杂度是 O(1)
- 最坏情况下:发生哈希冲突,链表为O(n), 红黑树为O(log n)。
- 写:
- 理想情况:与读操作类似,如果哈希函数分布均匀,写操作的时间复杂度也是 O(1)。
- 处理哈希冲突:如果发生哈希冲突,需要在链表尾部添加新元素或将链表转换为红黑树。在这种情况下,写操作的时间复杂度可能达到 O(n),其中 n 是链表的长度。
解决Hash冲突的方法有哪些? HashMap是如何解决 Hash 冲突的?
在哈希表(散列表)中,解决冲突主要有四大类方法:
-
链地址法(拉链法)
- 原理:将哈希表的每个位置(桶)看作一个链表(或红黑树)。当多个元素映射到同一个桶时,将它们放在该桶的链表/树中。
- 优点:处理冲突简单,平均查找时间短,删除节点容易。
- 缺点:需要额外的指针存储空间。
-
开放地址法
- 原理 :当冲突发生时,通过探测序列去寻找下一个空闲的桶。
- 常见探测方式 :线性探测(依次往后找)、二次探测(+12,−12,+22...+1^2, -1^2, +2^2...+12,−12,+22...)、双重散列。
- 优点:数据都存储在数组内,没有链表指针开销。
- 缺点 :删除节点麻烦(通常需标记为已删除),且容易产生"聚集"现象,性能退化为O(n)。
-
再哈希法
- 原理 :当发生冲突时,不是简单地线性移动一个固定步长,而是使用第二个哈希函数计算出一个新的步长,再根据这个步长寻找下一个空闲桶。
- 优点:不易发生聚集。
- 缺点:计算时间开销较大。
-
建立公共溢出区
- 原理:将哈希表分为基本表和溢出表,冲突的元素都放入溢出表。
HashMap 主要采用"链地址法",并引入了"红黑树"进行优化,同时配合"高位扰动"和"扩容后重新计算节点哈希"来减少冲突。
高位扰动:将 hashCode 的高 16 位与低 16 位进行异或运算,使哈希分布更均匀。
扩容后重新计算节点哈希位置:节点数量超过了装载因子与数组长度的乘积,会进行扩容,所有元素会重新计算新的桶位置
HashMap 的 put 方法流程?
-
初始化检查 :首先检查数组
table是否为null或长度为0。如果是,则调用resize()进行初始化(懒加载机制),默认容量分配空间。 -
计算索引并插入 :根据
key键的hashcode值计算索引下标i。检查table 对应 的下标
-
情况A(无冲突) :如果
table[i] == null,直接在该位置新建节点。 -
情况B(存在冲突) :如果
table[i] != null,则:-
先检查
table[i]的首节点是否与当前key相同(比较hashcode和equals)。若相同,则记录该节点,准备覆盖。 -
若不同,则判断该节点是否为树节点(
TreeNode)。如果是红黑树,则执行红黑树的插入逻辑。 -
如果是链表,则遍历链表。若遍历过程中找到相同
key,则记录节点准备覆盖;若未找到,则在链表尾部插入新节点。
-
-
-
链表树化 :在链表插入完成后,判断当前链表的长度是否 > 8。
-
如果
> 8,调用treeifyBin方法。但该方法会进一步判断当前数组长度是否 < 64: -
如果
< 64,优先执行扩容 (resize)。 -
如果
≥ 64,则将链表转换为红黑树
-
-
覆盖旧值 :如果在步骤2或遍历过程中找到了相同的
key,则用新值替换旧值,并返回旧值(方法结束)。 -
扩容判断 :如果是新增节点(非覆盖),则在插入成功后,判断当前
size是否大于扩容阈值threshold(容量 * 负载因子)。如果超过,则进行resize扩容。
HashMap 的扩容机制
触发扩容的时机
- 首次 put 时 :如果 table 为空或长度为 0,会触发初始化扩容(resize()),分配默认容量或指定容量。指定容量的话,会放大到2的幂的大小,以便进行模计算的时候可以直接减一按位与来计算
hashcode的模,降低直接进行模计算的开销 - put 后 size > threshold:每次插入新键值对后,检查当前元素个数是否大于阈值,若大于则扩容。
- 树化时:当链表长度达到 8 且数组长度小于 64 时,会先进行扩容,而非直接树化(JDK 1.8)。
扩容流程(以 JDK 1.8 为例)
1. 计算新容量和新阈值
-
新容量 = 旧容量 << 1(翻倍)
-
新阈值 = 新容量 × loadFactor
2. 创建新数组
- 分配一个新的 数组,长度为新容量
3. 元素迁移(重哈希)
遍历旧数组的每个桶(链表或红黑树):
桶中只有一个元素时,元素直接通过 e.hash & (newCap - 1) 计算新索引,放入新数组对应位置。(e是正在遍历的元素,e.hash 是Hashcode扰动后的值)
桶中是红黑树节点,调用红黑树的拆解方法,放入对应新数组位置
桶中是多个链表节点,遍历链表的多个元素,并不是(e.hash & (newCap - 1)) ,由于新容量是旧容量的 2 倍,元素的新索引要么是原索引,要么是"原索引 + 旧容量",所以直接判断节点高一位 是1 或者 0 就可以判断该节点在新数组中的位置。
-
而是利用 hash & oldCap 来判断高位的情况,将数组分为了低位链表和高位链表
-
若结果为 0,则元素在新数组中的位置不变(仍在 旧索引的位置),将其放入低位链表(尾插法,不改变顺序)
-
若结果不为 0,则元素在新数组中的位置为 旧索引+旧容量,将其放入高位链表
-
最后再分别插入到新的数组中
JDK 1.7
- 采用头插法,并发可能形成环。
- 迁移节点,是直接遍历,节点计算新下标,插入到新数组,开销更大。
-
HashMap通过高16位与低16位进行异或运算来让高位参与散列,提高散列效果;
-
HashMap控制数组的长度为2的整数次幂来简化取模运算,提高性能;(对hashcode进行求余运算和让hashcode与数组长度-1进行位与运算是相同的效果)
-
HashMap通过控制初始化的数组长度为2的整数次幂、扩容为原来的2倍来控制数组长度一定为2的整数次幂。
再优秀的hash算法永远无法避免出现hash冲突。hash冲突指的是两个不同的key经过hash计算之后得到的数组下标是相同的。解决hash冲突的方式很多,如开放定址法、再哈希法、公共溢出表法、链地址法。HashMap采用的是链地址法,jdk1.8之后还增加了红黑树的优化,
-
HashMap采用链地址法,当发生冲突时会转化为链表,当链表过长会转化为红黑树提高效率。
-
HashMap对红黑树进行了限制,让红黑树只有在极少数极端情况下进行抗压。
-
装载因子决定了HashMap扩容的阈值,需要权衡时间与空间,一般情况下保持0.75不作改动;
-
HashMap扩容机制结合了数组长度为2的整数次幂的特点,以一种更高的效率完成数据迁移,同时避免头插法造成链表环。
-
HashMap并不能保证线程安全,在多线程并发访问下会出现意想不到的问题,如数据丢失等
-
HashMap1.8采用尾插法进行扩容,防止出现链表环导致的死循环问题
-
解决并发问题的的方案有
Hashtable、Collections.synchronizeMap()、ConcurrentHashMap。其中最佳解决方案是ConcurrentHashMap -
上述解决方案并不能完全保证线程安全
-
快速失败是HashMap迭代机制中的一种并发安全保证
HashMap 是线程安全的吗?多线程下会有什么问题?如何实现线程安全?
HashMap 不是线程安全的。
因为所有方法(如 put、get、resize 等)都没有使用 synchronized 或 加锁 进行保护。这些操作都不是原子性的
在多线程环境下,使用 HashMap 可能会出现以下问题:
- 死循环 :在 JDK 1.7 中,HashMap 使用头插法插入元素,当多个线程同时进行扩容操作时,可能会导致环形链表的形成,后续对同一哈希桶的
get操作会陷入死循环。为了解决这个问题,在 JDK 1.8 中采用了尾插法插入元素,保持了链表元素的顺序,避免了死循环的问题。 - 数据覆盖:当多个线程同时执行 put 操作时,如果它们计算出的索引位置相同,可能会造成前一个 key 被后一个 key 覆盖的情况,从而导致元素的丢失。
关于多线程安全的实现方案,可以采取以下措施:
-
如果并发要求不高或对遗留代码兼容,
-
Hashtable是 JDK 1.0 的遗留类,所有方法都用synchronized修饰,属于全表锁,(不允许null键值),性能较差,新代码中不建议使用。 -
Collections.synchronizedMap是 JDK 1.2 提供的包装类,同样采用全表锁(通过互斥对象),(允许null键值),但并发能力依旧有限,仅适合并发要求极低的场景。
-
-
如果是高并发场景 ,我通常会使用
ConcurrentHashMap。它针对并发做了专门优化:-
在 JDK 1.7 中采用分段锁(Segment),将数据分块,减少锁粒度,允许多个线程同时操作不同段。
-
在 JDK 1.8 中优化为 CAS + synchronized 保证线程安全性,锁的粒度细化到数组的每个桶(Node),只有在操作同一个哈希桶时才会发生锁竞争,并发性能是更优。
-
总之,在多线程环境下,尽量不要直接使用 HashMap ,而是根据并发量的高低选择合适的封装类或 ConcurrentHashMap。
concurrentHashMap 如何保证线程安全
concurrentHashMap 相当于是 HashMap 的多线程版本,它的功能本质上和 HashMap 没有什么区别。因为HashMap在并发操作时会出现死循环、数据覆盖等问题。 这些问题使用concurrentHashMap 就可以完美解决。
JDK 1.7
- 基本结构:
ConcurrentHashMap在JDK 1.7中使用的数组 加 链表的结构,其中数组分为两类,大树组Segment和 小数组HashEntry。大数组Segment包含了多个 小数组HashEntry,每个HashEntry中有多条数据,这些数据采用的是链表结构。
![[Pasted image 20260326113850.png#pic_center|| 400]] - 分段锁 :多线程安全的实现,是通过每个 Segment 独立继承自 ReentrantLock(可重入锁),锁的粒度是每个 Segment。 这保证同一时间只能有一个线程操作对应的节点,因为
ConcurrentHashMap的线程安全建立在Segment的加锁基础上,所以被称为分段锁。
JDK1.8
因为大量数据的情况下,使用链表结构,需要遍历整个链表,可能会造成数据访问效率降低。
-
基本结构:
ConcurrentHashMap在JDK1.8中使用的是数组 加 链表 加 红黑树的方式实现(与HashMap 在Jdk 1.8 一致),当链表长度大于等于8,数组长度大于等于64的时候会将链表转换为红黑树。 -
通过
CAS+synchronized锁头节点 实现,并且缩小了锁的粒度到桶,并发场景下操作性能也更高。
具体:
-
CAS:在插入元素、初始化数组等操作时,使用无锁的 CAS 操作,例如当数组桶为空时,通过 CAS 将节点放入桶中,避免加锁。
-
synchronized :当发生桶非空的写操作、树化的时、扩容节点迁移时时,使用 synchronized 锁住当前桶的头节点
可重入锁指的是同一个线程可以多次获取同一把锁而不会死锁,内部通过计数器实现重复加锁与对应释放。
CAS 是 Compare-And-Swap (比较并交换)的缩写,在 Java 并发编程中是一种非常重要的无锁算法 技术。它利用 CPU 的原子指令,让多线程在更新共享变量时无需加锁 就能保证线程安全。具体:
CAS 操作包含三个操作数:
内存位置 V:要操作的变量在内存中的地址(对应 Java 中的某个变量的引用)。
期望值 A:线程认为该内存位置当前应该持有的值。
新值 B:线程希望将该内存位置更新为的值。
执行逻辑:
当且仅当 V 的值等于 A 时,CPU 才会原子地将 V 的值更新为 B,否则不执行任何操作。无论是否更新成功,都会返回 V 原来的值(或返回布尔值表示是否成功)。
整个过程是一条 CPU 原子指令(如 x86 架构下的 CMPXCHG),中间不会被其他线程打断,从而保证了操作的原子性。
HashMap 和 HashSet 的区别
HashMap 和 HashSet 都是Java中的集合类,但它们有以下区别:
- 存储内容不同
HashSet实现了Set接口,只存储对象;HashTable实现了Map接口,用于存储键值对
- 底层实现
HashSet的底层实际上就是HashMap。HashSet内部维护了一个HashMap的引用,当我们向HashSet添加元素时,实际上是把该元素作为HashMap的 Key 存入,而 Value 则是一个统一的静态常量对象 (PRESENT,空的new Object(),永远不变)。 - 重复值
HashSet不允许集合中有重复的值(如果有重复的值,会插入失败,add()方法返回false)。- 而HashMap的键不能重复,但是值可以重复(如果键重复会覆盖原来的值,因为
putVal()方法中onlyIfAbsent属性默认为false)
总的来说,可以理解为:HashSet 是'阉割版'的 HashMap,它只用了 HashMap 的 Key 部分,Value 统一占位,从而实现了元素的唯一性。
java
// HashSet 的部分源码
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
// 底层就是一个 HashMap
private transient HashMap<E,Object> map;
// 所有元素共用的虚拟 value
private static final Object PRESENT = new Object();
// 空参构造:直接 new 一个 HashMap
public HashSet() {
map = new HashMap<>();
}
// 带容量的构造
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
// 带容量+负载因子
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
// add 核心逻辑:put + 判断返回值
public boolean add(E e) {
// 这里的 map 就是上面 new 出来的 HashMap
// 只有第一次存储该键值才会返回null
// 重复存储会返回 旧Key的Value,也就是PRESENT,因为不为空,所以false,表示添加失败
return map.put(e, PRESENT) == null;
}
}
java
// HashMap
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
// HashMap 内部真正实现插入的方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// ...
// 关键逻辑在这里:
if (e != null) { // 键已经存在
V oldValue = e.value;
// onlyIfAbsent = false → 直接覆盖
// onlyIfAbsent = true → 只有值为null才覆盖
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
// ...
}
HashMap 和 HashTable 的区别
在 Java 中,HashMap 和 HashTable 都是对Map接口的实现,用于存储键值对的集合。
它们的区别在于:
- 线程安全
Hashtable是同步的,也就是线程安全的,因为Hashtable使用了synchronized给整个方法加了锁。HashMap不是同步的,也就是非线程安全的,在多线程环境下可能出现死循环或数据覆盖等问题。如果需要同步,可以使用Collections.synchronizedMap()方法来创建一个同步的HashMap。
- 性能
因为Hashtable给方法加了锁,所以性能不如HashMap(单线程和多线程都是) - 空值存储
Hashtable不允许存储null键以及null值HashMap允许键或值为null
- 继承
Hashtable:继承自Dictionary类(一个过时的抽象类)。HashMap:继承自AbstractMap类。这是 Java 集合框架(Java Collections Framework)的标准实现。
- 迭代器
Hashtable迭代器是Enumeration不支持fail-fastHashMap迭代器是Iterator支持fail-fast,即在迭代过程中如果其他线程修改集合,不会抛出ConcurrentModificationException
- 初始容量与扩容机制
HashTable:默认初始容量:11。 扩容机制:newCapacity = oldCapacity * 2 + 1,数组+链表结构,并且不会树化。
HashMap:默认初始容量:16,且容量总是 2 的幂次方(为了方便通过位运算取模,提高计算效率)。扩容机制:newCapacity = oldCapacity * 2(当元素数量超过阈值capacity * loadFactor时触发)。数组+链表+红黑树结构,链表长度大于8,数组长度大于等于64会树化。
Hashtable 不推荐使用
虽然 Hashtable 是线程安全的,但在多线程环境下官方也不推荐使用 Hashtable,因为 Hashtable 是给整个方法添加 synchronized 来实现线程安全的,所以它的性能很差。官方推荐在多线程环境下,使用线程安全的 ConcurrentHashMap 来完成数据存储。
ConcurrentHashMap 锁粒度更细,在多线程环境下的性能表现更好。
HashMap与ConcurrentHashMap的区别
| 对比项 | HashMap | ConcurrentHashMap |
|---|---|---|
| 线程安全 | 不安全 | 安全 |
| 锁粒度 | 无锁(全表操作时可能引发并发问题) | JDK1.7:分段锁;JDK1.8:CAS + synchronized 锁链表/红黑树头节点 |
| 允许 null | key 和 value 都允许为 null | key 和 value 都不允许为 null |
| 并发性能 | 多线程下可能死循环或数据错乱 | 高并发下性能良好,读操作几乎无锁 |
| 迭代器 | fail-fast(快速失败) | JDK1.8 迭代器是弱一致性(不会抛出 ConcurrentModificationException) |
| 继承体系 | 继承 AbstractMap | 继承 AbstractMap,实现 ConcurrentMap |
线程安全性
-
HashMap :非线程安全。
多线程环境下进行
put操作可能导致 死循环(JDK1.7 头插法扩容时)、数据丢失、size 不准确等问题。即使只做读操作,若同时有写操作,也可能读到脏数据。 -
ConcurrentHashMap :线程安全。
采用精细化的锁机制,允许多个线程并发修改不同的 segment(1.7)或不同的数组槽位(1.8),而不需要锁住整个 Map。
底层数据结构与锁实现(重点:版本差异)
JDK 1.7 的 ConcurrentHashMap
-
结构 :Segment 数组 + HashEntry 数组 。
Segment 继承自 ReentrantLock,每个 Segment 独立加锁,相当于将一个 HashMap 拆分成多个子 Map。
-
锁粒度 :Segment 级别(默认 16 个并发度)。
写操作只锁定对应的 Segment,不同 Segment 之间可以并发写入。
-
扩容:每个 Segment 内部独立扩容,不影响其他 Segment。
JDK 1.8 的 ConcurrentHashMap
-
结构 :Node 数组 + 链表 / 红黑树 (与 HashMap 1.8 一致)。
取消了 Segment 分段锁,采用 CAS + synchronized 实现更细粒度的锁。
-
锁粒度 :数组桶级别 。
插入时,若该位置为空,使用 CAS 插入;若不为空,则 synchronized 锁住该桶的头节点(链表头或红黑树根节点)。
不同桶之间完全无锁竞争,并发度大幅提升。
-
扩容 :支持多线程并发扩容,每个线程负责一部分数据迁移,提升了扩容效率。
面试加分点 :
JDK 1.8 的 ConcurrentHashMap 在锁粒度上比 1.7 更细,并且利用了 CAS 的无锁操作 + synchronized 的优化(synchronized 在 JDK 1.6 后引入了偏向锁、轻量级锁等优化,性能已经不逊于 ReentrantLock),同时解决了 1.7 中遍历时一致性弱的问题。
对 null 值的支持
-
HashMap :允许一个
nullkey 和任意多个nullvalue。 -
ConcurrentHashMap :key 和 value 都不能为 null 。
为了规避并发场景下的二义性,原因在于设计时为了避免并发情况下的歧义性(例如,
get(key)返回null时无法区分是 key 不存在还是 value 本身为 null),以及为了避免在并发环境下因null值引发的非线程安全问题。
迭代器与并发修改
-
HashMap 的迭代器是 fail-fast 机制:
在迭代过程中,如果检测到结构被修改(除了迭代器自身的
remove),会抛出ConcurrentModificationException。 -
ConcurrentHashMap 的迭代器是 弱一致性(weakly consistent) :
遍历过程中允许其他线程并发修改,迭代器 不抛出异常 ,并且 尽力保证遍历的线程安全 ,但 不保证能立即看到最新的修改。
性能对比
-
单线程环境 :
HashMap性能优于ConcurrentHashMap,因为ConcurrentHashMap引入了额外的线程安全机制(如 CAS 循环、volatile 变量等)。 -
多线程环境 :
ConcurrentHashMap远优于Collections.synchronizedMap(new HashMap())(全表锁),在高并发读写场景下表现优秀。