Categories
程式開發

逼著面試官問了我ArrayList和LinkedList的區別,他對我徹底服了


ArrayList 和LinkedList 有什麼區別,是面試官非常喜歡問的一個問題。可能大部分小伙伴和我一樣,能回答出“ArrayList 是基於數組實現的,LinkedList 是基於雙向鍊錶實現的。”

關於這一點,我之前的文章裡也提到過了。但說實話,這樣蒼白的回答並不能令面試官感到滿意,他還想知道的更多。

那假如小伙伴們繼續做出下面這樣的回答:

“ArrayList 在新增和刪除元素時,因為涉及到數組複製,所以效率比LinkedList 低,而在遍歷的時候,ArrayList 的效率要高於LinkedList。”

面試官會感到滿意嗎?我只能說,如果面試官比較仁慈的話,他可能會讓我們回答下一個問題;否則的話,他會讓我們回家等通知,這一等,可能意味著杳無音訊了。

為什麼會這樣呢?為什麼為什麼?回答的不對嗎?

暴躁的小伙伴請喝口奶茶冷靜一下。冷靜下來後,請隨我來,讓我們一起肩並肩、手拉手地深入地研究一下ArrayList 和LinkedList 的數據結構、實現原理以及源碼,可能神秘的面紗就揭開了。

01、ArrayList 是如何實現的?

逼著面試官問了我ArrayList和LinkedList的區別,他對我徹底服了 1

ArrayList 實現了List 接口,繼承了AbstractList 抽像類,底層是基於數組實現的,並且實現了動態擴容。

public class ArrayList extends AbstractList
implements List, RandomAccess, Cloneable, java.io.Serializable
{
private static final int DEFAULT_CAPACITY = 10;
transient Object[] elementData;
private int size;
}

ArrayList 還實現了RandomAccess 接口,這是一個標記接口:

public interface RandomAccess {
}

內部是空的,標記“實現了這個接口的類支持快速(通常是固定時間)隨機訪問”。快速隨機訪問是什麼意思呢?就是說不需要遍歷,就可以通過下標(索引)直接訪問到內存地址。

public E get(int index) {
Objects.checkIndex(index, size);
return elementData(index);
}
E elementData(int index) {
return (E) elementData[index];
}

ArrayList 還實現了Cloneable 接口,這表明ArrayList 是支持拷貝的。 ArrayList 內部的確也重寫了Object 類的clone() 方法。

public Object clone() {
try {
ArrayList v = (ArrayList) super.clone();
v.elementData = Arrays.copyOf(elementData, size);
v.modCount = 0;
return v;
} catch (CloneNotSupportedException e) {
// this shouldn't happen, since we are Cloneable
throw new InternalError(e);
}
}

ArrayList 還實現了Serializable 接口,同樣是一個標記接口:

public interface Serializable {
}

內部也是空的,標記“實現了這個接口的類支持序列化”。序列化是什麼意思呢? Java 的序列化是指,將對象轉換成以字節序列的形式來表示,這些字節序中包含了對象的字段和方法。序列化後的對象可以被寫到數據庫、寫到文件,也可用於網絡傳輸。

眼睛雪亮的小伙伴可能會注意到,ArrayList 中的關鍵字段elementData 使用了transient 關鍵字修飾,這個關鍵字的作用是,讓它修飾的字段不被序列化。

這不前後矛盾嗎?一個類既然實現了Serilizable 接口,肯定是想要被序列化的,對吧?那為什麼保存關鍵數據的elementData 又不想被序列化呢?

這還得從“ArrayList 是基於數組實現的”開始說起。大家都知道,數組是定長的,就是說,數組一旦聲明了,長度(容量)就是固定的,不能像某些東西一樣伸縮自如。這就很麻煩,數組一旦裝滿了,就不能添加新的元素進來了。

ArrayList 不想像數組這樣活著,它想能屈能伸,所以它實現了動態擴容。一旦在添加元素的時候,發現容量用滿了s == elementData.length,就按照原來數組的1.5 倍(oldCapacity >> 1)進行擴容。擴容之後,再將原有的數組複製到新分配的內存地址上Arrays.copyOf(elementData, newCapacity)。

private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}

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, /* minimum growth */
oldCapacity >> 1 /* preferred growth */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}

動態擴容意味著什麼?大傢伙想一下。嗯,還是我來告訴大家答案吧,有點迫不及待。

意味著數組的實際大小可能永遠無法被填滿的,總有多餘出來空置的內存空間。

比如說,默認的數組大小是10,當添加第11 個元素的時候,數組的長度擴容了1.5 倍,也就是15,意味著還有4 個內存空間是閒置的,對吧?

序列化的時候,如果把整個數組都序列化的話,是不是就多序列化了4 個內存空間。當存儲的元素數量非常非常多的時候,閒置的空間就非常非常大,序列化耗費的時間就會非常非常多。

於是,ArrayList 做了一個愉快而又聰明的決定,內部提供了兩個私有方法writeObject 和readObject 來完成序列化和反序列化。

private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();

// Write out size as capacity for behavioral compatibility with clone()
s.writeInt(size);

// Write out all elements in the proper order.
for (int i=0; i

从 writeObject 方法的源码中可以看得出,它使用了 ArrayList 的实际大小 size 而不是数组的长度(elementData.length)来作为元素的上限进行序列化。

此处应该有掌声啊!不是为我,为 Java 源码的作者们,他们真的是太厉害了,可以用两个词来形容他们——殚精竭虑、精益求精。

逼著面試官問了我ArrayList和LinkedList的區別,他對我徹底服了 2

02、LinkedList 是如何实现的?

逼著面試官問了我ArrayList和LinkedList的區別,他對我徹底服了 3

LinkedList 是一个继承自 AbstractSequentialList 的双向链表,因此它也可以被当作堆栈、队列或双端队列进行操作。

public class LinkedList
extends AbstractSequentialList
implements List, Deque, Cloneable, java.io.Serializable
{
transient int size = 0;
transient Node first;
transient Node last;
}

LinkedList 内部定义了一个 Node 节点,它包含 3 个部分:元素内容 item,前引用 prev 和后引用 next。代码如下所示:

private static class Node {
E item;
LinkedList.Node next;
LinkedList.Node prev;

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

LinkedList 还实现了 Cloneable 接口,这表明 LinkedList 是支持拷贝的。

LinkedList 还实现了 Serializable 接口,这表明 LinkedList 是支持序列化的。眼睛雪亮的小伙伴可能又注意到了,LinkedList 中的关键字段 size、first、last 都使用了 transient 关键字修饰,这不又矛盾了吗?到底是想序列化还是不想序列化?

答案是 LinkedList 想按照自己的方式序列化,来看它自己实现的 writeObject() 方法:

private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// Write out any hidden serialization magic
s.defaultWriteObject();

// Write out size
s.writeInt(size);

// Write out all elements in the proper order.
for (LinkedList.Node x = first; x != null; x = x.next)
s.writeObject(x.item);
}

发现没?LinkedList 在序列化的时候只保留了元素的内容 item,并没有保留元素的前后引用。这样就节省了不少内存空间,对吧?

那有些小伙伴可能就疑惑了,只保留元素内容,不保留前后引用,那反序列化的时候怎么办?

private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// Read in any hidden serialization magic
s.defaultReadObject();

// Read in size
int size = s.readInt();

// Read in all elements in the proper order.
for (int i = 0; i < size; i++) linkLast((E)s.readObject()); } void linkLast(E e) { final LinkedList.Node l = last; final LinkedList.Node newNode = new LinkedList.Node(l, e, null); last = newNode; if (l == null) first = newNode; else l.next = newNode; size++; modCount++; }

注意 for 循环中的 linkLast() 方法,它可以把链表重新链接起来,这样就恢复了链表序列化之前的顺序。很妙,对吧?

和 ArrayList 相比,LinkedList 没有实现 RandomAccess 接口,这是因为 LinkedList 存储数据的内存地址是不连续的,所以不支持随机访问。

03、ArrayList 和 LinkedList 新增元素时究竟谁快?

前面我们已经从多个维度了解了 ArrayList 和 LinkedList 的实现原理和各自的特点。那接下来,我们就来聊聊 ArrayList 和 LinkedList 在新增元素时究竟谁快?

1)ArrayList

ArrayList 新增元素有两种情况,一种是直接将元素添加到数组末尾,一种是将元素插入到指定位置。

添加到数组末尾的源码:

public boolean add(E e) {
modCount++;
add(e, elementData, size);
return true;
}

private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}

很简单,先判断是否需要扩容,然后直接通过索引将元素添加到末尾。

插入到指定位置的源码:

public void add(int index, E element) {
rangeCheckForAdd(index);
modCount++;
final int s;
Object[] elementData;
if ((s = size) == (elementData = this.elementData).length)
elementData = grow();
System.arraycopy(elementData, index,
elementData, index + 1,
s - index);
elementData[index] = element;
size = s + 1;
}

先检查插入的位置是否在合理的范围之内,然后判断是否需要扩容,再把该位置以后的元素复制到新添加元素的位置之后,最后通过索引将元素添加到指定的位置。这种情况是非常伤的,性能会比较差。

2)LinkedList

LinkedList 新增元素也有两种情况,一种是直接将元素添加到队尾,一种是将元素插入到指定位置。

添加到队尾的源码:

public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final LinkedList.Node l = last;
final LinkedList.Node newNode = new LinkedList.Node(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}

先将队尾的节点 last 存放到临时变量 l 中(不是说不建议使用 I 作为变量名吗?Java 的作者们明知故犯啊),然后生成新的 Node 节点,并赋给 last,如果 l 为 null,说明是第一次添加,所以 first 为新的节点;否则将新的节点赋给之前 last 的 next。

插入到指定位置的源码:

public void add(int index, E element) {
checkPositionIndex(index);

if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
LinkedList.Node node(int index) {
// assert isElementIndex(index);

if (index > 1)) {
LinkedList.Node x = first;
for (int i = 0; i < index; i++) x = x.next; return x; } else { LinkedList.Node x = last; for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
void linkBefore(E e, LinkedList.Node succ) {
// assert succ != null;
final LinkedList.Node pred = succ.prev;
final LinkedList.Node newNode = new LinkedList.Node(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}

先检查插入的位置是否在合理的范围之内,然后判断插入的位置是否是队尾,如果是,添加到队尾;否则执行 linkBefore() 方法。

在执行 linkBefore() 方法之前,会调用 node() 方法查找指定位置上的元素,这一步是需要遍历 LinkedList 的。如果插入的位置靠前前半段,就从队头开始往后找;否则从队尾往前找。也就是说,如果插入的位置越靠近 LinkedList 的中间位置,遍历所花费的时间就越多。

找到指定位置上的元素(succ)之后,就开始执行 linkBefore() 方法了,先将 succ 的前一个节点(prev)存放到临时变量 pred 中,然后生成新的 Node 节点(newNode),并将 succ 的前一个节点变更为 newNode,如果 pred 为 null,说明插入的是队头,所以 first 为新节点;否则将 pred 的后一个节点变更为 newNode。

逼著面試官問了我ArrayList和LinkedList的區別,他對我徹底服了 4

经过源码分析以后,小伙伴们是不是在想:“好像 ArrayList 在新增元素的时候效率并不一定比 LinkedList 低啊!”

当两者的起始长度是一样的情况下:

如果是从集合的头部新增元素,ArrayList 花费的时间应该比 LinkedList 多,因为需要对头部以后的元素进行复制。

public class ArrayListTest {
public static void addFromHeaderTest(int num) {
ArrayList list = new ArrayList(num);
int i = 0;

long timeStart = System.currentTimeMillis();

while (i < num) { list.add(0, i + "沉默王二"); i++; } long timeEnd = System.currentTimeMillis(); System.out.println("ArrayList从集合头部位置新增元素花费的时间" + (timeEnd - timeStart)); } } /** * @author 微信搜「沉默王二」,回复关键字 PDF */ public class LinkedListTest { public static void addFromHeaderTest(int num) { LinkedList list = new LinkedList(); int i = 0; long timeStart = System.currentTimeMillis(); while (i < num) { list.addFirst(i + "沉默王二"); i++; } long timeEnd = System.currentTimeMillis(); System.out.println("LinkedList从集合头部位置新增元素花费的时间" + (timeEnd - timeStart)); } }

num 为 10000,代码实测后的时间如下所示:

ArrayList从集合头部位置新增元素花费的时间595
LinkedList从集合头部位置新增元素花费的时间15

ArrayList 花费的时间比 LinkedList 要多很多。

如果是从集合的中间位置新增元素,ArrayList 花费的时间搞不好要比 LinkedList 少,因为 LinkedList 需要遍历。

public class ArrayListTest {
public static void addFromMidTest(int num) {
ArrayList list = new ArrayList(num);
int i = 0;

long timeStart = System.currentTimeMillis();
while (i < num) { int temp = list.size(); list.add(temp / 2 + "沉默王二"); i++; } long timeEnd = System.currentTimeMillis(); System.out.println("ArrayList从集合中间位置新增元素花费的时间" + (timeEnd - timeStart)); } } public class LinkedListTest { public static void addFromMidTest(int num) { LinkedList list = new LinkedList(); int i = 0; long timeStart = System.currentTimeMillis(); while (i < num) { int temp = list.size(); list.add(temp / 2, i + "沉默王二"); i++; } long timeEnd = System.currentTimeMillis(); System.out.println("LinkedList从集合中间位置新增元素花费的时间" + (timeEnd - timeStart)); } }

num 为 10000,代码实测后的时间如下所示:

ArrayList从集合中间位置新增元素花费的时间1
LinkedList从集合中间位置新增元素花费的时间101

ArrayList 花费的时间比 LinkedList 要少很多很多。

如果是从集合的尾部新增元素,ArrayList 花费的时间应该比 LinkedList 少,因为数组是一段连续的内存空间,也不需要复制数组;而链表需要创建新的对象,前后引用也要重新排列。

public class ArrayListTest {
public static void addFromTailTest(int num) {
ArrayList list = new ArrayList(num);
int i = 0;

long timeStart = System.currentTimeMillis();

while (i < num) { list.add(i + "沉默王二"); i++; } long timeEnd = System.currentTimeMillis(); System.out.println("ArrayList从集合尾部位置新增元素花费的时间" + (timeEnd - timeStart)); } } public class LinkedListTest { public static void addFromTailTest(int num) { LinkedList list = new LinkedList(); int i = 0; long timeStart = System.currentTimeMillis(); while (i < num) { list.add(i + "沉默王二"); i++; } long timeEnd = System.currentTimeMillis(); System.out.println("LinkedList从集合尾部位置新增元素花费的时间" + (timeEnd - timeStart)); } }

num 为 10000,代码实测后的时间如下所示:

ArrayList从集合尾部位置新增元素花费的时间69
LinkedList从集合尾部位置新增元素花费的时间193

ArrayList 花费的时间比 LinkedList 要少一些。

这样的结论和预期的是不是不太相符?ArrayList 在添加元素的时候如果不涉及到扩容,性能在两种情况下(中间位置新增元素、尾部新增元素)比 LinkedList 好很多,只有头部新增元素的时候比 LinkedList 差,因为数组复制的原因。

当然了,如果涉及到数组扩容的话,ArrayList 的性能就没那么可观了,因为扩容的时候也要复制数组。

04、ArrayList 和 LinkedList 删除元素时究竟谁快?

1)ArrayList

ArrayList 删除元素的时候,有两种方式,一种是直接删除元素(remove(Object)),需要直先遍历数组,找到元素对应的索引;一种是按照索引删除元素(remove(int))。

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; } fastRemove(es, i); return true; } 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(Object, int) 方法。

private void fastRemove(Object[] es, int i) {
modCount++;
final int newSize;
if ((newSize = size - 1) > i)
System.arraycopy(es, i + 1, es, i, newSize - i);
es[size = newSize] = null;
}

从源码可以看得出,只要删除的不是最后一个元素,都需要数组重组。删除的元素位置越靠前,代价就越大。

2)LinkedList

LinkedList 删除元素的时候,有四种常用的方式:

remove(int),删除指定位置上的元素

public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}

先检查索引,再调用 node(int) 方法( 前后半段遍历,和新增元素操作一样)找到节点 Node,然后调用 unlink(Node) 解除节点的前后引用,同时更新前节点的后引用和后节点的前引用:

E unlink(Node x) {
// assert x != null;
final E element = x.item;
final Node next = x.next;
final Node prev = x.prev;

if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}

if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}

x.item = null;
size--;
modCount++;
return element;
}

remove(Object),直接删除元素

public boolean remove(Object o) {
if (o == null) {
for (LinkedList.Node x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (LinkedList.Node x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}

也是先前后半段遍历,找到要删除的元素后调用 unlink(Node)。

removeFirst(),删除第一个节点

public E removeFirst() {
final LinkedList.Node f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}
private E unlinkFirst(LinkedList.Node f) {
// assert f == first && f != null;
final E element = f.item;
final LinkedList.Node next = f.next;
f.item = null;
f.next = null; // help GC
first = next;
if (next == null)
last = null;
else
next.prev = null;
size--;
modCount++;
return element;
}

删除第一个节点就不需要遍历了,只需要把第二个节点更新为第一个节点即可。

removeLast(),删除最后一个节点

删除最后一个节点和删除第一个节点类似,只需要把倒数第二个节点更新为最后一个节点即可。

可以看得出,LinkedList 在删除比较靠前和比较靠后的元素时,非常高效,但如果删除的是中间位置的元素,效率就比较低了。

这里就不再做代码测试了,感兴趣的小伙伴可以自己试试,结果和新增元素保持一致:

从集合头部删除元素时,ArrayList 花费的时间比 LinkedList 多很多;

从集合中间位置删除元素时,ArrayList 花费的时间比 LinkedList 少很多;

从集合尾部删除元素时,ArrayList 花费的时间比 LinkedList 少一点。

我本地的统计结果如下所示,小伙伴们可以作为参考:

ArrayList从集合头部位置删除元素花费的时间380
LinkedList从集合头部位置删除元素花费的时间4
ArrayList从集合中间位置删除元素花费的时间381
LinkedList从集合中间位置删除元素花费的时间5922
ArrayList从集合尾部位置删除元素花费的时间8
LinkedList从集合尾部位置删除元素花费的时间12

05、ArrayList 和 LinkedList 遍历元素时究竟谁快?

1)ArrayList

遍历 ArrayList 找到某个元素的话,通常有两种形式:

get(int),根据索引找元素

public E get(int index) {
Objects.checkIndex(index, size);
return elementData(index);
}

由于 ArrayList 是由数组实现的,所以根据索引找元素非常的快,一步到位。

indexOf(Object),根据元素找索引

public int indexOf(Object o) {
return indexOfRange(o, 0, size);
}

int indexOfRange(Object o, int start, int end) {
Object[] es = elementData;
if (o == null) {
for (int i = start; i < end; i++) { if (es[i] == null) { return i; } } } else { for (int i = start; i < end; i++) { if (o.equals(es[i])) { return i; } } } return -1; }

根据元素找索引的话,就需要遍历整个数组了,从头到尾依次找。

2)LinkedList

遍历 LinkedList 找到某个元素的话,通常也有两种形式:

get(int),找指定位置上的元素

public E get(int index) {
checkElementIndex(index);
return node(index).item;
}

既然需要调用 node(int) 方法,就意味着需要前后半段遍历了。

indexOf(Object),找元素所在的位置

public int indexOf(Object o) {
int index = 0;
if (o == null) {
for (LinkedList.Node x = first; x != null; x = x.next) {
if (x.item == null)
return index;
index++;
}
} else {
for (LinkedList.Node x = first; x != null; x = x.next) {
if (o.equals(x.item))
return index;
index++;
}
}
return -1;
}

需要遍历整个链表,和 ArrayList 的 indexOf() 类似。

那在我们对集合遍历的时候,通常有两种做法,一种是使用 for 循环,一种是使用迭代器(Iterator)。

如果使用的是 for 循环,可想而知 LinkedList 在 get 的时候性能会非常差,因为每一次外层的 for 循环,都要执行一次 node(int) 方法进行前后半段的遍历。

LinkedList.Node node(int index) {
// assert isElementIndex(index);

if (index > 1)) {
LinkedList.Node x = first;
for (int i = 0; i < index; i++) x = x.next; return x; } else { LinkedList.Node x = last; for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}

那如果使用的是迭代器呢?

LinkedList list = new LinkedList();
for (Iterator it = list.iterator(); it.hasNext();) {
it.next();
}

迭代器只会调用一次 node(int) 方法,在执行 list.iterator() 的时候:先调用 AbstractSequentialList 类的 iterator() 方法,再调用 AbstractList 类的 listIterator() 方法,再调用 LinkedList 类的 listIterator(int) 方法,如下图所示。

逼著面試官問了我ArrayList和LinkedList的區別,他對我徹底服了 5

最后返回的是 LinkedList 类的内部私有类 ListItr 对象:

public ListIterator listIterator(int index) {
checkPositionIndex(index);
return new LinkedList.ListItr(index);
}

private class ListItr implements ListIterator {
private LinkedList.Node lastReturned;
private LinkedList.Node next;
private int nextIndex;
private int expectedModCount = modCount;

ListItr(int index) {
// assert isPositionIndex(index);
next = (index == size) ? null : node(index);
nextIndex = index;
}

public boolean hasNext() {
return nextIndex < size; } public E next() { checkForComodification(); if (!hasNext()) throw new NoSuchElementException(); lastReturned = next; next = next.next; nextIndex++; return lastReturned.item; } }

执行 ListItr 的构造方法时调用了一次 node(int) 方法,返回第一个节点。在此之后,迭代器就执行 hasNext() 判断有没有下一个,执行 next() 方法下一个节点。

由此,可以得出这样的结论:遍历 LinkedList 的时候,千万不要使用 for 循环,要使用迭代器。

也就是说,for 循环遍历的时候,ArrayList 花费的时间远小于 LinkedList;迭代器遍历的时候,两者性能差不多。

06、总结

花了两天时间,终于肝完了!相信看完这篇文章后,再有面试官问你 ArrayList 和 LinkedList 有什么区别的话,你一定会胸有成竹地和他扯上半小时。

另外,我把自己看过的学习视频按照顺序分了类,共 500G,目录如下,还有 2020 年最新面试题,现在免费送给大家。

逼著面試官問了我ArrayList和LinkedList的區別,他對我徹底服了 6

链接:https://pan.baidu.com/s/1j2uB7-TF3t5BAzVXBgV7dA" 密码:cg1q

我是沉默王二,一枚沉默但有趣的程序员,感谢各位同学的:点赞、收藏和评论,我们下篇见!