Java 面试中集合框架相关的面试题也可以说是一个高频题了,而 List 更是一个重点考察的对象。它相比于 Map 而言较为简单,而对于数组则颇为复杂,但不管怎么说对于 List 我们应该做到会用、知源码、懂扩容机制、会如何安全的使用 List 等。
那看看本人 1 年经验如何聊 List(所有源码都基于 JDK11)。
1、List 介绍
List 体系结构图:
Java 集合类主要由两个接口派生而出:Collection 和 Map,而 List 就是 Collection 接口下派生而出的。
Java 编程语言中本身已经提供了操作一组数据的数据结构,数组,那为啥又要提出集合类这一体系呢!
原因无他,就是为了更方便、更丰富的操作一组数据。
List 是一个接口,其规范它所有实现类都应具备的通性:有序、可重复。而具体的实现类又可以进行非常多的扩展,如:ArrayList 集合,允许 null 值、线程不安全、内部数组扩容 1.5 倍;Vector 集合,允许 null 值、线程安全、内部扩容 2 倍;LinkedList 集合,内部用双向链表存储数据;CopyOnWeiteArrayList 集合,读写分离、线程安全等。
要想把 List 跟面试官聊得很彻底,那 List 下面的几个常用实现类是不可能不分析的,所以,请耐心的看我分析下去😁。
1、ArrayList 分析
ArrayList 大家都不陌生,面试中常考也必考的一个集合类,不光要知道其用法,对于源码特别是扩容要非常熟悉能做到滚瓜烂熟就尽量做到。
而对于 ArrayList 所要分析的如下:
- 类中重要属性的分析
- ArrayList 构造器
- add 方法
- 扩容机制分析
- 移除问题
- 序列化问题
巧的是 ArrayList 深入源码分析的文章,我很早之间就已经详细的写过一篇文章了。这也表明了 ArrayList 真的是非常非常容易在面试的时候碰到,以至于我不得不早早的对它下手。
这一节就跳到我的这篇文章阅读:《非专业解读人士的ArrayList源码深度解析》
2、Vector 分析
Vector 是一个出现比较早的操作集合类,它在 JDK1.0 的时候就出现了,而 ArrayList 则是 JDK1.2 的时候才出现的。其和 ArrayList 的内部原理基本差不多,唯一比较大的不同就是它是一个线程安全的类,在多线程环境下修改集合数据不会出现安全问题。
下面我结合 ArrayList 说明一下我通过对比两者的源码分析出的不同点:
1、elementData 数组未被关键字 transient 修饰
源码:
// 存储元素的数组 protected Object[] elementData;
源码中并没有给其数组修饰 transient 关键字,这点在长度可变的集合里面还是有很大优化之处的。
transient 表示的是在序列化时,不要序列化该关键字修饰的属性值。
那,为什么说这是一个可优化的点呢!
我们都知道 Vector 默认扩容倍数是 2 ,现如果往其中添加 100 个元素,其数组长度则会被扩容到 160 长度。然后我们再删除 99 个元素,只让数组保留一个元素,则数组中会有 159 个空值(Vector 没有数组缩小机制),那如果现在要将其序列化,是不是就会浪费很多空间存储 null 值,所以这是可以优化的,而 ArrayList 中就对此进行了优化。
2、初始化时,默认就会生成一个长度为 10 的数组
我们看它的构造器源码:
public Vector(int initialCapacity, int capacityIncrement) { super(); if (initialCapacity < 0) throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); // 在调用构造器是就创建一个指定长度数组 this.elementData = new Object[initialCapacity]; // 扩容时,设置固定扩容长度 this.capacityIncrement = capacityIncrement; }
这也个相对于其他集合类来说,也是一个非常需要优化的地方。有时候我们只是创建一个集合对象而不往里面放入任何数据,所以数组不需要创建,如果调用构造器的时候就初始化数组,则很浪费空间。
所以像 ArrayList 就是在第一次添加元素的时候才初始化数组。
3、初始化时可指定每次扩容的长度,未指定则扩容原来的 2 倍扩容
扩容方法源码:
private int newCapacity(int minCapacity) { // overflow-conscious code // 原始数组长度 int oldCapacity = elementData.length; // 新数组长度 = 原始数组长度 + (是否制定固定扩容长度,有则加上指定扩容长度,没有则添加 原始数组长度,这个相当于 2倍) int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity); // 判断长度是否合理 if (newCapacity - minCapacity <= 0) { if (minCapacity < 0) // overflow throw new OutOfMemoryError(); return minCapacity; } // 是否超过最大长度 return (newCapacity - MAX_ARRAY_SIZE <= 0) ? newCapacity : hugeCapacity(minCapacity); }
代码注释写的很清楚了,其重新定义数组长度逻辑就是初始化时指定扩容长度则按指定长度扩容,否则没错扩容原来的两倍。
4、修改数组数据,线程安全
在其内部所有关于数组元素修改的代码都被 synchronized 修饰,所以其线程安全,但往往效率不高。
3、LinkedList 分析
LinkedList 根据名字就知道,一个链表结构的集合类。其实现了 List 接口,内部是由一个双向链表进行数据的存储。
对于 LinkedList 内部实现逻辑倒不是很难,但有一点我认为理解起来还是比较困难的,就是 Node 节点的 per 和 next 指针指向问题。
具体分析跳转至我的另一篇文章:👉《九张图,探究 LinkedList 集合源码》
4、CopyOnWriteArrayList 分析
和 ArrayList 集合类非常相似,只不过它是一个线程安全类。
其内部通过对所有改变操作进行加锁实现线程安全,并通过复制数组的形式实现读写分离,具体的代码分析和特性总结可以跳转我的另一篇文章:👉《CopyOnWriteArrayList 源码分析》
5、我的面试答案
回答面试官 List 集合体系的问题,要做到广而全。广就是 List 体系的知识点回答的要广点特别是重点知识点,而全就是回答出来的知识点要给面试官讲解全咯才行,下面我就通过本人的一种自述聊聊我是怎么回答 List 问题的。
回答如下:
聊到 List 那我先来说说它在 Java 集合体系中的位置吧!
Java 集合框架体系分为 Collection 和 Map 两个大体系,而 List 就是属于 Collection 下的一个分支,除 List 外 Set 、Queue 这两个也是属于 Collection 体系。
Collection 分支下的集合数据结构特征就是线性的,如:数组结构、链表结构等都是线性数据结构;而 Map 分支下的数据结构则都是键值对形式。
回到 List ,它是一个接口规范了实现了该接口的集合类元素有序且可重复,常用的实现类有 ArrayList 、LinkedList、CopyOnWriteArrayList、Vector 四个。
下面我来总结性的分析这四个集合类(要详细的上面找到对应文章进行详细了解)。
ArrayList、平常写代码和项目中使用最为频繁的一个集合类了,具体要说出的点我列出如下:
默认创建一个空集合,元素个数为 0。
当第一次添加元素时,ArrayList 默认数组长度为 10,以后每次扩容时扩容长度都为当前长度的 1.5 倍。
内部存储数组的对象属性被 transient 修饰,不适用默认序列化规则,自己实现了一套序列化逻辑以节省 null 值带来的内存损耗问题。
获取元素时,根据元素下标获取元素时间复杂度 O(1)。
移除或添加元素时,如果操作的是末尾元素时间复杂度 O(1),反之则是 O(n)。
扩容时会调用 grow 方法进行扩容,底层调用 native 修饰的 System.arraycopy 方法进行元素的复制。虽然元素较少的时候元素复制不会造成多大的空间损耗,但当 ArrayList 集合中存放较多元素时一旦进行元素的复制就会使很多对象满足不可达条件进而触发 GC,最终导致用户线程卡顿情况,这也是我们常常要求在使用 ArrayList 时尽量传入默认容量,减少其扩容次数。
最后就是常说的 ArrayList 线程不安全,在多线程环境下使用可能会出现 ConcurrentModificationException(并发修改异常) 。
针对 ArrayList 不安全的问题,List 体系中倒是有一个类可以解决: Vector ,其底层的原理就是将改变元素的操作方法上全部加上了 synchronized 锁。
虽然 Vector 线程安全,但我们平时基本不使用它,很大一个原因就是 synchronized 直接修饰在方法上不论是读数据还是写数据一律线程互斥效率极低。并且 Vector 每次扩容都是原来数组长度的 2 倍,这对申请整片连续的大内存有点要求,综合以上劣势,所以才会很少有人去使用 Vector 。
那 List 中倒是有一个线程安全的集合类 CopyOnWriteArrayList 实际应用中也确实有使用,它内部维护着一个 lock 对象,使用读写分离策略配合 lock 对象实现线程安全。
CopyOnWriteArrayList 内部不存在扩容这一说法,它所有改变操作都是基于创建出来的副本操作,将修改完成的副本设置给 array 属性及完成改变操作。所以每次添加或修改操作都会进行数组的全量复制,这是其内存效率低下的原因,但因为现代 CUP 支持块级操作,所以运行效率基本影响不大。
在一些读多写少缓存场景中,CopyOnWriteArrayList 倒是有用武之地,记得在 Spring 框架中就有它对 Bean 的缓存应用。
以上我所介绍的集合底层都是基于数组的,而我们都知道数组是基于下标访问元素的时间复杂度 O(1) ,插入或删除操作时间复杂度都是 O(n) 操作尾部元素除外,所以这些都是适合读多写少的场景。在上面我说到过 List 体系中有一个集合类是基于链表结构的:LinkedList 。
LinkedList 底层是一个双向链表结构,创建时不需要申请一大片连续的内存空间,它内部是基于 Node 节点存储数据通过节点中的 per 、next 指针指向前驱节点和后驱节点形成一个不连续的链表结构。这种结构没有提供下标访问元素需要挨个遍历节点查找数据时间复杂度 O(n),但它的长处不是查找而是增删,它只需找到要操作的节点位置将 per、next 指针分别指向对应的位置则完成增删操作,没有元素的移动,适合元素经常发生变化的场景。
对于 List 相关的知识点我通过了一个简单的自述说完了,其实写完之后我感觉还是有很多扩展点没有说到,限于篇幅和整篇自述的逻辑结构就一笔带过。我知道在回答过程中面试官是一定会穿插的提问题的,如:“刚听到你说 ArrayList 会进行扩容且说到了 grow 方法,那能详细说说这个方法吗?”、“能详细说说 CopyOnWriteArrayList 读写分离的实现思路吗,或者给我详细说说 CopyOnWriteArrayList 集合中 add 方法对读写分离的实现”、“对于线程安全的集合除了你讲解的这些还有其他线程安全集合吗?”…等很多问题穿插进行。
但小伙伴们不用害怕,相信我,把这篇文章中附带的链接文章和本人自述给出的面试答案看个七八遍,不信还有 List 相关面试题能难倒你们。
呕心沥血写了快一周,终于是把这篇综合性较强的 List 面试题给完结了,希望各位能有所帮助👍👍👍。
好了,今天的内容到这里就结束了,我是 【J3】关注我,我们下期见。