[Java併發]Concurrenthashmap的size()

Duancf發表於2024-09-10

1.一致性定義
關於一致性的定義,大概如下:
一致性(Consistency)是指多副本(Replications)問題中的資料一致性。可以分為強一致性、順序一致性與弱一致性。
1.1 強一致性(Strict Consistency)
強一致性也被可以被稱做:
原子一致性(Atomic Consistency)
線性一致性(Linearizable Consistency)
要滿足強一致性,必須符合以下兩個要求:

任何一次讀都能讀到某個資料的最近一次寫的資料。
系統中的所有程序,看到的操作順序,都和全域性時鐘下的順序一致。

上述定義用通俗的話來解釋就是,假定對同一個資料集合,分別有兩個執行緒A、B進行操作,假定A首先進行的修改操作,那麼從時序上在A這個操作之後發生的所有B的操作都應該能看到A修改操作的結果。
1.2 弱一致性
資料更新之後,如果能容忍訪問不到或者只能部分訪問的情況,就是弱一致性。最終一致性是弱一致性的一個特例。
也就是說,對於資料集,分別有兩個執行緒A、B進行操作,假定A首先進行了修改操作,那麼可能從時許上滯後的B進行的讀取操作在一段時間內還讀取不到這個結果。讀取的還是A操作之前的結果。這就是弱一致性。
最終一致性就是說,只要A、B的都不進行任何更新操作,一段時間之後,資料都能讀取到最新的資料。
2.size方法原始碼
2.1 jdk1.8實現
2.1.1 size方法
我們來看看1.8版本中的ConcurrnetHashMap中size方法的原始碼:

/**
 * {@inheritDoc}
 */
public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}

2.1.2 sumCount
實際上底層呼叫的是sumCount方法:

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

可以看到,這個count,實際上是對CounterCell陣列進行遍歷,中間沒有任何鎖操作。
2.1.3 CounterCell
CounterCell原始碼如下:

/**
 * A padded cell for distributing counts.  Adapted from LongAdder
 * and Striped64.  See their internal docs for explanation.
 */
@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

這實際上就是一個volatile修飾的計數器。除了Contended這個註解之外,沒有什麼特別之處,在put、remove的時候,對這個計數器進行增減。
Contended這個註解我們在後面再來詳細解釋。
counterCells這個陣列,實際上size和table一致,這樣Counter中的value就是這個陣列中index對應到table中bucket的長度。
在table擴容的時候,這個計數器陣列也會擴容:
CounterCell[] rs = new CounterCell[n << 1];

2.1.4 addCount

那麼在put和remove以及clear等方式對size數量有影響的方法中,都會呼叫addCount對size進行增減。
x為正數表示增加,負數表示減小。同時check如果大於0則需要對結果進行check,避免在併發過程中由於併發操作帶來的計算不準確。

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    //判斷是否為空
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
        //a是計算出來的槽位
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
            //cas方式
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
              //增加
            fullAddCount(x, uncontended);
            return;
        }
        //如果不需要檢查就直接返回
        if (check <= 1)
            return;
        s = sumCount();
    }
    
    //如果需要檢查
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        //遍歷並重新計算
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

2.1.5 fullAddCount
這是執行增加的核心方法,其中大量使用了cas操作,另外還必須考慮到執行的並行性。
ini 程式碼解讀複製程式碼// See LongAdder version for explanation
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
wasUncontended = true;
}
boolean collide = false; // True if last slot nonempty
//死迴圈,cas方式修改
for (;😉 {
CounterCell[] as; CounterCell a; int n; long v;
if ((as = counterCells) != null && (n = as.length) > 0) {
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
CounterCell r = new CounterCell(x); // Optimistic create
if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean created = false;
try { // Recheck under lock
CounterCell[] rs; int m, j;
if ((rs = counterCells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
else if (counterCells != as || n >= NCPU)
collide = false; // At max size or stale
else if (!collide)
collide = true;
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {// Expand table unless stale
CounterCell[] rs = new CounterCell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
counterCells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
h = ThreadLocalRandom.advanceProbe(h);
}
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try { // Initialize table
if (counterCells == as) {
CounterCell[] rs = new CounterCell[2];
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}

2.1.6 總結
透過對上述方法分析不難看出,size方法是弱一致性的,這是因為,如果有A執行緒正在進行put操作,之後觸發了擴容或者紅黑樹轉置,那麼立即就會synchronized鎖定root節點。之後開始進行對應的操作,這個操作是需要時間的。但是這個時候,如果執行緒B來呼叫size方法,那麼size方法由於沒有任何鎖機制,肯定是能夠返回的,此時返回的size就是put之前的值。那麼這個結果就導致了弱一致性。即put在前的操作並不能馬上讓時許在其後面的操作得到結果,需要等一段時間。待synchronized執行完成。
ConcurrenthashMap的counter機制就是為了增加讀取效能而設計的,如果為了強一致性,那麼只能按HashTable的方式整個讀取方法都加鎖,那麼這樣肯定會影響效能的。
另外addCount,在增加操作的時候還會對數量進行檢查。以避免併發操作帶來的不一致性。
2.2 jdk1.7原始碼實現
由於1.7採用分段鎖的機制,因此設計沒有1.8複雜。
2.2.1 size方法原始碼

/**
 * Returns the number of key-value mappings in this map.  If the
 * map contains more than <tt>Integer.MAX_VALUE</tt> elements, returns
 * <tt>Integer.MAX_VALUE</tt>.
 *
 * @return the number of key-value mappings in this map
 */
public int size() {
    // Try a few times to get accurate count. On failure due to
    // continuous async changes in table, resort to locking.
    final Segment<K,V>[] segments = this.segments;
    int size;
    boolean overflow; // true if size overflows 32 bits
    long sum;         // sum of modCounts
    long last = 0L;   // previous sum
    int retries = -1; // first iteration isn't retry
    try {
    //死迴圈
        for (;;) {
        //遍歷 逐步鎖定段
            if (retries++ == RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    ensureSegment(j).lock(); // force creation
            }
            //假定初始的modCount
            sum = 0L;
            size = 0;
            overflow = false;
            //計算bucket
            for (int j = 0; j < segments.length; ++j) {
                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    //將modCount相加
                    sum += seg.modCount;
                    int c = seg.count;
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            //如果modCount在這個計算過程中沒有改變則說明size計算有效,否則會重置last之後重新計算
            if (sum == last)
                break;
            last = sum;
        }
    } finally {
    //將所有的lock進行unlock操作
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}

這個方法的邏輯是,在一開始,遍歷segment的時候,先鎖定一個段,計算size,然後判斷在這個過程中,modCount是否發生了改變,如果發生改變則說明計算結果會產生誤差,則重新計算。直到modCount在計算前後相等,則說計算可行,之後再移動到下要給bucket。
可以看到這實際上是個低效的操作,只有在所有的bucket都計算完成之後,才會統一在finally中進行unlock。這樣會導致全部的段都被鎖定。
也就是說,1.7中的size方法,最開始是個樂觀鎖,最終會轉換為悲觀鎖,這樣實際上是個強一致性的方法。
2.3 說明
透過上述對1.7和1.8原始碼中對size方法的對比,在1.7中,size能做到強一致性,但是這樣是有代價的,對分段鎖的lock導致了整體效能的降低。而在1.8中,為了增加效能,而增加了一大段複雜的程式碼將size變成了弱一致性。但是好處是在put的過程中不會對size造成阻塞。
由此可見原始碼作者為了提升ConcurrentHashMap所做的各種努力。
這也是我們在編碼過程中值得借鑑的地方。
至於@sun.misc.Contended,這是透過快取行對齊來避免偽共享問題,這個將在後續單獨介紹。

相關文章