在 Java 中,主要存在以下三种类型的集合:Set、List 和 Map,按照更加粗略的划分,可以分为:Collection 和 Map,这些类型的继承关系如下图所示:

  • Collection 是集合 List、Set、Queue 等最基本的接口
  • Iterator 即迭代器,可以通过迭代器遍历集合中的数据
  • Map 是映射关系的基本接口

本文将主要介绍有关 List 集合的相关内容,ArrayListLinkedList 是在实际使用中最常使用的两种 List,因此主要介绍这两种类型的 List

本文的 JDK 版本为 JDK 17

ArrayList

具体的使用可以参考 List 接口的相关文档,在此不做过多的介绍

初始化

ArrayList 存在三个构造函数,用于初始化 ArrayList

  • ArrayList()

    对应的源代码如下所示:

    // 默认的空元素列表
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 实际存储元素的数组
    transient Object[] elementData; public ArrayList() {
    // 调用次无参构造函数时,首先将元素列表指向空的列表,在之后的扩容操作中再进行进一步的替换
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }
  • ArrayList(int)

    对应的源代码如下所示:

    private static final Object[] EMPTY_ELEMENTDATA = {};
    
    public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
    this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
    this.elementData = EMPTY_ELEMENTDATA;
    } else {
    throw new IllegalArgumentException("Illegal Capacity: "+
    initialCapacity);
    }
    }
  • ArrayList(Collection<? extend E>)

    对应的源代码:

    public ArrayList(Collection<? extends E> c) {
    Object[] a = c.toArray();
    if ((size = a.length) != 0) {
    if (c.getClass() == ArrayList.class) {
    /*
    如果添加的集合和 ArrayList 相同,那么直接修改当前的数据列表的引用
    因此在某些情况下对于该数组的修改会出现一些奇怪的问题,
    因为对于当前数组元素的修改也会导致原数组元素的改变
    */
    elementData = a;
    } else {
    /*
    想比较之下,复制一个数组会更加安全,缺点在于执行速度不是那么的好
    */
    elementData = Arrays.copyOf(a, size, Object[].class);
    }
    } else {
    // 如果集合中元素的个数为 0,那么替换为空数组
    elementData = EMPTY_ELEMENTDATA;
    }
    }

添加元素

添加元素是比较重要的部分,特别是 ArrayList 的自动扩容机制

ArrayList 中存在以下三个重载函数:

  • add(E, Object[], int)
  • add(E)
  • add(int, E)

一般情况下,都会调用 add(E) 的重载方法完成添加元素的功能,具体的源代码如下所示:

public boolean add(E e) {
modCount++; // 记录当前的数组的修改次数,适用于并发环境下的检测
add(e, elementData, size);
return true;
}

添加时调用重载函数 add(E, Object[], int),对应的源代码如下所示:

private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow(); // 比较关键的地方在这,在这里完成自动扩容
elementData[s] = e;
size = s + 1;
}

add(int, E) 的目的是向数组中指定的位置插入对应的元素,对应的源代码如下所示:

public void add(int index, E element) {
rangeCheckForAdd(index); // 首先,检查插入的索引位置是否合法
modCount++; // 记录当前的修改次数
final int s;
Object[] elementData;
if ((s = size) == (elementData = this.elementData).length)
elementData = grow(); // 此时的数组长度不够,需要进行扩容
// 将扩容后(已经复制了原有数组的数据)的数组按照指定的 index 分开复制到原有数组中
System.arraycopy(elementData, index,
elementData, index + 1,
s - index);
// 修改 index 位置的数组元素
elementData[index] = element;
size = s + 1;
}

扩容

grow() 扩容的实现的源代码如下所示:

private Object[] grow() {
return grow(size + 1);
} private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 判断当前爱你的数组元素是否被修改过
// 首先,计算扩容后的数组的大小
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, // 最少需要增长的空间
oldCapacity >> 1 /* 每次增大的默认空间大小 ,为 0.5 倍的旧空间大小*/ );
// 然后将创建原有的数据元素数组的副本对象,再将数据填入到副本数组中,完成扩容操作
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
/*
由于没有被修改过,那么直接扩容到目标大小即可,
DEFAULT_CAPACITY=10,这是为了提供一个最小的初始容量,以免扩容机制过于频繁造成的性能损失
*/
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}

扩容时关键的是在于新的容量的计算,具体的源代码如下所示:

// jdk.internal.util.ArraysSupport
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {
/*
参照上文中 grow 中传入的参数,最小大小扩容到原来数组大小的 1.5 倍,
如果 minGrwoth 大于 oldCapacity,则以 minGrowth 为准
*/
int prefLength = oldLength + Math.max(minGrowth, prefGrowth);
// 加法操作可能会导致整形变量溢出
if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) {
return prefLength;
} else {
// 如果溢出的话则调用 hugeLength 进行计算
return hugeLength(oldLength, minGrowth);
}
} /*
之所以最大值为 Integer.MAX_VALUE - 8 是由于有些 JVM 的实现会限制其不会达到 Integer.MAX_VALUE
*/
public static final int SOFT_MAX_ARRAY_LENGTH = Integer.MAX_VALUE - 8; private static int hugeLength(int oldLength, int minGrowth) {
int minLength = oldLength + minGrowth;
if (minLength < 0) { // 这里溢出的说明确实是没有足够的空间可以进行分配了
throw new OutOfMemoryError(
"Required array length " + oldLength + " + " + minGrowth + " is too large");
} else if (minLength <= SOFT_MAX_ARRAY_LENGTH) { // 小于最大限制,直接使用即可
return SOFT_MAX_ARRAY_LENGTH;
} else {
// 这种情况说明在 SOFT_MAX_ARRAY_LENGTH — Integer.MAX_VALUE 之间,依旧是一个有效的 int 值
return minLength;
}
}

扩容完成之后,将元素直接放入到末尾位置,完成元素的插入

移除元素

ArrayList 中移除元素也存在两个重载函数:

  • remove(int):移除指定位置的元素
  • remove(Object):移除列表中的指定元素(依据 Object 的 equals 方法)

remove(int) 方法对应的源代码如下所示:

public E remove(int index) {
Objects.checkIndex(index, size); // 检查删除的索引位置是否是有效的
final Object[] es = elementData; // 获取这个索引位置的旧有元素
@SuppressWarnings("unchecked") E oldValue = (E) es[index];
fastRemove(es, index); // 移除该索引位置的元素 return oldValue;
}

比较关键的 fastRemove 方法对应的源代码如下所示:

private void fastRemove(Object[] es, int i) {
modCount++;
final int newSize;
// 直接调用 native 方法将 index 后的元素向前移动一个位置
if ((newSize = size - 1) > i)
System.arraycopy(es, i + 1, es, i, newSize - i);
// 最后一个位置现在已经是无效的,设置为 null 帮助 gc
es[size = newSize] = null;
}

remove(Object) 对应的源代码如下所示:

public boolean remove(Object o) {
final Object[] es = elementData;
final int size = this.size;
int i = 0;
/*
这一步的目的是找到对应的元素位置的索引
可以看到,在找到第一个元素后就不会继续向后找了,
因此使用该方法是需要注意这一点
*/
found: {
if (o == null) {
for (; i < size; i++)
if (es[i] == null)
break found;
} else {
for (; i < size; i++)
if (o.equals(es[i]))
break found;
}
return false;
}
// 同 remove(int) 的移除元素一致
fastRemove(es, i);
return true;
}

值得注意的是,在实际使用的过程中,由于 Java “自动装箱/拆箱” 机制的存在,如果此时恰好列表的元素类型为 Integer,那么在调用 remove 方法时将会自动完成拆箱调用 remove(int) 重载方法。可以显示地通过传入 Integer 对象来避免自动拆箱错误地调用 remove(int),如 list.remove(Integer.valueOf(1)) 就会正确地调用 remove(object ) 方法

收缩列表

由于扩容机制的存在,因此出现列表很长,但是数据元素不多的情况是可能的。ArrayList 并不会自动调用收缩列表的方法来收缩列表的长度,但是我们自己可以显示地通过调用 trimToSize 方法来收缩列表。

trimToSize 对应的源代码如下所示:

public void trimToSize() {
modCount++;
if (size < elementData.length) {
elementData = (size == 0)
? EMPTY_ELEMENTDATA
: Arrays.copyOf(elementData, size);
}
}

LinkedList

除了 ArrayList 之外,对于 List 接口的实现类,LinkedList 可能是使用得比较多的实现类。和 ArrayList 的实现不同,LinkedList 的底层数据结构是双向链表,具体的节点的定义如下所示:

private static class Node<E> {
E item; // 实际数据元素
Node<E> next; // 后继节点指针
Node<E> prev; // 前驱节点指针 Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}

添加元素

同样德,从 List 接口过来的两个重载方法:

  • add(E):添加一个元素到列表的末尾
  • add(int, E):添加一个元素到指定的索引位置

其中, add(E) 对应的源代码如下所示:

public boolean add(E e) {
linkLast(e);
return true;
} // 链接元素到链表的末尾
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}

这个就是简单的双向链表的插入操作,不做过多的介绍。

add(int, E) 对应的源代码如下所示:

public void add(int index, E element) {
checkPositionIndex(index); // 首先,检查插入的索引位置是否合法 // 如果插入的位置就是在末尾,那么直接链接到末尾即可
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}

比较关键的是确定插入位置的元素节点,即 node(index) 方法的实现,对应的源代码如下所示:

Node<E> node(int index) {
// 如果索引小于列表长度的一般,则从头开始查找位置节点
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
// 否则,从尾部节点开始,向前查找到对应的位置节点
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}

最后,将对应的元素插入到找到的元素节点之前即可完成该项操作

移除元素

依旧是从 List 接口带过来的重载方法:

  • remove(int):移除指定位置的元素
  • remove(Object):移除第一个出现的指定元素

remove(int) 对应的源代码如下所示:

public E remove(int index) {
checkElementIndex(index); // 检查索引位置是否合法
/*
首先通过 node(index) 方法找到对应的索引位置的节点,
然后移除它的链接即可(注意头节点和尾节点的变化)
*/
return unlink(node(index));
} E unlink(Node<E> x) {
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev; // 移除前驱节点,注意前驱节点为 null 的情况
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
} // 移除后继节点,注意后继节点为 null 的情况
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
} x.item = null; // 设置为 null 方便 gc
size--;
modCount++;
return element;
}

remove(Object) 对应的源代码如下所示:

public boolean remove(Object o) {
/*
简单来讲就是遍历整个链表,找到要移除的元素,
然后取消它在链表中的链接即可 unlink 方法在上文有所介绍
*/
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}

两者的比较

在实际使用中,ArrayList 的使用次数要比 LinkedList 的次数要高。这是由于使用 List 更加倾向于保存数据,然后获取数据的情况要多一些。

其实,在教科书上会提及链表和数组的比较,诸如:链表使用了额外的空间来维护节点的前后指针、插入和删除都在常数的时间复杂度内完成。但是实际上,这两者的操作性能不会有太大的区别,甚至可能 LinkedList 的插入操作的性能还不及 ArrayList(因为要找到插入的位置节点);同样的,ArrayList 对于空间的利用率在某些情况下可能还没有 LinkedList 高,这是由于扩容时默认会扩容到原来的 1.5 倍,有时那 0.5 倍的额外空间是完全没有被使用的。

按照本人的使用情况,一般在选取 List 的实现时会采用 ArrayList 作为具体的实现类,因为 ArrayList 在转换为数组的这个过程会比 LinkedList 更加便捷。而 LinkedList 不仅仅实现了 List 接口,而且还实现了 QueueDeque 等其它的接口,因此 LinkedList 一般会作为这些接口的默认实现类来使用

Java 集合(一)List的更多相关文章

  1. Java集合专题总结(1):HashMap 和 HashTable 源码学习和面试总结

    2017年的秋招彻底结束了,感觉Java上面的最常见的集合相关的问题就是hash--系列和一些常用并发集合和队列,堆等结合算法一起考察,不完全统计,本人经历:先后百度.唯品会.58同城.新浪微博.趣分 ...

  2. Scala集合和Java集合对应转换关系

    作者:Syn良子 出处:http://www.cnblogs.com/cssdongl 转载请注明出处 用Scala编码的时候,经常会遇到scala集合和Java集合互相转换的case,特意mark一 ...

  3. java集合你了解多少?

    用了java集合这么久,还没有系统的研究过java的集合结构,今天亲自画了下类图,总算有所收获. 一.所有集合都实现了Iterable接口. Iterable接口中包含一个抽象方法:Iterator& ...

  4. 深入java集合学习1-集合框架浅析

    前言 集合是一种数据结构,在编程中是非常重要的.好的程序就是好的数据结构+好的算法.java中为我们实现了曾经在大学学过的数据结构与算法中提到的一些数据结构.如顺序表,链表,栈和堆等.Java 集合框 ...

  5. Java集合框架List,Map,Set等全面介绍

    Java集合框架的基本接口/类层次结构: java.util.Collection [I]+--java.util.List [I]   +--java.util.ArrayList [C]   +- ...

  6. Java集合框架练习-计算表达式的值

    最近在看<算法>这本书,正好看到一个计算表达式的问题,于是就打算写一下,也正好熟悉一下Java集合框架的使用,大致测试了一下,没啥问题. import java.util.*; /* * ...

  7. 【集合框架】Java集合框架综述

    一.前言 现笔者打算做关于Java集合框架的教程,具体是打算分析Java源码,因为平时在写程序的过程中用Java集合特别频繁,但是对于里面一些具体的原理还没有进行很好的梳理,所以拟从源码的角度去熟悉梳 ...

  8. Java 集合框架

    Java集合框架大致可以分为五个部分:List列表,Set集合.Map映射.迭代器.工具类 List 接口通常表示一个列表(数组.队列.链表 栈),其中的元素 可以重复 的是:ArrayList 和L ...

  9. Java集合概述

    容器,是用来装东西的,在Java里,东西就是对象,而装对象并不是把真正的对象放进去,而是指保存对象的引用.要注意对象的引用和对象的关系,下面的例子说明了对象和对象引用的关系. String str = ...

  10. 深入java集合系列文章

    搞懂java的相关集合实现原理,对技术上有很大的提高,网上有一系列文章对java中的集合做了深入的分析, 先转载记录下 深入Java集合学习系列 Java 集合系列目录(Category) HashM ...

随机推荐

  1. python第2~5章 code

    02基本语法 print('he\aaa\aaa') # 这是一个打印语句,请你看见了不要慌张# 这是一个注释# 注释会被解释器所忽略# print(123+456) 这行代码被注释了,将不会执行pr ...

  2. commons中StringUtils的全解

    StringUtils()方法的导入包是:org.apache.commons.lang3.StringUtils 作用是:StringUtils()方法是 Apache Commons Lang 库 ...

  3. 当个 PM 式程序员「GitHub 热点速览」

    本周 GitHub 热点依旧是 GPT 类项目,当中的佼佼者自然是本文收录的 gpt-pilot,一周获得了 7k+ star.此外,像是 LangChain.Autogen 之类的 LLM 工具链项 ...

  4. Java多线程编程的优点和缺点

    优点: 加快响应用户的时间:多线程允许并发执行多个任务,可以充分利用多核处理器,从而提高程序的性能和响应速度.比如我们经常用的迅雷下载,都喜欢多开几个线程去下载,谁都不愿意用一个线程去下载,为什么呢? ...

  5. 小景的工具使用--Java诊断工具Arthas的使用说明

    小景最近在做程序和数据库的压测工作,期间监控压测数据,分析程序原因变成了一个待解决的问题,根据公司小伙伴的建议,接触了阿尔萨斯这个诊断工具,下面小景分别基于Linux操作系统和Windows操作系统, ...

  6. [Python急救站课程]计算1!+2!+3!+......+10!

    计算1!+2!+3!+......+10!程序 sum, tmp = 0, 1 for i in range(1, 11): tmp *= i sum += tmp print("运算结果是 ...

  7. 树莓派4b部署samba服务实现文件共享

    注意 samba 生命力很旺盛,软件是在不断更新的, 网上很多针对 samba 网速优化设置截止当前 实测发现有很多已经过期, 甚至有些设置会适得其反,使传输速度更低. 例如, 全网都在配置的参数,& ...

  8. Go 14周年

    原文在这里. 由 Russ Cox, for the Go team 发布于2023年11月10日 今天,我们庆祝Go开源发布的第十四个生日!Go在过去一年里取得了巨大的进展,发布了两个功能丰富的版本 ...

  9. Hadoop学习(一) 搭建伪分布式集群

    文章结构 1.准备工作 1.1 配置IP 1.2 关闭防火墙 1.3 修改主机名并与IP绑定 1.4 创建新用户 1.5 配置免密匙 2.安装并配置Hadoop伪分布式集群 2.1 安装Java 2. ...

  10. Python+Yolov8+ONNX实时缺陷目标检测

    相比于上一篇Windows10+Python+Yolov8+ONNX图片缺陷识别,并在原图中标记缺陷,有onnx模型则无需配置,无需训练. 优化了程序逻辑,降低了程序运行时间,增加了实时检测功能 目录 ...