1. 程式人生 > 實用技巧 >LinkedList(連結串列)

LinkedList(連結串列)

介紹

  • 本篇是關於連結串列LinkedList的初步介紹。

特點

  • 底層原理:用於表示連結的物件Node<E>,其中存放著上下連結物件以及物件E。此連結串列是雙向連結串列
  • 優點:插入和刪除元素快;
  • 缺點:查詢元素慢。修改元素的速度由查詢速度決定,本質上修改即等同於查詢後替換。
    • 如果是通過索引index查詢值,程式碼底層只都會從連結串列的一半中查詢;
    • 如果需要通過值value來查詢值,程式碼底層會對連結串列進行迭代。

連結串列結構

  • 陣列Array和陣列列表ArrayList有一個重大的缺陷:從陣列中間刪除一個元素的開銷很大,如果刪除一個元素,那麼對應的陣列中位於被刪元素之後的所有元素都要向陣列前端移動。同樣的,向陣列中插入一個元素也是如此。

  • 對於大部分的業務情況而言,我們使用集合是因為要用來儲存資料。當取用資料的時候,往往需要同時從儲存媒介中剔除該已取用的資料。這個時候無論使用Array還是ArrayList都會影響程式的效能。
  • 除了陣列結構外,Java也提供了另一種資料結構連結串列的實現LinkedList,連結串列結構可以實現元素的快速增刪。
  • 陣列是在連續的儲存位置上存放物件引用,而連結串列則是將每個物件存放在單獨的連結link中。
  • 每個連結link還存放著序列中的下一個連結link的引用。如下圖結構所示:

  • Java程式設計語言中,所有連結串列LinkedList實際上都是雙向連結doubly linked
    ,即每個連結link還存放著其前驅的引用。連結串列是一個有序集合ordered collection,每個物件的位置十分重要。
  • 通過LinkedList.add方法,可以將物件新增到連結串列的尾部。但是通常需要將元素新增到連結串列的中間。由於迭代器描述了集合中的位置,所以這種依賴於位置的add方法將由迭代器負責。只有對自然有序的集合使用迭代器新增元素才有實際意義。
  • Iterator介面中包含了四個方法:
    • hasNext():等待實現;
    • next():等待實現;
    • remove():丟擲UnsupportedOperationException("remove")異常;
    • forEachRemaining
      :用於迭代當前Iterator物件並取用其中元素的方法,類似於forEach()
package java.util;

import java.util.function.Consumer;
public interface Iterator<E> {
    
    boolean hasNext();
    
    E next();
    
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }
    
    default void forEachRemaining(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        while (hasNext())
            action.accept(next());
    }
}
  • 集合類庫則提供了一個Iterator介面的子類介面ListIterator,其中增加了一些方法:
    • hasPrevious()
    • previous()
    • nextIndex()
    • previousIndex()
    • set(E e)
    • add(E e)
package java.util;

public interface ListIterator<E> extends Iterator<E> {

    boolean hasNext();

    E next();

    boolean hasPrevious();

    E previous();

    int nextIndex();

    int previousIndex();

    void remove();

    void set(E e);

    void add(E e);
}
  • ListIterator<E>中一個較為有趣的地方是,該介面重新定義了一個remove()方法,那麼所有實現該介面的類就必須要覆寫remove()方法,同時該介面的父類介面Iterator<E>中的預設方法remove()也等同於被廢棄。
  • 在進行相關方法比較前,需要了解一下連結串列LinkedList的構成,連結串列中有一個私有靜態內部類Node<E>,這個類就相當於連結串列中的連結link,原始碼如下:
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;
    }
}
  • 連結link中除了封裝當前物件元素element之後,還封裝了上一個連結的物件引用prev與下一個連結的物件引用next
  • 眾所周知,外部類是可以隨意訪問其成員內部類中的成員變數、成員方法而不受任何許可權修飾符的限制。因此在LinkedList中可以使用Node<E>.filed的格式,獲取到連結串列中當前位置物件、上一個物件的連結及下一個物件的連結。
  • 連結串列LinkedList中還包含了一個方法node(int index),原始碼如下:
Node<E> node(int index) {
    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;
    }
}
  • 其中size為成員變數,記錄了當前連結串列長度,連結串列方法size()的呼叫將返回size
  • 方法node(int index)接收一個int型別作為引數,它將返回指定索引位index上的Node物件。注意,它每次只會從當前連結串列中的前半部分或後半部分去找。
  • 有符號右移位運算子>>右移1位等同於(int) Math.floor(size / 2),即node(int index)會首先計算出index處於當前連結串列的前半部還是後半部,之後才會根據計算結果分別進行正序或倒序遍歷。
  • 關於位移運算子的計算,可以參考以下程式碼:
package cn.dylanphang;

/**
 * @author dylan
 */
public class BitOperator2 {

    public static void main(String[] args) {
        // m進行有符號右移n位相當於(int) Math.floor(m / Math.pow(2, n))
        // 關於-7,採用8位解釋,運算前需要求出反碼和補碼,對補碼進行有符號右移,高位補1,之後得到原碼,結算得到結果:
        // 十進位制-7的原碼為:1000 0111,計算其補碼為:1111 1001,右移一位:1111 1100,計算原碼:1000 0100
        // 因此十進位制結果為:-4
        System.out.println(7 >> 1);
        System.out.println(-7 >> 1);
    }
}

關於add方法

  • LinkedListAPI中,提供了一個方法add(int index, E element),而ListIterator物件中的add方法也可以向連結串列中新增元素,那麼其本質是否一致呢?
  • 關於方法add(int index, E element),原始碼如下:
public void add(int index, E element) {
    checkPositionIndex(index);

    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}
  • 方法linkLast()會在當前連結串列末尾追加一個元素element,而linkBefore(E element, Node<E> succ)會在連結succ物件之前新增element元素,以下為linkLast()linkBefore()的原始碼:
/**
 * Links e as last element.
 */
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

/**
 * Inserts element e before non-null Node succ.
 */
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}
  • 實際中使用最多的獲取ListIterator物件的方法,是linkedList.listIterator()。但LinkedList中並沒有找到該方法的空參形式,僅有以下方法被定義在LinkedList中用於返回ListIterator<E>物件:
public ListIterator<E> listIterator(int index) {
    checkPositionIndex(index);
    return new ListItr(index);
}
  • 無疑該方法是通過繼承或實現的方式,從父類或介面處獲取的。通過閱讀原始碼,可以發現無參的listIterator()方法被定義在介面List<E>中,而該方法被介面AbstractList<E>覆寫為以下形式:
public ListIterator<E> listIterator() {
    return listIterator(0);
}
  • 以下為LinkedList<E>AbstractSequentialList<E>AbstractList<E>List<E>的部分原始碼:
public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable { 
	public ListIterator<E> listIterator(int index) {
        checkPositionIndex(index);
        return new ListItr(index);
    }
    
    private class ListItr implements ListIterator<E> { ... }
}
public abstract class AbstractSequentialList<E> extends AbstractList<E> { 
    public Iterator<E> iterator() {
        return listIterator();
    }

    public abstract ListIterator<E> listIterator(int index);
}
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> { 
    public Iterator<E> iterator() {
        return new Itr();
    }

    public ListIterator<E> listIterator() {
        return listIterator(0);
    }
    
    public ListIterator<E> listIterator(final int index) {
        rangeCheckForAdd(index);

        return new ListItr(index);
    }
    
    private class Itr implements Iterator<E> { ... }

    private class ListItr extends Itr implements ListIterator<E> { ... }
}
public interface List<E> extends Collection<E> {
    Iterator<E> iterator();
    
    ListIterator<E> listIterator();

    ListIterator<E> listIterator(int index);
}
  • 那麼一個完整的呼叫流程是:
    • linkedList.listIterator() -> AbstractList.listIterator() -> this.listIterator(0)
  • 即使用linkedList.listIterator()等同於使用了linkedList.listIterator(0)
  • 那麼listIterator中如何實現add(E element)方法呢?原始碼如下:
public ListIterator<E> listIterator(int index) {
    checkPositionIndex(index);
    return new ListItr(index);
}

private class ListItr implements ListIterator<E> {
    private Node<E> lastReturned; // 這個成員變數將記錄最後一次cursor跳過的那個連結Node,remove()方法依賴於它來刪除連結Node
    private Node<E> next; // 從構造器方法可以看出,這個就是等於index為0的連結Node或者連結串列為空的時候它等於null
    private int nextIndex; // 記錄當前cursor所指向位置後的那個索引
    private int expectedModCount = modCount; // 記錄當前連結串列被操作的次數,呼叫此類中的大部分方法會使此運算元+1

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

    public void add(E e) {
        checkForComodification();
        lastReturned = null;
        if (next == null)
            linkLast(e);
        else
            linkBefore(e, next);
        nextIndex++;
        expectedModCount++;
    }
    
    final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
    }
}
  • 重新貼一下LinkedList中的add(int index, E element)方法的原始碼:
public void add(int index, E element) {
    checkPositionIndex(index);

    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}

private void checkPositionIndex(int index) {
        if (!isPositionIndex(index))
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

private boolean isPositionIndex(int index) {
        return index >= 0 && index <= size;
}
  • 比較兩者,可以知道其中的邏輯是一模一樣的,前者通過判斷下一個連結物件引用next是否為null,來判定此時迭代器是否位於連結串列的尾部;而後者通過判斷當前索引是否等於連結串列的長度,來判定此時迭代器是否位於連結串列的尾部。

執行緒不安全

  • 使用add(int index, E element)向連結串列末尾新增元素即等同於呼叫linkLast(E e)方法,而連結串列提供的add(E e)方法也是呼叫linkLast(E e)方法用於新增元素。以下將使用add(E e)作一個執行緒不安全的測試。
  • 總所周知,LinkedList是執行緒不安全的,即允許多個執行緒同時對它進行操作,那麼想象以下場景:
    1. 假設有兩個執行緒同時對長度大於2的連結串列A進行新增元素的操作,使用add(E element)方法;
    2. 此時兩個執行緒都需要在連結串列末尾新增一個元素,假如兩個執行緒一前一後進入linkBefore(E e)
public boolean add(E e) {
    linkLast(e);
    return true;
}

void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}
  • 此時新增操作不會出現任何異常。但這可能會導致一個嚴重問題:插入連結串列的兩個元素的下一個連結next都會指向null,而上一個連結則均會指向last。而last中所指向的下一個連結則指向執行緒較慢時所插入的元素。
  • 雖然看似沒有異常,但對於連結串列來說卻是災難性的錯誤。連結串列元素中的連結指向出錯,將直接導致迭代器無法工作。
  • 以下測試中,會開啟5000個執行緒,同時對成員變數linkedList進行插入操作,程式將輸出以下日誌資訊:
    • this.counter:計算向linkedList中新增元素的有效次數,確保不是因為異常導致新增元素失敗;
    • this.linkedList.size():連結串列長度;
    • e.getClass().getSimpleName():迭代如果出現異常,則列印異常的名字;
    • currentIndex:異常出現在哪個連結之後。
package cn.dylanphang;

import cn.dylanphang.util.ThreadUtils;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import java.util.LinkedList;
import java.util.ListIterator;
import java.util.concurrent.CountDownLatch;

/**
 * @author dylan
 * @date 2021/01/01
 */
@Slf4j
public class LinkedListTest {

    private static final int CONCURRENT_TIMES = 5000;

    private final CountDownLatch cdl = new CountDownLatch(CONCURRENT_TIMES);
    private final CountDownLatch testCdl = new CountDownLatch(CONCURRENT_TIMES);
    private final LinkedList<String> linkedList = new LinkedList<>();

    private int counter = 0;

    /**
     * 由於無法控制LinkedList中的程式流程,採用高併發插入資料的方式去為同一個LinkedList新增元素。
     */
    @Test
    public void test() throws InterruptedException {
        for (int i = 0; i < CONCURRENT_TIMES; i++) {
            final String content = i + "";
            ThreadUtils.create(() -> {
                try {
                    this.cdl.await();
                    Thread.sleep(1000);
                    this.linkedList.add(content);
                    count();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                this.testCdl.countDown();
            });
            this.cdl.countDown();
        }

        this.testCdl.await();
        log.info("Successful write into LinkedList times is: {}", this.counter);
        log.info("Current insert operation finish. LinkedList's size is: {}", this.linkedList.size());

        int currentIndex = -1;
        final ListIterator<String> listIterator = this.linkedList.listIterator();

        while (listIterator.hasNext()) {
            try {
                listIterator.next();
                currentIndex++;
            } catch (Exception e) {
                log.error("Exception is: {}", e.getClass().getSimpleName());
                break;
            }
        }
        log.info("Mistake element index is: {}", currentIndex);
    }

    synchronized private void count() {
        this.counter++;
    }
}
  • 計算成功新增元素次數的count()方法,必須使用synchronized關鍵字修飾,此時不能使用以下程式碼進行替換:
    • count++;,此程式碼是執行緒不安全的。
  • 測試執行次數為3次,得到以下結果:

  • 三次測試表明,新增元素add()操作本身不會出現任何異常,但最終連結串列長度則表明其內部出現新增失敗的操作。
  • 而三次獲取的迭代器物件,在進行迭代時均出現NullPointerException,在不進行異常抓取的情況下,可以清晰看到異常出現的位置位於next()方法中的next = next.next行,不難推敲是由於當前連結的前置連結中next欄位值為null引起。
public E next() {
    checkForComodification();
    if (!hasNext())
        throw new NoSuchElementException();

    lastReturned = next;
    next = next.next;
    nextIndex++;
    return lastReturned.item;
}
  • 以上結論,可以知道在多執行緒的情況下使用linkLast(E e)方法,是可能會造成連結串列結構錯誤。那麼對於使用該方法的其他方法add(int index, E element)add(E e)來說,即同樣有可能造成結構錯誤。
  • 遺憾的是,在多執行緒情況下使用成員變數或類變數LinkedList時,該錯誤是不可避免的。

運算元modCount

  • LinkedList中大部分對連結串列操作的方法,都會記錄運算元,而這個運算元成員變數是modCount,其初始值為0。運算元需要與ListIterator中的expectedModCount配合使用,某些特殊情況下可以避免新增元素失敗的情況。
  • ListIterator中對運算元進行記錄的欄位為expectedModCount,該欄位在獲取ListIterator物件時,被初始化為當前連結串列的運算元欄位modCount的值。
  • 使用ListIterator對連結串列進行addremove操作時,其會呼叫LinkedList中的增刪方法,此時modCount會自增或自減的情況。而ListIteratoraddremove方法也會同步讓expectedModCount進行自增或自減的操作。
  • 其中關鍵點是ListIteratoradd()方法中呼叫的checkForComodification()方法:
    • 該方法檢查modCount是否與expectedModCount的值一致。一致則無事發生,否則丟擲異常。
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
  • 如果此時有兩個執行緒獲取了同一個LinkedListListIterator物件,此時它們獲取到的expectedModCount都為0。其中一個執行緒如果呼叫了ListIterator.add(),此時假設另一個執行緒為呼叫任何的方法。
  • LinkedList中的modCount就會被置為1。此時另一個執行緒開始呼叫ListIterator.add(),進入此方法會,程式會先進性校驗操作,呼叫checkForComodification()。明顯,此時modCount == 1expectedModCount == 0
  • modCount != expectedModCount的情況下,會丟擲ConcurrentModificationException異常。
  • 為了方便理解,編寫以下測試類:
package cn.dylanphang;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import java.lang.reflect.Field;
import java.util.AbstractList;
import java.util.ConcurrentModificationException;
import java.util.LinkedList;
import java.util.ListIterator;

/**
 * @author dylan
 */
@Slf4j
@SuppressWarnings("all")
public class ListIteratorTest {

    @Test
    public void test() throws InterruptedException, IllegalAccessException, InstantiationException, NoSuchFieldException {
        // 1.獲取LinkedList物件
        AbstractList<String> linkedList = new LinkedList<>();

        // 2.執行緒一
        new Thread(() -> {
            try {
                // 2.1.獲取listIterator物件,modCount/expectedModCount均為0
                ListIterator<String> listIterator = linkedList.listIterator();
                // 2.2.執行緒休眠2秒
                Thread.sleep(2000);
                // 2.3.執行緒結束休眠後,新增元素前需要經過checkForComodification()
                // *.此時執行緒二已經結束,modCount必然為1,checkForComodification()將丟擲ConcurrentModificationException
                listIterator.add("dylan");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ConcurrentModificationException e) {
                log.error("{}", e.toString());
            }
        }).start();

        // 3.執行緒二
        new Thread(() -> {
            try {
                // 3.1.獲取listIterator物件,modCount/expectedModCount均為0
                ListIterator<String> listIterator = linkedList.listIterator();
                // 3.2.執行緒休眠1秒
                Thread.sleep(1000);
                // 3.3.執行緒結束休眠後,新增元素完畢,modCount被更新為1
                listIterator.add("sunny");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        // 4.防止test執行緒結束
        Thread.sleep(3000);

        // 5.使用反射技術獲取AbstractList中modCount的欄位物件field
        Class<AbstractList> abstractListClass = AbstractList.class;
        Field field = abstractListClass.getDeclaredField("modCount");

        // 6.取消Java許可權控制檢查
        field.setAccessible(true);

        // 7.斷言modCount的值為1
        log.info("modCount: {}", field.get(linkedList));
    }
}
  • 以上程式開啟兩個執行緒,在對LinkedList進行add()操作前獲取其ListIterator物件,並進行不通過長度的休眠,以確保獲取的ListIterator物件中的expectedModCount值為0。執行緒二將先對連結串列進行操作,之後觀察執行緒一及後續的輸出。
  • 執行測試:

  • 沒有意外,程式捕獲到了ConcurrentModificationException
  • 但縱使如此,仍沒有消除LinkedList中執行緒不安全的問題。多個執行緒是極有可能在modCountexpectedModCount相等的情況下進行checkForComodification()判斷的,此時不會丟擲任何的異常。對於以下程式碼:
ListIterator<String> listIterator = linkedList.listIterator();
listIterator.add("something.");
  • 程式基本上在一瞬間就能獲取到ListIterator並使用add()modCount++,此時另一個執行緒獲取的ListIterator依然是新運算元modCount了。程式執行太快,使得在平常的程式中難以捕獲異常,但並不代表執行緒安全。
  • 綜上所述,LinkedList中的方法add(int index, E element),與ListIterator物件中的add(E element)同樣可以向連結串列中插入元素,其實現原理其實也是一致的:
    • 前者能帶來更強的便利性,通過直接指定索引的方式,可以在連結串列的任意一個位置新增新的連結link
    • 後者可以通過指定索引的方式listIterator(int index)先獲取到ListIterator物件引用,之後再呼叫該物件所提供的add(E element)方法新增元素,使用該物件也可以對連結串列進行增add、刪remove、改set操作;
    • 兩者均為執行緒不安全的方法,多執行緒操作linkedList.add()可能會導致連結串列出錯;多執行緒操作listIterator.add()則可能會導致ConcurrentModificationException異常。

總結

  • LinkedList是執行緒不安全的。
  • modCountexpectModCount的結合使用,在某些特殊情況下可以避免增刪元素導致連結串列結構錯誤的情況。