文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

Java 有序集合 List 深度解析

2024-11-28 15:11

关注

本文将深入探讨 Java 中的 List 集合,包括它的基本概念、主要实现类(如 ArrayList 和 LinkedList)、常见的操作方法以及优秀实践。无论您是初学者还是有一定经验的开发者,都能从本文中获得有价值的知识和实用技巧。

Java集合的体系概览

从Java顶层设计角度分类而言,集合整体可分为两大类型:

第1大类是存放单元素的Collection,从源码的注释即可看出,该接口用于表示一组对象的抽象,该接口下的实现的集合空间或允许或不允许元素重复,JDK不提供此几口的任何直接实现,也就是说,该接口底层有包括List、Set等接口的抽象实现:

The root interface in the collection hierarchy. A collection represents a group of objects, known as its elements. Some collections allow duplicate elements and others do not. Some are ordered and others unordered. The JDK does not provide any direct implementations of this interface: it provides implementations of more specific subinterfaces like Set and List. This interface is typically used to pass collections around and manipulate them where maximum generality is desired.

第2大类则是存放键值对的Map,该类型要求键不可重复,且每个键最多可以到映射到一个值(注意这是从宏观角度说的值,该值可以是一个对象、可以是一个集合):

An object that maps keys to values. A map cannot contain duplicate keys; each key can map to at most one value.

我们针对Collection接口进行展开说明,按照元素存储规则的不同我们又可以分为:

对应的我们给出类图:

同理我们将Map接口进行展开,他的特点就是每一个元素都是由键值对组成,我们可以通过key找到对应的value,类图如下,集合具体详情笔者会在后文阐述这里我们只要有一个粗略的印象即可:

详解List集合体系知识点

1.List集合概览

List即有序集合,该接口体系下所实现的集合可以精确控制每一个元素插入的位置,用户可以通过整数索引定位和访问元素:

An ordered collection (also known as a sequence). The user of this interface has precise control over where in the list each element is inserted. The user can access elements by their integer index (position in the list), and search for elements in the list.

从底层结构角度,有序集合还可以有两种实现,第一种也就是我们常说的ArrayList ,从ArrayList源码找到的ArrayList底层存储元素的变量elementData,可以看出ArrayList本质上就是对原生数组的封装:

transient Object[] elementData;

第2中则是LinkedList即双向链表所实现的有序集合,它由一个个节点构成,节点有双指针,分别指向前驱节点和后继节点。

private static class Node {
        E item;
        // 指向后继节点
        Node next;
        //指向前驱节点
        Node prev;

        Node(Node prev, E element, Node next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

Vector底层实现ArrayList一样都是通过空间连续的数组构成,与ArrayList的区别是它在操作时是有上锁的,这意味着多线程场景下它是可以保证线程安全的,但vector现在基本不用了,这里仅仅做个了解:

public class Vector
    extends AbstractList
    implements List, RandomAccess, Cloneable, java.io.Serializable
{
   //......
    protected Object[] elementData;
 //......
}

2.ArrayList容量是10,给它添加一个元素会发生什么?

我们不妨看看这样一段代码,可以看到我们将集合容量设置为10,第11次添加元素时,由于ArrayList底层使用的数组已满,为了能够容纳新的元素,它会进行一次动态扩容,即创建一个更大的容器将原有空间的元素拷贝过去:

   //创建1个容量为10的数组
        ArrayList arrayList = new ArrayList<>(10);
        //连续添加10次至空间满
        for (int i = 0; i < 10; i++) {
            arrayList.add(i);
        }
        //再次添加引发动态扩容
        arrayList.add(10);

我们查看add源码实现细节,可以每次插入前都会调用ensureCapacityInternal来确定当前数组空间是否可以容纳新元素:

public boolean add(E e) {
  //判断本次插入位置是否大于容量
        ensureCapacityInternal(size + 1);
        elementData[size++] = e;
        return true;
    }

查看ensureCapacityInternal的细节可知,一旦感知数组空间不足以容纳新元素时,ArrayList会创建一个新容器大小为原来的1.5倍,然后再将原数组元素拷贝到新容器中:

private void ensureCapacityInternal(int minCapacity) {
  //传入当前元素空间和所需的最小数组空间大小
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    }

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        //当需求空间大于数组
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

private void grow(int minCapacity) {
        // 创建一个原有容器1.5倍的数组空间
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
      //......
      //将原有元素elementData拷贝到新空间去
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

3.针对动态扩容导致的性能问题,你有什么解决办法嘛?

我们可以提前调用ensureCapacity顶下最终容量一次性完成动态扩容提高程序执行性能。

public static void main(String[] args) {
        int size = 1000_0000;
        ArrayList list = new ArrayList<>(1);
        long start = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            list.add(i);
        }
        long end = System.currentTimeMillis();
        System.out.println("无显示扩容,完成时间:" + (end - start));


        //显示扩容显示扩容,避免多次动态扩容的拷贝
        ArrayList list2 = new ArrayList<>(size);
        start = System.currentTimeMillis();
        list2.ensureCapacity(size);
        for (int i = 0; i < size; i++) {
            list2.add(i);
        }
        end = System.currentTimeMillis();
        System.out.println("显示扩容,完成时间:" + (end - start));
    }

输出结果如下,可以看到在显示指明大小空间的情况下,性能要优于常规插入:

无显示扩容,完成时间:6122
显示扩容,完成时间:761

4.ArrayList和LinkedList性能差异体现在哪

我们给出头插法的示例代码:

public static void main(String[] args) {
        int size = 10_0000;
        List arrayList = new ArrayList<>();
        List linkedList = new LinkedList<>();

        long start = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            arrayList.add(0, i);
        }
        long end = System.currentTimeMillis();
        System.out.println("arrayList头插时长:" + (end - start));


        start = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            linkedList.add(0, i);
        }
        end = System.currentTimeMillis();
        System.out.println("linkedList 头插时长:" + (end - start));


        start = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            ((LinkedList) linkedList).addFirst(i);
        }
        end = System.currentTimeMillis();
        System.out.println("linkedList addFirst 耗时:" + (end - start));
    }

从性能表现上来看arrayList表现最差,而linkedList 的addFirst 表现最出色。

arrayList头插时长:562
linkedList 头插时长:8
linkedList addFirst 耗时:4

这里我们不妨说一下原因,arrayList性能差原因很明显,每次头部插入都需要挪动整个数组,linkedList的add方法在进行插入时,若是头插法,它会通过node方法定位头节点,然后在使用linkBefore完成头插法。

 public void add(int index, E element) {
       //......

        if (index == size)
            linkLast(element);
        else
         //通过node定位到头节点,再进行插入操作
            linkBefore(element, node(index));
    }

而链表的addFirst 就不一样,它直接定位到头节点,进行头插法,正是这一点点性能上的差距造成两者性能表现上微小的差异。

private void linkFirst(E e) {
  //直接定位到头节点,进行头插法
        final Node f = first;
        //创建新节点
        final Node newNode = new Node<>(null, e, f);
        //直接让first 直接引用该节点
        first = newNode;
        //让原有头节点指向当前节点
        if (f == null)
            last = newNode;
        else
            f.prev = newNode;
        //调整元素空间数量    
        size++;
        modCount++;
    }

再来看看尾插法:

public static void main(String[] args) {
        int size = 10_0000;
        List arrayList = new ArrayList<>();
        List linkedList = new LinkedList<>();

        long start = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            arrayList.add(i, i);
        }
        long end = System.currentTimeMillis();
        System.out.println("arrayList 尾插时长:" + (end - start));


        start = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            linkedList.add(i, i);
        }
        end = System.currentTimeMillis();
        System.out.println("linkedList 尾插时长:" + (end - start));


        start = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            ((LinkedList) linkedList).addLast(i);
        }
        end = System.currentTimeMillis();
        System.out.println("linkedList 尾插时长:" + (end - start));

    }

输出结果,可以看到还是链表稍快一些,为什么arraylist这里性能也还不错呢?原因也很简单,无需为了插入一个节点维护其他位置。

arrayList 尾插时长:5
linkedList 尾插时长:2
linkedList 尾插时长:3

最后再来看看随机插入,为了公平实验,笔者将list初始化工作都放在计时之外,避免arrayList动态扩容的时间影响最终实验结果:

public static void main(String[] args) {
        int size = 10_0000;
        //填充足够量的数据
        ArrayList arrayList = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            arrayList.add(i);
        }
        //随机插入
        long begin = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            arrayList.add(RandomUtil.randomInt(0, size), RandomUtil.randomInt());
        }
        long end = System.currentTimeMillis();
        System.out.println("arrayList随机插入耗时:" + (end - begin));

        //填充数据
        LinkedList linkedList = new LinkedList<>();
        for (int i = 0; i < size; i++) {
            linkedList.add(i);
        }
        //随机插入
        begin = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            linkedList.add(RandomUtil.randomInt(0, size), RandomUtil.randomInt());
        }
        end = System.currentTimeMillis();
        System.out.println("linkedList随机插入耗时:" + (end - begin));

    }

从输出结果来看,随机插入也是arrayList性能较好,原因也很简单,arraylist随机访问速度远远快与linklist:

arrayList随机插入耗时:748
linkedList随机插入耗时:27741

针对两者的性能差异,笔者也在这里进行一下简单的小结:

5.ArrayList 和 Vector 的异同

这个问题我们可以从以下两个维度分析:

先来说说底层数据结构,两者底层存储都是采用数组,ArrayList存储用的是new Object[initialCapacity];

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);
        }
    }

Vector底层存储元素用的也是是 new Object[initialCapacity];,即一个对象数组:

public Vector(int initialCapacity, int capacityIncrement) {
        //......
  //基于定长容量初始化数组                                               
        this.elementData = new Object[initialCapacity];
        this.capacityIncrement = capacityIncrement;
    }

从并发安全角度来说,Vector 为线程安全类,ArrayList 线程不安全,如下所示我们使用ArrayList进行多线程插入出现的索引越界问题。

public static void main(String[] args) throws InterruptedException {
        List list = new ArrayList<>();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                list.add(i);
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                list.add(i);
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        Thread.sleep(5000);
        System.out.println(list.size());

    }

因为多线程访问的原因,底层索引不安全操作的自增,导致插入时得到一个错误的索引位置从而导致插入失败:

Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 823
 at java.util.ArrayList.add(ArrayList.java:463)
 at com.sharkChili.Main.lambda$main$0(Main.java:15)
 at java.lang.Thread.run(Thread.java:748)

Vector 线程安全代码示例:

 public static void main(String[] args) throws InterruptedException {
        List list = new Vector<>();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                list.add(i);
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                list.add(i);
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        Thread.sleep(5000);
        System.out.println(list.size());//2000

    }

原因很简单,vector的add方法有加synchronized 关键字,保证单位时间内只有一个线程可以操作底层的数组:

//任何操作都是上锁的,保证一次插入操作互斥和原子性
 public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }

6.ArrayList 与 LinkedList 的区别

从上文中我们基本可以了解两者区别,这里我们就做一个简单的小结:

7.ArrayList 的扩容机制

Java的ArrayList 底层默认数组大小为10,的动态扩容机制即ArrayList 确保元素正确存放的关键,了解核心逻辑以及如何基于该机制提高元素存储效率也是很重要的。

尽管从上面来看两者各有千秋,但比较有趣的是,LinkedList的作者Josh Bloch基本没有用过这个集合:

来源:写代码的SharkChili内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯