Skip to content

JAVA常用集合面试题

1、List

1、ArrayList和LinkedList的区别?分别用在什么场景?

  1. ArrayListLinkedList可从名字分析,它们一个是Array(动态数组)的数据结构,一个是Link(链表)的数据结构,此外,它们两个都是对List接口的实现。

    前者是数组队列,相当于动态数组;后者为双向链表结构,也可当作堆栈、队列、双端队列。

  2. 当随机访问List时(get和set操作),ArrayList比LinkedList的效率更高,因为LinkedList是线性的数据存储方式,所以需要移动指针从前往后依次查找

  3. 当对数据进行增加和删除的操作时(add和remove操作),LinkedList比ArrayList的效率更高,因为ArrayList是数组,所以在其中进行增删操作时,会对操作点之后所有数据的下标索引造成影响,需要进行数据的移动。

  4. 从利用效率来看,ArrayList自由性较低,因为它需要手动的设置固定大小的容量,但是它的使用比较方便,只需要创建,然后添加数据,通过调用下标进行使用;而LinkedList自由性较高,能够动态的随数据量的变化而变化,但是它不便于使用。

  5. ArrayList主要控件开销在于需要在List列表预留一定空间;而LinkedList主要控件开销在于需要存储结点信息以及结点指针信息

场景

链表:插入删除快,查找修改慢。 适用于频繁增删的场景。

数组:查找快,插入删除慢。 适用于频繁查找和修改的场景。

2、ArrayList扩容

扩容规则

  1. ArrayList() 会使用长度为零的数组

  2. ArrayList(int initialCapacity) 会使用指定容量的数组

  3. public ArrayList(Collection<? extends E> c) 会使用 c 的大小作为数组容量

  4. add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.5 倍

  5. addAll(Collection c) 没有元素时,扩容为 Math.max(10, 实际元素个数),有元素时为 Math.max(原容量 1.5 倍, 实际元素个数)

TestArrayList#arrayListGrowRule 演示了 add(Object) 方法的扩容规则,输入参数 n 代表打印多少次扩容后的数组长度

java
package com.xx.test;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

/**
 * @Author: xueqimiao
 * @Date: 2025/5/8 09:24
 */
public class TestArrayList {

    public static void main(String[] args) {
        System.out.println(arrayListGrowRule(30));
//        testAddAllGrowEmpty();
        testAddAllGrowNotEmpty();
    }

    private static List<Integer> arrayListGrowRule(int n) {
        List<Integer> list = new ArrayList<>();
        int init = 0;
        list.add(init);
        if (n >= 1) {
            init = 10;
            list.add(init);
        }
        for (int i = 1; i < n; i++) {
            init += (init) >> 1;
            list.add(init);
        }
        return list;
    }

    private static void testAddAllGrowEmpty() {
        ArrayList<Integer> list = new ArrayList<>();
//        list.addAll(List.of(1, 2, 3));
//        list.addAll(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11));
        System.out.println(length(list));
    }

    private static void testAddAllGrowNotEmpty() {
        ArrayList<Integer> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            list.add(i);
        }
//        list.addAll(List.of(1, 2, 3));
        list.addAll(List.of(1, 2, 3, 4, 5, 6));
        System.out.println(length(list));
    }

    public static int length(ArrayList<Integer> list) {
        try {
            Field field = ArrayList.class.getDeclaredField("elementData");
            field.setAccessible(true);
            return ((Object[]) field.get(list)).length;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
}

3、LinkedList

  1. 基于双向链表,无需连续内存
  2. 随机访问慢(要沿着链表遍历)
  3. 头尾插入删除性能高
  • ArrayListVsLinkedList#randomAccess 对比随机访问性能
  • ArrayListVsLinkedList#addMiddle 对比向中间插入性能
  • ArrayListVsLinkedList#addFirst 对比头部插入性能
  • ArrayListVsLinkedList#addLast 对比尾部插入性能
  • ArrayListVsLinkedList#linkedListSize 打印一个 LinkedList 占用内存
  • ArrayListVsLinkedList#arrayListSize 打印一个 ArrayList 占用内存
java
package com.xx.test;

import cn.hutool.core.date.StopWatch;
import org.openjdk.jol.info.ClassLayout;

import java.lang.reflect.Field;
import java.util.*;
import java.util.stream.Collectors;


/**
 * @Author: xueqimiao
 * @Date: 2025/5/8 10:52
 * --add-opens java.base/java.util=ALL-UNNAMED
 */
@SuppressWarnings("all")
public class ArrayListVsLinkedList {
    public static void main(String[] args) {
        int n = 1000;
        int insertIndex = n;
        for (int i = 0; i < 1; i++) {
            int[] array = randomArray(n);
            List<Integer> list1 = Arrays.stream(array).boxed().collect(Collectors.toList());
            LinkedList<Integer> list2 = new LinkedList<>(list1);

//            randomAccess(list1, list2, n / 2);
//            addFirst(list1,list2);
//            addMiddle(list1, list2, n / 2);
//            addLast(list1,list2);
            arrayListSize((ArrayList<Integer>) list1);
            linkedListSize(list2);
        }
    }

    /*
    <dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.10</version> <!-- 可根据需要替换版本 -->
    </dependency>
     */
    static void linkedListSize(LinkedList<Integer> list) {
        try {
            long size = 0;
            ClassLayout layout = ClassLayout.parseInstance(list);
//            System.out.println(layout.toPrintable());
            size += layout.instanceSize();
            Field firstField = LinkedList.class.getDeclaredField("first");
            firstField.setAccessible(true);
            Object first = firstField.get(list);
//            System.out.println(ClassLayout.parseInstance(first).toPrintable());
            long nodeSize = ClassLayout.parseInstance(first).instanceSize();
            size += (nodeSize * (list.size() + 2));
            long elementSize = ClassLayout.parseInstance(list.getFirst()).instanceSize();
            System.out.println("LinkedList size:[" + size + "],per Node size:[" + nodeSize + "],per Element size:[" + elementSize * list.size() + "]");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static void arrayListSize(ArrayList<Integer> list) {
        try {
            long size = 0;
            ClassLayout layout = ClassLayout.parseInstance(list);
//            System.out.println(layout.toPrintable());
            size += layout.instanceSize();
            Field elementDataField = ArrayList.class.getDeclaredField("elementData");
            elementDataField.setAccessible(true);
            Object elementData = elementDataField.get(list);
//            System.out.println(ClassLayout.parseInstance(elementData).toPrintable());
            size += ClassLayout.parseInstance(elementData).instanceSize();
            long elementSize = ClassLayout.parseInstance(list.get(0)).instanceSize();
            System.out.println("ArrayList size:[" + size + "],array length:[" + length(list) + "],per Element size:[" + elementSize * list.size() + "]");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static void randomAccess(List<Integer> list1, LinkedList<Integer> list2, int mid) {
        StopWatch sw = new StopWatch();
        sw.start("ArrayList");
        list1.get(mid);
        sw.stop();

        sw.start("LinkedList");
        list2.get(mid);
        sw.stop();

        System.out.println(sw.prettyPrint());
    }

    private static void addMiddle(List<Integer> list1, LinkedList<Integer> list2, int mid) {
        StopWatch sw = new StopWatch();
        sw.start("ArrayList");
        list1.add(mid, 100);
        sw.stop();

        sw.start("LinkedList");
        list2.add(mid, 100);
        sw.stop();

        System.out.println(sw.prettyPrint());
    }

    private static void addFirst(List<Integer> list1, LinkedList<Integer> list2) {
        StopWatch sw = new StopWatch();
        sw.start("ArrayList");
        list1.add(0, 100);
        sw.stop();

        sw.start("LinkedList");
        list2.addFirst(100);
        sw.stop();

        System.out.println(sw.prettyPrint());
    }

    private static void addLast(List<Integer> list1, LinkedList<Integer> list2) {
        StopWatch sw = new StopWatch();
        sw.start("ArrayList");
        list1.add(100);
        sw.stop();

        sw.start("LinkedList");
        list2.add(100);
        sw.stop();

        System.out.println(sw.prettyPrint());
    }


    public static int length(ArrayList<Integer> list) {
        try {
            Field field = ArrayList.class.getDeclaredField("elementData");
            field.setAccessible(true);
            return ((Object[]) field.get(list)).length;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }

    public static int[] randomArray(int n) {
        int[] result = new int[n];
        Random random = new Random();

        for (int i = 0; i < n; i++) {
            result[i] = random.nextInt(101); // 生成0到100之间的随机整数
        }

        return result;
    }
}

2、Map

1、HashMap 的数据结构?

哈希表结构(链表散列:数组+链表)实现,结合数组和链表的优点。当链表长度超过 8 时,链表转换为红黑树。transient Node<K,V>[] table;

2、HashMap 的工作原理?

HashMap 底层是 hash 数组和单向链表实现,数组中的每个元素都是链表,由 Node 内部类(实现 Map.Entry<K,V>接口)实现,HashMap 通过 put & get 方法存储和获取。

存储对象时,将 K/V 键值传给 put() 方法:

1、 调用 hash(K) 方法计算 K 的 hash 值,然后结合数组长度,计算得数组下标; 2、 调整数组大小(当容器中的元素个数大于 capacity * loadfactor 时,容器会进行扩容resize 为 2n); 3、 i.如果 K 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞; ii.如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 true,则更新键值对;iii. 如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。

(JDK 1.7 之前使用头插法、JDK 1.8 使用尾插法) (注意:当碰撞导致链表大于 TREEIFY_THRESHOLD = 8 时,就把链表转换成红黑树)

获取对象时,将 K 传给 get() 方法:①、调用 hash(K) 方法(计算 K 的 hash 值)从而获取该键值所在链表的数组下标;②、顺序遍历链表,equals()方法查找相同 Node 链表中 K 值对应的 V 值。

hashCode 是定位的,存储位置;equals是定性的,比较两者是否相等。

3、当两个对象的 hashCode 相同会发生什么?

因为 hashCode 相同,不一定就是相等的(equals方法比较),所以两个对象所在数组的下标相同,"碰撞"就此发生。又因为 HashMap 使用链表存储对象,这个 Node 会存储到链表中。

4、你知道 hash 的实现吗?为什么要这样实现?

JDK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。

5、为什么要用异或运算符?

保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。

6、HashMap 的 table 的容量如何确定?loadFactor 是什么?该容量如何变化?这种变化会带来什么问题?

  1. table 数组大小是由 capacity 这个参数确定的,默认是16,也可以构造时传入,最大限制是1<<30;
  2. loadFactor 是装载因子,主要目的是用来确认table 数组是否需要动态扩展,默认值是0.75,比如table 数组大小为 16,装载因子为 0.75 时,threshold 就是12,当 table 的实际大小超过 12 时,table就需要动态扩容;
  3. 扩容时,调用 resize() 方法,将 table 长度变为原来的两倍(注意是 table 长度,而不是 threshold)
  4. 如果数据很大的情况下,扩展时将会带来性能的损失,在性能要求很高的地方,这种损失很可能很致命。

7、HashMap中put方法与扩容

put 流程

  1. HashMap 是懒惰创建数组的,首次使用才创建数组
  2. 计算索引(桶下标)
  3. 如果桶下标还没人占用,创建 Node 占位返回
  4. 如果桶下标已经有人占用
    1. 已经是 TreeNode 走红黑树的添加或更新逻辑
    2. 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
  5. 返回前检查容量是否超过阈值,一旦超过进行扩容

1.7 与 1.8 的区别

  1. 链表插入节点时,1.7 是头插法,1.8 是尾插法

  2. 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容

  3. 1.8 在扩容计算 Node 索引时,会优化

扩容(加载)因子为何默认是 0.75f

  1. 在空间占用与查询时间之间取得较好的权衡
  2. 大于这个值,空间节省了,但链表就会比较长影响性能
  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

8、树化与退化和索引计算

树化意义

  • 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
  • hash 表的查找,更新的时间复杂度是 $O(1)$,而红黑树的查找,更新的时间复杂度是 $O(log_2⁡n )$,TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
  • hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小

树化规则

  • 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化

退化规则

  • 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
  • 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表

索引计算方法

  • 首先,计算对象的 hashCode()
  • 再进行调用 HashMap 的 hash() 方法进行二次哈希
    • 二次 hash() 是为了综合高位数据,让哈希分布更为均匀
  • 最后 & (capacity – 1) 得到索引

数组容量为何是 2 的 n 次幂

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

注意

  • 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
  • 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable

9、拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?

之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

10、说说你对红黑树的见解?

1、 每个节点非红即黑 2、 根节点总是黑色的 3、 如果节点是红色的,则它的子节点必须是黑色的(反之不一定) 4、 每个叶子节点都是黑色的空节点(NIL节点) 5、 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

11、jdk8中对HashMap做了哪些改变?

1、 在java 1.8中,如果链表的长度超过了8,那么链表将转换为红黑树。(桶的数量必须大于64,小于64的时候只会扩容) 2、 发生hash碰撞时,java 1.7 会在链表的头部插入,而java 1.8会在链表的尾部插入 3、 在java 1.8中,Entry被Node替代(换了一个马甲)。

12、HashMap,LinkedHashMap,TreeMap 有什么区别?

LinkedHashMap 保存了记录的插入顺序,在用 Iterator 遍历时,先取到的记录肯定是先插入的;遍历比 HashMap 慢;

TreeMap 实现 SortMap 接口,能够把它保存的记录根据键排序(默认按键值升序排序,也可以指定排序的比较器)

13、HashMap & TreeMap & LinkedHashMap 使用场景?

一般情况下,使用最多的是 HashMap。HashMap:在 Map 中插入、删除和定位元素时;TreeMap:在需要按自然顺序或自定义顺序遍历键的情况下;LinkedHashMap:在需要输出的顺序和输入的顺序相同的情况下。

14、HashMap 和 HashTable 有什么区别?

1、 HashMap 是线程不安全的,HashTable 是线程安全的; 2、 由于线程安全,所以 HashTable 的效率比不上 HashMap; 3、 HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而 HashTable不允许; 4、 HashMap 默认初始化数组的大小为16,HashTable 为 11,前者扩容时,扩大两倍,后者扩大两倍+1; 5、 HashMap 需要重新计算 hash 值,而 HashTable 直接使用对象的 hashCode

15、Java 中的另一个线程安全的与 HashMap 极其类似的类是什么?同样是线程安全,它与 HashTable 在线程同步上有什么不同?

ConcurrentHashMap 类(是 Java并发包 java.util.concurrent 中提供的一个线程安全且高效的 HashMap 实现)。

HashTable 是使用 synchronize 关键字加锁的原理(就是对对象加锁);

而针对 ConcurrentHashMap,在 JDK 1.7 中采用 分段锁的方式;JDK 1.8 中直接采用了CAS(无锁算法)+ synchronized。

16、HashMap & ConcurrentHashMap 的区别?

除了加锁,原理上无太大区别。另外,HashMap 的键值对允许有null,但是ConCurrentHashMap 都不允许。

17、为什么 ConcurrentHashMap 比 HashTable 效率要高?

HashTable 使用一把锁(锁住整个链表结构)处理并发问题,多个线程竞争一把锁,容易阻塞;

ConcurrentHashMap

JDK 1.7 中使用分段锁(ReentrantLock + Segment + HashEntry),相当于把一个 HashMap 分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry。

JDK 1.8 中使用 CAS + synchronized + Node + 红黑树。锁粒度:Node(首结点)(实现 Map.Entry<K,V>)。锁粒度降低了。

18、针对 ConcurrentHashMap 锁机制具体分析(JDK 1.7 VS JDK 1.8)?

JDK 1.7 中,采用分段锁的机制,实现并发的更新操作,底层采用数组+链表的存储结构,包括两个核心静态内部类 Segment 和 HashEntry。

1、 Segment 继承 ReentrantLock(重入锁) 用来充当锁的角色,每个 Segment 对象守护每个散列映射表的若干个桶; 2、 HashEntry 用来封装映射表的键-值对; 3、 每个桶是由若干个 HashEntry 对象链接起来的链表

image-20210922215536451

JDK 1.8 中,采用Node + CAS + Synchronized来保证并发安全。取消类 Segment,直接用 table 数组存储键值对;当 HashEntry 对象组成的链表长度超过 TREEIFY_THRESHOLD 时,链表转换为红黑树,提升性能。底层变更为数组 + 链表 + 红黑树。

image-20210922215547373

19、ConcurrentHashMap 在 JDK 1.8 中,为什么要使用内置锁 synchronized 来代替重入锁 ReentrantLock?

1、 粒度降低了; 2、 JVM 开发团队没有放弃 synchronized,而且基于 JVM 的 synchronized 优化空间更大,更加自然。 3、 在大量的数据操作下,对于 JVM 的内存压力,基于 API 的 ReentrantLock 会开销更多的内存。

20、ConcurrentHashMap 简单介绍?

1、重要的常量:

​ private transient volatile int sizeCtl; 当为负数时,-1 表示正在初始化,-N 表示 N - 1 个线程正在进行扩容;当为 0 时,表示 table 还没有初始化;当为其他正数时,表示初始化或者下一次进行扩容的大小。

2、数据结构:

​ Node 是存储结构的基本单元,继承 HashMap 中的 Entry,用于存储数据;TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储结构,用于红黑树中存储数据;TreeBin 是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。

3、存储对象时(put() 方法):

1、 如果没有初始化,就调用 initTable() 方法来进行初始化; 2、 如果没有 hash 冲突就直接 CAS 无锁插入; 3、 如果需要扩容,就先进行扩容; 4、 如果存在 hash 冲突,就加锁来保证线程安全,两种情况:一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入; 5、 如果该链表的数量大于阀值 8,就要先转换成红黑树的结构,break 再一次进入循环 6、 如果添加成功就调用 addCount() 方法统计 size,并且检查是否需要扩容。

4、扩容方法 transfer():默认容量为 16,扩容时,容量变为原来的两倍。

helpTransfer():调用多个工作线程一起帮助进行扩容,这样的效率就会更高。

5、获取对象时(get()方法):

1、 计算 hash 值,定位到该 table 索引位置,如果是首结点符合就返回; 2、 如果遇到扩容时,会调用标记正在扩容结点 ForwardingNode.find()方法,查找该结点,匹配就返回; 3、 以上都不符合的话,就往下遍历结点,匹配就返回,否则最后就返回 null。

21、ConcurrentHashMap 的并发度是什么?

程序运行时能够同时更新 ConccurentHashMap 且不产生锁竞争的最大线程数。默认为 16,且可以在构造函数中设置。当用户设置并发度时,ConcurrentHashMap 会使用大于等于该值的最小2幂指数作为实际并发度(假如用户设置并发度为17,实际并发度则为32)

22、HashMap并发问题

扩容死链(1.7 会存在)

1.7 源码如下:

java
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}
  • e 和 next 都是局部变量,用来指向当前节点和下一个节点
  • 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移

image-20210831084325075

  • 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移

image-20210831084723383

  • 第一次循环
    • 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
    • e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
    • 当循环结束是 e 会指向 next 也就是 b 节点

image-20210831084855348

  • 第二次循环
    • next 指向了节点 a
    • e 头插节点 b
    • 当循环结束时,e 指向 next 也就是节点 a

image-20210831085329449

  • 第三次循环
    • next 指向了 null
    • e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
    • 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出

image-20250508110313310