前言
说真的,在 Java 使用最多的集合类中,List 绝对占有一席之地的,它和 Map 一样适用于很多场景,非常方便我们的日常开发,毕竟存储一个列表的需求随处可见。尽管如此,还是有很多同学没有弄明白 List 中 ArrayList 和 LinkedList 有什么区别,这简直太遗憾了,这两者其实都是数据结构中的基础内容,这篇文章会从基础概念开始,分析两者在 Java 中的具体源码实现,寻找两者的不同之处,最后思考它们使用时的注意事项。
这篇文章会包含以下内容。
- 介绍线性表的概念,详细介绍线性表中数组和链表的数据结构。
- 进行 ArrayList 的源码分析,比如存储结构、扩容机制、数据新增、数据获取等。
- 进行 LinkedList 的源码分析,比如它的存储结构、数据插入、数据查询、数据删除和 LinkedList 作为队列的使用方式等。
- 进行 ArrayList 和 LinkedList 的总结。
线性表
要研究 ArrayList 和 LinkedList ,首先要弄明白什么是线性表,这里引用百度百科的一段文字。
线性表是最基本、最简单、也是最常用的一种数据结构。线性表(linear list)是数据结构的一种,一个线性表是n个具有相同特性的数据元素的有限序列。
你肯定看到了,线性表在数据结构中是一种最基本、最简单、最常用的数据结构。它将数据一个接一个的排成一条线(可能逻辑上),也因此线性表上的每个数据只有前后两个方向,而在数据结构中,数组、链表、栈、队列都是线性表。你可以想象一下整整齐齐排队的样子。
线性表
看到这里你可能有疑问了,有线性表,那么肯定有非线性表喽?没错。二叉树和图就是典型的非线性结构了。不要被这些花里胡哨的图吓到,其实这篇文章非常简单,希望同学耐心看完点个赞。
非线性接口(图片来自网络)
数组
既然知道了什么是线性表,那么理解数组也就很容易了,首先数组是线性表的一种实现。数组是由相同类型元素组成的一种数据结构,数组需要分配一段连续的内存用来存储。注意关键词,相同类型,连续内存,像这样。
数组
不好意思放错图了,像这样。
数组概念
上面的图可以很直观的体现数组的存储结构,因为数组内存地址连续,元素类型固定,所有具有快速查找某个位置的元素的特性;同时也因为数组需要一段连续内存,所以长度在初始化长度已经固定,且不能更改。Java 中的 ArrayList 本质上就是一个数组的封装。
链表
链表也是一种线性表,和数组不同的是链表不需要连续的内存进行数据存储,而是在每个节点里同时存储下一个节点的指针,又要注意关键词了,每个节点都有一个指针指向下一个节点。那么这个链表应该是什么样子呢?看图。
单向链表
哦不,放错图了,是这样。
链表存储结构(图片来自网络)
上图很好的展示了链表的存储结构,图中每个节点都有一个指针指向下一个节点位置,这种我们称为单向链表;还有一种链表在每个节点上还有一个指针指向上一个节点,这种链表我们称为双向链表。图我就不画了,像下面这样。
双向链表
可以发现链表不必连续内存存储了,因为链表是通过节点指针进行下一个或者上一个节点的,只要找到头节点,就可以以此找到后面一串的节点。不过也因此,链表在查找或者访问某个位置的节点时,需要**O(n)的时间复杂度。但是插入数据时可以达到O(1)**的复杂度,因为只需要修改节点指针指向。
ArratList
上面介绍了线性表的概念,并举出了两个线性表的实际实现例子,既数组和链表。在 Java 的集合类 ArrayList 里,实际上使用的就是数组存储结构,ArrayList 对 Array 进行了封装,并增加了方便的插入、获取、扩容等操作。因为 ArrayList 的底层是数组,所以存取非常迅速,但是增删时,因为要移动后面的元素位置,所以增删效率相对较低。那么它具体是怎么实现的呢?不妨深入源码一探究竟。
ArrayList 存储结构
查看 ArrayList 的源码可以看到它就是一个简单的数组,用来数据存储。
/** * The array buffer into which the elements of the ArrayList are stored. * The capacity of the ArrayList is the length of this array buffer. Any * empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA * will be expanded to DEFAULT_CAPACITY when the first element is added. */ transient Object[] elementData; // non-private to simplify nested class access /** * Shared empty array instance used for default sized empty instances. We * distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when * first element is added. */ private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * Default initial capacity. */ private static final int DEFAULT_CAPACITY = 10;
通过上面的注释了解到,ArrayList 无参构造时是会共享一个长度为 0 的数组 DEFAULTCAPACITY_EMPTY_ELEMENTDATA. 只有当第一个元素添加时才会第一次扩容,这样也防止了创建对象时更多的内存浪费。
ArrayList 扩容机制
我们都知道数组的大小一但确定是不能改变的,那么 ArrayList 明显可以不断的添加元素,它的底层又是数组,它是怎么实现的呢?从上面的 ArrayList 存储结构以及注释中了解到,ArrayList 在初始化时,是共享一个长度为 0 的数组的,当第一个元素添加进来时会进行第一次扩容,我们可以想像出 ArrayList 每当空间不够使用时就会进行一次扩容,那么扩容的机制是什么样子的呢?
依旧从源码开始,追踪 add() 方法的内部实现。
/** * Appends the specified element to the end of this list. * * @param e element to be appended to this list * @return <tt>true</tt> (as specified by {@link Collection#add}) */ public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; } // 开始检查当前插入位置时数组容量是否足够 private void ensureCapacityInternal(int minCapacity) { // ArrayList 是否未初始化,未初始化是则初始化 ArrayList ,容量给 10. if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } // 比较插入 index 是否大于当前数组长度,大于就 grow 进行扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length > 0) grow(minCapacity); } /** * Increases the capacity to ensure that it can hold at least the * number of elements specified by the minimum capacity argument. * * @param minCapacity the desired minimum capacity */ private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; // 扩容规则是当前容量 + 当前容量右移1位。也就是1.5倍。 int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; // 是否大于 Int 最大值,也就是容量最大值 if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: // 拷贝元素到扩充后的新的 ArrayList elementData = Arrays.copyOf(elementData, newCapacity); }
通过源码发现扩容逻辑还是比较简单的,整理下具体的扩容流程如下:
- 开始检查当前插入位置时数组容量是否足够
- ArrayList 是否未初始化,未初始化是则初始化 ArrayList ,容量给 10.
- 判断当前要插入的下标是否大于容量
- 不大于,插入新增元素,新增流程完毕。
- 如果所需的容量大于当前容量,开始扩充。
- 扩容规则是当前容量 + 当前容量右移1位。也就是1.5倍。
int newCapacity = oldCapacity + (oldCapacity >> 1);
- 如果扩充之后还是小于需要的最小容量,则把所需最小容量作为容量。
- 如果容量大于默认最大容量,则使用 最大值 Integer 作为容量。
- 拷贝老数组元素到扩充后的新数组
- 插入新增元素,新增流程完毕。
ArrayList 数据新增
上面分析扩容时候已经看到了新增一个元素的具体逻辑,因为底层是数组,所以直接指定下标赋值即可,非常简单。
public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; // 直接赋值 return true; }
但是还有一种新增数据得情况,就是新增时指定了要加入的下标位置。这时逻辑有什么不同呢?
/** * Inserts the specified element at the specified position in this * list. Shifts the element currently at that position (if any) and * any subsequent elements to the right (adds one to their indices). * * @param index index at which the specified element is to be inserted * @param element element to be inserted * @throws IndexOutOfBoundsException {@inheritDoc} */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! // 指定下标开始所有元素后移一位 System.arraycopy(elementData, index, elementData, index + 1,size - index); elementData[index] = element; size++; }
可以发现这种新增多了关键的一行,它的作用是把从要插入的坐标开始的元素都向后移动一位,这样才能给指定下标腾出空间,才可以放入新增的元素。
比如你要在下标为 3 的位置新增数据100,那么下标为3开始的所有元素都需要后移一位。
ArrayList 随机新增数据
由此也可以看到 ArrayList 的一个缺点,随机插入新数据时效率不高。
ArrayList 数据获取
数据下标获取元素值,一步到位,不必多言。
public E get(int index) { rangeCheck(index); return elementData(index); } E elementData(int index) { return (E) elementData[index]; }