點選上方“芋道原始碼”,選擇“置頂公眾號”
技術文章第一時間送達!
原始碼精品專欄
摘要: 原創出處 http://cmsblogs.com/?p=2283 「小明哥」歡迎轉載,保留摘要,謝謝!
-
重要概念
-
重要內部類
-
建構式
-
初始化: initTable()
-
put操作
-
get操作
-
擴容操作
-
轉換紅黑樹
此篇部落格所有原始碼均來自JDK 1.8
HashMap是我們用得非常頻繁的一個集合,但是由於它是非執行緒安全的,在多執行緒環境下,put操作是有可能產生死迴圈的,導致CPU利用率接近100%。為瞭解決該問題,提供了Hashtable和Collections.synchronizedMap(hashMap)兩種解決方案,但是這兩種方案都是對讀寫加鎖,獨佔式,一個執行緒在讀時其他執行緒必須等待,吞吐量較低,效能較為低下。故而Doug Lea大神給我們提供了高效能的執行緒安全HashMap:ConcurrentHashMap。
ConcurrentHashMap的實現
ConcurrentHashMap作為Concurrent一族,其有著高效地併發操作,相比Hashtable的笨重,ConcurrentHashMap則更勝一籌了。
在1.8版本以前,ConcurrentHashMap採用分段鎖的概念,使鎖更加細化,但是1.8已經改變了這種思路,而是利用CAS+Synchronized來保證併發更新的安全,當然底層採用陣列+連結串列+紅黑樹的儲存結構。
關於1.7和1.8的區別請參考佔小狼部落格:談談ConcurrentHashMap1.7和1.8的不同實現:http://www.jianshu.com/p/e694f1e868ec
我們從如下幾個部分全面瞭解ConcurrentHashMap在1.8中是如何實現的:
-
重要概念
-
重要內部類
-
ConcurrentHashMap的初始化
-
put操作
-
get操作
-
size操作
-
擴容
-
紅黑樹轉換
重要概念
ConcurrentHashMap定義瞭如下幾個常量:
// 最大容量:2^30=1073741824
private static final int MAXIMUM_CAPACITY = 1 <30;
// 預設初始值,必須是2的幕數
private static final int DEFAULT_CAPACITY = 16;
//
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//
private static final float LOAD_FACTOR = 0.75f;
// 連結串列轉紅黑樹閥值,> 8 連結串列轉換為紅黑樹
static final int TREEIFY_THRESHOLD = 8;
//樹轉連結串列閥值,小於等於6(tranfer時,lc、hc=0兩個計數器分別++記錄原bin、新binTreeNode數量,<=UNTREEIFY_THRESHOLD 則untreeify(lo))
static final int UNTREEIFY_THRESHOLD = 6;
//
static final int MIN_TREEIFY_CAPACITY = 64;
//
private static final int MIN_TRANSFER_STRIDE = 16;
//
private static int RESIZE_STAMP_BITS = 16;
// 2^15-1,help resize的最大執行緒數
private static final int MAX_RESIZERS = (1 <32 - RESIZE_STAMP_BITS)) - 1;
// 32-16=16,sizeCtl中記錄size大小的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
// forwarding nodes的hash值
static final int MOVED = -1;
// 樹根節點的hash值
static final int TREEBIN = -2;
// ReservationNode的hash值
static final int RESERVED = -3;
// 可用處理器數量
static final int NCPU = Runtime.getRuntime().availableProcessors();
上面是ConcurrentHashMap定義的常量,簡單易懂,就不多闡述了。下麵介紹ConcurrentHashMap幾個很重要的概念。
-
table:用來存放Node節點資料的,預設為null,預設大小為16的陣列,每次擴容時大小總是2的冪次方;
-
nextTable:擴容時新生成的資料,陣列為table的兩倍;
-
Node:節點,儲存key-value的資料結構;
-
ForwardingNode:一個特殊的Node節點,hash值為-1,其中儲存nextTable的取用。只有table發生擴容的時候,ForwardingNode才會發揮作用,作為一個佔位符放在table中表示當前節點為null或則已經被移動
-
sizeCtl
:控制識別符號,用來控制table初始化和擴容操作的,在不同的地方有不同的用途,其值也不同,所代表的含義也不同
-
負數代表正在進行初始化或擴容操作
-
-1代表正在初始化
-
-N 表示有N-1個執行緒正在進行擴容操作
-
正數或0代表hash表還沒有被初始化,這個數值表示初始化或下一次進行擴容的大小
重要內部類
為了實現ConcurrentHashMap,Doug Lea提供了許多內部類來進行輔助實現,如Node,TreeNode,TreeBin等等。下麵我們就一起來看看ConcurrentHashMap幾個重要的內部類。
Node
作為ConcurrentHashMap中最核心、最重要的內部類,Node擔負著重要角色:key-value鍵值對。所有插入ConCurrentHashMap的中資料都將會包裝在Node中。定義如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; //帶有volatile,保證可見性
volatile Node next; //下一個節點的指標
Node(int hash, K key, V val, Node next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
/** 不允許修改value的值 */
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
public final boolean equals(Object o) {
Object k, v, u; Map.Entry,?> e;
return ((o instanceof Map.Entry) &&
(k = (e = (Map.Entry,?>)o).getKey()) != null &&
(v = e.getValue()) != null &&
(k == key || k.equals(key)) &&
(v == (u = val) || v.equals(u)));
}
/** 賦值get()方法 */
Node find(int h, Object k) {
Node e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
}
在Node內部類中,其屬性value、next都是帶有volatile的。同時其對value的setter方法進行了特殊處理,不允許直接呼叫其setter方法來修改value的值。最後Node還提供了find方法來賦值map.get()。
TreeNode
我們在學習HashMap的時候就知道,HashMap的核心資料結構就是連結串列。在ConcurrentHashMap中就不一樣了,如果連結串列的資料過長是會轉換為紅黑樹來處理。當它並不是直接轉換,而是將這些連結串列的節點包裝成TreeNode放在TreeBin物件中,然後由TreeBin完成紅黑樹的轉換。所以TreeNode也必須是ConcurrentHashMap的一個核心類,其為樹節點類,定義如下:
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode parent; // red-black tree links
TreeNode left;
TreeNode right;
TreeNode prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node next,
TreeNode parent) {
super(hash, key, val, next);
this.parent = parent;
}
Node find(int h, Object k) {
return findTreeNode(h, k, null);
}
//查詢hash為h,key為k的節點
final TreeNode findTreeNode(int h, Object k, Class> kc) {
if (k != null) {
TreeNode p = this;
do {
int ph, dir; K pk; TreeNode q;
TreeNode pl = p.left, pr = p.right;
if ((ph = p.hash) > h)
p = pl;
else if (ph p = pr;
else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir 0) ? pl : pr;
else if ((q = pr.findTreeNode(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
}
return null;
}
}
原始碼展示TreeNode繼承Node,且提供了findTreeNode用來查詢查詢hash為h,key為k的節點。
TreeBin
該類並不負責key-value的鍵值對包裝,它用於在連結串列轉換為紅黑樹時包裝TreeNode節點,也就是說ConcurrentHashMap紅黑樹存放是TreeBin,不是TreeNode。該類封裝了一系列的方法,包括putTreeVal、lookRoot、UNlookRoot、remove、balanceInsetion、balanceDeletion。由於TreeBin的程式碼太長我們這裡只展示構造方法(構造方法就是構造紅黑樹的過程):
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode root;
volatile TreeNode first;
volatile Thread waiter;
volatile int lockState;
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
TreeBin(TreeNode b) {
super(TREEBIN, null, null, null);
this.first = b;
TreeNode r = null;
for (TreeNode x = b, next; x != null; x = next) {
next = (TreeNode) x.next;
x.left = x.right = null;
if (r == null) {
x.parent = null;
x.red = false;
r = x;
} else {
K k = x.key;
int h = x.hash;
Class> kc = null;
for (TreeNode p = r; ; ) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
r = balanceInsertion(r, x);
break;
}
}
}
}
this.root = r;
assert checkInvariants(root);
}
/** 省略很多程式碼 */
}
透過構造方法是不是發現了部分端倪,構造方法就是在構造一個紅黑樹的過程。
ForwardingNode
這是一個真正的輔助類,該類僅僅只存活在ConcurrentHashMap擴容操作時。只是一個標誌節點,並且指向nextTable,它提供find方法而已。該類也是整合Node節點,其hash為-1,key、value、next均為null。如下:
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node[] nextTable;
ForwardingNode(Node[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
Node find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
outer: for (Node[] tab = nextTable;;) {
Node e; int n;
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (;;) {
int eh; K ek;
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode)e).nextTable;
continue outer;
}
else
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
}
}
}
建構式
ConcurrentHashMap提供了一系列的建構式用於建立ConcurrentHashMap物件:
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
public ConcurrentHashMap(Map extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
初始化: initTable()
ConcurrentHashMap的初始化主要由initTable()方法實現,在上面的建構式中我們可以看到,其實ConcurrentHashMap在建構式中並沒有做什麼事,僅僅只是設定了一些引數而已。其真正的初始化是發生在插入的時候,例如put、merge、compute、computeIfAbsent、computeIfPresent操作時。其方法定義如下:
private final Node[] initTable() {
Node[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//sizeCtl
if ((sc = sizeCtl) 0)
Thread.yield();
// 如果該執行緒獲取了初始化的權利,則用CAS將sizeCtl設定為-1,表示本執行緒正在初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 進行初始化
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node[] nt = (Node[])new Node,?>[n];
table = tab = nt;
// 下次擴容的大小
sc = n - (n >>> 2); ///相當於0.75*n 設定一個擴容的閾值
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
初始化方法initTable()的關鍵就在於sizeCtl,該值預設為0,如果在建構式時有引數傳入該值則為2的冪次方。該值如果 < 0,表示有其他執行緒正在初始化,則必須暫停該執行緒。如果執行緒獲得了初始化的許可權則先將sizeCtl設定為-1,防止有其他執行緒進入,最後將sizeCtl設定0.75 * n,表示擴容的閾值。
put操作
ConcurrentHashMap最常用的put、get操作,ConcurrentHashMap的put操作與HashMap並沒有多大區別,其核心思想依然是根據hash值計算節點插入在table的位置,如果該位置為空,則直接插入,否則插入到連結串列或者樹中。但是ConcurrentHashMap會涉及到多執行緒情況就會複雜很多。我們先看原始碼,然後根據原始碼一步一步分析:
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ... 超過微信文章長度
}
按照上面的原始碼,我們可以確定put整個流程如下:
-
判空;ConcurrentHashMap的key、value都不允許為null
-
計算hash。利用方法計算hash值。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
} -
遍歷table,進行節點插入操作,過程如下:
-
如果table為空,則表示ConcurrentHashMap還沒有初始化,則進行初始化操作:initTable()
-
根據hash值獲取節點的位置i,若該位置為空,則直接插入,這個過程是不需要加鎖的。計算f位置:i=(n – 1) & hash
-
如果檢測到fh = f.hash == -1,則f是ForwardingNode節點,表示有其他執行緒正在進行擴容操作,則幫助執行緒一起進行擴容操作
-
如果f.hash >= 0 表示是連結串列結構,則遍歷連結串列,如果存在當前key節點則替換value,否則插入到連結串列尾部。如果f是TreeBin型別節點,則按照紅黑樹的方法更新或者增加節點
-
若連結串列長度 > TREEIFY_THRESHOLD(預設是8),則將連結串列轉換為紅黑樹結構
-
呼叫addCount方法,ConcurrentHashMap的size + 1
這裡整個put操作已經完成。
get操作
ConcurrentHashMap的get操作還是挺簡單的,無非就是透過hash來找key相同的節點而已,當然需要區分連結串列和樹形兩種情況。
public V get(Object key) {
// ... 超過微信文章長度
}
get操作的整個邏輯非常清楚:
-
計算hash值
-
判斷table是否為空,如果為空,直接傳回null
-
根據hash值獲取table中的Node節點(tabAt(tab, (n – 1) & h)),然後根據連結串列或者樹形方式找到相對應的節點,傳回其value值。
size 操作
ConcurrentHashMap的size()方法我們雖然用得不是很多,但是我們還是很有必要去瞭解的。ConcurrentHashMap的size()方法傳回的是一個不精確的值,因為在進行統計的時候有其他執行緒正在進行插入和刪除操作。當然為了這個不精確的值,ConcurrentHashMap也是操碎了心。
為了更好地統計size,ConcurrentHashMap提供了baseCount、counterCells兩個輔助變數和一個CounterCell輔助內部類。
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
//ConcurrentHashMap中元素個數,但傳回的不一定是當前Map的真實元素個數。基於CAS無鎖更新
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;
這裡我們需要清楚CounterCell 的定義
size()方法定義如下:
public int size() {
long n = sumCount();
return ((n 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
內部呼叫sunmCount():
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i //遍歷,所有counter求和
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
sumCount()就是迭代counterCells來統計sum的過程。我們知道put操作時,肯定會影響size(),我們就來看看CouncurrentHashMap是如何為了這個不和諧的size()操碎了心。
在put()方法最後會呼叫addCount()方法,該方法主要做兩件事,一件更新baseCount的值,第二件檢測是否進行擴容,我們只看更新baseCount部分:
private final void addCount(long x, int check) {
// ... 超過微信文章長度
}
x == 1,如果counterCells == null,則U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x),如果併發競爭比較大可能會導致改過程失敗,如果失敗則最終會呼叫fullAddCount()方法。其實為了提高高併發的時候baseCount可見性的失敗問題,又避免一直重試,JDK 8 引入了類Striped64,其中LongAdder和DoubleAdder都是基於該類實現的,而CounterCell也是基於Striped64實現的。如果counterCells != null,且uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)也失敗了,同樣會呼叫fullAddCount()方法,最後呼叫sumCount()計算s。
其實在1.8中,它不推薦size()方法,而是推崇mappingCount()方法,該方法的定義和size()方法基本一致:
public long mappingCount() {
long n = sumCount();
return (n 0L) ? 0L : n; // ignore transient negative values
}
擴容操作
當ConcurrentHashMap中table元素個數達到了容量閾值(sizeCtl)時,則需要進行擴容操作。在put操作時最後一個會呼叫addCount(long x, int check),該方法主要做兩個工作:1.更新baseCount;2.檢測是否需要擴容操作。如下:
private final void addCount(long x, int check) {
// ... 超過微信文章長度
}
transfer()方法為ConcurrentHashMap擴容操作的核心方法。由於ConcurrentHashMap支援多執行緒擴容,而且也沒有進行加鎖,所以實現會變得有點兒複雜。整個擴容操作分為兩步:
-
構建一個nextTable,其大小為原來大小的兩倍,這個步驟是在單執行緒環境下完成的
-
將原來table裡面的內容複製到nextTable中,這個步驟是允許多執行緒操作的,所以效能得到提升,減少了擴容的時間消耗
我們先來看看原始碼,然後再一步一步分析:
private final void transfer(Node[] tab, Node[] nextTab) {
// ... 超過微信文章長度
}
上面的原始碼有點兒長,稍微複雜了一些,在這裡我們拋棄它多執行緒環境,我們從單執行緒角度來看:
-
為每個核心分任務,並保證其不小於16
-
檢查nextTable是否為null,如果是,則初始化nextTable,使其容量為table的兩倍
-
死迴圈遍歷節點,知道finished:節點從table複製到nextTable中,支援併發,請思路如下:
-
如果節點 f 為null,則插入ForwardingNode(採用Unsafe.compareAndSwapObjectf方法實現),這個是觸發併發擴容的關鍵
-
如果f為連結串列的頭節點(fh >= 0),則先構造一個反序連結串列,然後把他們分別放在nextTable的i和i + n位置,並將ForwardingNode 插入原節點位置,代表已經處理過了
-
如果f為TreeBin節點,同樣也是構造一個反序 ,同時需要判斷是否需要進行unTreeify()操作,並把處理的結果分別插入到nextTable的i 和i+nw位置,並插入ForwardingNode 節點
-
所有節點複製完成後,則將table指向nextTable,同時更新sizeCtl = nextTable的0.75倍,完成擴容過程
在多執行緒環境下,ConcurrentHashMap用兩點來保證正確性:ForwardingNode和synchronized。當一個執行緒遍歷到的節點如果是ForwardingNode,則繼續往後遍歷,如果不是,則將該節點加鎖,防止其他執行緒進入,完成後設定ForwardingNode節點,以便要其他執行緒可以看到該節點已經處理過了,如此交叉進行,高效而又安全。
下圖是擴容的過程(來自:http://blog.csdn.net/u010723709/article/details/48007881):
[
在put操作時如果發現fh.hash = -1,則表示正在進行擴容操作,則當前執行緒會協助進行擴容操作。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
helpTransfer()方法為協助擴容方法,當呼叫該方法的時候,nextTable一定已經建立了,所以該方法主要則是進行複製工作。如下:
final Node[] helpTransfer(Node[] tab, Node f) {
// ... 超過微信文章長度
}
轉換紅黑樹
在put操作是,如果發現連結串列結構中的元素超過了TREEIFY_THRESHOLD(預設為8),則會把連結串列轉換為紅黑樹,已便於提高查詢效率。如下:
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
呼叫treeifyBin方法用與將連結串列轉換為紅黑樹。
private final void treeifyBin(Node[] tab, int index) {
// ... 超過微信文章長度
}
從上面原始碼可以看出,構建紅黑樹的過程是同步的,進入同步後過程如下:
-
根據table中index位置Node連結串列,重新生成一個hd為頭結點的TreeNode
-
根據hd頭結點,生成TreeBin樹結構,並用TreeBin替換掉原來的Node物件。
整個紅黑樹的構建過程有點兒複雜,關於ConcurrentHashMap 紅黑樹的構建過程,我們後續分析。
【註】:ConcurrentHashMap的擴容和連結串列轉紅黑樹稍微複雜點,後續另起博文分析
666. 彩蛋
如果你對 Java 併發感興趣,歡迎加入我的知識星球一起交流。
如果你對 Dubbo / Netty 等等原始碼與原理感興趣,歡迎加入我的知識星球一起交流。長按下方二維碼噢:
目前在知識星球更新了《Dubbo 原始碼解析》目錄如下:
01. 除錯環境搭建
02. 專案結構一覽
03. 配置 Configuration
04. 核心流程一覽
05. 拓展機制 SPI
06. 執行緒池
07. 服務暴露 Export
08. 服務取用 Refer
09. 註冊中心 Registry
10. 動態編譯 Compile
11. 動態代理 Proxy
12. 服務呼叫 Invoke
13. 呼叫特性
14. 過濾器 Filter
15. NIO 伺服器
16. P2P 伺服器
17. HTTP 伺服器
18. 序列化 Serialization
19. 叢集容錯 Cluster
20. 優雅停機
21. 日誌適配
22. 狀態檢查
23. 監控中心 Monitor
24. 管理中心 Admin
25. 運維命令 QOS
26. 鏈路追蹤 Tracing
… 一共 69+ 篇
目前在知識星球更新了《Netty 原始碼解析》目錄如下:
01. 除錯環境搭建
02. NIO 基礎
03. Netty 簡介
04. 啟動 Bootstrap
05. 事件輪詢 EventLoop
06. 通道管道 ChannelPipeline
07. 通道 Channel
08. 位元組緩衝區 ByteBuf
09. 通道處理器 ChannelHandler
10. 編解碼 Codec
11. 工具類 Util
… 一共 61+ 篇
目前在知識星球更新了《資料庫物體設計》目錄如下:
01. 商品模組
02. 交易模組
03. 營銷模組
04. 公用模組
… 一共 17+ 篇
原始碼不易↓↓↓↓↓
點贊支援老艿艿↓↓