Java 进阶面试题总结,涉及集合、JVM、并发等内容,文章将不断更新。
1. Java集合
1.1 Java中常见的集合有哪些?
Java 中的集合主要可以分为四个部分:List
、Set
、Map
和工具类(如 Iterator
迭代器、Enumeration
枚举类、Arrays
和 Collections
)。这些集合类主要由两个接口派生而来,即 Collection
(包含 List
、Set
、Queue
)和 Map
,它们是集合框架的根接口。
List
:一种有序列表的集合,例如,按索引排列的元素的List
。常见的实现类有ArrayList
、LinkedList
和Vector
。Set
:一种保证没有重复元素的集合。常见的实现类有HashSet
、LinkedHashSet
和TreeSet
。Map
:一种通过键值(Key-Value)查找的映射表集合。常见的实现类有HashMap
和TreeMap
。
1.2 线程安全的集合有哪些?
Java 中的线程安全集合主要包括以下几种:
Vector
:这是一个线程安全的动态数组,它提供了与ArrayList
类似的功能,但每个方法都是同步的,这意味着在多线程环境下,它的性能会比ArrayList
差。Hashtable
:这是一个线程安全的哈希表实现,类似于HashMap
,但是Hashtable
的方法都是同步的。Stack
:这是一个线程安全的栈实现,它继承自Vector
。ConcurrentLinkedQueue
:这是一个线程安全的队列实现,使用了非阻塞算法。BlockingQueue
接口的实现类,如ArrayBlockingQueue
,LinkedBlockingQueue
等。CopyOnWriteArrayList
和CopyOnWriteArraySet
:这是两个线程安全的集合,它们会在修改操作时复制一份数据,避免了修改时的并发问题。ConcurrentHashMap
:这是一个线程安全的HashMap
,它通过将数据分段,从而达到并发控制,性能要优于Hashtable
。ConcurrentSkipListMap
:线程安全且排序的哈希表。
值得注意的是,为了保证集合是线程安全的,相应的效率也比较低;线程不安全的集合效率相对会高一些。如果你的代码只在一个线程中运行,或者多个线程只是读取集合而不修改集合,那么你可以选择线程不安全的集合,因为它们的性能通常会更好。
1.3 ArrayList和LinkedList的异同点?
ArrayList
和 LinkedList
都是 Java 中常用的 List
实现类,它们有一些共同点,也有一些不同点。
共同点:
ArrayList
和LinkedList
都是单列集合中List
接口的实现类,它们都是存取允许重复,且有序的元素。
不同点:
- 内部实现:
ArrayList
是基于动态数组实现的,底层使用数组来存储元素。而LinkedList
是基于链表实现的,底层使用双向链表来存储元素。 - 随机访问:对于随机访问
get
和set
方法,ArrayList
的速度通常优于LinkedList
,因为ArrayList
可以根据下标以O(1)
的时间复杂度对元素进行随机访问,而LinkedList
的每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的时间复杂度是O(n)
。 - 插入和删除操作:对于插入和删除操作,
LinkedList
的速度通常优于ArrayList
,因为当元素被添加到LinkedList
任意位置的时候,不需要像ArrayList
那样重新计算大小或者是更新索引。 - 内存占用:
LinkedList
比ArrayList
更占内存,因为LinkedList
的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。而ArrayList
使用数组来存储元素,因此插入和删除元素时需要移动其他元素占用内存,所以在频繁进行插入和删除操作时,ArrayList
的性能会比较低,且可能会造成内存浪费。
1.4 ArrayList的扩容机制是什么?
ArrayList
的扩容机制是其核心特性之一。在 ArrayList
中添加元素时,如果当前的数组已经满了,那么 ArrayList
会创建一个新的、更大的数组,并将原有数组的元素复制到新的数组中,这个过程就叫做扩容。
具体来说,ArrayList
的扩容机制如下:
- 当向
ArrayList
中添加元素时,首先会检查ArrayList
的当前大小(也就是它内部的数组大小)是否能够容纳新的元素。如果可以,那么新元素就直接被添加到ArrayList
中。 - 如果
ArrayList
的当前大小不足以容纳新的元素,那么ArrayList
就需要进行扩容操作。在扩容操作中,ArrayList
会创建一个新的数组,新数组的大小是原数组大小的1.5倍,这个值是在 JDK 的源码中定义的。 - 接着,
ArrayList
会使用System.arraycopy
方法,将原有数组中的所有元素复制到新的数组中。 - 最后,新的数组会替代原有的数组,成为
ArrayList
的内部数组。
值得注意的是,ArrayList
的扩容操作需要重新分配内存空间,并将原来的元素复制到新的数组中,这可能会导致性能问题。因此,在实例化 ArrayList
时设置足够的初始容量,并且尽可能减少数组扩容的次数,可以帮助提高性能:
1 | ArrayList<Integer> list = new ArrayList<>(100); |
1.5 HashMap的底层数据结构是什么?
HashMap
的底层数据结构主要包括哈希表(数组)、链表和红黑树。
- 哈希表(数组):
HashMap
主要依赖于哈希表来存储数据。哈希表中的每个元素被称为bucket
。数组的每个位置都可以存放一个元素(键值对),数组的索引是通过键的哈希码经过哈希函数计算得来的。这样我们就可以通过键快速定位到数组的某个位置,取出相应的值,这就是HashMap
快速获取数据的原理。 - 链表:在理想的情况下,哈希函数将每个键均匀地散列到哈希表的各个位置。但在实际中,我们可能会遇到两个不同的键计算出相同的哈希值,这就是所谓的哈希冲突。
HashMap
通过使用链表来解决这个问题。当哈希冲突发生时,HashMap
会在冲突的bucket
位置增加一个链表,新的元素会被添加到链表的末尾。每个链表中的元素都包含了相同哈希值的键值对。 - 红黑树:从 Java 8 开始,如果链表的长度超过一定的阈值(默认为8),那么链表会被转换为红黑树。红黑树是一种自平衡的二叉查找树,通过保持树的平衡,可以提高查找效率。
1.6 为什么在解决Hash冲突的时候,不直接用红黑树,而是先用链表,再转红黑树?
在解决 Hash 冲突的时候,HashMap
在链表长度大于8的时候才会将链表转换为红黑树,而不是直接使用红黑树,这主要有以下几个原因:
- 查询效率:红黑树的平均查找长度是
log(n)
,当链表长度为8时,查找长度为log(8) = 3
,而链表的平均查找长度为n/2
,当长度为8时,平均查找长度为8 / 2 = 4
。因此,当链表长度小于等于8时,使用链表的查询效率其实并不比红黑树差。 - 插入效率和空间效率:链表的插入操作比红黑树快,且链表的空间占用也比红黑树小。因此,在元素数量较少时,使用链表比红黑树更高效1。
- 防止频繁转换:如果链表长度在8左右徘徊,且频繁地进行插入和删除操作,可能会导致链表和红黑树之间频繁地转换,这会降低效率。因此,
HashMap
设计了两个阈值,链表长度超过8时转为红黑树,少于6时转回链表,这样可以减少转换的频率。
总的来说,这种设计是为了在保证查询效率的同时,尽可能地提高插入效率和空间效率,以及减少因频繁转换而带来的开销。
1.7 什么是负载因子?为什么HashMap的默认负载因子为0.75?
负载因子是用于表示哈希表中元素填满的程度的一个参数。在哈希表(如 Java 的 HashMap
)中,负载因子是和扩容机制有关的,当哈希表中的元素个数超过了容量乘以负载因子时,就会进行扩容。例如,如果当前的容器容量是16,负载因子是0.75,16 * 0.75 = 12
,也就是说,当容量达到了12的时候就会进行扩容操作。
负载因子的大小对哈希表的性能有重要影响。如果负载因子过大,那么哈希表中的冲突会更频繁,导致查找效率降低。反之,如果负载因子过小,那么哈希表的空间利用率就会降低,导致内存浪费。因此,选择一个合适的负载因子,可以在时间效率和空间效率之间达到一个平衡。在 Java 的 HashMap
中,负载因子的默认值是0.75,这是一个在时间和空间效率之间的折衷选择。