Java: 集合

Java 集合类有哪些

Java 中的集合类主要由 CollectionMap 这两个接口派生而出,其中 Collection 接口又派生出三个子接口,分别是 SetListQueue。所有的 Java 集合类,都是 SetListQueueMap 这四个接口的实现类。

Collection 接口(单列集合)

  1. 定义Collection所有单列集合的根接口
    用于存储一系列不重复或重复的单个元素。
  2. 分类
    • 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 接口(双列集合)

  1. 特点
    • 双列集合用于存储键值对Key-Value Pair
    • 键(Key)是唯一的,用于快速查找对应的值。
    • 值(Value)可以重复
    • 一个键最多映射一个值
  2. 常见的实现类
    • 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的区别是什么?

  1. ArrayList vs Array
    • 长度:ArrayList是动态数组的实现,可以自动扩容。而Array 是固定长度的数组。
    • 功能:ArrayList 提供了更多的功能,比如自动扩容 、增加和删除元素等,Array只有 length 属性和下标访问,无内置增删方法
    • 存储类型:Array 可以直接存储基本类型数据,也可以存储对象。 ArrayList 中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)
  2. 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

  1. 底层实现

    在JDK 1.8之前,HashMap数组和链表 组成,当发生哈希冲突时,多个元素会以链表的形式存储在同一个数组位置。JDK 1.8开始引入了红黑树 ,当链表长度大于等于8时,数组长度大于等于64时,链表会转换成红黑树,以提高搜索效率,链表大小小于等于6时就会变成链表,因为红黑树在节点数量较大时,优势才会明显体现出来,节点数少的时候红黑树的维护会引来额外开销。

  2. 为什么链表大小超过 8 会自动转为红黑树,小于 6 时重新变成链表

根据泊松分布 ,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分 之一,所以将7作为一个分水岭, 等于 7 的时候不转换,大于等于 8 的时候才转换成红黑树,小于等于 6 的时候转化为链表。

  1. 为什么引入红黑树,而不是其他树?

是因为红黑树具有以下几点性质

  • 不追求绝对的平衡,插入或删除节点时,允许有一定的局部不平衡,相较于AVL树的绝对自平衡,减少了很多性能开销;
  • 红黑树是一种自平衡的二叉搜索树,因此插入和删除操作的时间复杂度都是O(log n)
  1. HashMap 读和写的时间复杂度是多少?
  • 读:
    • 在最佳情况下:读操作的时间复杂度是 O(1)
    • 最坏情况下:发生哈希冲突,链表为O(n), 红黑树为O(log n)。
  • 写:
  • 理想情况:与读操作类似,如果哈希函数分布均匀,写操作的时间复杂度也是 O(1)。
  • 处理哈希冲突:如果发生哈希冲突,需要在链表尾部添加新元素或将链表转换为红黑树。在这种情况下,写操作的时间复杂度可能达到 O(n),其中 n 是链表的长度。

解决Hash冲突的方法有哪些? HashMap是如何解决 Hash 冲突的?

在哈希表(散列表)中,解决冲突主要有四大类方法:

  1. 链地址法(拉链法)

    • 原理:将哈希表的每个位置(桶)看作一个链表(或红黑树)。当多个元素映射到同一个桶时,将它们放在该桶的链表/树中。
    • 优点:处理冲突简单,平均查找时间短,删除节点容易。
    • 缺点:需要额外的指针存储空间。
  2. 开放地址法

    • 原理 :当冲突发生时,通过探测序列去寻找下一个空闲的桶。
    • 常见探测方式 :线性探测(依次往后找)、二次探测(+12,−12,+22...+1^2, -1^2, +2^2...+12,−12,+22...)、双重散列。
    • 优点:数据都存储在数组内,没有链表指针开销。
    • 缺点 :删除节点麻烦(通常需标记为已删除),且容易产生"聚集"现象,性能退化为O(n)
  3. 再哈希法

    • 原理 :当发生冲突时,不是简单地线性移动一个固定步长,而是使用第二个哈希函数计算出一个新的步长,再根据这个步长寻找下一个空闲桶。
    • 优点:不易发生聚集。
    • 缺点:计算时间开销较大。
  4. 建立公共溢出区

    • 原理:将哈希表分为基本表和溢出表,冲突的元素都放入溢出表。

HashMap 主要采用"链地址法",并引入了"红黑树"进行优化,同时配合"高位扰动"和"扩容后重新计算节点哈希"来减少冲突。

高位扰动:将 hashCode 的高 16 位与低 16 位进行异或运算,使哈希分布更均匀。

扩容后重新计算节点哈希位置:节点数量超过了装载因子与数组长度的乘积,会进行扩容,所有元素会重新计算新的桶位置

HashMap 的 put 方法流程?

  1. 初始化检查 :首先检查数组table是否为null或长度为0。如果是,则调用resize()进行初始化(懒加载机制),默认容量分配空间。

  2. 计算索引并插入 :根据key 键的hashcode值计算索引下标i

    检查table 对应 的下标

    • 情况A(无冲突) :如果table[i] == null,直接在该位置新建节点。

    • 情况B(存在冲突) :如果table[i] != null,则:

      • 先检查table[i]的首节点是否与当前key相同(比较hashcodeequals)。若相同,则记录该节点,准备覆盖。

      • 若不同,则判断该节点是否为树节点(TreeNode)。如果是红黑树,则执行红黑树的插入逻辑。

      • 如果是链表,则遍历链表。若遍历过程中找到相同key,则记录节点准备覆盖;若未找到,则在链表尾部插入新节点。

  3. 链表树化 :在链表插入完成后,判断当前链表的长度是否 > 8

    • 如果 > 8,调用treeifyBin方法。但该方法会进一步判断当前数组长度是否 < 64

    • 如果 < 64,优先执行扩容resize)。

    • 如果 ≥ 64,则将链表转换为红黑树

  4. 覆盖旧值 :如果在步骤2或遍历过程中找到了相同的key,则用新值替换旧值,并返回旧值(方法结束)。

  5. 扩容判断 :如果是新增节点(非覆盖),则在插入成功后,判断当前size是否大于扩容阈值threshold容量 * 负载因子)。如果超过,则进行resize扩容。

HashMap 的扩容机制

触发扩容的时机

  1. 首次 put 时 :如果 table 为空或长度为 0,会触发初始化扩容(resize()),分配默认容量或指定容量。指定容量的话,会放大到2的幂的大小,以便进行模计算的时候可以直接减一按位与来计算hashcode的模,降低直接进行模计算的开销
  2. put 后 size > threshold:每次插入新键值对后,检查当前元素个数是否大于阈值,若大于则扩容。
  3. 树化时:当链表长度达到 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

  1. 采用头插法,并发可能形成环。
  2. 迁移节点,是直接遍历,节点计算新下标,插入到新数组,开销更大。

  1. HashMap通过高16位与低16位进行异或运算来让高位参与散列,提高散列效果;

  2. HashMap控制数组的长度为2的整数次幂来简化取模运算,提高性能;(对hashcode进行求余运算和让hashcode与数组长度-1进行位与运算是相同的效果)

  3. HashMap通过控制初始化的数组长度为2的整数次幂、扩容为原来的2倍来控制数组长度一定为2的整数次幂。

再优秀的hash算法永远无法避免出现hash冲突。hash冲突指的是两个不同的key经过hash计算之后得到的数组下标是相同的。解决hash冲突的方式很多,如开放定址法、再哈希法、公共溢出表法、链地址法。HashMap采用的是链地址法,jdk1.8之后还增加了红黑树的优化,

  1. HashMap采用链地址法,当发生冲突时会转化为链表,当链表过长会转化为红黑树提高效率。

  2. HashMap对红黑树进行了限制,让红黑树只有在极少数极端情况下进行抗压。

  3. 装载因子决定了HashMap扩容的阈值,需要权衡时间与空间,一般情况下保持0.75不作改动;

  4. HashMap扩容机制结合了数组长度为2的整数次幂的特点,以一种更高的效率完成数据迁移,同时避免头插法造成链表环。

  5. HashMap并不能保证线程安全,在多线程并发访问下会出现意想不到的问题,如数据丢失等

  6. HashMap1.8采用尾插法进行扩容,防止出现链表环导致的死循环问题

  7. 解决并发问题的的方案有HashtableCollections.synchronizeMap()ConcurrentHashMap。其中最佳解决方案是ConcurrentHashMap

  8. 上述解决方案并不能完全保证线程安全

  9. 快速失败是HashMap迭代机制中的一种并发安全保证

HashMap 是线程安全的吗?多线程下会有什么问题?如何实现线程安全?

HashMap 不是线程安全的。

因为所有方法(如 putgetresize 等)都没有使用 synchronized 或 加锁 进行保护。这些操作都不是原子性的

在多线程环境下,使用 HashMap 可能会出现以下问题:

  • 死循环 :在 JDK 1.7 中,HashMap 使用头插法插入元素,当多个线程同时进行扩容操作时,可能会导致环形链表的形成,后续对同一哈希桶的get操作会陷入死循环。为了解决这个问题,在 JDK 1.8 中采用了尾插法插入元素,保持了链表元素的顺序,避免了死循环的问题。
  • 数据覆盖:当多个线程同时执行 put 操作时,如果它们计算出的索引位置相同,可能会造成前一个 key 被后一个 key 覆盖的情况,从而导致元素的丢失。

关于多线程安全的实现方案,可以采取以下措施:

  1. 如果并发要求不高或对遗留代码兼容

    • Hashtable 是 JDK 1.0 的遗留类,所有方法都用 synchronized 修饰,属于全表锁,(不允许 null 键值),性能较差,新代码中不建议使用

    • Collections.synchronizedMap 是 JDK 1.2 提供的包装类,同样采用全表锁(通过互斥对象),(允许 null 键值),但并发能力依旧有限,仅适合并发要求极低的场景。

  2. 如果是高并发场景 ,我通常会使用 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 锁住当前桶的头节点


可重入锁指的是同一个线程可以多次获取同一把锁而不会死锁,内部通过计数器实现重复加锁与对应释放。
CASCompare-And-Swap (比较并交换)的缩写,在 Java 并发编程中是一种非常重要的无锁算法 技术。它利用 CPU 的原子指令,让多线程在更新共享变量时无需加锁 就能保证线程安全。

具体:

CAS 操作包含三个操作数:

内存位置 V:要操作的变量在内存中的地址(对应 Java 中的某个变量的引用)。

期望值 A:线程认为该内存位置当前应该持有的值。

新值 B:线程希望将该内存位置更新为的值。

执行逻辑:

当且仅当 V 的值等于 A 时,CPU 才会原子地将 V 的值更新为 B,否则不执行任何操作。无论是否更新成功,都会返回 V 原来的值(或返回布尔值表示是否成功)。

整个过程是一条 CPU 原子指令(如 x86 架构下的 CMPXCHG),中间不会被其他线程打断,从而保证了操作的原子性。

HashMap 和 HashSet 的区别

HashMapHashSet 都是Java中的集合类,但它们有以下区别:

  1. 存储内容不同
  • HashSet实现了Set接口,只存储对象;
  • HashTable实现了Map接口,用于存储键值对
  1. 底层实现
    HashSet 的底层实际上就是 HashMapHashSet 内部维护了一个 HashMap 的引用,当我们向 HashSet 添加元素时,实际上是把该元素作为 HashMapKey 存入,而 Value 则是一个统一的静态常量对象PRESENT,空的 new Object(),永远不变)。
  2. 重复值
  • 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 中,HashMapHashTable 都是对Map接口的实现,用于存储键值对的集合。

它们的区别在于:

  1. 线程安全
    • Hashtable是同步的,也就是线程安全的,因为Hashtable使用了synchronized给整个方法加了锁。
    • HashMap不是同步的,也就是非线程安全的,在多线程环境下可能出现死循环或数据覆盖等问题。如果需要同步,可以使用 Collections.synchronizedMap() 方法来创建一个同步的 HashMap
  2. 性能
    因为Hashtable给方法加了锁,所以性能不如HashMap (单线程和多线程都是)
  3. 空值存储
    • Hashtable不允许存储 null 键以及 null
    • HashMap允许键或值为null
  4. 继承
    • Hashtable:继承自 Dictionary 类(一个过时的抽象类)。
    • HashMap:继承自 AbstractMap 类。这是 Java 集合框架(Java Collections Framework)的标准实现。
  5. 迭代器
    • Hashtable 迭代器是 Enumeration 不支持 fail-fast
    • HashMap 迭代器是 Iterator 支持 fail-fast,即在迭代过程中如果其他线程修改集合,不会抛出 ConcurrentModificationException
  6. 初始容量与扩容机制
    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 :允许一个 null key 和任意多个 null value。

  • ConcurrentHashMapkey 和 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())(全表锁),在高并发读写场景下表现优秀。

相关推荐
ch.ju3 小时前
Java程序设计(第3版)第四章——动态部分
java·开发语言
_Evan_Yao3 小时前
从 select 到 epoll,再到 Agent 循环:如何用 I/O 多路复用撑起千军万马?
java·数据库·人工智能·后端
诙_3 小时前
C++学习总结
开发语言·c++·学习
2401_865439633 小时前
探索JavaScript对象创建的灵活方式
开发语言·javascript·ecmascript
程序猿~厾罗3 小时前
回归更新,一个简单的重新认识
开发语言
我命由我123453 小时前
Android 开发:Unable to start service Intent { ... } U=0: not found
android·开发语言·android studio·android jetpack·android-studio·android runtime
代码不停3 小时前
记忆化搜索题目练习
java·算法
长谷深风1113 小时前
SpringBoot开发秘籍【个人八股】
java·spring boot·后端·spring·八股
三品吉他手会点灯3 小时前
C语言学习笔记 - 34.数据类型 - 编程规范与高效学习方法
c语言·开发语言·笔记·学习