Java集合框架整理3--List体系ArrayList和LinkedList源码解析

前言

Collection框架中Collection的子类List是用于存放有序、可以重复的数据的结构,本文就通过源码来分析下List的各种实现类是如何实现的。

List的实现主要分成两种类型,数组和链表

数组的特点是:内存存储地址连续,查询效率高,增删效率低,适合场景为顺序存储,查询频繁

链表的特点是:内存存储地址散列,查询效率低,增删效率高,适合场景为增删频繁而查询次数很少

另外在使用集合时还需要考虑线程安全的问题,能否在多线程情况下安全操作。

一、ArrayList源码解析

1.1、ArrayList初始化

ArrayList顾名思义是通过数组来实现的,先从构造看起,ArrayList的构造方法有三个,分别如下:

 1 /**ArrayList存储数据的数组*/
 2     transient Object[] elementData;
 3 
 4     /**默认空数组*/
 5     private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}
 6 
 7     /**
 8      * 默认构造方法:给内部Object数组初始化一个空的数组
 9      * */
10     public ArrayList() {
11         this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
12     }
13 
14     /**
15      * 传入List的容量
16      * */
17     public ArrayList(int initialCapacity) {
18         if (initialCapacity > 0) {
19             //初始化指定容量大小的Object数组
20             this.elementData = new Object[initialCapacity];
21         } else if (initialCapacity == 0) {
22             //初始化空数组
23             this.elementData = EMPTY_ELEMENTDATA;
24         } else {
25             //容量大小不合法
26             throw new IllegalArgumentException("Illegal Capacity: "+
27                     initialCapacity);
28         }
29     }
30 
31     /**
32      * 传入Collection集合对象
33      * */
34     public ArrayList(Collection<? extends E> c) {
35         //将集合对象转换成Object数组
36         elementData = c.toArray();
37         if ((size = elementData.length) != 0) {
38             // c.toArray might (incorrectly) not return Object[] (see 6260652)
39             if (elementData.getClass() != Object[].class)
40                 //通过Arrays.copyOf方法复制集合中的数据
41                 elementData = Arrays.copyOf(elementData, size, Object[].class);
42         } else {
43             // 初始化空数组
44             this.elementData = EMPTY_ELEMENTDATA;
45         }
46     }

可以看出ArrayList内部有一个Obejct数组对象elementData用于存储List的元素数据,初始化的过程实际就是初始化Object数组的过程,默认是空数组,也可以传入指定容量的数组,还可以传入集合,将集合中的数据复制到Object数组中。

1.2、ArrayList插入数据

List插入数据可以在尾部插入、在指定位置插入、插入整个集合,在指定位置插入整个集合。插入方法源码如下:

 1 /**默认容量*/
 2     private static final int DEFAULT_CAPACITY = 10;
 3 
 4     /**修改次数*/
 5     protected transient int modCount = 0;
 6 
 7     /**在List尾部插入元素*/
 8     public boolean add(E e) {
 9         /**给数组扩容,容量+1*/
10         ensureCapacityInternal(size + 1);  // Increments modCount!!
11         /**将插入元素赋值到指定位置,位置为size的值,然后对size进行自增操作
12          * 注意这里的size和数组的大小是可能不一样的
13          * */
14         elementData[size++] = e;
15         return true;
16     }
17 
18     /**
19      * 确保数组的容量足够,不够则扩容
20      * */
21     private void ensureCapacityInternal(int minCapacity) {
22         ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
23     }
24 
25     private void ensureExplicitCapacity(int minCapacity) {
26         //修改次数自增一次
27         modCount++;
28 
29         /**
30          * 当需要的容量比数组大小大时,则进行扩容;否则无需扩容
31          * */
32         if (minCapacity - elementData.length > 0)
33             /**数组扩容*/
34             grow(minCapacity);
35     }
36 
37     /**
38      * 计算扩容后需要的容量大小
39      * 参数为当前数组大小和想要扩容后的容量
40      * */
41     private static int calculateCapacity(Object[] elementData, int minCapacity) {
42         if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
43             /**当数组为空时,也就是刚初始化时
44              * 设置数组容量为 默认值 和 需要容量的最大值,默认容量为10,
45              * 也就是当需要容量小于10时,就一次性分配10个容量;否则就分配需要的容量大小
46              * f
47              * */
48             return Math.max(DEFAULT_CAPACITY, minCapacity);
49         }
50         /**当前数组不为空时,返回最小容量值*/
51         return minCapacity;
52     }
53 
54     /**
55      * 数组扩容
56      * 参数是最小需要容量
57      * */
58     private void grow(int minCapacity) {
59         //原先数组大小
60         int oldCapacity = elementData.length;
61         //扩容后大小为原先数组大小的1.5倍
62         int newCapacity = oldCapacity + (oldCapacity >> 1);
63         //如果新数组大小不足最小需要的容量,则按最小需要的容量算
64         if (newCapacity - minCapacity < 0)
65             newCapacity = minCapacity;
66         //如果新数组大小大于List最大长度,则按List最大长度计算,List最大长度为Integer的最大值-8
67         /**
68          * ArrayList的最大长度为 2^31-8, 为什么需要-8,因为有些虚拟机会在数组中保留一些头信息,所以为了防止内存溢出
69          * 保留了8位来存储头信息
70          * */
71         if (newCapacity - MAX_ARRAY_SIZE > 0)
72             newCapacity = hugeCapacity(minCapacity);
73         //调用Arrays.copyOf方法来创建新的数组
74         elementData = Arrays.copyOf(elementData, newCapacity);
75     }

在这里调用插入方法时,第一步先判断数组容量是否足够,不够的话就对数组进行扩容,第一次容量为10,后面扩容每次扩容为原容量的1.5倍,或者一次性插入太多超过1.5倍了就按需要的实际容量进行扩容。扩容的最大值为2^31-8。

当扩容完成之后,将插入的元素存入数组的当前元素个数的位置,并将size自增。

再看下在指定位置插入元素的方法实现,list在指定位置插入元素之后,在指定位置后面的元素会全部向后移动一位。源码如下:

 1 /**
 2      * 在指定位置插入元素
 3      * */
 4     public void add(int index, E element) {
 5         rangeCheckForAdd(index);
 6 
 7         //确保list容量足够
 8         ensureCapacityInternal(size + 1);  // Increments modCount!!
 9         /**
10          * 将数组elementData的index位置开始后面的数据复制给elementData的index+1位置开始的数据
11          * 效果为:
12          * 1、elementData数组扩容+1
13          * 2、elementData数组index开始往后的数据全部后移一位
14          */
15 
16         System.arraycopy(elementData, index, elementData, index + 1,
17                 size - index);
18         //将插入的元素保存到数组index位置
19         elementData[index] = element;
20         size++;
21     }
22 
23     /**判断index是否在List的容量范围之内*/
24     private void rangeCheckForAdd(int index) {
25         //如果index大于size获取index<0,则直接抛异常
26         if (index > size || index < 0)
27             throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
28     }

可以发现在指定位置插入元素和直接插入元素的方法还是有一定的区别的,然后会调用rangeCheckForAdd方法检查index是否合法,index的值必须大于0且小于等于size值,也就是说数组的位置只能是[0]~[size]区间内。

然后调用System.arraycopy方法对数组进行复制操作,将数组进入扩容+1,并且将index之后对数据全部后移一位。

分析完add的两个方法之后,批量插入的两个方法addAll原理基本上一样,有兴趣可以自行分析下源码。

1.3、ArrayList获取数据

由于ArrayList是数组,所以获取指定位置的元素实际就是获取数组指定位置的数据,源码如下:

 1 /**获取指定位置的元素*/
 2     public E get(int index) {
 3         rangeCheck(index);
 4         /**直接返回数组的指定位置的数据*/
 5         return elementData(index);
 6     }
 7 
 8     /**校验index是否合法*/
 9     private void rangeCheck(int index) {
10         if (index >= size)
11             throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
12     }

1.4、ArrayList删除数据

ArrayList删除数据一般有两种方式,一种是删除指定位置的数据,一种是删除指定元素值的数据,两者都只会删除一个数据,及时List中存在多个重复值,也只会删除第一个符合条件的值,源码如下:

 1  /**删除指定位置的元素*/
 2     public E remove(int index) {
 3         rangeCheck(index);
 4         modCount++;
 5         //获取需要删除的元素
 6         E oldValue = elementData(index);
 7         /**移动数据个数位数 = size - index - 1;
 8          * 表示需要移动的元素个数,index后面的元素全部需要移动
 9          * */
10         int numMoved = size - index - 1;
11         if (numMoved > 0)
12             /**调用System.arraycopy方法将index+1开始的数据移动到index的位置,相当于将index后面的数据全部前移1位*/
13             System.arraycopy(elementData, index+1, elementData, index,
14                     numMoved);
15             /**将数组最后一位置为空,因为虽然index后面的数据前移了,当时最后一位数据还是存在的,所以需要置为空处理*/
16         elementData[--size] = null; // clear to let GC do its work
17         return oldValue;
18     }
19 
20     /**删除指定元素值*/
21     public boolean remove(Object o) {
22         /**
23          * 遍历数组,删除第一个与参数值一样当数据,及时存在多个重复值,也只会删除第一个
24          * */
25         if (o == null) {
26             for (int index = 0; index < size; index++)
27                 if (elementData[index] == null) {
28                     fastRemove(index);
29                     return true;
30                 }
31         } else {
32             for (int index = 0; index < size; index++)
33                 if (o.equals(elementData[index])) {
34                     fastRemove(index);
35                     return true;
36                 }
37         }
38         return false;
39     }

 1.5、ArrayList的线程安全性

众所周知,ArrayList是线程不安全的,因为ArrayList没有做任何和线程安全相关的操作。在单线程情况下不会出现问题,在多线程情况下就会容易出现数据错乱的问题,比如插入数据的方法中,代码如下:

1 public boolean add(E e) {
2         ensureCapacityInternal(size + 1);  // Increments modCount!!
3         elementData[size++] = e;
4         return true;
5     }

其中插入数据时采用elementData[size++] = e的操作,但是size++ 这个操作不是原子操作,而是分成了elementData[size]=e和size++两步操作,所以当线程A调用执行了elementData[size]=e 之后,此时如果线程B也执行了add方法,并且也执行了elementData[size]之后,就会导致线程B把线程A插入的数据给覆盖了,然后线程AB分别执行size++的操作,结果导致size值是2,但是数组中只有一条数据,此时就出了线程不安全的问题。

再比如扩容的时候,假设两个线程同时插入数据都判断需要进行扩容操作,此时就会导致两个线程同时对数据进行扩容,从而将数组扩容成了1.5 * 2 = 3倍的容量,虽然不会显示的导致其他问题,但是很显然也是不符合线程安全的操作的。

诸如此类的还会有其他非查询操作都存在类似的问题,所以如果在单线程情况下使用ArrayList没有任何问题,多线程情况下就需要慎重选择是否使用ArrayList。

1.5、ArrayList总结:

1、ArrayList底层数据结构为数组,初始化默认大小为0,当容量不足时会进行扩容,第一次为10,超过则按1.5倍容量进行扩容,批量插入时如果超过1.5倍则按实际需要容量进行扩容

2、ArrayList查询数据直接返回内部数组的指定位置的数据即可

3、ArrayList删除数据需要将数组指定位置后的数据前移,然后将最后一位置空处理

4、ArrayList扩容和删除数据时都会调用本地方法System.arraycopy方法来进行数组的复制操作

5、ArrayList是线程不安全的,在进行扩容和插入数据时都是不安全的

二、LinkedList源码解析

LinkedList是以链表为存储结构的List,用法和ArrayList如出一辙,只是底层的实现和ArrayList完全不一样,源码解析如下:

2.1、LinkedList的初始化

 LinkedList有两个构造函数,一个是无参构造函数,一个是参数为Collection的构造函数,有参的构造函数内部也是调用了无参构造函数然后调用了addAll()方法将集合元素保存到List中,后面插入数据时再慢慢看。

1 /**无参构造函数*/
2     public LinkedList() {
3     }
4     /**参数为Collection集合的构造函数*/
5     public LinkedList(Collection<? extends E> c) {
6         this();
7         addAll(c);
8     }

 可以看出LinkedList从初始化就和ArrayList不同,LinkedList初始化过程没有任何操作,而ArrayList初始化过程需要初始化内部的Object数组

2.2、LinkedList插入数据

add(E e)实现源码如下

/**链表头节点*/
    transient Node<E> first;

    /**链表尾节点*/
    transient Node<E> last;

    /**LinkedList内部类
     * Node:链表的节点
     * */
    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;
        }
    }

    /**插入元素*/
    public boolean add(E e) {
        linkLast(e);//调用内部方法linkLast(e)
        return true;
    }

    /**从链表尾部插入元素*/
    void linkLast(E e) {
        //1.获取链表尾部节点
        final Node<E> l = last;
        //2.构造新的节点,头节点引用为当前尾节点;数据为插入的数据;尾节点引用为空
        final Node<E> newNode = new Node<>(l, e, null);
        //3.将当前节点赋值给尾节点引用
        last = newNode;
        //4.如果当前尾为空,表示当前链表为空,则头节点和尾节点都是当前新插入的节点
        if (l == null)
            first = newNode;
        //5.如果当前尾不为空,则将当前节点赋值给当前尾节点的next引用
        else
            l.next = newNode;
        //6.链表长度自增
        size++;
        modCount++;
    }

 从源码可看出,LinkedList存储数据的格式是一个双向链表,节点是LinkedList的内部类Node对象,Node对象保存存储的数据,前后节点的引用,插入节点时实际就是新建一个Node对象,加入到链表到尾部,将当前尾节点的next引用指向新节点。

从插入数据也可和ArrayList进行比较,ArrayList插入数据时设计到扩容,数组的复制等操作,而LinkedList插入数据时只需要新建一个节点,并且将当前尾节点的next引用指向新节点即可,过程不会影响List中其他的节点。所以效率比ArrayList要高很多。

除了在链表尾部插入元素,还可以从指向位置插入新元素,只不过流程上会比较复杂,因为需要对链表进行遍历才能找到需要插入的具体位置。

add(int index, E e)实现源码如下:

 1 /**在指定位置插入元素*/
 2     public void add(int index, E element) {
 3         //1.校验index值是否合法
 4         checkPositionIndex(index);
 5         //2.如果index==size,则表示从链表尾部插入元素,等价于add(E e)方法的实现逻辑
 6         if (index == size)
 7             linkLast(element);
 8         //3.如果index<size,则表示需要从链表中间插入元素,直接调用linkBefore方法
 9         else
10             linkBefore(element, node(index));
11     }
12 
13     /**获取指定位置的Node对象*/
14     Node<E> node(int index) {
15         /**
16          * 当index < size/2 时从头节点开始遍历查找index节点
17          * */
18         if (index < (size >> 1)) {
19             Node<E> x = first;
20             for (int i = 0; i < index; i++)
21                 x = x.next;
22             return x;
23         }
24         /**
25          * 当index >= size/2 时从尾节点开始遍历查找index节点
26          * */
27         else {
28             Node<E> x = last;
29             for (int i = size - 1; i > index; i--)
30                 x = x.prev;
31             return x;
32         }
33     }
34 
35 
36     /**在指定节点之前插入节点*/
37     void linkBefore(E e, Node<E> succ) {
38         //1.获取index节点的前节点
39         final Node<E> pred = succ.prev;
40         //2.构造新节点
41         final Node<E> newNode = new Node<>(pred, e, succ);
42         //3.原index节点的prev执行新节点
43         succ.prev = newNode;
44         //4.当index为首节点时,新节点为首节点
45         if (pred == null)
46             first = newNode;
47         //5.当index非首节点时,原前节点的next指向新节点
48         else
49             pred.next = newNode;
50         size++;
51         modCount++;
52     }
53 
54     /**校验index是否合法,index值需要大于0且小于等于size的值*/
55     private void checkPositionIndex(int index) {
56         if (!isPositionIndex(index))
57             throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
58     }
59 
60     private boolean isPositionIndex(int index) {
61         return index >= 0 && index <= size;
62     }

可以看出主要分成几步

1、校验index是否合法

2、如果index值为size值,则表示直接从链表尾部插入

3、如果index值不为size值,表示需要从链表中间插入,从链表中间插入步骤如下:

3.1、 找到当前index位置的节点Node对象,通过二分查找,如果在链表的上半区就从头节点开始遍历;如果在链表的下半区就从尾节点开始遍历

3.2、构造新节点,将新节点的prev指向前一个节点;next执行后一个节点,并且修改前后节点对应的next和prev引用。

可以看出虽然LinkedList从尾部插入元素比较简单,但是一旦从链表中间插入,就需要对链表做一半的遍历才能找到需要插入的位置,然后修改前后节点的指向,虽然插入操作效率还是比较高,但是查询位置的过程还是比较麻烦。所以LinkedList不适合从中间插入数据的操作。

2.3、LinkedList的删除数据

 1 /**删除指定位置的元素*/
 2     public E remove(int index) {
 3         //校验index值合法性
 4         checkElementIndex(index);
 5         //调用unlink方法删除指定Node节点
 6         return unlink(node(index));
 7     }
 8 
 9     /**删除指定元素*/
10     public boolean remove(Object o) {
11         //1.当参数为空时,则从头节点开始遍历,删除第一个值为空的节点
12         if (o == null) {
13             for (Node<E> x = first; x != null; x = x.next) {
14                 if (x.item == null) {
15                     unlink(x);
16                     return true;
17                 }
18             }
19         }
20         //2.当参数不为空时,则从头节点开始遍历,删除第一个equals方法值为true的节点
21         else {
22             for (Node<E> x = first; x != null; x = x.next) {
23                 if (o.equals(x.item)) {
24                     unlink(x);
25                     return true;
26                 }
27             }
28         }
29         return false;
30     }
31 
32     /**
33      * 从链表中移除指定Node节点对象
34      * */
35     E unlink(Node<E> x) {
36         /** 1.获取Node对象的值、前节点、后节点 */
37         final E element = x.item;
38         final Node<E> next = x.next;
39         final Node<E> prev = x.prev;
40 
41         /** 2.将前节点的next引用指向后节点
42          *    将后节点的prev引用指向前节点
43          *    将当前节点的item、next、prev引用全部置为空,方便GC回收
44          * */
45 
46         if (prev == null) {
47             first = next;
48         } else {
49             prev.next = next;
50             x.prev = null;
51         }
52 
53         if (next == null) {
54             last = prev;
55         } else {
56             next.prev = prev;
57             x.next = null;
58         }
59         x.item = null;
60         size--;
61         modCount++;
62         return element;
63     }

从代码可以看出,无论是删除指定位置的节点还是删除指定数据的节点,实际都是先遍历找到这个Node节点,然后调用unlink方法来删除该节点,删除的过程比较简单,实际就是将该节点的前后节点互相指向,前节点的next指向后节点,后节点的prev指向

前节点,然后将当前节点的属性全部置为空等待被回收即可。 

 2.4、LinkedList的线程安全性

和ArrayList一样,LinkedList同样是线程不安全的,在插入和删除过程中都是线程不安全的,多线程情况下会出现数据不一致问题,比如向链表尾部插入数据时

 1 /**从链表尾部插入元素*/
 2     void linkLast(E e) {
 3         //1.获取链表尾部节点
 4         final Node<E> l = last;
 5         //2.构造新的节点,头节点引用为当前尾节点;数据为插入的数据;尾节点引用为空
 6         final Node<E> newNode = new Node<>(l, e, null);
 7         //3.将当前节点赋值给尾节点引用
 8         last = newNode;
 9         //4.如果当前尾为空,表示当前链表为空,则头节点和尾节点都是当前新插入的节点
10         if (l == null)
11             first = newNode;
12         //5.如果当前尾不为空,则将当前节点赋值给当前尾节点的next引用
13         else
14             l.next = newNode;
15         //6.链表长度自增
16         size++;
17         modCount++;
18     }

当两个线程都执行到第四步的时候,发现当前首节点为空,则都将自己设置成了首节点,这样第二个线程就会将第一个线程设置的first引用给替换了。

 2.5、LinkedList总结

 1、LinkedList初始化和插入数据时都无需扩容操作,所以可以说LinkedList的长度是无限的,但是由于size是int类型的,所以实际最大长度还是int的最大值

2、LinkedList插入过程实际就是创建新节点,然后修改前后节点的prev或next引用即可

3、LinkedList在尾部插入时效率最高,但是如果涉及查询或者在链表中间插入时,就需要对链表进行遍历操作,影响效率

4、LinkedList在遍历时做了优化,通过二分将链表一分为二,只从链表的前半段根据头节点开始遍历或者后半段的尾节点开始遍历

5、LinkedList同样是线程不安全的,在多线程增删数据时会出现数据被覆盖或者不一致的情况。

三、Extra

 3.1、ArrayList和LinkedList的区别?

1、ArrayList底层是通过数组存储数据;LinkedList底层是通过双向链表存储数据

2、ArrayList增删数据需要对数组进行初始化和扩容操作,所以会占有更多的内存;LinkedList无需容量,只存储有效数据,所以不会占有额外的内存

3、ArrayList存储的数据在内存中是连续,连续访问时和查询时效率较高;LinkedList存储的数据在内存中是散列分别,查询的效率较低,往往需要从一端开始遍历

4、ArrayList扩容时需要对数组进行复制操作,增加额外的性能消耗;LinkedList无需扩容,增删节点只需要修改相邻节点的引用即可,增删数据效率较高。

5、ArrayList适合查询较多,增删较少的场景;LinkedList适合查询较少,增删频繁的场景

3.2、ArrayList插入数据性能一定比LinkedList低吗?LinkedList的查询效率一定比ArrayList低吗?

均不一定

ArrayList插入数据实际只需要将数据赋值给数组的指定位置即可,如果数组以及提前扩容好,插入数据只有一步数组赋值的过程,性能同样很高,只是当涉及到扩容和删除数据会涉及到数组的复制才会导致性能较差;而且LinkedList如果在指定位置插入数据,还需要先进行遍历来查询到index对应的节点,如果链表足够长,插入的效率同样比较低;

LinkedList查询的效率低主要是由于需要遍历,但是如果每次都是查询两端的数据的话,就会查询一次就可以查询到数据,避免了遍历的过程,查询的效率同样也是比较高的。

3.3、System.arraycopy()和Arrays.copyOf()方法的区别?

ArrayList内部的数组复制全部是通过System.arraycopy()或者是Arrays.copyOf()方法来实现的,两者底层实现原理基本上一样,因为Arrays.copyOf()方法底层实际就是调用的System.arraycopy()方法来实现的

System.arraycopy(Object src, int srcPos, Obejct dest, int destPos, int length)方法作用是将源数组src从指定位置srcPos开始,复制长度为length个元素到目标数组dest的destPos位置,此方法是一个本地方法。

而Arrays.copyOf([] original, int newLength)方法作用相当于新建一个数组,数组长度为newLength,如果长度大于original的长度相当于就是扩容,如果小于original的长度就相当于是截取;

底层就是调用了System.arraycopy(original, 0, new Array(), 0, (original.length 和 newLength的最小值))

3.4、如何实现线程安全的ArrayList和LinkedList?

ArrayList和LinkedList都是线程不安全的,如果向要在多线程情况下使用,就需要采用一些线程安全的方式,主要有以下几种方式:

1、采用线程安全的集合工具类,如Vector、CopyOnWriteArrayList、ConcurrentLinkedQueue等

Vector实现原理和ArrayList几乎一摸一样,只不过Vector等所有对外方法都通过Syncrhonized来实现同步效果

CopyOnWriteArrayList和ConcurrentLinkedQueue都是JUC中通过CAS+volatile来实现的线程安全的List子类

2、通过Collections.synchroniizedList(List<T> list)方法来将ArrayList或LinkedList对象转化成线程安全的集合,不过Collections实现原理是有一个静态内部类实现了List,实现List的方法就是调用传入参数的List子类的方法,在调用之前

添加了Synchronized关键字进行同步管理,本质上还是通过Synchronized来实现的同步

3、同步调用方法,比如调用ArrayList和LinkedList的方法之前,调用方自己做同步处理,从而避免多线程来操作ArrayList和LinkedList。如通过ReentrantLock、Synchronzied等来同步调用List的方法

原文地址:https://www.cnblogs.com/jackion5/p/12988354.html