Java集合常见面试题总结(上)

集合概述

Java集合,也叫容器,主要是由两大接口派生而来:一个是Collection接口,主要用于存放单一元素;

另一个是Map接口,主要用于存放键值对。

对于Collection接口,下面又有三个主要的子接口:List,Set,Queue

Java集合框架如下图所示:

注:图中只列举了主要的继承派生关系,并没有列举所有关系。

比方省略了AbstractList,NavigablSet等抽象类以及启动一些辅助类,如想深入了解,可自行查看源码。

说说List,Set,Queue,Map四者的区别?

List(对付顺序的好帮手):存储的元素是有序的,可重复的。

Set(注重独一无二的性质):存储的元素不可重复的。

Queue(实现排队功能的叫号机):按特定的排队规则来确定先后顺序,存储的元素的有序的,可重复的。

Map(用key来搜索的专家):使用键值对(key-value)存储,类似与数学上的函数y=f(x),"x"代表key,"y"代表value,key是无序的,不可重复的,value是无序的,可重复的,每个键最多映射到一个值。

集合框架底层数据结构总结

先来看一下Collection接口下面的集合。

List

ArrayList:Object[]数组。

详情可以查看:ArrayList源码分析。

Vector:Object[]数组。

LinkedList:双向链表(JDK1.6之前为循环链表,JDK.17取消了循环)。

详情可以查看:LinkedList源码分析。

Set

HashSet(无序,唯一):基于HashMap实现的,底层采用HashMap来保存元素。

LinkedHashSet:LinkedHashSet是HashSet的子类,并且其内部是通过LinkedHashMap来实现的。

TreeSet(有序,唯一):红黑树(自平衡的排序二叉树)

Queue

PriorityQueue:Object[]数组来实现小顶堆。

详情可以查看:PriorityQueue源码分析。

DelayQueue:PriorityQueue。

详情可以查看:DelayQueue源码分析。

ArrayDeque:可扩展动态双向数组。

再来看看Map接口下面的集合。

Map

HashMap:JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)(将链表转换成红黑数前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

详情可以查看HashMap源码分析。

LinkedHashMap:LinkedHashMap继承自HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。

另外,LinkedHashMap在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。

同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。

详情可以查看:LinkedHashMap源码分析

Hashtable:数组+链表组成的,数组是Hashtable的主体,链表则是主要为了解决哈希冲突而存在的。

TreeMap:红黑树(自平衡的排序二叉树)

如何选用集合?

我们主要根据集合的特点来选择合适的集合。

比如:

我们需要根据键值获取到元素值时就选用Map接口下的集合,需要排序时选择TreeMap,不需要排序时就选择HashMap,需要保证线程安全就选用ConcurrentHashMap

我们只需要存放元素值时,就选择实现Collection接口的集合。

需要保证元素唯一时选择实现Set接口的集合比如TreeSet或HashSet,不需要就选择实现List接口的比如ArrayList或LinkedList,然后根据实现这些接口的集合的特点来选用。

为什么要使用集合?

当我们需要存储一组类型相同的数据时,数组的最常用且最基本的容器之一。

但是,使用数组存储对象存在一些不足之处,因为在实际开发中,存储的数据类型多种多样且数量不确定。

这时,Java集合就派上用场了。

与数组相比,Java集合提供了更灵活,更有效的方法来存储多个数据对象。

Java集合框架中的各种集合类和接口可以存储不同类型和数量的对象,同时还具有多样化的操作方式。

相较于数组,Java集合的优势在于它们的大小可变,支持泛型,具有内建算法等。

总的来说,Java集合提高了数据的存储和处理灵活性,可以更好地适用现代软件开发中多样化的数据需求,并支持高质量的代码编写

List

ArrayList和Array(数组)的区别?

ArrayList内部基于动态数组实现,比Array(静态数组)使用起来更加灵活:

ArrayList会根据实际存储的元素动态地扩容和缩容,而Array被创建之后就不能改变它的长度了。

ArrayList运行你使用泛型来确保类型安全,Array则不可以。

ArrayList中只能存储对象。

对于基本类型数据,需要使用其对应的包装类(如Integer,Double等)

Array可以直接存储基本类型数据,也可以存储对象。

ArrayList支持插入,删除,遍历等常见操作,并且提供了丰富的API操作方法,比如add(),remove()等。

Array只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加,删除元素的能力。

ArrayList创建时不需要指定大小,而Array创建时必须指定大小。

下面是二者使用的简单对比:

Array:

// 初始化一个String类型的数组
String[] stringArr = new String[]{"hello","world","!"};
// 修改数组元素的值
stringArr[0] = "goodbye";
System.out.println(Arrays.toString(stringArr));//[goodbye,world,!]
// 删除数组中的元素,需要手动移动后面的元素
for (int i = 0;i < stringArr.length - 1;i++){
    stringArr[i] = stringArr[i + 1];
}
stringArr[stringArr,length - 1] = null;
System.out.println(Arrays.toString(stringArr));//[world,!,null]

ArrayList:

// 初始一个String类型的 ArrayList
ArrayList<String> stringList = new ArrayList<>(Arrays.asList("hello","world","!"));
// 添加元素到ArrayList中
stringList.add("goodbye");
System.out.println(stringList);// [hello,world,!,goodbye]
// 修改ArrayList中的元素
stringList.set(0,"hi");
System.out.println(stringList);//[hi,world,!,goodbye]
// 删除 ArrayList中的元素
stringList.remove(0);
System.out.println(stringList);// [world,!,goodbye]

ArrayList和Vector的区别?(了解即可)

ArrayList是List的主要实现类,底层使用Object[]存储,适用与频繁的查找工作,线程不安全。

Vector是List的古老实现类,底层使用Object[]存储,线程安全。

Vector和Stack的区别?(了解即可)

Vector和Stack两者都是线程安全的,都是synchronized关键字进行同步处理。

Stack继承自Vector,是一个后进先出的栈,而Vector是一个列表。

随着Java并发编程的发展,Vector和Stack已经被淘汰,推荐使用并发集合类(ConcurrentHashMap,CopyOnWriteArrayList等)或者手动实现线程安全的方法来提供安全的多现场操作支持。

ArrayList可以添加null值吗?

ArrayList中可以存储任何类型的对象,包括null值。

不过,不建议向ArrayList中添加null值,null值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。

示例代码:

ArrayList<String> listOfStrings = new ArrayList<>();
listOfStrings.add(null);
listOfString.add("java");
System.out.println(listOfString);

输出:

[null,java]

ArrayList插入和删除元素的时间复杂度?

对于插入:

头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是O(n).

尾部插入:当ArrayList的容量未到极限时,往列表末尾插入元素的时间复杂度是O(1),因为它只需要在数组末尾添加一个元素即可;

当容量已达到极限并且需要扩容1时,则需要执行一次O(n)的操作将原数组复制到新的更大的数组中,然后再执行O(1)的操作添加元素。

指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。

这个过小需要移动平均n/2个元素,因此时间复杂度O(n)

对于删除:

头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是O(n)

尾部删除:当删除的元素位于列表末尾时,时间复杂度为O(1)

指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均n/2个元素,时间复杂度为O(n).

这里简单列举一个例子:

// ArrayList的底层数组大小为10,此时存储7个元素
+---+---+---+---+---+---+---+---+---+---+
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |   |   |   |
+---+---+---+---+---+---+---+---+---+---+
  0   1   2   3   4   5   6   7   8   9
// 在索引为1的位置插入一个元素8,该元素后面的所有元素都要向右移动一位
+---+---+---+---+---+---+---+---+---+---+
| 1 | 8 | 2 | 3 | 4 | 5 | 6 | 7 |   |   |
+---+---+---+---+---+---+---+---+---+---+
  0   1   2   3   4   5   6   7   8   9
// 删除索引为1的位置的元素,该元素后面的所有元素都要向左移动一位
+---+---+---+---+---+---+---+---+---+---+
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |   |   |   |
+---+---+---+---+---+---+---+---+---+---+
  0   1   2   3   4   5   6   7   8   9

LinkedList插入和删除元素的时间复杂度?

头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为O(1).

尾部插入/删除:只需修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为O(1)/

指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均n/4个元素,时间复杂度为O(n).

这里简单列举一个例子:假如我们要删除节点9的话,需要先遍历链表找到该节点。

然后,再执行相应节点指针指向的更改,具体的源码可以参考:LinkedList源码分析

LinkedList为什么不能实现RandomAccess接口?

RandomAccess是一个标记接口,用来表明实现接口的类支持随机访问(即可以通过索引快速访问元素)。

由于LinkedList底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现RandomAccess接口

ArrayList和LinkedList区别?

是否保证线程安全:ArrayList和LinkedList都是不同步的,也就是不保证线程安全;

底层数据结构:ArrayList底层使用的是Object数组;LinkedList底层使用的是双向链表数据结构(JDK1.6之前为循环链表,JDK1.7取消了循环。

注意双向链表和双向循环链表的区别,下面有介绍到!)

插入和删除是否受元素位置影响:

ArrayList采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。

比如:执行add(E e)方法的时候,ArrayList会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1).

但是如果要在指定位置i插入和删除元素的话(add(int index,E element)),时间复杂度就为O(n).

因为在进行上述操作的时候集合中第i和第i个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。

LinkedList采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e),addFirst(E e),addLast(E e),removeFirst(),removeLast()),时间复杂度为O(1),如果是要在指定位置i插入和删除元素的话(add(int index,E element),remove(Object o),remove(int index)),时间复杂度为O(n),因为需要先移动到指定位置再插入和删除。

是否支持快速随机访问:LinkedList不支持高效的随机访问,而ArrayList(实现了RandomAccess接口)支持。

快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。

内存空间占用:ArrayList的空间浪费主要体现在在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据)。

我们在项目中一般是不会使用到LinkedList的,需要用到LinkedList的场景几乎都可以使用ArrayList来代替,并且,性能通常会更好!

就连LinkedList的作者约书亚.布洛克(Josh Bloch)自己都说从来不会使用LinkedList.

 

另外,不要下意思地认为LinkedList作为链表就最适合元素增删的场景。

我在上面也说了,LinkedList仅仅在头尾插入或者删除元素的时间复杂度近似O(1),其他情况增删元素的平均时间复杂度都是O(n).

补充内容:双向链表和双向循环链表

双向链表:包含两个指针,一个prev指向前一个节点,一个next指向后一个节点。

 

双向循环链表:最后一个节点的next指向head,而head的prex指向最后一个节点,构成一个环。

补充内容RandomAccess接口

public interface RandomAccess {
}

查看源码我们发现实际上RandomAccess接口中什么都没有定义。

所以,在我看来RandomAccess接口不过是一个标识罢了。

标识什么?

标识实现这个接口的类具有·随机访问功能。

在binarySearch()方法中,它要判断传入的list是否RandomAccess的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法

public static <T> int binarySearch(List<? extends Comparable<? super T>> list,T key) {
    if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
        return Collections.indexedBinarySearch(list,key);
    else
        return Collections.iteratorBinarySearch(list,key);
}

ArrayList实现了RandomAccess接口,而LinkedList没有实现。

为什么呢?

我觉得还是和底层数据结构有关!

ArrayList底层是数组,而LinkedList底层是链表。

数组天然支持随机访问,时间复杂度为O(1),所以称为快速随机访问。

链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为O(n),所以不支持快速随机访问。

ArrayList实现了RandomAccess接口,就表明了他具有快速随机访问功能。

RandomAccess接口只是标识,并不是说ArrayList实现RandomAccess接口才具有快速随机访问功能的!

说一说ArrayList的扩容机制吧

详见笔主的这篇文章:ArrayList扩容机制分析

Set

Comparable和Comparator的区别

Compatable接口和Comparator接口都是Java中用于排序的接口,它们在实现类对象之间比较大小,排序等方面发挥了重要作用:

Comparable接口实际上是出自java.lang包 它有一个compareTo(Object obj)方法用来排序

Compatator接口实际上是出自java.util包它有一个compare(Object obj1,Object obj2)方法用来排序

一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,比如一个song对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()方法和使用自制的Comparatoe方法或者以两个Comparator来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的Collections.sort().

Comparator定制排序

ArrayList<Integet> arrayList = new ArrayList<Integer>();
arrayList.add(-1);
arrayList.add(3);
arrayList.add(3);
arrayList.add(-5);
arrayList.add(7);
arrayList.add(4);
arrayList.add(-9);
arrayList.add(-7);
System.out.println("原始数组:");
System.out.println(arrayList);
// void reverse(List list): 反转
Collections.reverse(arrayList);
System.out.println("Collections.reverse(arrayList):");
System.out.println(arrayList);

// void sort(List list),按自然排序的升序排序
Collections.sort(arrayList);
System.out.println("Collections.sort(arrayList):");
System.out.println(arrayList);
// 定制排序的用法
Collections.sort(arrayList,new Comparator<Integer>() {
    @Override
    public int compare(Integer o1,Integer o2) {
        return o2.compareTo(o1);
    }
});
System.out.println("定制排序后:");
System.out.println(arrayList);
原始数组
[-1,3,3,-5,7,4,-9,-7]
Collections.reverse(arrayList):
[-7,-9,4,7,-5,3,3,-1]
Collections.sort(arrayList):
[-9,-7,-5,-1,3,3,4,7]
定制排序后:
[7,4,3,3,-1,-5,-7,-9]

重写compareTo方法实现按年龄来排序

// person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列
// 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他
// 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了
public class Person implements Comparable<Persin> {
    private String name;
    private int age;

    public Person(String name,int age) {
        super();
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }
    
    public void setAge(int age) {
        this.age = age;
    }
    
    /**
    * T重写compareTo方法实现按年龄来排序
    */
    @Override
    public int compareTo(Person o) {
        if (this.age > o.getAge()) {
            return 1;
        }
        if (this.age < o.getAge()) {
            return -1;
        }
        return 0;
    }
}
public static void main(String[] args) {
    TreeMap<Person,String> pdata = new TreeMap<Person,String>();
    pdata.put(new Person("张三",30),"张三");
    pdata.put(new Person("李四",20,"lisi");
    pdata.put(new Person("王五",10),"wangwu");
    pdata.put(new Person("小红",5),"xiaohong");
    // 得到key的值的同时得到key所对应的值
    Set<Person> keys = pdata.keySet();
    for (Person key:keys) {
        System.out.println(key.getAge() + "-" + key.getName());

    }
}

Output:

5-小红
10-王五
20-李四
30-张三

无序性和不可重复性的含义是什么

无序性不等于随机性,无序性是指存储的数据在底层数组中并非安装数组索引的顺序添加,而是根据数据的哈希值决定的。

不可重复性是指添加的元素安装equals()判断时,返回false,需要同时重写equals()方法和hashCode()方法.

比较HashSet,LinkedHashSet和TreeSet三者的异同

HashSet,LinkedHashSet和TreeSet都是Set接口的实现类,都能保证元素唯一,并且都不是线程安全的。

HashSet,LinkedHashSet和TreeSet的主要区别在于底层数据结构不同。

HashSet的底层数据结构是哈希表(基于HashMap实现)。

LinkedHashSet的底层数据结构是链表和哈希表,元素的插入和取出顺序满足FIFO.

TreeSet底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。

底层数据结构 不同又导致这三者的应用场景不同。

HashSet用于不需要保证元素插入和取出顺序的场景。

LinkedHashSet用于保证元素的插入和取出顺序满足FIFO的场景,TreeSst用于支持对元素自定义排序规则的场景。

Queue

Queue与Deque的区别

Queue是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循先进先出(FIFO)规则。

Queue扩展了Collection的接口,根据因为容量问题而导致操作失败后处理方式的不同可以分为两类方法:

一种在操作失败后抛出异常,另一种则返回特殊值

Queue 接口抛出异常返回特殊值
插入队尾add(E e)offer(E e)
删除队首remove()poll()
查询队首元素element()peek()

Deque是双端队列,在队列两端均可以插入或删除元素

Deque扩展了Queue的接口,增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:

Deque 接口抛出异常返回特殊值
插入队首addFirst(E e)offerFirst(E e)
插入队尾addLast(E e)offerLast(E e)
删除队首removeFirst()pollFirst()
删除队尾removeLast()pollLast()
查询队首元素getFirst()peekFirst()
查询队尾元素getLast()peekLast()

事实上,Deque还提供有push()和pop()等其他方式,可用于模拟栈。

ArrayDeque与LinkedList的区别

ArrayDeque和LinkedList都实现了Deque接口,两者都具有队列的功能,但两者有什么区别呢?
ArrayDeque是基于可变长的数组和双指针来实现,而LinkedList则通过链表来实现。

ArrayDeque不支持NULL数据,但LinkedList支持

ArrayDeque是在JDK1.6才被引入的,而LinkedList早在JDK1.2时就已经存在。

ArrayDeque插入时可能存在扩容过程,不过均摊后 的插入操作依然为O(1).

虽然LinkedList不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。

从性能的角度上,选用ArrayDeque来实现队列要比LinedList更好。

此外,ArrayDeque也可以用于实现栈。

说一说PriorityQueue

PriorityQueue是在JDK1.5中被引入的,其与Queue的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。

这里列举其相关的一些要点:

PriorityQueue利用了二叉队的数据结构来实现的,底层使用可变长的数组来存储数据。

PriorityQueue通过堆元素的上浮和下沉,实现了O(logn)的时间复杂度内插入元素和删除堆顶元素。

PriorityQueue是非线程安全的,且不支持存储NULL和non-comparable的对象

PriorityQueue默认是小顶堆,但可以接收一个Comparator作为构造参数,从而自定义元素优先级的先后。

PriorityQueue在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序,求第K大的数,带权图的遍历等,所以需要熟练使用才行。

什么是BlockingQueue?

BlockingQueue(阻塞队列)是一个接口,继承自Queue。

BlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;

还支持如果队列已满,一直等到队列可以放入新元素时再放入

public interface BlokingQueue<E> extends Queue<E> {
    // ...
}

BlockingQueue常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。

 BlokingQueue的实现类有那些?

Java中常用的阻塞队列实现类有以下几种:

1.ArrayBlockingQueue:使用数组实现的有界队列。

在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。

2.LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。

在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE.

和ArrayBlockingQueue不同的是,它仅支持非 公平的锁访问机制。

3.PriorityBlockingQueue:支持优先级排序的无界阻塞队列。

元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入null元素。

4.SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。

每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。

因此,SynchronousQueue通常用于线程之间的直接传递数据。

5.DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列出队。

6......

日常开发中,这些队列使用的其实都不多,了解即可

ArrayBlockingQueue和LinkedBlokingQueue有什么区别?
ArrayBlockingQueue和LinkedBlockingQueue是Java并发包中常用的两种阻塞队列实现,它们都是线程安全的。

不过,不过他们之间也存在下面这些区别:

底层实现:ArrayBlockingQueue基于数组实现,而LinkedBlockingQueue基于链表实现

是否有界:ArrayBlockingQueue是有界队列,必须在创建时指定容量大小。

LinkeBlockingQueue创建时可以不指定容量大小,默认是Integet.MAX_VALUE,也就是无界的。

但也可以指定队列大小,从而成为有界的

锁是否分离:ArrayBlockingQueue中的锁是没有分类的,即生产和消费用的是同一个锁;

LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。

内存占用:ArrayBlockingQueue需要提前分配数组的内存,而LinkedBlockingQueue则是动态分配链表节点内存。

这意味着,ArrayBlockingQueue在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue则是根据元素的增加而逐步占用内存空间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值