Java集合面试题
本文《Java集合面试题》由小宇宙从原站整理搬运,详细讲解相关技术要点。
大家好,我是小宇宙。 Java 集合是面试里几乎必考的一块,而且这块的题目有个特点:入门容易,但深挖起来没有底。很多人用 ArrayList 和 HashMap 用了好几年,但面试被问到"HashMap 为什么用红黑树而不是平衡二叉树""ConcurrentHashMap 在 JDK 1.7 和 1.8 的实现有什么区别"时,就开始说不清楚了。集合类看起来只是工具,但背后涉及的数据结构、哈希算法、并发机制,才是面试真正在考的东西。 这篇文章整理了 Java 集合框架面试中最常被问到的问题,涵盖 List、Set、Map 三大体系,重点包括 ArrayList 和 LinkedList 的差异、HashMap 的底层实现和扩容机制、ConcurrentHashMap 的线程安全方案,以及 HashSet、TreeMap、LinkedHashMap 这些常用实现类的原理和使用场景。 有几块内容在面试里被问得特别多,建议重点花时间: HashMap :put 过程、扩容机制、为什么初始容量是 2 的幂次方、链表转红黑树的条件,这些几乎每次都会问,而且很容易被追问到细节。 ConcurrentHashMap :JDK 1.7 的分段锁和 JDK 1.8 的 CAS + synchronized 方案的区别,是并发面试里的高频考点。 ArrayList 的线程安全问题 :为什么不安全、具体会出现哪些问题、有哪些替代方案,这个看起来简单但答起来很容易流于表面。 equals 和 hashCode 的关系 :这两个方法为什么要配套重写,放到 HashMap 和 HashSet 里会有什么影响,基础但经常被忽略。 如果你是第一次系统准备这块,建议先搞清楚 ArrayList 和 HashMap 的底层原理,再去看线程安全相关的集合类,这样理解起来会顺很多。 概念 数组与集合区别,用过哪些? 数组和集合的区别: 数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或减少元素。 数组可以包含基本数据类型和对象,而集合只能包含对象。 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素。 我用过的一些 Java 集合类: 1. ArrayList: 动态数组,实现了List接口,支持动态增长。 2. LinkedList: 双向链表,也实现了List接口,支持快速的插入和删除操作。 3. HashMap: 基于哈希表的Map实现,存储键值对,通过键快速查找值。 4. HashSet: 基于HashMap实现的Set集合,用于存储唯一元素。 5. TreeMap: 基于红黑树实现的有序Map集合,可以按照键的顺序进行排序。 6. LinkedHashMap: 基于哈希表和双向链表实现的Map集合,保持插入顺序或访问顺序。 7. PriorityQueue: 优先队列,可以按照比较器或元素的自然顺序进行排序。 说说Java中的集合? List是有序的Collection,使用此接口能够精确的控制每个元素的插入位置,用户能根据索引访问List中元素。常用的实现List的类有LinkedList,ArrayList,Vector,Stack。 ArrayList 是容量可变的非线程安全列表,其底层使用数组实现。当集合扩容时,会创建更大的数组,并把原数组复制到新数组。ArrayList 支持对元素的快速随机访问,在尾部追加/删除元素效率很高,但在中间位置插入/删除需要搬移元素,代价较高。 LinkedList 本质是一个双向链表,支持高效的头尾插入/删除和作为双端队列使用。需要注意的是:"LinkedList 插入/删除比 ArrayList 更快"是一个常见误区:其 O(1) 的前提是 已经持有目标节点的引用 ;如果要在任意位置插入/删除,仍需先 O(n) 遍历链表找到位置,加上每个节点都需要独立分配、对 CPU 缓存不友好,实测大多数场景下 LinkedList 反而比 ArrayList 慢,这也是现在主流建议优先使用 ArrayList 的原因。 Set不允许存在重复的元素,与List不同,set中的元素是无序的。常用的实现有HashSet,LinkedHashSet和TreeSet。 HashSet通过HashMap实现,HashMap的Key即HashSet存储的元素,所有Key都是用相同的Value,一个名为PRESENT的Object类型常量。使用Key保证元素唯一性,但不保证有序性。由于其底层的 HashMap 本身就是非线程安全的,因此 HashSet 也是非线程安全的。 LinkedHashSet继承自HashSet,通过LinkedHashMap实现,使用双向链表维护元素插入顺序。 TreeSet通过TreeMap实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。 Map 是一个键值对集合,存储键、值和之间的映射。Key 无序,唯一;value 不要求有序,允许重复。Map 没有继承于 Collection 接口,从 Map 集合中检索元素时,只要给出键对象,就会返回对应的值对象。主要实现有TreeMap、HashMap、Hashtable、LinkedHashMap、ConcurrentHashMap HashMap:JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的("拉链法"解决冲突),JDK1.8 以后在解决哈希冲突时有了较大的变化:当某个桶的链表长度 ≥ 8 且哈希表数组长度 ≥ 64 时,才会将该链表转化为红黑树,以减少搜索时间;如果数组长度 < 64,则只会触发扩容而不做树化。 LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。 Hashtable:数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的 TreeMap:红黑树(自平衡的排序二叉树) ConcurrentHashMap:Node数组+链表+红黑树实现,线程安全的(jdk1.8以前Segment锁,1.8以后volatile + CAS 或者 synchronized) Java中的线程安全的集合是什么? 在 java.util 包中的线程安全的类主要 2 个,其他都是非线程安全的。 Vector :线程安全的动态数组,其内部方法基本都经过synchronized修饰,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。 Hashtable :线程安全的哈希表,Hashtable 的加锁方法是给每个方法加上 synchronized 关键字,这样锁住的是整个 Table 对象,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用,如果要保证线程安全的哈希表,可以用ConcurrentHashMap。 java.util.concurrent 包提供的都是线程安全的集合: 并发Map: ConcurrentHashMap :它与 Hashtable 的主要区别是二者加锁粒度的不同,在 JDK 1.7 ,ConcurrentHashMap 加的是分段锁,也就是 Segment 锁,每个 Segment 含有整个 table 的一部分,这样不同分段之间的并发操作就互不影响。在 JDK 1.8 ,它取消了 Segment,直接在 table 元素(桶的头节点)上加锁,使加锁粒度进一步缩小到单个桶级别。对于 put 操作,如果 Key 对应的数组槽位为 null,则通过 CAS 操作(Compare and Swap)将新节点写入该槽位;如果槽位不为 null(即已存在链表头或红黑树根节点),则对该头节点使用 加锁,然后遍历桶中的数据执行替换或新增。如果该 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是一个合适的选择。 Collections和Collection的区别 Collection是Java集合框架中的一个接口,它是所有集合类的基础接口。它定义了一组通用的操作和方法,如添加、删除、遍历等,用于操作和管理一组对象。Collection接口有许多实现类,如List、Set和Queue等。 Collections(注意有一个s)是Java提供的一个工具类,位于java.util包中。它提供了一系列静态方法,用于对集合进行操作和算法。Collections类中的方法包括排序、查找、替换、反转、随机化等等。这些方法可以对实现了Collection接口的集合进行操作,如List和Set。 集合遍历的方法有哪些? 在Java中,集合的遍历方法主要有以下几种: 普通 for 循环: 可以使用带有索引的普通 for 循环来遍历 List。 增强 for 循环(for each循环): 用于循环访问数组或集合中的元素。 Iterator 迭代器: 可以使用迭代器来遍历集合,特别适用于需要删除元素的情况。 ListIterator 列表迭代器: ListIterator是迭代器的子类,可以双向访问列表并在迭代过程中修改元素。 使用 forEach 方法: Java 8引入了 forEach 方法,可以对集合进行快速遍历。 Stream API: Java 8的Stream API提供了丰富的功能,可以对集合进行函数式操作,如过滤、映射等。 这些是常用的集合遍历方法,根据情况选择合适的方法来遍历和操作集合。 List 常见的List集合(非线程安全): 基于动态数组实现,它允许快速的随机访问,即通过索引访问元素的时间复杂度为 O (1)。在添加和删除元素时,如果操作位置不是列表末尾,可能需要移动大量元素,性能相对较低。适用于需要频繁随机访问元素,而对插入和删除操作性能要求不高的场景,如数据的查询和展示等。 基于双向链表实现,在插入和删除元素时,只需修改链表的指针,不需要移动大量元素,时间复杂度为 O (1)。但随机访问元素时,需要从链表头或链表尾开始遍历,时间复杂度为 O (n)。适用于需要频繁进行插入和删除操作的场景,如队列、栈等数据结构的实现,以及需要在列表中间频繁插入和删除元素的情况。 常见的List集合(线程安全): 和 类似,也是基于数组实现。 中的方法大多是同步的,这使得它在多线程环境下可以保证数据的一致性,但在单线程环境下,由于同步带来的开销,性能会略低于 。 在对列表进行修改(如添加、删除元素)时,会创建一个新的底层数组,将修改操作应用到新数组上,而读操作仍然在原数组上进行,这样可以保证读操作不会被写操作阻塞,实现了读写分离,提高了并发性能。适用于读操作远远多于写操作的并发场景,如事件监听列表等,在这种场景下可以避免大量的锁竞争,提高系统的性能和响应速度。 讲一下java里面list的几种实现,几种实现有什么不同? 在Java中, 接口是最常用的集合类型之一,用于存储元素的有序集合。以下是Java中常见的 实现及其特点: Vector 是 Java 早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。 ArrayList 是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与 Vector 近似,ArrayList 也是可以根据需要调整容量,不过两者的调整逻辑有所区别:Vector 默认按 2 倍扩容(如果构造时指定了 ,则按该值线性增长),而 ArrayList 则是增加 50%(即 1.5 倍)。 LinkedList 顾名思义是 Java 提供的双向链表,所以它不需要像上面两种那样调整容量,它也不是线程安全的。 这几种实现具体在什么场景下应该用哪种? Vector 和 ArrayList 作为动态数组,其内部元素以数组形式顺序存储的,所以非常适合随机访问的场合。除了尾部插入和删除元素,往往性能会相对较差,比如我们在中间位置插入一个元素,需要移动后续所有元素。 而 LinkedList 进行节点插入、删除却要高效得多,但是随机访问性能则要比动态数组慢。 list可以一边遍历一边修改元素吗? 在 Java 中, 在遍历过程中是否可以修改元素取决于遍历方式和具体的 实现类,以下是几种常见情况: 使用普通for循环遍历:可以在遍历过程中修改元素,只要修改的索引不超出 的范围即可。 使用 foreach 循环遍历:一般不建议在 循环中直接 修改集合结构 (add/remove),因为 底层基于迭代器实现,集合结构被修改后,迭代器下一次调用 时会检测到 ,从而抛出 异常。注意:"替换元素值"(即 ) 并不会改变 ,所以 本身不会抛 CME;但 / 会。下面是一个会抛 CME 的反例: 使用迭代器遍历时:如果需要在遍历过程中删除元素,应使用 ;如果需要替换元素,使用 是最通用、最推荐的做法。直接调用 虽然不会抛 CME(因为它不改变结构),但通过 更符合"遍历中修改"的惯用写法,可读性也更好。 对于线程安全的 ,如 ,由于其采用了写时复制的机制,在遍历的同时可以进行修改操作,不会抛出 异常,但可能会读取到旧的数据,因为修改操作是在新的副本上进行的。 list如何快速删除某个指定下标的元素? 提供了 方法来删除指定下标的元素,该方法在删除元素后,会将后续元素向前移动,以填补被删除元素的位置。如果删除的是列表末尾的元素,时间复杂度为 O (1);如果删除的是列表中间的元素,时间复杂度为 O (n),n 为列表中元素的个数,因为需要移动后续的元素。示例代码如下: 的 方法也可以用来删除指定下标的元素。它需要先遍历到指定下标位置,然后修改链表的指针来删除元素。时间复杂度为 O (n),n 为要删除元素的下标。不过,如果已知要删除的元素是链表的头节点或尾节点,可以直接通过修改头指针或尾指针来实现删除,时间复杂度为 O (1)。示例代码如下: 的 方法同样可以删除指定下标的元素。由于 在写操作时会创建一个新的数组,所以删除操作的时间复杂度取决于数组的复制速度,通常为 O (n),n 为数组的长度。但在并发环境下,它的删除操作不会影响读操作,具有较好的并发性能。示例代码如下: Arraylist和LinkedList的区别,哪个集合是线程安全的? ArrayList和LinkedList都是Java中常见的集合类,它们都实现了List接口。 底层数据结构不同 :ArrayList 使用动态数组实现,通过索引可以快速定位到元素。LinkedList 使用双向链表实现,每个节点都存储了元素本身以及指向前一个和后一个节点的指针,通过节点之间的指针关联来访问和操作元素。 插入和删除操作的效率不同 :ArrayList 在尾部进行插入和删除操作时效率较高,因为不需要移动其他元素;但如果是在中间或开头插入、删除,就需要移动后面的所有元素,效率会比较低。LinkedList 在头部和尾部进行插入、删除操作时效率很高,只需要调整节点的指针即可;但如果是在中间位置操作,需要先从头或尾遍历链表找到目标位置,时间复杂度也是 O (n),不过找到位置后只需要调整指针,不需要像 ArrayList 那样移动大量元素,所以在某些特定场景下还是有优势的,而且 LinkedList 实现了 Deque 接口,还可以当作双端队列、栈来使用。 随机访问的效率不同 :ArrayList 支持通过索引直接快速访问元素,时间复杂度为 O (1)。LinkedList 不支持随机访问,想要获取某个位置的元素,必须从头节点或尾节点开始逐个遍历,时间复杂度为 O (n)。 空间占用 :ArrayList 在创建时会分配一段连续的内存空间,虽然会有一定的容量浪费(比如实际元素没装满数组),但只需要存储元素本身。LinkedList 每个节点除了存储元素,还需要额外存储两个指针(指向前一个和后一个节点),所以在存储相同数量元素的情况下,LinkedList 的空间占用通常会比 ArrayList 更大一些。 使用场景 :ArrayList 更适合需要频繁随机访问元素,或者主要在尾部进行插入、删除操作的场景。LinkedList 更适合需要频繁在头部或尾部进行插入、删除操作,或者需要作为双端队列、栈使用的场景;如果是通过迭代器直接操作已知位置的节点,在中间插入、删除时也能发挥它调整指针快的优势。 线程安全 :这两个集合都不是线程安全的,如果在多线程环境下使用,需要自己加锁保证线程安全,或者使用线程安全的 List 集合,比如 Vector、Collections.synchronizedList () 包装的 List,或者 CopyOnWriteArrayList。 arraylist和vector 区别是什么? ArrayList 和 Vector 都是 Java 中常用的动态数组实现,用于存储和操作对象集合,但它们在设计上有几个关键区别,主要体现在线程安全性、性能和功能细节上。 首先是线程安全性,这是最核心的区别。Vector 是线程安全的,它的大部分方法(比如 add、remove、get 等)都被 synchronized 修饰,这意味着多线程环境下操作 Vector 时,不需要额外处理同步问题。而 ArrayList 没有任何同步机制,是非线程安全的,在多线程并发修改时可能会出现数据不一致的问题,比如抛出 ConcurrentModificationException 异常。 正因为同步机制的存在,两者在性能上也有差异。由于 Vector 的方法需要加锁释放锁,在单线程环境下,它的操作效率通常比 ArrayList 低。所以如果是单线程场景,或者能自己保证线程安全的情况下,ArrayList 是更优的选择,性能更好。 另外,在扩容机制上,两者也有所不同。当集合元素数量超过当前容量时,都会自动扩容。Vector 默认的扩容策略是翻倍(如果没有指定容量增量的话),比如初始容量 10,满了之后会扩容到 20。而 ArrayList 默认扩容为原来的 1.5 倍( ),这一策略从 JDK 1.7 起就一直沿用至今,相对来说扩容幅度更小,能在一定程度上节省内存空间。Vector 可以通过构造方法指定容量增量 (按固定数值线性增长),灵活控制扩容幅度,而 ArrayList 没有这个功能。 总的来说,选择两者时主要看是否需要线程安全:如果是多线程环境且需要内置同步支持,可能会用到 Vector;但现在更多时候会用 ArrayList,因为它性能更好,而且在需要线程安全时,可以通过 Collections.synchronizedList () 方法将 ArrayList 包装成线程安全的集合,灵活性更高。 ArrayList线程安全吗?把ArrayList变成线程安全有哪些方法? 不是线程安全的,ArrayList变成线程安全的方式有: 使用Collections类的synchronizedList方法将ArrayList包装成线程安全的List: 使用CopyOnWriteArrayList类代替ArrayList,它是一个线程安全的List实现: 使用Vector类代替ArrayList,Vector是线程安全的List实现: 为什么ArrayList不是线程安全的,具体来说是哪里不安全? 在高并发添加数据下,ArrayList会暴露三个问题; 部分值为null(我们并没有add null进去) 索引越界异常 size与我们add的数量不符 为了知道这三种情况是怎么发生的,ArrayList,add 增加元素的代码如下: ensureCapacityInternal()这个方法的详细代码我们可以暂时不看,它的作用就是判断如果将当前的新元素加到列表后面,列表的elementData数组的大小是否满足,如果size + 1的这个需求长度大于了elementData这个数组的长度,那么就要对这个数组进行扩容。 大体可以分为三步: 判断数组需不需要扩容,如果需要的话,调用grow方法进行扩容; 将数组的size位置设置值(因为数组的下标是从0开始的); 将当前集合的大小加1 下面我们来分析三种情况都是如何产生的: 部分值为null:当线程1走到了扩容那里发现当前size是9,而数组容量是10,所以不用扩容,这时候cpu让出执行权,线程2也进来了,发现size是9,而数组容量是10,所以不用扩容,这时候线程1继续执行,将数组下标索引为9的位置set值了,还没有来得及执行size++,这时候线程2也来执行了,又把数组下标索引为9的位置set了一遍,这时候两个先后进行size++,导致下标索引10的地方就为null了。 索引越界异常:线程1走到扩容那里发现当前size是9,数组容量是10不用扩容,cpu让出执行权,线程2也发现不用扩容,这时候数组的容量就是10,而线程1 set完之后size++,这时候线程2再进来size就是10,数组的大小只有10,而你要设置下标索引为10的就会越界(数组的下标索引从0开始); size与我们add的数量不符:这个基本上每次都会发生,这个理解起来也很简单,因为size++本身就不是原子操作,可以分为三步:获取size的值,将size的值加1,将新的size值覆盖掉原来的,线程1和线程2拿到一样的size值加完了同时覆盖,就会导致一次没有加上,所以肯定不会与我们add的数量保持一致的; ArrayList 和 LinkedList 的应用场景? ArrayList适用于需要频繁访问集合元素的场景。它基于数组实现,可以通过索引快速访问元素,因此在按索引查找、遍历和随机访问元素的操作上具有较高的性能。当需要频繁访问和遍历集合元素,并且集合大小不经常改变时,推荐使用ArrayList LinkedList适用于频繁进行插入和删除操作的场景。它基于链表实现,插入和删除元素的操作只需要调整节点的指针,因此在插入和删除操作上具有较高的性能。当需要频繁进行插入和删除操作,或者集合大小经常改变时,可以考虑使用LinkedList。 ArrayList的扩容机制说一下 ArrayList在添加元素时,如果当前元素个数已经达到了内部数组的容量上限,就会触发扩容操作。ArrayList的扩容操作主要包括以下几个步骤: 计算新的容量:一般情况下,新的容量会扩大为原容量的 1.5 倍(JDK 9 起 的实现被重构并提取到 ,但 1.5 倍这个比例本身没有变化),然后检查是否超过了最大容量限制。 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组。 将元素复制:将原来数组中的元素逐个复制到新数组中。 更新引用:将ArrayList内部指向原数组的引用指向新数组。 完成扩容:扩容完成后,可以继续添加新元素。 ArrayList的扩容操作涉及到数组的复制和内存的重新分配,所以在频繁添加大量元素时,扩容操作可能会影响性能。为了减少扩容带来的性能损耗,可以在初始化ArrayList时预分配足够大的容量,避免频繁触发扩容操作。 之所以扩容是 1.5 倍,是因为 1.5 可以充分利用移位操作,减少浮点数或者运算时间和运算次数。 线程安全的 List, CopyonWriteArraylist是如何实现线程安全的 CopyOnWriteArrayList底层也是通过一个数组保存数据,使用volatile关键字修饰数组,保证当前线程对数组对象重新赋值后,其他线程可以及时感知到。 在写入操作时,加了一把互斥锁ReentrantLock以保证线程安全。 看到源码可以知道写入新元素时,首先会先将原来的数组拷贝一份并且让原来数组的长度+1后就得到了一个新数组,新数组里的元素和旧数组的元素一样并且长度比旧数组多一个长度,然后将新加入的元素放置都在新数组最后一个位置后,用新数组的地址替换掉老数组的地址就能得到最新的数据了。 在我们执行替换地址操作之前,读取的是老数组的数据,数据是有效数据;执行替换地址操作之后,读取的是新数组的数据,同样也是有效数据,而且使用该方式能比读写都加锁要更加的效率。 现在我们来看读操作,读是没有加锁的,所以读是一直都能读 List< 里面填基本数据类型为什么会报错? 等泛型集合类要求填充的必须是 引用类型 (对象类型),而不能直接使用 基本数据类型 (如 、 、 等),否则会编译报错。 这是因为 Java 的泛型机制在设计时就只支持引用类型,不支持基本数据类型。例如,下面的代码会报错: 解决的办法是,使用基本数据类型对应的包装类。因此,正确的写法是: 这么设计的原因是: 泛型的类型擦除机制 :Java 泛型在编译后会被擦除为 类型,而 只能接收引用类型,不能接收基本数据类型。 历史原因 :Java 最初设计时基本数据类型和引用类型是严格区分的,泛型是后期(JDK 1.5)才引入的特性,为了兼容已有的类型系统,选择只支持引用类型。 通过使用包装类,结合 Java 的 自动装箱 (基本类型 → 包装类)和 自动拆箱 (包装类 → 基本类型)机制,可以很方便地在泛型集合中操作基本数据类型的数据。 List和数组如何互相转换? List 转数组 主要有两种方式,核心是用 List 的 方法,重点注意「泛型和类型匹配」: 无参 toArray ()(返回 Object [],不推荐) 这种方式返回的是 Object 数组,若强转成 String [] 会抛 ClassCastException,仅适合不确定数组类型的场景,基本不用。 带参 toArray (T [] a)(推荐,指定类型) 这是最常用的方式,传入对应类型的数组,List 会把元素复制到该数组中,若传入的数组长度不足,会自动创建新数组,推荐传空数组(JDK 会优化长度)。 数组转 List 核心是用 ,但要注意「返回的 List 不可变」和「基本类型数组的坑」: 普通对象数组转 List(常用) 返回的 List 不是 ArrayList,而是 Arrays 的内部类,不支持添加 / 删除操作,想修改就套一层 ArrayList。 基本类型数组转 List(避坑) 基本类型数组(int []、long [])直接用 会把整个数组当成一个元素,必须手动装箱或用 Stream 流转换为包装类(Integer)的 List。 Set Java 集合中 List 和 Set区别是什么? Java 里 List 和 Set 作为 Collection 的核心子接口,最核心的区别就是「是否允许元素重复」和「是否保证有序」,原理和使用场景也因此完全不同。 先说说 List ,它是有序的集合,这里的 “有序” 指的是元素的存储顺序和添加顺序一致,而且允许元素重复,甚至可以存多个 null 值。 比如往 ArrayList 里依次加 1、2、1,遍历出来还是 1、2、1,能通过下标(索引)直接访问元素,像 get (0) 就能拿到第一个元素,这是 List 独有的特性。底层实现比如 ArrayList 靠数组、LinkedList 靠双向链表,都是为了维护顺序和支持下标操作,适合需要按顺序存取、频繁根据位置访问元素的场景,比如购物车列表、订单明细这类要保留添加顺序的场景。 再看 Set ,它的核心是「元素唯一」,不允许重复,HashSet / LinkedHashSet 最多只能存一个 null 值(TreeSet 默认不允许 null,因为排序时调用 compare 会抛 NPE),而且默认不保证元素的存储顺序(除了 TreeSet、LinkedHashSet 这类特殊实现)。 比如往 HashSet 里加 1、2、1,最终只会存 1 和 2,重复的 1 会被过滤掉。Set 判断元素重复的依据是 equals () 和 hashCode () 方法(HashSet、LinkedHashSet),或者元素的自然排序 / 自定义比较器(TreeSet),它没有下标,没法通过索引访问元素,只能遍历。适合需要去重的场景,比如用户标签、商品分类、抽奖名单(避免同一个用户重复中奖)这类不允许重复元素的场景。 补充一点特殊实现的差异:List 里的 Vector 是线程安全的,但性能差,现在基本不用;Set 里的 LinkedHashSet 既保证元素唯一,又能保留添加顺序,TreeSet 则会按元素大小排序,而 ArrayList、HashSet 都是非线程安全的。 如何对Set排序? Java 里 Set 本身默认不保证有序,但要实现 Set 的排序,核心是选带排序特性的 Set 实现类,或把普通 Set 转成有序结构。 TreeSet 底层是红黑树,插入时自动排序,支持「自然排序」(元素实现 Comparable)和「自定义 Comparator 排序」。 如果只是想按「插入顺序」遍历,不用按元素值排序,用 LinkedHashSet 即可,性能比 TreeSet 高: Map 常见的Map集合(非线程安全): 是基于哈希表实现的 ,它根据键的哈希值来存储和获取键值对,JDK 1.8 中使用数组 + 链表 + 红黑树来实现。 是非线程安全的,在多线程环境下可能出现数据不一致的问题。需要区分两个时代: JDK 1.7 使用头插法 + 并发扩容时可能形成环形链表,进而触发 时的死循环; JDK 1.8 改为尾插法后已经 不会再出现死循环 ,但多线程 仍存在数据覆盖和丢失等线程安全问题。 继承自 ,它在 的基础上,使用双向链表维护了键值对的插入顺序或访问顺序,使得迭代顺序与插入顺序或访问顺序一致。由于它继承自 ,在多线程并发访问时,同样会出现与 类似的线程安全问题。 是基于红黑树实现的 ,它可以对键进行排序,默认按照自然顺序排序,也可以通过指定的比较器进行排序。 是非线程安全的,在多线程环境下,如果多个线程同时对 进行插入、删除等操作,可能会破坏红黑树的结构,导致数据不一致或程序出现异常。 常见的Map集合(线程安全): 是早期 Java 提供的线程安全的 实现,它的实现方式与 类似,但在方法上使用了 关键字来保证线程安全。通过在每个可能修改 状态的方法上加上 关键字,使得在同一时刻,只能有一个线程能够访问 的这些方法,从而保证了线程安全。 在 JDK 1.8 以前采用了分段锁等技术来提高并发性能。在 中,将数据分成多个段(Segment),每个段都有自己的锁。在进行插入、删除等操作时,只需要获取相应段的锁,而不是整个 的锁,这样可以允许多个线程同时访问不同的段,提高了并发访问的效率。在 JDK 1.8 以后是通过 volatile + CAS 或者 synchronized 来保证线程安全的。 如何对map进行快速遍历? 使用for each循环和entrySet()方法:这是一种较为常见和简洁的遍历方式,它可以同时获取 中的键和值 使用for each循环和keySet()方法:如果只需要遍历 中的键,可以使用 方法,这种方式相对简单,性能也较好。 使用迭代器:通过获取Map的entrySet()或keySet()的迭代器,也可以实现对Map的遍历,这种方式在需要删除元素等操作时比较有用。 使用 Lambda 表达式和forEach()方法:在 Java 8 及以上版本中,可以使用 Lambda 表达式和 方法来遍历 ,这种方式更加简洁和函数式。 使用Stream API:Java 8 引入的 也可以用于遍历 ,可以将 转换为流,然后进行各种操作。 HashMap实现原理介绍一下? 在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap通过哈希算法将元素的键(Key)映射到数组中的槽位(Bucket)。如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了。 所以在 JDK 1.8 版本的时候做了优化:当某个桶的链表长度 ≥ 8 ( )且哈希表数组长度 ≥ 64 ( )时,会把链表转换为 红黑树 ,把该桶的查找时间复杂度从 O(n) 降低到 O(log n);如果数组长度 < 64,则只会触发扩容( ),并不会立刻树化。反向地,在 过程中,若某个桶的节点数 ≤ 6 ( ),红黑树会被退化回链表。 HashMap链表发生转换后为什么不用平衡二叉树? AVL 树是严格平衡的二叉树,要求任意节点的左右子树高度差不超过 1,这意味着: 插入 / 删