Java集合(2)一 ArrayList 與 LinkList

knock_小新發表於2017-12-12

引言

ArrayList<E>和LinkList<E>在繼承關係上都繼承自List<E>介面,上篇文章我們分析了List<E>介面的特點:有序,可以重複,並且可以通過整數索引來訪問。 他們在自身特點上有很多相似之處,在具體實現上ArrayList<E>和LinkList<E>又有很大不同,ArrayList<E>通過陣列實現,LinkList<E>則使用了雙向連結串列。將他們放到一起學習可以更清楚的理解他們的區別。

框架結構

Java集合(2)一 ArrayList 與 LinkList Java集合(2)一 ArrayList 與 LinkList

從上面的結構圖可以看出ArrayList<E>和LinkList<E>在繼承結構上基本相同,值得注意的是LinkList<E>在繼承了List<E>介面的同時還繼承了Deque<E>介面。 Deque<E>是一個雙端佇列的介面,LinkList<E>由於在實現上採用了雙向連結串列,所以可以很自然的實現雙端佇列頭尾進出的特點。

資料結構

上一篇文章中我們說過,為什麼一個Collection<E>介面會衍生出這麼多實現類,其中最大的原因就是每一種實現在資料結構上都有差別,而不同的資料結構又導致了每種集合在使用場景上又各有不同。 ArrayList<E>和LinkList<E>的根本區別就在資料結構上,只有瞭解了他們各自的資料結構,才能更加深入的明白他們各自的使用場景。 在ArrayList<E>的原始碼中有一個elementData變數,這個變數就代表了ArrayList<E>所使用的資料結構:陣列。

//The array buffer into which the elements of the ArrayList are stored.
transient Object[] elementData;
複製程式碼

elementData變數是ArrayList<E>操作的基礎,他所有的操作都是基於elementData這個Object型別的陣列來實現的。 陣列有以下幾個特點:

  • 陣列大小一旦初始化之後,長度固定。
  • 陣列中元素之間的記憶體地址是連續的。
  • 只能儲存一種類資料型別的元素。 Java集合(2)一 ArrayList 與 LinkList

在這裡面有個transient關鍵字值得注意,他的作用是標誌當前物件不需要序列化。 如果大家瞭解序列化,請跳過下面的介紹: 序列化是什麼? 序列化簡單說就是將一個物件持久化的過程。將物件轉換成位元組流的過程就叫序列化,一個物件要在網路中傳播就必須被轉換成位元組流。對應的,一個物件從位元組流轉換成物件的過程就叫反序列化。 在Java中,標誌一個物件可以被序列化只需要繼承Serializable介面即可,Serializable介面是一個空介面。 明白了什麼是序列化的概念,再來看transient關鍵字,java中規定被宣告為transient的關鍵在被序列化的時候會被忽略,可是為什麼要忽略這個物件呢?如果被忽略了那反序列化的時候這個物件怎樣恢復呢? 我們先來想想什麼樣的物件在序列化時需要被忽略?序列化是一個耗時也耗費空間的過程,一般在一個物件中除了必須持久化的變數,還會存在很多中間變數或臨時變數,宣告這些變數的作用是方便我們操作這個類,舉個例子:

import java.io.IOException;
import java.io.ObjectInputStream;

public class SerializableDateTime implements java.io.Serializable {

	private static final long serialVersionUID = -8291235042612920489L;

	private String date = "2011-11-11";

	private String time = "11:11";

    //不需要序列化的物件
	private transient String dateTime;

	public void initDateTime() {
		dateTime = date + time;
	}

    //反序列化的時候呼叫,給dateTime賦值
	private void readObject(ObjectInputStream inputStream) throws IOException, ClassNotFoundException {
		inputStream.defaultReadObject();
		initDateTime();
	}
}
複製程式碼

SerializableDateTime物件中的dateTime物件如果在外界呼叫的時候會賦值,但是這個物件並不是基礎資料,不需要序列化,在反序列化的時候可以通過呼叫initDateTime返回獲取他的值,所以只需要序列化date和time物件即可。將dateTime物件標記為transient,則可以達到按需序列化的目的。 那在ArrayList<E>中為什麼要忽略elementData這個物件呢? 主要是因為elementData物件不僅包含了所有有用的元素,還存在許多沒有未使用的空間,而這些空間是不需要全部序列化的,為了節約空間,所以只序列化了elementData中存有物件的那一部分,在反序列化的時候又恢復elementData物件的空間,這樣可以達到節約序列化空間和時間的目的。

//序列化時呼叫
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 behavioural compatibility with clone()
    s.writeInt(size);

    // Write out all elements in the proper order.
    //序列化size大小的元素,size的大小是實際儲存元素的大小,不是elementData元素的大小
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }

    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}

//反序列化時呼叫
private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    elementData = EMPTY_ELEMENTDATA;

    // Read in size, and any hidden stuff
    s.defaultReadObject();

    // Read in capacity
    s.readInt(); // ignored

    if (size > 0) {
        // be like clone(), allocate array based upon size not capacity
        //恢復elementData物件的空間
        ensureCapacityInternal(size);

        Object[] a = elementData;
        // Read in all elements in the proper order.
        for (int i=0; i<size; i++) {
            //填充elementData元素的內容
            a[i] = s.readObject();
        }
    }
}
複製程式碼

這種序列化和反序列化的方法非常巧妙,在我們程式設計的過程中也可以借鑑這種辦法來節約序列化和反序列化的空間和時間。

LinkedList<E>在底層實現上採用了連結串列這種資料結構,而且是雙向連結串列,即每個元素都包含他的上一個和下一個元素的引用:

//連結串列的第一個元素
transient Node<E> first;

//連結串列的最後一個元素
transient Node<E> last;

//連結串列的內部類表示
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;
    }
}
複製程式碼

連結串列的特點:

  • 長度不固定,可以隨時增加和減少
  • 連結串列中的元素在記憶體地址上可以是連續的,也可以是不連續的,大部分情況下都是不連續的。
Java集合(2)一 ArrayList 與 LinkList

建構函式

ArrayList<E>提供了3種構造方式,預設的建構函式會初始化一個空的陣列,在之後新增元素的過程中會對陣列進行擴容,擴容操作在一定程度上會影響陣列的效能。如果能提前預估最終的陣列使用空間大小,可以通過ArrayList(int 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);
    }
}

public ArrayList() {
    //初始化一個空的陣列
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

public ArrayList(Collection<? extends E> c) {
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // replace with empty array.
        this.elementData = EMPTY_ELEMENTDATA;
    }
}
複製程式碼

LinkList<E>只提供了2種構造方式,預設的建構函式是一個空函式,因為連結串列這種資料結構在使用上不需要初始化空間,也不需要擴容,每次需要新增元素時直接追加就可以,在空間的最大化利用上鍊表比陣列更加合理。這並不代表連結串列使用的空間小,相反,連結串列每個節點因為要儲存下一個節點引用(雙向連結串列會儲存上下兩個節點的引用),在相同元素空間使用上會比陣列大的多。

public LinkedList() {
}

public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}
複製程式碼

新增元素

ArrayList<E>在新增元素的過程中,需要考慮陣列空間是否足夠,不夠的情況下需要擴容。

//ArrayList<E>新增元素到末尾
public boolean add(E e) {
    //檢查陣列容量,不夠就擴容,擴容呼叫grow(int minCapacity) 方法
    ensureCapacityInternal(size + 1);  
    elementData[size++] = e;
    return true;
}

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

    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

//擴容
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    //向右位移一位,相當於除以2,比除法運算要快,每次擴容在原容量的基礎上增加一半,新的容量為原容量的1.5倍。
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    //拷貝所有資料元素到新的陣列中,內部呼叫System.arraycopy來拷貝所有陣列元素
    elementData = Arrays.copyOf(elementData, newCapacity);
}
複製程式碼

不擴容: Java集合(2)一 ArrayList 與 LinkList 擴容: Java集合(2)一 ArrayList 與 LinkList

從中可以看出,不擴容的情況下新增元素到末尾非常方便,時間複雜度為O(1),擴容的情況下每次都需要拷貝所有元素到新陣列,時間複雜度上為O(n),存在一定效能損耗。
LinkedList<E>在新增元素時由於連結串列的特性,不需要考慮擴容的問題,但LinkedList<E>每次都需要new一個Node來儲存元素。

//LinkedList<E>新增元素到末尾
public boolean add(E e) {
    linkLast(e);
    return true;
}

void linkLast(E e) {
    final Node<E> l = last;
    //new一個新的連結串列元素並連結到末尾
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}
複製程式碼
Java集合(2)一 ArrayList 與 LinkList
ArrayList<E>在新增元素到指定索引位置的時候,除了檢查容量之外,由於陣列具有在空間連續儲存的特性,還需要對插入元素之後的所有節點做一次位移。 ```java //ArrayList新增元素到指定索引位置 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++;
複製程式碼

}

<img src="http://images2017.cnblogs.com/blog/368583/201711/368583-20171130181801667-175597278.png" style="max-width: 770px">

LinkedList&lt;E>新增到指定位置時首先需要先查詢元素的位置,然後新增。
```java
//LinkedList<E>新增元素到指定索引位置
public void add(int index, E element) {
    checkPositionIndex(index);
    
    if (index == size)
        //直接新增元素到末尾
        linkLast(element);
    else
        //新增到指定位置前先查詢當前位置已經存在的元素
        linkBefore(element, node(index));
}

//查詢指定索引的元素
Node<E> node(int index) {
    // assert isElementIndex(index);
    //指定索引小於元素數量的一半時從first開始遍歷,大於元素數量的一半時從last開始遍歷
    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;
    }
}
複製程式碼
Java集合(2)一 ArrayList 與 LinkList

LinkedList<E>的這種查詢對效能有影響嗎?相比ArrayList<E>的擴容以及位移插入位後面所有的元素效能如何?我們來對插入到頭部、尾部以及中間位置3種特殊情況做個簡單測試。 插入到尾部:

private static void addTailElementArrayList(int count) {

    long startTime = System.currentTimeMillis();
    List<Integer> list = new ArrayList<Integer>();
    for (int i = 0; i < count; i++) {
        list.add(i);
    }
    long endTime = System.currentTimeMillis();
    System.out.println("addTailElementArrayList time: " + (endTime - startTime));
}

private static void addTailElementLinkedList(int count) {

    long startTime = System.currentTimeMillis();
    List<Integer> list = new LinkedList<Integer>();
    for (int i = 0; i < count; i++) {
        list.add(i);
    }
    long endTime = System.currentTimeMillis();
    System.out.println("addTailElementLinkedList time: " + (endTime - startTime));
}
複製程式碼
100 1000 10000 100000
ArrayList 0 0 1 160
LinkList 0 0 1 110

插入到頭部:

private static void addHeadElementArrayList(int count) {

    long startTime = System.currentTimeMillis();
    List<Integer> list = new ArrayList<Integer>();
    for (int i = 0; i < count; i++) {
        list.add(0, i);
    }
    long endTime = System.currentTimeMillis();
    System.out.println("addHeadElementArrayList time: " + (endTime - startTime));
}

private static void addHeadElementLinkedList(int count) {

    long startTime = System.currentTimeMillis();
    List<Integer> list = new LinkedList<Integer>();
    for (int i = 0; i < count; i++) {
        list.add(0, i);
    }
    long endTime = System.currentTimeMillis();
    System.out.println("addHeadElementLinkedList time: " + (endTime - startTime));
}
複製程式碼
100 1000 10000 100000
ArrayList 0 1 10 900
LinkList 0 1 1 6

插入到中間:

private static void addCenterIndexElementArrayList(int count) {

    long startTime = System.currentTimeMillis();
    List<Integer> list = new ArrayList<Integer>();
    for (int i = 0; i < count; i++) {
        list.add(list.size()>>1, i);
    }
    long endTime = System.currentTimeMillis();
    System.out.println("addCenterIndexElementArrayList time: " + (endTime - startTime));
}

private static void addCenterIndexElementLinkedList(int count) {

    long startTime = System.currentTimeMillis();
    List<Integer> list = new LinkedList<Integer>();
    for (int i = 0; i < count; i++) {
        list.add(list.size()>>1, i);
    }
    long endTime = System.currentTimeMillis();
    System.out.println("addCenterIndexElementLinkedList time: " + (endTime - startTime));
}
複製程式碼
100 1000 10000 100000
ArrayList 0 1 6 400
LinkList 0 3 80 10000

從中可以得處幾個簡單結論:

  • 在新增到末尾時,ArrayList<E>和LinkedList<E>在效能上差距不明顯,儘管ArrayList<E>需要擴容,但LinkedList<E>也需要new一個Node物件。
  • 在插入到頭部時,LinkedList<E>效能明顯好於ArrayList<E>,因為ArrayList<E>每次都需要將所有元素向後移動一個位置,而LinkedList<E>由於是雙向連結串列每次只需要改變first元素就可以了。
  • 在插入到中間位置的時候,ArrayList<E>效能優明顯好於LinkedList<E>,這是因為ArrayList<E>此時只需要移動一半的元素,而LinkedList<E>因為其雙向連結串列查詢元素的特殊性,只能從頭或者尾部開始遍歷,每次都需要遍歷一半的元素,這個操作耗費了大量時間,而ArrayList<E>在擴容以及移動元素上的效能消耗比想象的要小。

我們在ArrayList<E>和LinkedList<E>的選擇上,需要充分考慮使用時的場景,LinkedList<E>在插入資料上並不是一定比ArrayList<E>效能好,相反的在很多情況下ArrayList<E>效能反而要好的多。不能因為插入操作多,就一定選用LinkedList<E>,還需要考慮插入元素的位置等其他因素來最終決定。

刪除元素

ArrayList<E>刪除元素通過遍歷元素查詢到相等的元素然後使用索引刪除,刪除之後還要將被刪除元素後的元素前移。

public boolean remove(Object o) {
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
        for (int index = 0; index < size; index++)
            //查詢到equals的元素的索引然後刪除
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        //所有刪除元素後的元素前移
        System.arraycopy(elementData, index+1, elementData, index,
                            numMoved);
    elementData[--size] = null; // clear to let GC do its work
}
複製程式碼
Java集合(2)一 ArrayList 與 LinkList

LinkedList<E>通過向後遍歷連結串列的方式查詢到equals的元素直接刪除即可。

public boolean remove(Object o) {
    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;
}
複製程式碼
Java集合(2)一 ArrayList 與 LinkList

遍歷元素

在遍歷元素上ArrayList<E>存在更有效的方式,他實現了RandomAccess介面,代表ArrayList<E>支援快速訪問。 RandomAccess本身是一個空介面,這種介面一般用來代表一類特徵,RandomAccess代表實現類具有快速訪問的特徵。ArrayList<E>實現快速訪問的方式是通過索引。這代表ArrayList<E>在遍歷時通過for迴圈方式要比通過Iterator或ListIterator迭代器方式要快。LinkedList<E>沒有實現這個藉口,所以一般還是通過Iterator迭代器來訪問。

相關文章