Java容器常见面试题一
关于Java容器你了解多少?
接下来,我将从Java的主要接口,Collection和Map两大接口介绍一下我对Java容器的理解。
【引言】
Java集合框架为开发人员提供了强大的数据结构和操作方法,其中最核心的是Collection
接口和Map
接口。
【Collection接口】
Collection
接口是Java集合框架的根接口,提供了对集合中元素的增删改查等操作。
常见的Collection
接口的派生了三个子接口,其中分别是List
、Set
和Queue
。List
接口表示有序的集合,允许重复元素,常用的实现类有ArrayList
、LinkedList
等。Set
接口表示不包含重复元素的集合,常用的实现类有HashSet
、TreeSet
等。Queue
接口表示队列,允许在一端插入元素,在另一端删除元素,常用的实现类有LinkedList
、PriorityQueue
等。
Collection
接口提供了一些基本的方法,例如add
用于添加元素,remove
用于删除元素,contains
用于判断集合中是否包含某个元素,size
用于获取集合的大小,iterator
用于获取迭代器等。
【Map接口】
Map
接口是Java集合框架中用于存储键值对的接口,并且可以通过键来操作和访问值。
Map
接口中的键是唯一的,但值可以重复。常见的Map
接口的实现类有HashMap
、TreeMap
、LinkedHashMap
等。
Map
接口提供了一些常用的方法,例如put
用于向映射中添加键值对,get
用于根据键获取值,containsKey
用于判断映射中是否包含某个键,remove
用于根据键删除键值对,size
用于获取映射中键值对的数量等。
为什么要使用集合
类型安全
:Java集合框架支持泛型,通过泛型,开发人员可以明确指定集合中存储的元素类型,提高了代码的可读性和健壮性。- 与数组相比,集合类是可以实现动态扩容的,可以根据需要实现动态扩容和缩容,而数组的长度是固定的,一旦创建之后无法改变,而且还不支持动态添加和删除。
ArrayList和LinkedList的区别?
ArrayList
和LinkedList
是Java集合框架中常见的列表实现类,它们在底层数据结构、插入和访问效率等方面有一些区别。
- 底层数据结构:
ArrayList
底层使用数组实现,可以随机访问元素,通过索引快速获取元素。LinkedList
底层使用双向链表实现,每个元素都包含前驱和后继节点的引用。
- 插入和删除操作:
ArrayList
对于末尾的插入和删除操作效率较高,时间复杂度为O(1)。但在中间位置插入和删除元素时,需要移动后续元素,时间复杂度为O(n)。LinkedList
对于任意位置的插入和删除操作效率较高,因为只需要调整相邻节点的引用即可,时间复杂度为O(1)。
- 随机访问:
ArrayList
支持随机访问,可以通过索引直接访问元素,时间复杂度为O(1)。LinkedList
不支持随机访问,需要从头节点或尾节点开始遍历,直到找到目标元素,时间复杂度为O(n)。
- 内存占用:
ArrayList
在存储元素时需要预留连续的内存空间,因此在存储大量元素时可能会浪费一定的内存空间。LinkedList
在存储元素时只需要为每个元素分配节点对象的内存空间,不需要预留连续的内存空间,因此在存储大量元素时相对节省内存空间。
基于上述区别,如果需要频繁进行随机访问操作或者对末尾的插入和删除操作较多,可以选择ArrayList
。如果需要频繁进行任意位置的插入和删除操作,或者不需要频繁随机访问操作,可以选择LinkedList
。
LinkedList在指定位置插入和删除元素的代码逻辑如下:
LinkedList<String> linkedList = new LinkedList<>();
linkedList.add("Apple");
linkedList.add("Banana");
linkedList.add(1, "Orange"); // 在索引1的位置插入元素
System.out.println(linkedList); // 输出: [Apple, Orange, Banana]
linkedList.remove(1);// 删除索引1位置的元素
System.out.println(linkedList);// 输出: [Apple, Banana]
AarryList的扩容机制
ArrayList
是基于数组实现的动态数组,它会自动进行扩容操作以适应添加更多元素的需求。下面是ArrayList
实现动态扩容的基本原理:
-
初始容量:
当创建一个新的ArrayList
对象时,它会分配一个初始容量的数组作为底层数据结构。这个初始容量通常是10,可以通过构造函数指定初始容量大小。/** * Default initial capacity. */ private static final int DEFAULT_CAPACITY = 10;
-
容量管理:
ArrayList
维护一个size
属性来跟踪当前元素的个数,以及一个elementData
数组来存储实际的元素。当元素添加到ArrayList
中时,会将其存储在elementData
数组中,并将size
增加1。transient Object[] elementData; // non-private to simplify nested class access /** * The size of the ArrayList (the number of elements it contains). * * @serial */ private int size; public boolean add(E e) { //判断是否需要扩容的方法 ensureCapacityInternal(size + 1); // Increments modCount!! 存储到elementData数组中,并size++ elementData[size++] = e; return true; }
-
扩容操作:
当元素数量超过当前数组的容量时,ArrayList
会触发扩容操作。它会创建一个新的更大容量的数组,并将原数组中的元素复制到新数组中。默认情况下,新数组的容量是原数组的1.5倍(即扩容50%),但可以通过调整ArrayList
的负载因子来修改扩容策略。 -
复制元素:
扩容时,ArrayList
使用System.arraycopy()
方法或类似的技术将原数组中的元素复制到新数组中。这个过程可以保证元素的顺序不变,并且是一个高效的操作。 -
更新引用:
扩容后,ArrayList
会更新elementData
数组的引用,使其指向新的数组。这样,原来的数组就可以被垃圾回收机制回收释放内存。
通过动态扩容,ArrayList
可以根据需要自动增加容量,避免了固定容量的限制。这样就可以方便地添加更多的元素而不用手动处理容量的问题。但需要注意,频繁的扩容操作会带来一定的性能开销,因此在已知大致元素数量时,可以通过初始化指定足够的初始容量来减少扩容次数,提高性能。
HashSet的底层实现
在底层实现中,HashSet
实际上是通过一个HashMap
对象来实现的,其中元素作为HashMap
中的键,而固定的占位对象(比如PRESENT
)则作为HashMap
中的值(下面有解释这个是什么?)。这样,HashSet
中的元素实际上是通过HashMap
中的键来存储的。
当调用HashSet
的add()
方法时,它会先计算要插入元素的哈希码(通过调用元素的hashCode()
方法),然后根据哈希码找到元素在内部数组中的存储位置。如果该位置已经存在元素,那么HashSet
会通过调用元素的equals()
方法来检查是否存在重复元素。如果没有重复元素,那么该元素将被添加到该位置。
HashSet
的底层实现主要依赖于哈希表,它具有良好的插入、删除和查找性能,平均时间复杂度为O(1)。但在遍历元素时,由于哈希表中元素的存储顺序是根据哈希码计算的,所以元素的顺序是不确定的。
总结起来,HashSet
是通过哈希表实现的,它具有快速的插入、删除和查找性能,适用于需要快速判断元素是否存在、去重等场景。
HashSet 使用add()解析为什么它存储的value是一个固定的占位对象?
// 这是HashSet中的源码
private static final Object PRESENT = new Object();
//HashSet的add()
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
在这段代码中,
private static final Object PRESENT = new Object();
创建了一个静态的、不可修改的PRESENT
对象,它的类型是Object
。这个对象的作用是充当HashSet
中的值,用于表示集合中的每个键 对应的固定占位对象。为什么要使用这个固定的占位对象呢?这是因为在
HashSet
集合类的底层实现中,它们使用键值对存储数据。由于键(key)是唯一的,而值(value)则可以是重复的。为了实现集合中元素的唯一性,只需要关心键的存在与否,而不需要关心值的具体内容。因此,为了节省内存空间并提高性能,可以使用一个固定的占位对象作为值。在这段代码中,
PRESENT
对象充当了这个固定的占位对象,它的具体内容并不重要,只要保证是一个唯一的对象即可。一般情况下,使用一个空的Object
对象作为占位对象是比较常见的做法。在实际使用中,当调用
HashSet
的add()
方法,将键作为键,PRESENT
作为值存储在底层的哈希表中。这样,通过检查键的存在与否,就可以判断集合中是否包含某个元素,而无需关心值的具体内容。需要注意的是,
PRESENT
对象并不是公开的API的一部分,它只是在底层实现中使用的一个技巧,用于表示集合中键的存在。
什么是HashMap,它有什么特点?
HashMap以键值对(key-value)的形式存储和操作数据
HashMap的特点如下:
- 基于哈希表:HashMap内部使用哈希表作为底层数据结构。通过哈希函数将键映射到哈希表的桶(bucket)中,实现快速的查找和插入操作。
- 键的唯一性:HashMap中的键是唯一的,不允许重复键的存在。当插入具有相同键的键值对时,后面的键值对将会覆盖前面的键值对。
- 允许null键和null值:HashMap允许使用null作为键和值。可以将null作为键插入到HashMap中,并且可以将null作为值与键相关联,但 null 作为键只能有一个,null 作为值可以有多个。
- 无序性:HashMap中的元素是无序的,即元素在哈希表中的存储顺序和插入顺序不一致。遍历HashMap时不能保证元素的顺序。
- 高效性能:HashMap具有高效的查找和插入操作。在理想情况下,插入、删除和查找操作的时间复杂度为O(1)。但在某些情况下,由于哈希冲突(不同的键映射到相同的桶),操作的性能可能会下降。
- 初始容量和负载因子:HashMap可以通过指定初始容量和负载因子来进行初始化。初始容量指定了哈希表的大小,负载因子表示哈希表在自动扩容之前可以达到的填充比例。适当选择初始容量和负载因子可以平衡空间利用率和性能。
需要注意的是,由于HashMap不是线程安全的,不同线程同时访问和修改HashMap可能导致不一致的结果。如果在多线程环境中使用HashMap,可以考虑使用线程安全的ConcurrentHashMap或使用适当的同步机制来确保线程安全性。
总结而言,HashMap是一个高效的哈希表实现,以键值对的形式存储和操作数据。它具有快速的查找和插入操作、允许null键和null值、键的唯一性和无序性等特点,适用于需要快速查找和插入数据的场景。
HashMap的底层原理
HashMap在jdk1.8之后,底层实现是由数组+链表(或红黑树)组成的,其中数组是HashMap的主题,而链表和红黑树是用来解决哈希冲突的。
当发生哈希冲突时(即不同的键计算出的哈希值相同),HashMap使用链表或红黑树来解决冲突,通过比较键的值来确定具体的元素。
而其中链表转为红黑树的前提是链表的长度大于或等于8(如果当前的数组长度小于64,会先进行数组扩容)时,链表会转变为红黑树,减少搜索时间。
链表的长度大于或等于8(如果当前的数组长度小于64,会先进行数组扩容)时,链表会转变为红黑树
它旨在避免在较小的集合中过早地引入红黑树,因为红黑树的结构相对复杂,在存储和维护上会带来额外的开销。因此,只有在链表长度较长且数组较大的情况下,转换为红黑树才能带来更好的性能表现
HashMap为什么不是线程安全的
HashMap在多线程环境下不是线程安全的主要原因是它的内部结构和操作不是针对并发访问进行设计的。以下是导致HashMap线程不安全的几个主要原因:
- 非同步操作:HashMap的各种操作(如put、get、remove等)不是原子性的,涉及到多个步骤,包括计算哈希值、查找桶位置、插入或替换元素等。在多线程环境下,如果多个线程同时执行这些操作,可能会导致数据的不一致性。
- 状态改变导致的问题:在HashMap的内部结构发生变化时(如扩容、重新散列等),如果同时有其他线程进行操作,就可能导致数据丢失、死循环或数据不一致等问题。
- 并发冲突:由于哈希表中的元素存储是基于哈希值进行的,当不同的键计算出相同的哈希值并发生冲突时,HashMap使用链表或红黑树来解决冲突。在并发环境中,多个线程同时操作可能会导致链表或红黑树的结构被破坏,导致数据丢失或错误。
为了在多线程环境下安全地使用HashMap,可以采取以下措施:
- 使用线程安全的替代类:Java提供了一些线程安全的HashMap的替代类,如ConcurrentHashMap。它们采用了不同的并发控制机制,可以支持并发访问而不需要外部同步。
- 使用同步措施:在多线程环境下使用HashMap时,可以使用显式的同步措施(如使用synchronized关键字或锁)来保护并发访问。但需要注意,这可能会降低性能并引入潜在的死锁和性能问题。
总结起来,HashMap在多线程环境下不是线程安全的,主要是由于其非同步操作、状态改变和并发冲突等原因。在多线程环境中,应该考虑使用线程安全的替代类或者采取适当的同步措施来保证安全访问HashMap。
HashMap的遍历方式
这篇文章写得特别好:https://mp.weixin.qq.com/s/zQBN3UvJDhRTKP6SzcZFKw
为什么CncurrentHashMap是线程安全的
ConcurrentHashMap
是Java提供的线程安全的哈希表实现,它具有高并发性能和线程安全性。下面是ConcurrentHashMap实现线程安全的几个关键方式:
分段锁(Segmented Locking)
:ConcurrentHashMap将整个哈希表分成多个段(Segment),每个段相当于一个小的哈希表。每个段都有自己的锁,不同的线程可以同时访问不同的段,从而提高了并发性能。只有在同一个段内的操作才需要获取对应段的锁,不同段之间的操作可以并发进行,避免了整个哈希表的锁竞争。CAS操作(Compare and Swap)
:ConcurrentHashMap使用CAS操作来确保数据的原子性。CAS是一种无锁的并发控制方式,它通过比较并交换的方式来实现数据的更新。当多个线程同时对同一个段进行操作时,使用CAS操作可以避免锁竞争,提高并发性能。写入时复制(Copy-on-Write)
:ConcurrentHashMap在进行扩容操作时使用了写入时复制的策略。当需要扩容时,会创建一个新的哈希表,并将原始数据复制到新的哈希表中。在复制过程中,读操作仍然可以继续访问原始数据,不受写操作的影响。完成复制后,新的哈希表取代原始哈希表,完成扩容操作。这样可以避免在扩容过程中对整个哈希表进行锁定,提高并发性能。线程安全的操作
:ConcurrentHashMap提供了一些线程安全的操作方法,如putIfAbsent、remove、replace等。这些操作方法在执行过程中使用了适当的同步措施,保证了数据的一致性和线程安全性。
综上所述,ConcurrentHashMap通过分段锁、CAS操作、写入时复制和线程安全的操作方法等方式实现了线程安全。它在多线程环境下能够提供高并发性能和数据一致性,并且不需要外部的同步控制。这使得ConcurrentHashMap成为处理高并发场景下的首选集合类之一。
若有不恰当之处,欢迎各位小伙伴在评论区进行指正!