基於LinkedList實現的固定大小線性排序數據結構


概要:

本文詳細講解了在Java中使用LinkedList實現一種可以設置固定大小的線性集合,該集合線程安全,需要達到業務的最優性能。

1. 緣起

最近工作過程中碰到一個做周期性更新排行榜的需求。涉及的數據字段和記錄條數非常多。概括如下:

  1. 數據分布於后台數據庫100張數據表中;
  2. 每張表的數據更新非常快,每天預估數據增量在1W條左右;
  3. 排行榜的數據生成來源於這100張表中,只取前面100條;

約束:

  • 數據庫服務目前只有一個主從,數據周期性變化,原始數據必須放MySQL持久化,不能放Redis
  • 盡可能減少數據庫連接獲取,盡可能減少數據庫查詢,這些昂貴的資源必須優先保證核心業務

2. 基本思路

如果數據量比較少,我們直接放到一個List然后調用Collections.sort排序即可。但是假如我們的排行榜要求的數據量是1W或者10W,每條記錄按1KB的大小計算,那么100張表的數據量就是10010W1KB,服務器內存早就爆表。顯然這種方案是不行的。

我們需要這樣一種結構,每次一張數據庫表中查出前m條記錄,將這m條記錄插入到一個容器中,這個容器會自動將這m條記錄按大小排序,從另外一張表中查出m條記錄,繼續放到這個容器,容器依然能保證從大到小存儲前m條記錄。

這就好像一個擂台,我們可以設置這個擂台大小。每次放到擂台的選手,經過比賽之后,自動從大到小排好序,如果超過擂台大小,排在后面的選手自動淘汰。新加進來的選手可以來踢館,最終強者留下。如果擂台大小還有剩余,可以容納更多的選手。直到達到擂台大小就進入淘汰賽。

同時,還有一個需求,我們100張表可以用100個線程一次查詢出來,因此這個容器必須線程安全。同時,性能要好。

縱觀JDK的集合包,似乎沒有現成的數據結構可以用。那是否可以直接使用J.U.C包中線程安全的集合?理論上是可以的。通過組合比如LinkedBlockingQueue,PriorityBlockingQueue或其他線程安全的數據結構。問題是我們還要時刻保持有序,大小固定這兩個要求,隱含一個性能要好的隱形需求。如果直接使用線程安全的集合,顯然還要額外加鎖,兩把鎖以上的數據結構有可能存在死鎖問題。

與其苦苦死鎖尋找JDK給我們的餡餅,還不如自己動手,豐衣足食。即便重復寫的輪子不如大師的輪子,但是先寫一個放到測試環境跑一跑,只要大流程不出問題,還是不會給公司帶來損失,發現小bug后面再慢慢優化就是。

3. 初步實現

首先,這個擂台比如增刪頻繁,因此使用鏈式結構是最優選。容器的元素一開始就有序,並且后面新加的元素要有序,使用插入排序是最優選,后面新加元素要找到合適的插入位置。就必定要和容器中的元素打擂,但並沒必要一一比賽,因為容器的元素已經是有序的了。這時候使用二分查找尋找待插入元素的位置便是最優解。

插入:O(1),使用鏈表,無需移動元素
二分查找: O(lgn),使用鏈表,退化到O(n)
遍歷集合: O(n)

我們JDK提供的集合排序會根據數據量規模自動選擇排序算法,最優性能是O(nlgn),因為集合中的元素一開始是無序的。因此相比較而言,理論上我們的集合性能上要優於直接使用JDK提供的排序方法。

元素類型不確定,比較依據也不確定,固定大小可以由使用者設置,一旦設置就不能更改。因此這里必須使用泛型,同時由用戶傳入一個比較器,可以使用匿名內部類。同時傳入一個固定大小。容器內部每添加一個元素前都要判斷,當前是否已經達到擂台大小,如果新加入的元素超出了擂台大小,則必須進入淘汰賽,將最小元素淘汰。當然,維護從大到小還是從小到大可以通過比較器的返回值決定,這里我的需求是從大到小。

綜合以上的條件,我們可以動手寫代碼了。

4. 核心實現

public class ArenaList<E> {
/*the arena capacity*/
private final int capacity;
/*need user to define,int r = compare(e1, e2),when r > 0,e1 > e2,r = 0, e1 = e2, r < 0, e1 < e2*/
private final Comparator<E> comparator;
private final LinkedList<E> values;
/*if there is an element in list e1, that e1 equal e2, which e2 is we want to add,
 * if keepNewerElement is false,we will drop e2,else drop e1*/
private final boolean keepNewerElement;
/*at this moment,the element count, when eCount < size, we can add new element into list and will not remove the last element*/
private volatile int eCount = 0;

容器定義的字段如上代碼所示,
capacity必須在構造函數中傳入,一旦初始化之后就不能更改。同時以后可能會實現序列化,雖然現在沒有考慮序列化問題。
comparator是用戶定義的比較器,也必須在構造時初始化。我們約定,調用比較器的compare(e1, e2)比較元素,當返回值r > 0,表示e1 > e2,當r < 0,表示e1 < e2。r = 0則表示元素相等。
values是存儲元素的最終容器,因為增刪頻繁,因此使用鏈表,初始化之后就不能更改;
keepNewerElement是這樣一個作用,當排在最后的兩個元素en和em,如果en = em,em是待加入元素,並且擂台已滿,是否用新加入的淘汰舊的。默認這個開關是false。
eCount,表示當前容器中添加了多少元素。當擂台沒有滿時,eCount < size,當擂台滿了的時候 eCount = size。這里加了volatile修飾,是為了保證多線程下該變量的可見性。這里不深入探討Java並發問題,因為這個問題要說清楚可以另開一篇文章來說。簡單的說volatile是輕量級同步鎖,可能保證變量的可見性但不能保證原子性。同時保證了一些可能引發潛在問題的重排序問題。這些涉及到Java內存模型(JMM),不在多說。這里沒有用J.U.C包下的原子變量是因為我們這里容器的增刪也要加鎖,為了更好的性能,就沒必要CAS自旋消耗性能。

提供的兩個構造函數:

public ArenaList(int capacity, Comparator<E> comparator) {
    if (capacity <= 0) {
        throw new IllegalArgumentException("size can't be negative number");
    }
    this.capacity = capacity;
    this.comparator = comparator;
    this.keepNewerElement = false;
    this.values = new LinkedList<E>();
}

默認keepNewerElement是false,我們也可以自定義為true

public ArenaList(int capacity, Comparator<E> comparator, boolean keepNewerElement) {
    if (capacity <= 0) {
        throw new IllegalArgumentException("size can't be negative number");
    }
    this.capacity = capacity;
    this.comparator = comparator;
    this.keepNewerElement = keepNewerElement;
    this.values = new LinkedList<E>();
}

這個容器中,凡是涉及到容器的增刪操作都要考慮線程安全問題。因此都要加鎖。鎖的實現也有好多種。比如粗粒度的方法鎖,或JDK提供的可重入鎖(ReentrantLock)。但隱含的性能要求最好時將臨界區保持最小。同時代碼簡潔,因此這里我選用了synchronized對象鎖。以values作為鎖對象。

添加刪除元素的部分代碼如下:

/**
 * add e into list with thread-safe
 * @param e
 */
public void add(E e) {
    if (null == e) {
        throw new NullPointerException("Null Element.");
    }
    synchronized (values) {
        int index = findElementIndex(e);
        if (values.size() >= capacity) {
            if (keepNewerElement && index <= values.size()) {
                values.removeLast();
            } else if (index < values.size()){
                //e is in the middle of this list
                values.removeLast();
            } else {
                //e is at the end of this list and e equals the minElement, no need to add.
                return;
            }
        }
        values.add(index, e);
        eCount++;
    }
}

刪除元素:

/**
 * remove e from list with thread-safe
 * @param e
 * @return
 */
public boolean remove(E e) {
    if (null == e) {
        throw new NullPointerException("Null Element.");
    }
    synchronized (values) {
        if(values.remove(e)) {
            eCount--;
            return true;
        }
        return false;
    }
}

尋找待插入元素元素采用的算法是二分查找,這個算法原理非常簡單,但是陷阱非常多,一不小心就會陷入死循環。代碼如下:

/**
 * find the index of e should be
 * @param e
 * @return
 */
private int findElementIndex(E e) {
    int index = 0;
    if (values.size() == 0) {
        return index;
    }
    //values.size() > 0
    E minElement = values.getLast();
    //the largest element at the index of 0
    int high = values.lastIndexOf(minElement);
    E maxElement = values.getFirst();
    //the least element at the index of values.size() - 1
    int low = values.indexOf(maxElement);
    if (compareTwoElement(minElement, e) >= 0) {
        //minElement >= e
        index = high + 1;
        return index;
    }
    if (compareTwoElement(maxElement, e) <= 0) {
        //maxElement <= e
        return index;
    }
    //only one element
    if (low == high) {
        if (capacity == 1) {
            return index;
        }
        if (compareTwoElement(minElement, e) > 0) {
            //minElement > e
            index = high + 1;
            return index;
        } else if (compareTwoElement(minElement, e) < 0) {
            //minElement < e
            return index;
        } else {
            //minElement = maxElement = e
            return index;
        }
    }
    //more than one element and e must at the middle of this list,use binary search to find index
    int middle = 0;
    while (low < high) {
        middle = (low + high) / 2;
        E minddleE = values.get(middle);
        if (compareTwoElement(minddleE, e) == 0) {
            //find it.
            index = middle;
            return index;
        }
        if (compareTwoElement(minddleE, e) > 0) {
            //middleE > e
            low = middle;
        } else {
            //middleE < e
            high = middle;
        }
        if (high - low == 1) {
            //if there only two element, e is in the middle of this list,low will never be higher or equal high
            //at this moment,should check out
            //[4,2,1],3 need to insert into it or [4,3,1],2 need to insert into it
            return low + 1;
        }
    }
    //at this moment,low >= high,and (middle + 1)is we should find
    index = middle + 1;
    return index;
}

這里注意的是,容器中,大的元素在index為0,因此low表示大元素,high表示小元素。這點有悖於約定俗稱。當只剩一個元素比較時就要特別注意了。

5. 總結

到這里,基本分析完了。我寫過測試用例,嘗試過使用100個線程來添加元素。目前業務上似乎沒有問題。

這個容器的原理非常簡單,涉及的知識點也不算難。都是大學學過的。但是真正寫起來卻花費了很多時間。需要想各種測試場景寫各種測試代碼。到現在我完全沒有信心說這個容器沒有任何bug。但目前基本能滿足我們的業務需求。Java並發是一門非常精深非常有意思的學問。很多時候我們寫出的代碼功能是符合要求,但是性能上本來可以做到更好的。其中並發就是一門需要豐富的經驗和鑽研才能寫出高並發高性能程序的學問。在這條路上,我才剛剛起步。

所有代碼開源,可以自由使用。歡迎提出bug或更好的意見。特別是並發優化部分。

源碼下載:https://github.com/34benma/MyTools/blob/master/src/com/util/ArenaList.java

6. 參考資料


個人博客:http://wantedonline.cn


免責聲明!

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



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