头图

这是我花费时间为大家整理的腾讯面试中常问的集合面试题,看看你掌握多少?

  1. ArrayList 的添加与删除元素为什么慢?
  2. ArrayList 是如何扩容的?
  3. ArrayList 是线程安全的吗?
  4. 什么是 HashMap?介绍一下
  5. HashMap 的底层实现是怎样的?
  6. 拉链法导致的链表过深问题为什么用红黑树?

ArrayList 的添加与删除元素为什么慢?

这里面的原因是主要是其内部实现基于数组的特性所导致的。

ArrayList 的添加与删除操作慢,主要是因为其内部实现基于数组,而数组在插入和删除元素时需要移动其他元素来保证连续性和顺序性,这个过程需要耗费较多的时间。

相对于基于链表的数据结构(如 LinkedList),ArrayList 的插入和删除操作的时间复杂度是 O(n)级别的,而链表的时间复杂度为 O(1)。

更加具体地说,

当在 ArrayList 的尾部添加元素时,如果当前数组的容量还未达到最大值,只需要将新元素添加到数组的末尾即可,此时时间复杂度为 O(1)。

但是,当数组容量已满时,需要进行扩容操作。扩容操作通常会将数组的容量增加到当前容量的 1.5 倍或 2 倍,并将原数组中的所有元素复制到新的更大的数组中。这一过程的时间复杂度为 O(n),其中 n 为当前数组中的元素数量。

当在 ArrayList 的指定位置(非尾部)插入元素时,需要将目标位置之后的所有元素向后移动一个位置,然后将新元素插入到指定位置。这个过程涉及到移动元素的操作,时间复杂度为 O(n),在最坏情况下,如头部插入,需要移动所有的元素。

当在 ArrayList 的指定位置(非尾部)删除元素时,需要将删除点之后的所有元素向前移动一个位置,以填补被删除元素的位置。这个过程同样涉及到移动元素的操作,时间复杂度为 O(n),在最坏情况下,如头部删除,需要移动除了被删除元素之外的所有元素。

ArrayList 是如何扩容的?

ArrayList 的扩容机制是 Java 集合框架中一个重要的概念,它允许 ArrayList 在需要时自动增加其内部数组的大小以容纳更多的元素。

在 Java 中 ArrayList 的扩容过程,主要涉及到以下的几个步骤:

1. 初始容量和扩容因子:
当创建一个新的 ArrayList 对象时,它通常会分配一个初始容量,这个初始容量默认为 10。
ArrayList 的扩容因子是一个用于计算新容量的乘数,默认为 1.5。
2. 扩容触发条件:
当向 ArrayList 中添加一个新元素,并且该元素的数量超过当前数组的容量时,就会触发扩容操作。
3. 扩容策略:
扩容时,首先根据当前容量和扩容因子计算出一个新的容量。新容量的计算公式为:newCapacity = oldCapacity + (oldCapacity >> 1),这实际上是将原容量增加 50%(即乘以 1.5)。

如果需要的容量大于Integer.MAX_VALUE - 8(因为数组的长度是一个 int 类型,其最大值是Integer.MAX_VALUE,但 ArrayList 需要预留一些空间用于内部操作),则会使用Integer.MAX_VALUE作为新的容量。
4. 扩容过程:
创建一个新的数组,其长度为新计算的容量。

将原数组中的所有元素复制到新数组中。

将 ArrayList 的内部引用从原数组更新为新数组。

将新元素添加到新数组的末尾。

ArrayList 是线程安全的吗?

答案是 ArrayList 不是线程安全的。在多线程环境下,当多个线程同时对 ArrayList 进行添加、删除等操作时,可能会导致数组大小的变化,从而引发数据不一致的问题。

举个例子:

例如,当一个线程在对 ArrayList 进行添加元素的操作时(这通常分为两步:先在指定位置存放元素,然后增加 size 的值),另一个线程可能同时进行删除或其他操作,导致数据的不一致或错误。

比如下面的这个代码,就是实际上 ArrayList 放入元素的代码:

elementData[size] = e;
size = size + 1;
  1. elementData[size] = e; 这一行代码是将新的元素 e 放置在 ArrayList 的内部数组 elementData 的当前大小 size 的位置上。这里假设 elementData 数组已经足够大,可以容纳新添加的元素(实际上 ArrayList 在必要时会增长数组的大小)。
  2. size = size + 1; 这一行代码是更新 ArrayList 的大小,使其包含新添加的元素。

而如果两个线程同时尝试向同一个 ArrayList 实例中添加元素,那么可能会发生以下情况:

线程 A 执行 elementData[size] = eA;(假设当前 size 是 0)


线程 B 执行 elementData[size] = eB;(由于线程 A 尚未更新 size,线程 B 看到的 size 仍然是 0)
此时,elementData[0] 被线程 B 的 eB 覆盖,线程 A 的 eA 丢失
线程 A 更新 size = 1;
线程 B 更新 size = 1;(现在 size 仍然是 1,但是应该是 2,因为有两个元素被添加)

什么是 HashMap?介绍一下

首先,HashMap 主要是用于存储键值对。它是基于哈希表实现的,提供了快速的插入、删除和查找操作。

其次,从安全角度,HashMap 不是线程安全的。如果多个线程同时访问一个 HashMap 并且至少有一个线程修改了它,则必须手动同步。

然后,HashMap 允许一个 null 键和多个 null 值。HashMap 不保证映射的顺序,特别是它不保证顺序会随着时间的推移保持不变。HashMap 提供了 O(1) 时间复杂度的基本操作(如 get 和 put),前提是哈希函数的分布良好且冲突较少。

HashMap 还提供了大量的方法,对数据进行操作,下面是具体介绍:

  • put(K key, V value):将指定的值与该映射中的指定键关联。如果映射以前包含一个该键的映射,则旧值将被替换。
  • get(Object key):返回指定键所映射的值;如果此映射不包含该键的映射,则返回 null。
  • remove(Object key):如果存在一个键的映射,则将其从映射中移除。
  • containsKey(Object key):如果此映射包含指定键的映射,则返回 true。
  • containsValue(Object value):如果此映射将一个或多个键映射到指定值,则返回 true。
  • size():返回此映射中的键值映射关系的数量。
  • isEmpty():如果此映射不包含键值映射关系,则返回 true。
  • clear():从此映射中移除所有键值映射关系。
import java.util.HashMap;
import java.util.Map;

public class HashMapExample {
    public static void main(String[] args) {
        // 创建一个 HashMap 实例
        Map<String, Integer> map = new HashMap<>();
        // 添加键值对
        map.put("apple", 1);
        map.put("banana", 2);
        map.put("orange", 3);
        // 访问元素
        System.out.println("Value for key 'apple': " + map.get("apple"));
        // 检查是否包含某个键或值
        System.out.println("Contains key 'banana': " + map.containsKey("banana"));
        System.out.println("Contains value 3: " + map.containsValue(3));

        // 移除元素
        map.remove("orange");
        // 遍历 HashMap
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
        }
        // 获取大小
        System.out.println("Size of map: " + map.size());
        // 清空 HashMap
        map.clear();
        System.out.println("Is map empty: " + map.isEmpty());
    }
}

如前所述,HashMap 不是线程安全的。如果需要线程安全的映射,可以使用 Collections.synchronizedMap 来包装 HashMap,或者使用 ConcurrentHashMap,后者在高并发环境下性能更好。

HashMap 的底层实现是怎样的?

关于 Hash Map 的底层实现,可以分为两个阶段,下面是具体内容:

JDK8 之前:
JDK 8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。
HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度)。
如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。
JDK8 之后:
在 JDK 8 中,HashMap 的实现进行了显著的优化,特别是在处理哈希冲突方面,引入了红黑树数据结构。这些改进旨在提高在高冲突情况下的性能。
HashMap 的底层结构仍然是基于数组和链表的组合,但在 JDK 8 中,当链表长度超过一定阈值时,会将链表转换为红黑树,以提高操作效率。
当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。
只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。

拉链法导致的链表过深问题为什么用红黑树?

在 JDK 8 中,HashMap 采用红黑树来解决拉链法导致的链表过深问题,主要是为了提高在高冲突情况下的性能。

在传统的拉链法中,当多个键的哈希值冲突时,这些键会被存储在同一个桶(bucket)中,形成一个链表。如果链表过长,查找、插入和删除操作的时间复杂度会退化为 O(n),其中 n 是链表中的元素个数。这种情况下,HashMap 的性能会显著下降。

为什么选择红黑树呢?这就不得不提到红黑树自身的结构特点了。

红黑树是一种自平衡的二叉搜索树,具有以下特性:
1.自平衡 :红黑树通过颜色属性(红色和黑色)和一系列的旋转操作,保证树的高度近似平衡。其高度不会超过 2 * log(n),其中 n 是树中的节点数。
2.高效的查找、插入和删除 :红黑树的查找、插入和删除操作的时间复杂度为 O(log n),远优于链表在最坏情况下的 O(n)。

下面是关于使用红黑树的具体好处

  1. 性能优化 :在链表长度较短时,链表操作的性能是可以接受的。然而,当链表长度超过一定阈值(JDK 8 中为 8)时,链表操作的性能会显著下降。此时,将链表转换为红黑树,可以将操作的时间复杂度从 O(n)降低到 O(log n),显著提高性能。
  2. 平衡性 :红黑树通过自平衡机制,保证树的高度始终保持在一个较低的水平,避免了链表过长导致的性能问题。

就业陪跑训练营学员投稿

欢迎关注 ❤

我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。

没准能让你能刷到自己意向公司的最新面试题呢。

感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:思否面试群。


王中阳讲编程
805 声望297 粉丝