Java集合(2)一 ArrayList 與 LinkList


目錄

Java集合(1)一 集合框架
Java集合(2)一 ArrayList 與 LinkList
Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)
Java集合(4)一 紅黑樹、TreeMap與TreeSet(下)
Java集合(5)一 HashMap與HashSet

引言

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

框架結構

從上面的結構圖可以看出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類型的數組來實現的。
數組有以下幾個特點:

  • 數組大小一旦初始化之后,長度固定。
  • 數組中元素之間的內存地址是連續的。
  • 只能存儲一種類數據類型的元素。

在這里面有個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;
    }
}

鏈表的特點:

  • 長度不固定,可以隨時增加和減少
  • 鏈表中的元素在內存地址上可以是連續的,也可以是不連續的,大部分情況下都是不連續的。

構造函數

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

不擴容:

擴容:

從中可以看出,不擴容的情況下添加元素到末尾非常方便,時間復雜度為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++;
}

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

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
}

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

遍歷元素

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM