1. Java 中常用的容器有哪些?
在 Java 中,容器是一种特殊的数据结构,用于存储其他对象。它们可以帮助我们更高效地管理和操作大量的数据。以下是 Java 中常用的几种容器:
-
List:有序集合(也是动态数组),元素可以重复。List 里的元素都有索引,元素可以重复,允许空元素。常用的实现类有
ArrayList
,LinkedList
和Vector
。ArrayList
:基于动态数组实现,查询效率高,增删效率低。LinkedList
:基于链表实现,增删效率高,查询效率低。Vector
:是线程安全的,但性能相对较低。
-
Set:无序集合,不允许元素重复。Set 中的元素没有索引,元素不可以重复,不允许空元素。常用的实现类有
HashSet
,LinkedHashSet
和TreeSet
。HashSet
:基于哈希表实现,性能较好。LinkedHashSet
:用链表维护元素的插入顺序,在遍历的时候可以按元素的插入顺序遍历。TreeSet
:基于红黑树实现,可以对元素进行排序。
-
Queue:队列,先进先出(FIFO)。队列中的元素不可以重复,元素是有序的,遵循先入先出的原则。常用的实现类有
LinkedList
,PriorityQueue
等。LinkedList
:可以作为队列使用,提供addFirst
,addLast
,removeFirst
,removeLast
等方法。PriorityQueue
:是一个无界的队列,用于按自然顺序或比较器的顺序进行排序。
-
Map:存储键值对(key-value pair)的集合,键不可以重复,值可以重复。常用的实现类有
HashMap
,LinkedHashMap
,TreeMap
和Hashtable
。HashMap
:基于哈希表实现,性能较好。LinkedHashMap
:用链表维护键值对的插入顺序,或者访问顺序(由构造函数的 accessOrder 参数决定)。TreeMap
:基于红黑树实现,可以对键进行排序。Hashtable
:是线程安全的,但性能相对较低。
这些容器类都提供了丰富的 API,可以方便地进行元素的添加、删除、查找等操作。在选择容器时,应根据具体的需求和场景来选择合适的容器类型。
2. ArrayList 和 LinkedList 的区别?
ArrayList和LinkedList都是Java中的List接口的实现,用于存储元素的动态数组,但它们在内部实现、性能特性以及使用场景上有一些关键的区别。
-
内部实现:
- ArrayList:基于动态数组实现。在内存中,ArrayList分配一块连续的内存空间来存储元素。当添加或删除元素时,如果需要,ArrayList会进行数组的扩容或元素的移动。
- LinkedList:基于双向链表实现。每个元素都包含对前一个元素和后一个元素的引用。因此,LinkedList的元素在内存中不需要连续存储。
-
性能特性:
- 访问元素:对于ArrayList,由于元素在内存中连续存储,因此通过索引访问元素(get和set操作)非常快,时间复杂度为O(1)。而LinkedList则需要从头或尾开始遍历,直到找到目标元素,时间复杂度为O(n)。
- 插入和删除元素:在ArrayList的开头或中间插入或删除元素可能涉及到元素的移动,因此时间复杂度为O(n)。然而,在ArrayList的末尾添加或删除元素通常很快,因为不需要移动其他元素。相反,LinkedList在开头或中间插入或删除元素只需要修改几个引用,时间复杂度为O(1)。但是,在LinkedList的末尾添加或删除元素可能需要遍历到末尾,时间复杂度为O(n)。
-
内存使用:LinkedList由于每个元素都包含额外的引用(指向前一个元素和后一个元素),因此相比ArrayList会占用更多的内存。然而,当ArrayList进行扩容时,它可能需要分配更大的连续内存空间,这可能导致一些额外的开销。
-
线程安全:ArrayList和LinkedList都不是线程安全的。如果需要在多线程环境中使用,需要额外的同步措施。Java提供了
Collections.synchronizedList()
方法来创建一个线程安全的列表,或者可以使用CopyOnWriteArrayList
和ConcurrentLinkedDeque
等并发集合类。 -
使用场景:
- 如果你需要频繁地通过索引访问元素,并且元素的数量可能会变化,那么ArrayList可能是一个更好的选择。
- 如果你需要频繁地在列表的开头或中间插入或删除元素,那么LinkedList可能更合适。
- 如果你需要实现一个队列或双端队列,LinkedList是一个很好的选择,因为它提供了在头部和尾部添加和删除元素的高效操作。
3. ArrayList 实现 RandomAccess 接口有何作用?为何 LinkedList 却没实现这个接口?
RandomAccess
接口在 Java 中是一个标记接口,它本身并没有定义任何方法。当一个类实现了 RandomAccess
接口,它仅仅是表明这个类的实例支持快速随机访问其元素。具体来说,如果一个类实现了 RandomAccess
接口,那么使用 get(int index)
方法访问其元素时,通常期望能在常数时间内完成,而不依赖于集合的大小。
对于 ArrayList
来说,由于它基于动态数组实现,可以通过索引直接访问数组中的元素,因此其 get(int index)
方法的时间复杂度是 O(1)。这使得 ArrayList
是一个适合快速随机访问的集合。因此,ArrayList
实现了 RandomAccess
接口,以表明它支持这种高效的随机访问特性。
相反,LinkedList
是基于链表实现的。在链表中,访问特定索引位置的元素需要从头节点开始遍历链表,直到到达目标位置。因此,LinkedList
的 get(int index)
方法的时间复杂度是 O(n),其中 n 是链表的大小。由于 LinkedList
不支持高效的随机访问,所以它没有实现 RandomAccess
接口。
在实现算法或数据结构时,了解一个集合是否实现了 RandomAccess
接口是有用的。例如,在排序算法中,如果知道一个集合支持快速随机访问,那么可能会选择一种不同的排序策略,比如归并排序或快速排序,而不是基于交换元素的排序算法(如冒泡排序或插入排序)。因为对于支持快速随机访问的集合,通过直接访问和交换元素可以更加高效地执行排序操作。
4. ArrayList 的扩容机制?
ArrayList的扩容机制是为了解决在动态添加元素过程中可能出现的空间不足问题。当ArrayList中的元素数量达到当前数组容量时,如果再尝试添加新元素,就会触发扩容操作。以下是ArrayList扩容机制的主要步骤:
-
检查容量:当向ArrayList添加新元素时,首先会检查当前元素数量是否已经达到了数组的容量上限。如果未达到上限,则直接添加新元素。如果达到了上限,就进入扩容流程。
-
计算新容量:ArrayList扩容时,通常会创建一个新的数组,其大小是原数组容量的1.5倍。这个增长因子(1.5)可以根据具体的实现或JVM版本有所不同,但目的是为了在空间利用和扩容开销之间找到一个平衡。
-
复制元素:扩容操作开始后,ArrayList会将原数组中的所有元素复制到新的数组中。这个过程通常使用
System.arraycopy()
方法完成,这是一种高效的内存块复制操作。 -
更新引用:复制完成后,ArrayList会更新其内部的数组引用,使其指向新的、容量更大的数组,并丢弃旧的数组。
-
添加新元素:现在,ArrayList拥有了更大的容量,可以将新的元素添加到扩容后的ArrayList中,而不会导致容量不足的问题。
需要注意的是,虽然ArrayList的自动扩容机制使得其可以动态地增长以适应元素数量的变化,但这种扩容操作并不是没有代价的。每次扩容都涉及到内存的申请和已有元素的复制,这在处理大量数据时可能会成为性能瓶颈。因此,在实际使用中,如果预知需要添加大量元素,最好通过构造方法或ensureCapacity()
方法预先指定一个较大的初始容量,以减少扩容操作的次数,从而提高性能。
5. Array 和 ArrayList 有何区别?什么时候更适合用 Array?
Array和ArrayList在Java中都是用于存储数据的集合类型,但它们之间存在一些重要的区别,这些区别决定了在特定情况下应该选择使用哪一种。
-
大小和可变性:
- Array的大小在声明时就已经确定,并且之后无法改变。这意味着一旦你创建了一个固定大小的Array,你就不能增加或减少它的容量。
- ArrayList的大小则是可变的。它基于动态数组实现,因此可以根据需要增长或缩小。这使得ArrayList在处理大小不确定或需要动态调整大小的数据集时更为灵活。
-
数据类型:
- Array可以是任何类型的对象,包括基本数据类型(如int、char等)的数组。
- ArrayList则只能存储引用类型的对象。它不能直接存储基本数据类型,但可以存储它们的包装类(如Integer、Character等)。
-
数据访问:
- Array和ArrayList都支持通过索引直接访问元素。Array可以直接使用索引访问,而ArrayList则提供了get(int index)方法来获取指定索引处的元素。
-
内存管理:
- Array直接存储在内存中,其地址是连续的。这种连续的内存布局使得数组在查询操作时效率较高。
- ArrayList则基于动态数组实现,其底层仍然是一个数组,但根据需要可以动态调整大小。因此,ArrayList需要额外的空间来存储对象本身以及维护元素的新增和删除操作所需的额外开销。
-
性能:
- 对于已知大小且不会改变的数据集,Array通常具有更好的性能,因为它的内存布局是连续的,且没有额外的空间开销。
- ArrayList在动态调整大小时可能会涉及到数据的复制和重新分配内存,这可能会带来一定的性能开销。
基于上述区别,以下是一些建议的使用场景:
-
适合使用Array的情况:
- 当你知道数据集的大小是固定的,并且不会改变时。
- 当需要高效地进行连续内存访问时,例如进行大量的数学运算或图形处理。
- 当处理基本数据类型且不希望引入额外的包装和解包开销时。
-
适合使用ArrayList的情况:
- 当数据集的大小可能会动态变化时。
- 当需要灵活地添加、删除或修改元素时。
- 当处理的对象是引用类型时。
6. HashMap 的实现原理/底层数据结构?JDK1.7 和 JDK1.8
JDK1.7中的HashMap:
- 底层数据结构:主要基于数组和链表。
- 实现原理:
- 创建一个Entry类型的一维数组,默认初始化长度为16。
- 当执行put操作时,会计算key的哈希值,并根据哈希值和数组长度确定元素在数组中的存储索引位置。
- 如果该位置为空,则直接存储键值对;如果已存在元素(哈希冲突),则形成链表。
JDK1.8中的HashMap:
- 底层数据结构:数组、链表和红黑树。
- 实现原理:
- 与JDK1.7类似,首先根据key的哈希值和数组长度确定元素在数组中的存储位置。
- 当链表长度达到一定阈值(默认为8)时,链表会转化为红黑树,以提高查询效率。
- 当红黑树的节点数量少于一定阈值(默认为6)时,会退化为链表。
- 当HashMap中的元素数量超过负载因子(默认为0.75)乘以数组长度时,会触发扩容机制,数组长度加倍,并重新计算元素的存储位置。
此外,无论在哪个版本的JDK中,HashMap都不保证映射的顺序,特别是它不保证该顺序恒久不变。因此,如果需要有序的映射,可以考虑使用LinkedHashMap等其他数据结构。
7. HashMap 的 put 方法的执行过程?
HashMap
是 Java 中常用的一个类,它实现了 Map
接口,用于存储键值对。put
方法是 HashMap
中用于添加或更新键值对的方法。以下是 HashMap
的 put
方法的大致执行过程:
-
计算键的哈希值:
当调用put(K key, V value)
方法时,首先会计算键(key)的哈希值。这通常是通过调用键对象的hashCode()
方法来完成的。哈希值是一个整数,用于确定键在哈希表中的存储位置。 -
定位桶的位置:
根据计算出的哈希值和哈希表的容量,通过一定的算法(通常是哈希值与表容量减一的按位与运算)确定键应该存储在哪个桶(bucket)中。这里的桶实际上就是哈希表中的一个位置或索引。 -
处理冲突:
如果计算出的桶位置已经有键值对存在(即发生了哈希冲突),那么HashMap
会采用链表或红黑树(当链表长度超过一定阈值时)的形式来处理这些冲突的键值对。新插入的键值对会被添加到链表或红黑树的末尾。 -
插入或更新键值对:
如果桶位置为空,或者桶位置的键值对中的键与新键不同,那么就在该桶位置插入新的键值对。如果桶位置已经有与新键相同的键值对存在,那么就用新值替换旧值。 -
调整表的大小(如果需要):
如果哈希表的当前大小不足以容纳所有的键值对,或者哈希表的大小超过了某个阈值(通常是加载因子与当前容量的乘积),那么HashMap
会进行表格扩展(即重新分配一个更大的数组,并重新计算所有键值对的桶位置)。这个过程称为哈希表的重新哈希(rehashing)。 -
返回旧值(如果存在):
put
方法会返回之前与该键关联的值(如果存在的话),否则返回null
。
需要注意的是,HashMap
的性能在很大程度上取决于哈希函数的质量和哈希表的加载因子。一个好的哈希函数应该能够均匀分布键到各个桶中,从而减少哈希冲突并提高性能。加载因子则是用于控制哈希表何时进行扩展的一个参数,它影响了空间利用率和性能之间的权衡。