HashMap? ConcurrentHashMap? 相信看完這篇沒人能難住你!

前言

Map 這樣的 Key Value 在軟件開發(fā)中是非常經(jīng)典的結(jié)構(gòu)尘喝,常用于在內(nèi)存中存放數(shù)據(jù)骑科。

本篇主要想討論 ConcurrentHashMap 這樣一個并發(fā)容器呛凶,在正式開始之前我覺得有必要談?wù)?HashMap,沒有它就不會有后面的 ConcurrentHashMap。

HashMap

眾所周知 HashMap 底層是基于 數(shù)組 + 鏈表 組成的缴淋,不過在 jdk1.7 和 1.8 中具體實(shí)現(xiàn)稍有不同。

Base 1.7

1.7 中的數(shù)據(jù)結(jié)構(gòu)圖:

先來看看 1.7 中的實(shí)現(xiàn)沈跨。

這是 HashMap 中比較核心的幾個成員變量心肪;看看分別是什么意思?

初始化桶大小纠吴,因?yàn)榈讓邮菙?shù)組硬鞍,所以這是數(shù)組默認(rèn)的大小。

桶最大值戴已。

默認(rèn)的負(fù)載因子(0.75)

table 真正存放數(shù)據(jù)的數(shù)組固该。

Map 存放數(shù)量的大小。

桶大小糖儡,可在初始化時顯式指定伐坏。

負(fù)載因子,可在初始化時顯式指定休玩。

重點(diǎn)解釋下負(fù)載因子:

由于給定的 HashMap 的容量大小是固定的著淆,比如默認(rèn)初始化:

public HashMap() {? ? this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);}public HashMap(int initialCapacity, float loadFactor) {? ? if (initialCapacity < 0)? ? ? ? throw new IllegalArgumentException("Illegal initial capacity: " +? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? initialCapacity);? ? if (initialCapacity > MAXIMUM_CAPACITY)? ? ? ? initialCapacity = MAXIMUM_CAPACITY;? ? if (loadFactor <= 0 || Float.isNaN(loadFactor))? ? ? ? throw new IllegalArgumentException("Illegal load factor: " +? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? loadFactor);? ? this.loadFactor = loadFactor;? ? threshold = initialCapacity;? ? init();}

給定的默認(rèn)容量為 16劫狠,負(fù)載因子為 0.75。Map 在使用過程中不斷的往里面存放數(shù)據(jù)永部,當(dāng)數(shù)量達(dá)到了 16 * 0.75 = 12 就需要將當(dāng)前 16 的容量進(jìn)行擴(kuò)容独泞,而擴(kuò)容這個過程涉及到 rehash、復(fù)制數(shù)據(jù)等操作苔埋,所以非常消耗性能懦砂。

因此通常建議能提前預(yù)估 HashMap 的大小最好,盡量的減少擴(kuò)容帶來的性能損耗组橄。

根據(jù)代碼可以看到其實(shí)真正存放數(shù)據(jù)的是

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

這個數(shù)組荞膘,那么它又是如何定義的呢?

Entry 是 HashMap 中的一個內(nèi)部類玉工,從他的成員變量很容易看出:

key 就是寫入時的鍵拓瞪。

value 自然就是值义郑。

開始的時候就提到 HashMap 是由數(shù)組和鏈表組成,所以這個 next 就是用于實(shí)現(xiàn)鏈表結(jié)構(gòu)。

hash 存放的是當(dāng)前 key 的 hashcode呻拌。

知曉了基本結(jié)構(gòu)热康,那來看看其中重要的寫入亚茬、獲取函數(shù):

put 方法

public V put(K key, V value) {? ? if (table == EMPTY_TABLE) {? ? ? ? inflateTable(threshold);? ? }? ? if (key == null)? ? ? ? return putForNullKey(value);? ? int hash = hash(key);? ? int i = indexFor(hash, table.length);? ? for (Entry e = table[i]; e != null; e = e.next) {? ? ? ? Object k;? ? ? ? if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {? ? ? ? ? ? V oldValue = e.value;? ? ? ? ? ? e.value = value;? ? ? ? ? ? e.recordAccess(this);? ? ? ? ? ? return oldValue;? ? ? ? }? ? }? ? modCount++;? ? addEntry(hash, key, value, i);? ? return null;}

判斷當(dāng)前數(shù)組是否需要初始化铸题。

如果 key 為空,則 put 一個空值進(jìn)去翰萨。

根據(jù) key 計算出 hashcode脏答。

根據(jù)計算出的 hashcode 定位出所在桶。

如果桶是一個鏈表則需要遍歷判斷里面的 hashcode亩鬼、key 是否和傳入 key 相等殖告,如果相等則進(jìn)行覆蓋,并返回原來的值辛孵。

如果桶是空的丛肮,說明當(dāng)前位置沒有數(shù)據(jù)存入;新增一個 Entry 對象寫入當(dāng)前位置魄缚。

void addEntry(int hash, K key, V value, int bucketIndex) {? ? if ((size >= threshold) && (null != table[bucketIndex])) {? ? ? ? resize(2 * table.length);? ? ? ? hash = (null != key) ? hash(key) : 0;? ? ? ? bucketIndex = indexFor(hash, table.length);? ? }? ? createEntry(hash, key, value, bucketIndex);}void createEntry(int hash, K key, V value, int bucketIndex) {? ? Entry e = table[bucketIndex];? ? table[bucketIndex] = new Entry<>(hash, key, value, e);? ? size++;}

當(dāng)調(diào)用 addEntry 寫入 Entry 時需要判斷是否需要擴(kuò)容。

如果需要就進(jìn)行兩倍擴(kuò)充焚廊,并將當(dāng)前的 key 重新 hash 并定位冶匹。

而在 createEntry 中會將當(dāng)前位置的桶傳入到新建的桶中,如果當(dāng)前桶有值就會在位置形成鏈表咆瘟。

get 方法

再來看看 get 函數(shù): public V get(Object key) { if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); } final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }

首先也是根據(jù) key 計算出 hashcode嚼隘,然后定位到具體的桶中。

判斷該位置是否為鏈表袒餐。

不是鏈表就根據(jù) key飞蛹、key 的 hashcode 是否相等來返回值谤狡。

為鏈表則需要遍歷直到 key 及 hashcode 相等時候就返回值。

啥都沒取到就直接返回 null 卧檐。

Base 1.8

不知道 1.7 的實(shí)現(xiàn)大家看出需要優(yōu)化的點(diǎn)沒有墓懂?

其實(shí)一個很明顯的地方就是:

當(dāng) Hash 沖突嚴(yán)重時,在桶上形成的鏈表會變的越來越長霉囚,這樣在查詢時的效率就會越來越低捕仔;時間復(fù)雜度為 O(N)。

因此 1.8 中重點(diǎn)優(yōu)化了這個查詢效率盈罐。

1.8 HashMap 結(jié)構(gòu)圖:

先來看看幾個核心的成員變量:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16/** * The maximum capacity, used if a higher value is implicitly specified * by either of the constructors with arguments. * MUST be a power of two <= 1<<30. */static final int MAXIMUM_CAPACITY = 1 << 30;/** * The load factor used when none specified in constructor. */static final float DEFAULT_LOAD_FACTOR = 0.75f;static final int TREEIFY_THRESHOLD = 8;transient Node[] table;/** * Holds cached entrySet(). Note that AbstractMap fields are used * for keySet() and values(). */transient Set> entrySet;/** * The number of key-value mappings contained in this map. */transient int size;

和 1.7 大體上都差不多榜跌,還是有幾個重要的區(qū)別:

TREEIFY_THRESHOLD 用于判斷是否需要將鏈表轉(zhuǎn)換為紅黑樹的閾值。

HashEntry 修改為 Node盅粪。

Node 的核心組成其實(shí)也是和 1.7 中的 HashEntry 一樣钓葫,存放的都是 key value hashcode next 等數(shù)據(jù)。

再來看看核心方法票顾。

put 方法

看似要比 1.7 的復(fù)雜础浮,我們一步步拆解:

判斷當(dāng)前桶是否為空,空的就需要初始化(resize 中會判斷是否進(jìn)行初始化)库物。

根據(jù)當(dāng)前 key 的 hashcode 定位到具體的桶中并判斷是否為空霸旗,為空表明沒有 Hash 沖突就直接在當(dāng)前位置創(chuàng)建一個新桶即可。

如果當(dāng)前桶有值( Hash 沖突)戚揭,那么就要比較當(dāng)前桶中的 key诱告、key 的 hashcode 與寫入的 key 是否相等,相等就賦值給 e,在第 8 步的時候會統(tǒng)一進(jìn)行賦值及返回民晒。

如果當(dāng)前桶為紅黑樹精居,那就要按照紅黑樹的方式寫入數(shù)據(jù)。

如果是個鏈表潜必,就需要將當(dāng)前的 key靴姿、value 封裝成一個新節(jié)點(diǎn)寫入到當(dāng)前桶的后面(形成鏈表)。

接著判斷當(dāng)前鏈表的大小是否大于預(yù)設(shè)的閾值磁滚,大于時就要轉(zhuǎn)換為紅黑樹佛吓。

如果在遍歷過程中找到 key 相同時直接退出遍歷。

如果 e != null 就相當(dāng)于存在相同的 key,那就需要將值覆蓋垂攘。

最后判斷是否需要進(jìn)行擴(kuò)容维雇。

get 方法

public V get(Object key) {? ? Node e;? ? return (e = getNode(hash(key), key)) == null ? null : e.value;}final Node getNode(int hash, Object key) {? ? Node[] tab; Node first, e; int n; K k;? ? if ((tab = table) != null && (n = tab.length) > 0 &&? ? ? ? (first = tab[(n - 1) & hash]) != null) {? ? ? ? if (first.hash == hash && // always check first node? ? ? ? ? ? ((k = first.key) == key || (key != null && key.equals(k))))? ? ? ? ? ? return first;? ? ? ? if ((e = first.next) != null) {? ? ? ? ? ? if (first instanceof TreeNode)? ? ? ? ? ? ? ? return ((TreeNode)first).getTreeNode(hash, key);? ? ? ? ? ? do {? ? ? ? ? ? ? ? if (e.hash == hash &&? ? ? ? ? ? ? ? ? ? ((k = e.key) == key || (key != null && key.equals(k))))? ? ? ? ? ? ? ? ? ? return e;? ? ? ? ? ? } while ((e = e.next) != null);? ? ? ? }? ? }? ? return null;}

get 方法看起來就要簡單許多了。

首先將 key hash 之后取得所定位的桶晒他。

如果桶為空則直接返回 null 吱型。

否則判斷桶的第一個位置(有可能是鏈表、紅黑樹)的 key 是否為查詢的 key陨仅,是就直接返回 value津滞。

如果第一個不匹配铝侵,則判斷它的下一個是紅黑樹還是鏈表。

紅黑樹就按照樹的查找方式返回值触徐。

不然就按照鏈表的方式遍歷匹配返回值咪鲜。

從這兩個核心方法(get/put)可以看出 1.8 中對大鏈表做了優(yōu)化,修改為紅黑樹之后查詢效率直接提高到了 O(logn)锌介。

但是 HashMap 原有的問題也都存在嗜诀,比如在并發(fā)場景下使用時容易出現(xiàn)死循環(huán)。

final HashMap map = new HashMap();for (int i = 0; i < 1000; i++) {? ? new Thread(new Runnable() {? ? ? ? @Override? ? ? ? public void run() {? ? ? ? ? ? map.put(UUID.randomUUID().toString(), "");? ? ? ? }? ? }).start();}

但是為什么呢孔祸?簡單分析下隆敢。

看過上文的還記得在 HashMap 擴(kuò)容的時候會調(diào)用 resize() 方法,就是這里的并發(fā)操作容易在一個桶上形成環(huán)形鏈表崔慧;這樣當(dāng)獲取一個不存在的 key 時拂蝎,計算出的 index 正好是環(huán)形鏈表的下標(biāo)就會出現(xiàn)死循環(huán)。

如下圖:

遍歷方式

還有一個值得注意的是 HashMap 的遍歷方式惶室,通常有以下幾種:

Iterator> entryIterator = map.entrySet().iterator();? ? ? ? while (entryIterator.hasNext()) {? ? ? ? ? ? Map.Entry next = entryIterator.next();? ? ? ? ? ? System.out.println("key=" + next.getKey() + " value=" + next.getValue());? ? ? ? }? ? ? ? Iterator iterator = map.keySet().iterator();? ? ? ? while (iterator.hasNext()){? ? ? ? ? ? String key = iterator.next();? ? ? ? ? ? System.out.println("key=" + key + " value=" + map.get(key));? ? ? ? }

強(qiáng)烈建議使用第一種 EntrySet 進(jìn)行遍歷温自。

第一種可以把 key value 同時取出,第二種還得需要通過 key 取一次 value皇钞,效率較低悼泌。

簡單總結(jié)下 HashMap:無論是 1.7 還是 1.8 其實(shí)都能看出 JDK 沒有對它做任何的同步操作,所以并發(fā)會出問題夹界,甚至 1.7 中出現(xiàn)死循環(huán)導(dǎo)致系統(tǒng)不可用(1.8 已經(jīng)修復(fù)死循環(huán)問題)馆里。

因此 JDK 推出了專項(xiàng)專用的 ConcurrentHashMap ,該類位于 java.util.concurrent 包下可柿,專門用于解決并發(fā)問題鸠踪。

堅(jiān)持看到這里的朋友算是已經(jīng)把 ConcurrentHashMap 的基礎(chǔ)已經(jīng)打牢了,下面正式開始分析复斥。

ConcurrentHashMap

ConcurrentHashMap 同樣也分為 1.7 营密、1.8 版,兩者在實(shí)現(xiàn)上略有不同目锭。

Base 1.7

先來看看 1.7 的實(shí)現(xiàn)评汰,下面是他的結(jié)構(gòu)圖:

如圖所示,是由 Segment 數(shù)組痢虹、HashEntry 組成键俱,和 HashMap 一樣,仍然是數(shù)組加鏈表世分。

它的核心成員變量:

/** * Segment 數(shù)組臭埋,存放數(shù)據(jù)時首先需要定位到具體的 Segment 中。 */final Segment[] segments;transient Set keySet;transient Set> entrySet;

Segment 是 ConcurrentHashMap 的一個內(nèi)部類畅蹂,主要的組成如下:

static final class Segment extends ReentrantLock implements Serializable {? ? ? private static final long serialVersionUID = 2249069246763182397L;? ? ? ? ? ? ? // 和 HashMap 中的 HashEntry 作用一樣液斜,真正存放數(shù)據(jù)的桶? ? ? transient volatile HashEntry[] table;? ? ? transient int count;? ? ? transient int modCount;? ? ? transient int threshold;? ? ? final float loadFactor;? ? ? }

看看其中 HashEntry 的組成:

和 HashMap 非常類似,唯一的區(qū)別就是其中的核心數(shù)據(jù)如 value 叠穆,以及鏈表都是 volatile 修飾的少漆,保證了獲取時的可見性。

原理上來說:ConcurrentHashMap 采用了分段鎖技術(shù)硼被,其中 Segment 繼承于 ReentrantLock示损。不會像 HashTable 那樣不管是 put 還是 get 操作都需要做同步處理,理論上 ConcurrentHashMap 支持 CurrencyLevel (Segment 數(shù)組數(shù)量)的線程并發(fā)嚷硫。每當(dāng)一個線程占用鎖訪問一個 Segment 時检访,不會影響到其他的 Segment。

下面也來看看核心的 put get 方法仔掸。

put 方法

public V put(K key, V value) {? ? Segment s;? ? if (value == null)? ? ? ? throw new NullPointerException();? ? int hash = hash(key);? ? int j = (hash >>> segmentShift) & segmentMask;? ? if ((s = (Segment)UNSAFE.getObject? ? ? ? ? // nonvolatile; recheck? ? ? ? (segments, (j << SSHIFT) + SBASE)) == null) //? in ensureSegment? ? ? ? s = ensureSegment(j);? ? return s.put(key, hash, value, false);}

首先是通過 key 定位到 Segment脆贵,之后在對應(yīng)的 Segment 中進(jìn)行具體的 put。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {? ? HashEntry node = tryLock() ? null :? ? ? ? scanAndLockForPut(key, hash, value);? ? V oldValue;? ? try {? ? ? ? HashEntry[] tab = table;? ? ? ? int index = (tab.length - 1) & hash;? ? ? ? HashEntry first = entryAt(tab, index);? ? ? ? for (HashEntry e = first;;) {? ? ? ? ? ? if (e != null) {? ? ? ? ? ? ? ? K k;? ? ? ? ? ? ? ? if ((k = e.key) == key ||? ? ? ? ? ? ? ? ? ? (e.hash == hash && key.equals(k))) {? ? ? ? ? ? ? ? ? ? oldValue = e.value;? ? ? ? ? ? ? ? ? ? if (!onlyIfAbsent) {? ? ? ? ? ? ? ? ? ? ? ? e.value = value;? ? ? ? ? ? ? ? ? ? ? ? ++modCount;? ? ? ? ? ? ? ? ? ? }? ? ? ? ? ? ? ? ? ? break;? ? ? ? ? ? ? ? }? ? ? ? ? ? ? ? e = e.next;? ? ? ? ? ? }? ? ? ? ? ? else {? ? ? ? ? ? ? ? if (node != null)? ? ? ? ? ? ? ? ? ? node.setNext(first);? ? ? ? ? ? ? ? else? ? ? ? ? ? ? ? ? ? node = new HashEntry(hash, key, value, first);? ? ? ? ? ? ? ? int c = count + 1;? ? ? ? ? ? ? ? if (c > threshold && tab.length < MAXIMUM_CAPACITY)? ? ? ? ? ? ? ? ? ? rehash(node);? ? ? ? ? ? ? ? else? ? ? ? ? ? ? ? ? ? setEntryAt(tab, index, node);? ? ? ? ? ? ? ? ++modCount;? ? ? ? ? ? ? ? count = c;? ? ? ? ? ? ? ? oldValue = null;? ? ? ? ? ? ? ? break;? ? ? ? ? ? }? ? ? ? }? ? } finally {? ? ? ? unlock();? ? }? ? return oldValue;}

雖然 HashEntry 中的 value 是用 volatile 關(guān)鍵詞修飾的起暮,但是并不能保證并發(fā)的原子性卖氨,所以 put 操作時仍然需要加鎖處理。

首先第一步的時候會嘗試獲取鎖鞋怀,如果獲取失敗肯定就有其他線程存在競爭双泪,則利用 scanAndLockForPut() 自旋獲取鎖。

嘗試自旋獲取鎖。

如果重試的次數(shù)達(dá)到了 MAX_SCAN_RETRIES 則改為阻塞鎖獲取,保證能獲取成功逾滥。

再結(jié)合圖看看 put 的流程讥巡。

將當(dāng)前 Segment 中的 table 通過 key 的 hashcode 定位到 HashEntry欢顷。

遍歷該 HashEntry,如果不為空則判斷傳入的 key 和當(dāng)前遍歷的 key 是否相等,相等則覆蓋舊的 value鳖链。

不為空則需要新建一個 HashEntry 并加入到 Segment 中,同時會先判斷是否需要擴(kuò)容灌侣。

最后會解除在 1 中所獲取當(dāng)前 Segment 的鎖侧啼。

get 方法

public V get(Object key) {? ? Segment s; // manually integrate access methods to reduce overhead? ? HashEntry[] tab;? ? int h = hash(key);? ? long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;? ? if ((s = (Segment)UNSAFE.getObjectVolatile(segments, u)) != null &&? ? ? ? (tab = s.table) != null) {? ? ? ? for (HashEntry e = (HashEntry) UNSAFE.getObjectVolatile? ? ? ? ? ? ? ? (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);? ? ? ? ? ? e != null; e = e.next) {? ? ? ? ? ? K k;? ? ? ? ? ? if ((k = e.key) == key || (e.hash == h && key.equals(k)))? ? ? ? ? ? ? ? return e.value;? ? ? ? }? ? }? ? return null;}

get 邏輯比較簡單:

只需要將 Key 通過 Hash 之后定位到具體的 Segment 椭更,再通過一次 Hash 定位到具體的元素上湿滓。

由于 HashEntry 中的 value 屬性是用 volatile 關(guān)鍵詞修飾的,保證了內(nèi)存可見性朝氓,所以每次獲取時都是最新值。

ConcurrentHashMap 的 get 方法是非常高效的,因?yàn)檎麄€過程都不需要加鎖

Base 1.8

1.7 已經(jīng)解決了并發(fā)問題毙死,并且能支持 N 個 Segment 這么多次數(shù)的并發(fā),但依然存在 HashMap 在 1.7 版本中的問題再菊。

那就是查詢遍歷鏈表效率太低。

因此 1.8 做了一些數(shù)據(jù)結(jié)構(gòu)上的調(diào)整。

首先來看下底層的組成結(jié)構(gòu):

看起來是不是和 1.8 HashMap 結(jié)構(gòu)類似臀叙?

其中拋棄了原有的 Segment 分段鎖荠雕,而采用了 CAS + synchronized 來保證并發(fā)安全性既鞠。

也將 1.7 中存放數(shù)據(jù)的 HashEntry 改為 Node,但作用都是相同的。

其中的 val next 都用了 volatile 修飾郭毕,保證了可見性显押。

put 方法

重點(diǎn)來看看 put 函數(shù):

根據(jù) key 計算出 hashcode 兽肤。

判斷是否需要進(jìn)行初始化。

f 即為當(dāng)前 key 定位出的 Node害驹,如果為空表示當(dāng)前位置可以寫入數(shù)據(jù)瓦糕,利用 CAS 嘗試寫入亥揖,失敗則自旋保證成功圣贸。

如果當(dāng)前位置的 hashcode == MOVED == -1,則需要進(jìn)行擴(kuò)容在张。

如果都不滿足,則利用 synchronized 鎖寫入數(shù)據(jù)瘟斜。

如果數(shù)量大于 TREEIFY_THRESHOLD 則要轉(zhuǎn)換為紅黑樹槽华。

get 方法

根據(jù)計算出來的 hashcode 尋址披摄,如果就在桶上那么直接返回值。

如果是紅黑樹那就按照樹的方式獲取值灌砖。

就不滿足那就按照鏈表的方式遍歷獲取值。

1.8 在 1.7 的數(shù)據(jù)結(jié)構(gòu)上做了大的改動,采用紅黑樹之后可以保證查詢效率(O(logn))窜醉,甚至取消了 ReentrantLock 改為了 synchronized读串,這樣可以看出在新版的 JDK 中對 synchronized 優(yōu)化是很到位的聊记。

總結(jié)

看完了整個 HashMap 和 ConcurrentHashMap 在 1.7 和 1.8 中不同的實(shí)現(xiàn)方式相信大家對他們的理解應(yīng)該會更加到位。

其實(shí)這塊也是面試的重點(diǎn)內(nèi)容恢暖,通常的套路是:

談?wù)勀憷斫獾?HashMap排监,講講其中的 get put 過程。

1.8 做了什么優(yōu)化杰捂?

是線程安全的嘛挨队?

不安全會導(dǎo)致哪些問題蝶俱?

如何解決浅侨?有沒有線程安全的并發(fā)容器崔步?

ConcurrentHashMap 是如何實(shí)現(xiàn)的春感? 1.7、1.8 實(shí)現(xiàn)有何不同?為什么這么做诲宇?

這一串問題相信大家仔細(xì)看完都能懟回面試官。

除了面試會問到之外平時的應(yīng)用其實(shí)也蠻多型奥,像之前談到的 Guava 中 Cache 的實(shí)現(xiàn)就是利用 ConcurrentHashMap 的思想夹纫。

同時也能學(xué)習(xí) JDK 作者大牛們的優(yōu)化思路以及并發(fā)解決方案称诗。

其實(shí)寫這篇的前提是源于 GitHub 上的一個 Issues实抡,也希望大家能參與進(jìn)來胯盯,共同維護(hù)好這個項(xiàng)目。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市应媚,隨后出現(xiàn)的幾起案子携龟,更是在濱河造成了極大的恐慌宾抓,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件折柠,死亡現(xiàn)場離奇詭異宾娜,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)扇售,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進(jìn)店門前塔,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人承冰,你說我怎么就攤上這事华弓。” “怎么了困乒?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵寂屏,是天一觀的道長。 經(jīng)常有香客問我,道長凑保,這世上最難降的妖魔是什么冈爹? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮欧引,結(jié)果婚禮上频伤,老公的妹妹穿的比我還像新娘。我一直安慰自己芝此,他們只是感情好憋肖,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著婚苹,像睡著了一般岸更。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上膊升,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天怎炊,我揣著相機(jī)與錄音,去河邊找鬼廓译。 笑死评肆,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的非区。 我是一名探鬼主播瓜挽,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼征绸!你這毒婦竟也來了久橙?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤管怠,失蹤者是張志新(化名)和其女友劉穎淆衷,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體渤弛,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡吭敢,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了暮芭。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡欲低,死狀恐怖辕宏,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情砾莱,我是刑警寧澤瑞筐,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站腊瑟,受9級特大地震影響聚假,放射性物質(zhì)發(fā)生泄漏块蚌。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一膘格、第九天 我趴在偏房一處隱蔽的房頂上張望峭范。 院中可真熱鬧,春花似錦瘪贱、人聲如沸纱控。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽甜害。三九已至,卻和暖如春球昨,著一層夾襖步出監(jiān)牢的瞬間尔店,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工主慰, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留嚣州,地道東北人。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓河哑,卻偏偏與公主長得像避诽,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子璃谨,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評論 2 353

推薦閱讀更多精彩內(nèi)容