42Java集合基础

Java集合基础

img

概念

#数组与集合区别,用过哪些?

数组和集合的区别:

  • 数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或减少元素。
  • 数组可以包含基本数据类型和对象,而集合只能包含对象。
  • 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素。

Java中的线程安全的集合是什么?

在 java.util 包中的线程安全的类主要 2 个,其他都是非线程安全的。

  • Vector:线程安全的动态数组,其内部方法基本都经过synchronized修饰,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
  • Hashtable:线程安全的哈希表,HashTable 的加锁方法是给每个方法加上 synchronized 关键字,这样锁住的是整个 Table 对象,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用,如果要保证线程安全的哈希表,可以用ConcurrentHashMap。

java.util.concurrent 包提供的都是线程安全的集合:

并发Map:

  • ConcurrentHashMap:它与 HashTable 的主要区别是二者加锁粒度的不同,在JDK1.7,ConcurrentHashMap加的是分段锁,也就是Segment锁,每个Segment 含有整个 table 的一部分,这样不同分段之间的并发操作就互不影响。在JDK 1.8 ,它取消了Segment字段,直接在table元素上加锁,实现对每一行进行加锁,进一步减小了并发冲突的概率。对于put操作,如果Key对应的数组元素为null,则通过CAS操作(Compare and Swap)将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。如果该 put 操作使得当前链表长度超过一定阈值,则将该链表转换为红黑树,从而提高寻址效率。
  • ConcurrentSkipListMap:实现了一个基于SkipList(跳表)算法的可排序的并发集合,SkipList是一种可以在对数预期时间内完成搜索、插入、删除等操作的数据结构,通过维护多个指向其他元素的“跳跃”链接来实现高效查找。

并发Set:

  • ConcurrentSkipListSet:是线程安全的有序的集合。底层是使用ConcurrentSkipListMap实现。
  • CopyOnWriteArraySet:是线程安全的Set实现,它是线程安全的无序的集合,可以将它理解成线程安全的HashSet。有意思的是,CopyOnWriteArraySet和HashSet虽然都继承于共同的父类AbstractSet;但是,HashSet是通过“散列表”实现的,而CopyOnWriteArraySet则是通过“动态数组(CopyOnWriteArrayList)”实现的,并不是散列表。

并发List:

  • CopyOnWriteArrayList:它是 ArrayList 的线程安全的变体,其中所有写操作(add,set等)都通过对底层数组进行全新复制来实现,允许存储 null 元素。即当对象进行写操作时,使用了Lock锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换掉旧数组;若进行的读操作,则直接返回结果,操作过程中不需要进行同步。

并发 Queue:

  • ConcurrentLinkedQueue:是一个适用于高并发场景下的队列,它通过无锁的方式(CAS),实现了高并发状态下的高性能。通常,ConcurrentLinkedQueue 的性能要好于 BlockingQueue 。
  • BlockingQueue:与 ConcurrentLinkedQueue 的使用场景不同,BlockingQueue 的主要功能并不是在于提升高并发时的队列性能,而在于简化多线程间的数据共享。BlockingQueue 提供一种读写阻塞等待的机制,即如果消费者速度较快,则 BlockingQueue 则可能被清空,此时消费线程再试图从 BlockingQueue 读取数据时就会被阻塞。反之,如果生产线程较快,则 BlockingQueue 可能会被装满,此时,生产线程再试图向 BlockingQueue 队列装入数据时,便会被阻塞等待。

并发 Deque:

  • LinkedBlockingDeque:是一个线程安全的双端队列实现。它的内部使用链表结构,每一个节点都维护了一个前驱节点和一个后驱节点。LinkedBlockingDeque 没有进行读写锁的分离,因此同一时间只能有一个线程对其进行操作
  • ConcurrentLinkedDeque:ConcurrentLinkedDeque是一种基于链接节点的无限并发链表。可以安全地并发执行插入、删除和访问操作。当许多线程同时访问一个公共集合时,ConcurrentLinkedDeque是一个合适的选择。

List

ArrayList 和 LinkedList 的应用场景?

  • ArrayList适用于需要频繁访问集合元素的场景。它基于数组实现,可以通过索引快速访问元素,因此在按索引查找、遍历和随机访问元素的操作上具有较高的性能。当需要频繁访问和遍历集合元素,并且集合大小不经常改变时,推荐使用ArrayList
  • LinkedList适用于频繁进行插入和删除操作的场景。它基于链表实现,插入和删除元素的操作只需要调整节点的指针,因此在插入和删除操作上具有较高的性能。当需要频繁进行插入和删除操作,或者集合大小经常改变时,可以考虑使用LinkedList。

ArrayList的扩容机制说一下

ArrayList在添加元素时,如果当前元素个数已经达到了内部数组的容量上限,就会触发扩容操作。ArrayList的扩容操作主要包括以下几个步骤:

  • 计算新的容量:一般情况下,新的容量会扩大为原容量的1.5倍(在JDK 10之后,扩容策略做了调整),然后检查是否超过了最大容量限制。
  • 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组。
  • 将元素复制:将原来数组中的元素逐个复制到新数组中。
  • 更新引用:将ArrayList内部指向原数组的引用指向新数组。
  • 完成扩容:扩容完成后,可以继续添加新元素。

ArrayList的扩容操作涉及到数组的复制和内存的重新分配,所以在频繁添加大量元素时,扩容操作可能会影响性能。为了减少扩容带来的性能损耗,可以在初始化ArrayList时预分配足够大的容量,避免频繁触发扩容操作。

Map

了解的哈希冲突解决方法有哪些?

  • 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
  • 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。
  • 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
  • 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率

HashMap是线程安全的吗?

如果要保证线程安全,可以通过这些方法来保证:

  • 多线程环境可以使用Collections.synchronizedMap同步加锁的方式,还可以使用HashTable,但是同步的方式显然性能不达标,而ConurrentHashMap更适合高并发场景使用。
  • ConcurrentHashmap在JDK1.7和1.8的版本改动比较大,1.7使用Segment+HashEntry分段锁的方式实现,1.8则抛弃了Segment,改为使用CAS+synchronized+Node实现,同样也加入了红黑树,避免链表过长导致性能的问题。

hashmap的put过程介绍一下

img

HashMap HashMap的put()方法用于向HashMap中添加键值对,当调用HashMap的put()方法时,会按照以下详细流程执行(JDK8 1.8版本):

第一步:根据要添加的键的哈希码计算在数组中的位置(索引)。

第二步:检查该位置是否为空(即没有键值对存在)

  • 如果为空,则直接在该位置创建一个新的Entry对象来存储键值对。将要添加的键值对作为该Entry的键和值,并保存在数组的对应位置。将HashMap的修改次数(modCount)加1,以便在进行迭代时发现并发修改。

第三步:如果该位置已经存在其他键值对,检查该位置的第一个键值对的哈希码和键是否与要添加的键值对相同?

  • 如果相同,则表示找到了相同的键,直接将新的值替换旧的值,完成更新操作。

第四步:如果第一个键值对的哈希码和键不相同,则需要遍历链表或红黑树来查找是否有相同的键:

如果键值对集合是链表结构,从链表的头部开始逐个比较键的哈希码和equals()方法,直到找到相同的键或达到链表末尾。

  • 如果找到了相同的键,则使用新的值取代旧的值,即更新键对应的值。
  • 如果没有找到相同的键,则将新的键值对添加到链表的头部。

如果键值对集合是红黑树结构,在红黑树中使用哈希码和equals()方法进行查找。根据键的哈希码,定位到红黑树中的某个节点,然后逐个比较键,直到找到相同的键或达到红黑树末尾。

  • 如果找到了相同的键,则使用新的值取代旧的值,即更新键对应的值。
  • 如果没有找到相同的键,则将新的键值对添加到红黑树中。

第五步:检查链表长度是否达到阈值(默认为8):

  • 如果链表长度超过阈值,且HashMap的数组长度大于等于64,则会将链表转换为红黑树,以提高查询效率。

第六步:检查负载因子是否超过阈值(默认为0.75):

  • 如果键值对的数量(size)与数组的长度的比值大于阈值,则需要进行扩容操作。

第七步:扩容操作:

  • 创建一个新的两倍大小的数组。
  • 将旧数组中的键值对重新计算哈希码并分配到新数组中的位置。
  • 更新HashMap的数组引用和阈值参数。

第八步:完成添加操作。

此外,HashMap是非线程安全的,如果在多线程环境下使用,需要采取额外的同步措施或使用线程安全的ConcurrentHashMap。

为什么HashMap要用红黑树而不是平衡二叉树?

  • 平衡二叉树追求的是一种 “完全平衡” 状态:任何结点的左右子树的高度差不会超过 1,优势是树的结点是很平均分配的。这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋右旋来进行调整,使之再次成为一颗符合要求的平衡树。
  • 红黑树不追求这种完全平衡状态,而是追求一种 “弱平衡” 状态:整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁着调整,这也是我们为什么大多数情况下使用红黑树的原因

hashmap key可以为null吗?

可以为 null。

  • hashMap中使用hash()方法来计算key的哈希值,当key为空时,直接另key的哈希值为0,不走key.hashCode()方法;

img

  • hashMap虽然支持key和value为null,但是null作为key只能有一个,null作为value可以有多个;
  • 因为hashMap中,如果key值一样,那么会覆盖相同key值的value为最新,所以key为null只能有一个。

重写HashMap的equal和hashcode方法需要注意什么?

HashMap使用Key对象的hashCode()和equals方法去决定key-value对的索引。当我们试着从HashMap中获取值的时候,这些方法也会被用到。如果这些方法没有被正确地实现,在这种情况下,两个不同Key也许会产生相同的hashCode()和equals()输出,HashMap将会认为它们是相同的,然后覆盖它们,而非把它们存储到不同的地方。

同样的,所有不允许存储重复数据的集合类都使用hashCode()和equals()去查找重复,所以正确实现它们非常重要。equals()和hashCode()的实现应该遵循以下规则:

  • 如果o1.equals(o2),那么o1.hashCode() == o2.hashCode()总是为true的。
  • 如果o1.hashCode() == o2.hashCode(),并不意味着o1.equals(o2)会为true。

重写HashMap的equal方法不当会出现什么问题?

HashMap在比较元素时,会先通过hashCode进行比较,相同的情况下再通过equals进行比较。

所以 equals相等的两个对象,hashCode一定相等。hashCode相等的两个对象,equals不一定相等(比如散列冲突的情况)

重写了equals方法,不重写hashCode方法时,可能会出现equals方法返回为true,而hashCode方法却返回false,这样的一个后果会导致在hashmap等类中存储多个一模一样的对象,导致出现覆盖存储的数据的问题,这与hashmap只能有唯一的key的规范不符合。

往hashmap存20个元素,会扩容几次?

当插入 20 个元素时,HashMap 的扩容过程如下:

初始容量:16

  • 插入第 1 到第 12 个元素时,不需要扩容。
  • 插入第 13 个元素时,达到负载因子限制,需要扩容。此时,HashMap 的容量从 16 扩容到 32。

扩容后的容量:32

  • 插入第 14 到第 24 个元素时,不需要扩容。

因此,总共会进行一次扩容。

列举HashMap在多线程下可能会出现的问题?

  • JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
  • 多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。

HashMap的扩容机制介绍一下

hashMap默认的负载因子是0.75,即如果hashmap中的元素个数超过了总容量75%,则会触发扩容,扩容分为两个步骤:

  • 第1步是对哈希表长度的扩展(2倍)
  • 第2步是将旧哈希表中的数据放到新的哈希表中。

因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。

如我们从16扩展为32时,具体的变化如下所示:

img

因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

img

因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了(原有的值相当于是暴露出来了可以使用了),是0的话索引没变,是1的话索引变成“原索引+oldCap”。可以看看下图为16扩充为32的resize示意图:

img

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

ConcurrentHashMap

ConcurrentHashMap用了悲观锁还是乐观锁?

悲观锁和乐观锁都有用到。

添加元素时首先会判断容器是否为空:

  • 如果为空则使用 volatile 加 CAS (乐观锁) 来初始化。
  • 如果容器不为空,则根据存储的元素计算该位置是否为空。
  • 如果根据存储的元素计算结果为空,则利用 CAS(乐观锁) 设置该节点;
  • 如果根据存储的元素计算结果不为空,则使用 synchronized(悲观锁) ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了

HashTable 底层实现原理是什么?

img

  • Hashtable的底层数据结构主要是数组加上链表,数组是主体,链表是解决hash冲突存在的。
  • HashTable是线程安全的,实现方式是Hashtable的所有公共方法均采用synchronized关键字,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态。

Set

Set集合有什么特点?如何实现key无重复的?

  • set集合特点:Set集合中的元素是唯一的,不会出现重复的元素。
  • set实现原理:Set集合通过内部的数据结构(如哈希表、红黑树等)来实现key的无重复。(插入key,value为一个常量值present)

初始容量问题

集合类 无参构造初始容量 首次添加后容量 最小允许显式容量 备注
ArrayList 0 10 0 Java 8+延迟初始化
HashMap 0 16 1 容量总是2的幂
  1. HashMap容量规则
    • 容量总是2的幂
    • 指定容量0会被调整为1
    • 指定容量1-16都会被调整为16(首次添加元素时)
    • 实际容量计算:大于等于指定值的最小2的幂
  2. 性能影响
    • 如果已知集合大小,最好在构造时指定合适的初始容量
    • 避免频繁扩容带来的性能开销
    • ArrayList扩容时会增加50%的容量
    • HashMap扩容会翻倍容量

Vector (遗留类,线程安全)

  • 无延迟初始化:构造时立即分配数组
  • 默认容量10,扩容时翻倍(与ArrayList的1.5倍不同

PriorityQueue

  • 无延迟初始化,构造时分配大小为11的数组
  • 为什么是11?历史原因,与堆数据结构特性有关

ArrayDeque

  • 无延迟初始化
  • 为什么是16?优化循环数组操作,2的幂便于位运算

end


42Java集合基础
http://example.com/2025/11/23/42Java集合基础/
作者
無鎏雲
发布于
2025年11月23日
许可协议