Java集合-Map

什么是Map

? ? ? ?不同于List單列的線性結構,Map提供的是一種雙列映射的存儲集合,它能夠提供一對一的數據處理能力,雙列中的第一列我們稱為key,第二列就是value,一個key只能夠在一個Map中出現最多一次,通過一個key能夠獲取Map中唯一一個與之對應的value值,正是它的這種一對一映射的數據處理關系,在實際應用中可以通過一個key快速定位到對應的value。綜合上面的概念,可以概括出以下幾個核心點:

  • Map存儲是以k-v鍵值對的方式進行存儲的,是雙列的
  • Map中的key具有唯一性,不可重復
  • 每個key對應的value值是唯一的
    ? ? ? ?Java中Map是一個接口,它不繼承任何其他的接口,可以說它是java中所有Map的頂級父接口。它的設計理念完全遵循上面的規則,只是具體的實現類種類很多,對應不同應用場景的使用,所以可能具體細節以及設計上存在差異。

Java的Map中提供了三種Map視圖以便于展示Map中的內容:

  • 只包含key的Set集合
  • 只包含value的Collection
  • 同時包含key-value映射的EntrySet

另外需要額外注意:不能使用可變的對象作為Map的key,因為一旦該對象出現變化它會導致Map的行為無法預期(這里的變化指的是影響equals方法比較結果的變化);同時不能將Map本身作為一個Map的key,但是允許將Map本身作為value存入Map結構中。

使用Map的時機

存儲雙列結果

? ? ? ?有很多情況下我們都需要將數據梳理成雙列的結果集存儲起來,最常見的就是當查詢數據庫時,它返回的結果集中,對應字段名key和記錄值value就是一個map,當然如果是列表查詢,還會在Map的基礎上包裝一層List,但是它的每一條記錄結果的表示形式就是借助Map來存儲的,再比如在數據接收時,如果沒有合適的對象接收時,可以考慮使用Map進行接收,最常見的就是前端傳入json字符串,后端使用Map來接收數據,但是現在基本都采用JSONObject的方式來接收了,但是Map也是可以作為一個備用選項,在沒有其他第三方插件可用的情況下,可以考慮使用Map,或者String接收,然后轉成Map。

快速定位數據

? ? ? ?因為Map的一對一映射的數據關系,利用這一特性,可以快速定位具體數據,現在的一些緩存操作就是利用的這一特點,我們將數據以Map的形式存儲在內存中,在緩存的眾多數據當中,未來如果需要獲取數據時只需要給一個指定的key,可以快速定位到緩存的內容,而不必像List結構那樣,需要記住具體的位置才能快速定位,當然如果能夠確切記得元素位置,也可以使用List,而且效率更高,但是更多時候是不現實的,我們需要記住每一個元素在List中的位值,數據過多時就比較麻煩了,而且寫出來的程序可讀性也很差,因為只是通過整型的Index獲取,而Map的key可以是任何類型,完全可以定義一個具有明確意義的內容。

需要唯一性存儲的時候

? ? ? ?因為Map的key具有唯一性的特點,我們完全可以利用這一特點作為一個“變異版”的Set來使用,我們知道Set的特點就是不可重復,實際上在Java中,HashSet確實就是這么干的,它將存入的元素放入一個HashMap(Map的一種實現)的key中,而Map中所有的value都是一個固定的Object類型的PRESENT常量,因為它的key不可冗余的特性正好符合了Set的特點,所以在HashSet的底層實現就依托于HashMap,而且Map本身也是無需的,注意:這里的“無序”是“不保證有序”,而不是“保證無序”,這兩個概念是有區別的,前者說明結果可能會有序,也可能無序,不能保證;而后者說明結果一定是無序的。所以有時可以發現在遍歷HashSet時竟然是有序的,這其實并不沖突。

Map的實現原理

? ? ? ?因為Java中Map只是一個接口,它只是定義了一些方法以及規范,所以提到實現原理,還得結合具體的實現類來說明,而且Map的不同實現類,實現的原理也有所不同;Map的實現類很多,就以我當前使用的Java 10.0.2為例,Map接口的實現類就達到了31個之多(包括抽象類),這還只是JDK內部的實現類,不包括第三方庫的實現。所以這里僅僅以幾個常見的Map來詳細說明,也是面試中經常被提及的幾個Map類型:HashMap、HashTable、ConcurrentHashMap、LinkedHashMap等。

HashMap

概念

? ? ? ?它是基于Hash表對Map接口的實現,所謂Hash其實就是散列,這里只需要記住:散列就是將一段變長的數據轉換成一個固定長度的數據,這個過程稱之為散列。在這個過程中會有一系列的算法計算,這里暫不深究;而Java獲取對象的hash值比較方便,因為Object類中定義了一個hashCode方法,所以Java中的任何類都有直接或間接繼承了hashCode方法,而HashMap就是根據key的hash散列結果來具體定位Map中的元素的;另外在HashMap中是可以使用null鍵和null值的;而且HashMap不是線程安全的;如果拋去這兩點來看,HashMap其實與HashTable大致是相同的。它不能保證映射的順序恒久不變,即:無法保證有序性;它的容量和加載因子緊密關系著它的迭代性能。

實現原理

? ? ? ?整體設計概念上來說,HashMap采用的是數組加鏈表的方式來存儲元素,因為hash可能存在沖突的問題,所以增加鏈表來存儲hash值相同的元素,這種解決hash沖突的方法也叫鏈地址法。

首先,如果要深入了解HashMap,就必須明白以下四個概念:

  • capacity:它是指HashMap的容量,表示的是當前HashMap中最大可以存儲的數據個數。
  • size:它反映的是當前HashMap中已經存放的元素個數
  • loadFactor:加載因子,它表示當前HashMap中的裝滿程度,默認為0.75,,它跟threshold計算有關
  • threshold:表示臨界值,因為HashMap的容量不是一層不變的,當達到一定程度,就需要對HashMap進行擴容,而這個擴容的臨界值就是用threshold表示,它的計算方式是capacity*loadFactor;因為加載因子默認是0.75,也就是說達到最大容量的四分之三時,再往里面加元素,就會擴容。

上面只是一個大致的介紹,下面就來逐步深入介紹相關的設計原理。

hash沖突

? ? ? ?hash本身的意思就是散列,它的作用前面也介紹過,就是將一個變長的數據散列成一個固定長度的數據,在Java中,散列的結果是一個int整形數,也就是說hash的結果最大也不會超過Java中整型的最大值,任何對象都具有hashCode方法,我們可以重寫hashCode方法,但是對象的數量是海量的,但是hash值就那么多,所以必然存在一種情況:hash值相同但是對象不同,這種現象就是hash沖突。在Java中如果是比較兩個對象是否相同也是有策略的,首先會比較兩個對象的hashCode方法,hashCode不同,對象肯定不同,這種判斷可以隔絕大多數的比較了,而當hashCode相同時,會再去調用equals方法比較,如果equals也為true,這才能說明兩個對象是相等的。所以equals為true的對象,hashCode一定是相等的,反之則不然。

HashMap的內部存儲容器

? ? ? ?map中最基礎的存儲結構就是數組,具體在HashMap中就是一個Node<K, V>類型的數組table(即hash表),這是一個可以存儲鍵值對的數據類型組成的數組,我們放入map中的元素全部都被存入到這個數組中,而map容量的擴充實際上也就是對這個數組的不斷變更。

HashMap的初始容量設計

? ? ? ?使用HashMap的時候,最常用的就是無參的構造方法,然后調用put方法放入鍵值對,這種方式采用的是默認的初始化策略,此時當我們剛剛new出一個HashMap對象時,它的內部table數組實際上是一個null(我這里是以java 10.0.2為版本介紹的,其他版本可能略有不同)。只有當我們第一次調用put時才會進行table的初始化。此時它的capacity會被設置為DEFAULT_INITIAL_CAPACITY = 1 << 4,也就是16。加載因子loadFactor是默認的0.75,所以這時候它的threshold是12。

但是我們在創建HashMap時也可以指定capacity甚至是loadFactor,這里一般不推薦修改loadFactor:

//DEFAULT_LOAD_FACTOR = 0.75f
public HashMap(int initialCapacity) {
 this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

? ? ? ?需要注意的是:它傳入的initialCapacity并不是最終我們構建的HashMap對象的容器大小,它會經過一次運算,得到一個比initialCapacity值稍大的數值,并且一定是離initialCapacity最近的那個2的N次冪的值,比如:我們傳入5,它就會找到8,傳入9,就會找到16。也就是說最終得到的capacity的值一定是2的某個次冪。這個過程是需要計算的,JDK中采用是一種比較高效的位移運算,具體如下:

首先來看幾個簡單示例:

5   0000 0000 0000 0101      |      19  0000 0000 0001 0011
7   0000 0000 0000 0111      |      31  0000 0000 0001 1111
8   0000 0000 0000 1000      |      32  0000 0000 0010 0000
---------------------------------------------------------------------------------------------------
9   0000 0000 0000 1001      |      37  0000 0000 0010 0101
15  0000 0000 0000 1111      |      63  0000 0000 0011 1111
16  0000 0000 0001 0000      |      64  0000 0000 0100 0000

? ? ? ?根據上面的數據展示,可以看到如果我們想要將5最終變成離它最近的那個8,需經歷:5 -- 7 -- 8這么一個過程,也就是將它的原本二進制數據有效部分全部轉換成1,然后在此基礎上加1就可以得到目標值,同理9到16,19到32等也是如此,而JDK中HashMap源碼采用就是這種不斷右移然后按位或運算,最終得到一個有效數據部分全為1的數值,然后加1得到結果,這樣再來看HashMap的這部分計算源碼就不會迷惑了:

static final int tableSizeFor(int cap) {
 int n = cap - 1;
 n |= n >>> 1;
 n |= n >>> 2;
 n |= n >>> 4;
 n |= n >>> 8;
 n |= n >>> 16;
 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

? ? ? ?可以看到,最終計算出來的n與MAXIMUM_CAPACITY比較,只要比它小,就會加1,而在此之前的邏輯主要就是為了將對應的二進制位全部轉換成1。這里有一點需要注意:按照上面介紹的邏輯會有有個情況,就是如果我們傳入的數值本身就是2的次冪值,那就會有問題,因為此時傳入的capacity本身就已經可以作為初始容量了,解決方式很簡單,就是上面方法中的第一步操作,先將傳入的capacity減1,再進行計算,此時如果傳入的是8,那么減1之后,n其實是7,經過計算后正好是8,符合邏輯;那如果此時傳入的是9,雖然減1后得到了8,但是此時需要的是更大數值的16,經過位移計算,正好得到15,加1后得到16,所以邏輯上也沒有問題。

HashMap的存儲

? ? ? ?HashMap的存儲方式就是利用了map中的key的hash值作為定位基礎的,具體做法就是:我們在存入一個k-v鍵值對的時候,它會計算key的hash值,也就是調用key對象的hashCode方法,因為hashCode的取值范圍是整個整型范圍,所以可能會非常大,為了讓它能夠和table數組的下標index掛鉤,這里就會將得到的hashCode值與table的長度取模,這樣得到的數據肯定是在table.length之內的整數,然后用它作為數組對應的下標。注意:這里JDK采用了一定的優化,它的取模并不是常規的hash % table.length,它使用的是hash & (table.length - 1)這種按位與的操作,這么做有兩個好處:

  • 首先就是效率很高,比正常的取模運算符要快
  • 避免了負數問題,結合前面的初始容量設計可以知道,table.length - 1得到的一定是一個正數值,所以它的最高位一定是0,而0與任何數按位與運算,得到的一定是0,此時無論hashCode值是整數還是負數,計算出來的結果一定是正數。

而為何會出現hash & (table.length - 1) == hash % table.length呢?其實想想也能明白:

經過前面分析可以得到table的長度必然是:table.length == 2^N,則hash % table.length實際上就是hash / table.length然后取余數,也就是hash >> N 位移運算過程中,移出的那部分數據即為需要的模運算結果。而table.length - 1的結果必定是一個二進制有效位全為1的數據(參考前面容量初始化設計),此時hash與減1后的值進行按位與,可以保證低位的結果保留,而超過table.length - 1數值的高位部分得0,這個結果正好就是前面位移運算過程中需要得到的那個移出的部分。

? ? ? ?按照上面的介紹,最終可以根據key的hash得到一個對應的數組位置index,但是我們也知道hash是會沖突的,這里如果出現了沖突,經過計算后得到的index位置上已經有元素了怎么辦,這時候鏈表結構就發生作用了,它會將最新添加的元素放在數組中,將該位置上之前的元素以鏈表的形式放在新加入的元素的后面,Node的設計本身就是一種鏈表存儲格式:

static class Node<K,V> implements Map.Entry<K,V> {
 final int hash;
 final K key;
 V value;
 Node<K,V> next;
 ...
}

所以:數組中的元素肯定是最新放入的元素,之前的老元素會按照鏈表的方式掛在最新元素的后面。這種存儲就是數組加鏈表的存儲方式。

需要注意的是:HashMap中使用到的hash值并非是對應key對象上調用hashCode方法,它又經歷了一步計算:

static final int hash(Object key) {
 int h;
 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

? ? ? ?這么做的目的也就是為了降低hash沖突,盡可能的讓key的每一位數據都會影響到散列的結果,所以它會對hash進行擾動計算,防止hashCode的高位不同但低位相同導致的hash沖突,將hash的高位和低位的特征結合起來,共同影響hash結果,盡可能將每一位數據都與hash結果相關聯,從而降低沖突。具體的計算,強烈推薦H大的博文:透徹分析hash()

? ? ? ?隨著數據的不斷增多,沖突的可能性就會越來越大,極端情況就是鏈表部分會變得很長,而鏈表結構的增刪的效率很高,但查詢的效率很低,這樣如果鏈表過長,會影響Map的查詢性能,所以JDK1.8之后,對鏈表部分做了改動,一旦鏈表的長度超過了8個Node,就會直接轉換成紅黑樹結構,檢索的時間復雜度大大降低。

? ? ? ?另外對于擴容方面,如果元素個數超過了capacity*threshold的值,容器就會調用resize方法擴容,擴容很簡單:就是將原來的oldCapacity << 1,也就是直接擴大一倍,這樣也保證了capacity的值始終都是2的指數值。

使用HashMap時機

? ? ? ?總體上來說,HashMap的查找效率比較高,常見的應用場景中,需要使用Map的地方,HashMap足以解決大多數問題,而且它的效率也是很高的,如果數據量較大,這個效率提升還是很可觀的,如果沒有線程安全的要求,并且可以允許Key出現null值的情況下,可以考慮使用HashMap。

HashMap的問題

線程安全

? ? ? ?觀察HashMap的源碼,可以看到,沒有任何線程安全的概念,對于put,remove等操作方法,沒有任何加鎖、同步化的操作,所以它是線程不安全的,在多線程環境下,可以考慮如下方案:

  • 使用HashTable,但是效率極低,它是方法加鎖,加鎖粒度很粗糙,性能較低
  • 使用Collections工具類可以創建一個線程安全的HashMap,實際的結果跟前面一種類似,也是一種方法加鎖,不推薦
  • 可以考慮在自己的業務邏輯中自行實現同步邏輯,靈活性較高,但是會影響代碼的整體閱讀邏輯
  • 可以使用ConcurrentHashMap,它是也是線程安全的map,引入了段加鎖機制,效率比HashTable要高很多

另外,據網上有些博客提出:在并發狀況下,多個線程同時對一個map進行put,可能會導致get死循環,具體可能表現為:

  • 多線程put操作后,get操作導致死循環。
  • 多線程put非NULL元素后,get操作得到NULL值。
  • 多線程put操作,導致元素丟失。

? ? ? ?但是這種情況我寫了一些demo,暫時沒有出現上面這種情況,可能是因為JDK版本的緣故,新版本的HashMap做了一定的優化,故暫時無法重現這個問題。

性能開銷

? ? ? ?hash函數的計算會影響這個性能,對于一些極端情況,例如對象比較復雜,hash的計算可能比較耗時,這部分性能的損耗也應該考慮在后續實際生產中,如果Map中存儲的key比較復雜,就要考慮是否需要換一種存儲方式了。

初始容量設置問題

? ? ? ?我們在創建一個HashMap的時候,一般都是用默認的無參構造方法,這種方法雖然方便,但是性能上面可能并不是最符合我們當前正在運行的程序環境,所以一般都建議指定一個初始容量,這樣可以盡可能保證減少map擴容帶來的性能耗損問題,同時也減少了rehash(擴容后重新進行hash計算)的次數,對性能的提升也有一定好處。

? ? ? ?但是初始容量的設計也是有講究的,不能越大越好,要最符合當前的應用環境,當然前提是能夠預知到map中到底會存儲多少元素,否則默認的構造方法是最好的選擇。在阿里巴巴的Java開發者手冊中給出了一個計算初始容量的公式:

capacity = (要存儲元素個數 / 加載因子loadFactor) + 1
//加載因子loadFactor默認0.75

? ? ? ?其實這種計算方案在Google的Java開源工具庫guava也有,最終的靈感來源還是JDK源碼中HashMap的putAll方法得來的,它里面采用的就是這種計算方式,它這么設計是有一定的好處的:

例如現在需要加入7個元素,如果只是直接傳入7,那么最終的HashMap容量會被設置為8,但是由于此時threshold = 8 * 0.75 = 6,所以在加入第七個元素的時候會有一個hash表擴容,這個是比較耗費資源的操作。而如果使用上面推薦的公式,可以計算最終傳入的值為:7 / 0.75 + 1 = 10;那么最終計算后的capacity為16,在進行元素裝入的時候就可以有效避免過于頻繁的hash表擴容操作,從而提升性能。

其他

? ? ? ?說到HashMap,這里順便說一下HashSet,它在前面也稍微提到了一下,它是一種Set,Set的特性就是:無序,唯一。而HashSet的底層實現就是利用的HashMap的key唯一性特點,而且HashMap本身也是不保證有序的,所以正好與Set的特性不謀而合,所以HashSet在存儲元素的時候,都是將元素存入HashMap的key中,value部分始終都是一個固定的常量Object對象,沒有實際意義。

HashTable

? ? ? ?首先在概念上,它基本上與HashMap沒有太大區別,甚至于使用的方式都差不多,不同的只是它的底層設計上與HashMap存在一定的差異,而正是這些差異,使它的使用場景更加單一。另外:HashTable是不存能出null值的,無論是key還是value,這點與HashMap完全不同,而且它是線程安全的。

實現原理

? ? ? ?它的整體設計思路基本和HashMap差不多,都是基于Node數組進行存儲,這個Node實際上就是一個鏈表的節點類型,內部維護一個Node類型的數組,Node的結構設計基本都差不多,下面主要從幾個與HashMap不同的地方說明以下具體的細節差異。

線程安全

? ? ? ?首先明確一點,HashTable是線程安全的,看它的源碼就知道了,它里面的所有涉及到更新HashTable的操作都被加了synchronized鎖。

繼承關系

? ? ? ?與HashMap不同的是HashTable繼承自Dictionary(HashMap繼承自AbstractMap),這是一個非常古老的抽象類,它從java1.0的時候就已經存在了,現在連這個類的頭部注釋中都說這個類已經是obsolete(過時的)類了,對于現在的JDK而言,Dictionary所能達到的效果,Map接口都能達到,甚至還比它的靈活性更高(因為接口可以多實現,類只能單繼承),所以官方推薦后續的實現都基于Map接口了。

迭代

? ? ? ?HashTable的遍歷使用的是枚舉Enumeration,而不是我們常見的Iterator迭代器,例如我們如果調用它的keys方法,它會返回一個Enumeration類型的對象,這個查看一下源碼就可以很清楚的看到。當然,它本身也是有Iterator的,Enumeration是因為歷史遺留問題才一直存在的。它的Iterator迭代與HashMap都是支持fast-fail的,fail-fast這里就不再贅述,參考Java集合--List

初始容量設計

? ? ? ?它默認的初始容量為11,并非HashMap的16,但是它的默認加載因子loadFactor卻仍然是0.75,官方采用0.75做加載因子其實也是經過時間空間的消耗權衡得到的結果,按照官方的注釋解釋:如果過高,雖然會減少空間上的開銷,但是會增加查詢上的時間成本,所以才說不建議修改loadFactor。

擴容設計

它的擴容不同于HashMap的直接翻倍,每次當容量達到threshold后,新的capacity = 2 * oldCapacity + 1。所以它的到的capacity值始終都是一個奇數,默認是從11開始的。另外它也沒有對傳入的capacity再計算的過程,它的源碼中只有如下設計:

if (initialCapacity==0)
 initialCapacity = 1;
this.loadFactor = loadFactor;
table = new Entry<?,?>[initialCapacity];
threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);

? ? ? ?可以看到,只是對initialCapacity進行了是否為0的判斷,如果為0,默認賦值為1,否則capacity的值就是傳入的值。所以我們在構建HashTable對象時,如果需要傳入capacity,最好也按照它的設計初衷,傳入一個奇素數。

hash計算

? ? ? ?HashTable的hash計算就是直接調用key對象的hashCode方法,沒有進行進一步擴散計算,而且它的取模運算也沒有HashMap那種采用效率更高的&操作,據說這是跟HashTable的長度數值有關,當哈希表的大小為素數時,簡單的取模,哈希的結果會更加均勻。沒有具體深入研究過,有待探討。

HashTable的問題

性能

? ? ? ?它的性能問題是廣為詬病的,因為它的synchronized鎖都是加在方法上的,synchronized本身就是重量級鎖,并且加鎖粒度又很粗糙(方法級鎖),我們在現實場景中,其實99%的情況可能都不會出現線程安全問題,所以僅僅為了那1%的并發安全去使用它多少有點浪費性能,完全可以自己控制同步,而且如果有選擇,選擇ConcurrentHashMap也是一種不錯的方案。只有在一些特殊的應用場景中可能會采用HashTable存儲數據。

并發迭代修改

? ? ? ?雖然HashTable被描述為線程安全的,似乎在多線程環境下就不存在任何問題了,但是仍需注意,如果我們使用迭代器對HashTable進行遍歷的時候,它采用的是fail-fast機制,所以仍然有可能拋出ConcurrentModificationException異常。另外HashTable的另一種迭代方式:Enumeration,它是不支持fail-fast的,所以如果有需要檢測這種并發修改迭代的情況,Iterator是唯一的選擇。

ConcurrentHashMap

原理

? ? ? ?因為HashTable的加鎖粒度過大,所以JDK1.5以后出現了這個同樣支持并發操作的Map結構,ConcurrentHashMap引入了Segment(段)的機制,所謂段,就是將Map的容器進行分段(也就是常說的“桶”),每段單獨加鎖,所以當并發修改時,如果不是同時操作同一個段內的數據,段與段之間是互不影響的,這就是所謂的鎖分段技術,正是因為段與段之間是獨立的加鎖,所以大大提升了并發性能。

? ? ? ?對于java6和java7的版本中,主要就是segment機制的引入,內部擁有一個Entry數組,數組中每個元素又是一個鏈表結構,但是同時也是一個ReentrantLock(Segment繼承了ReentrantLock)。

? ? ? ?而Java8以后,ConcurrentHashMap做了很大的改動,有些甚至是顛覆性的,它摒棄了之前一直使用的Segment機制,雖然源碼中仍然存在該類,但是源碼的注釋中已經說明了它是一個unused(無用的)類,它只是用來兼容之前版本的ConcurrentHashMap。新版本的ConcurrentHashMap采用了CAS算法,無鎖化的操作大大提高了性能,底層仍然采用HashMap的那套實現,即:“數組+鏈表+紅黑樹”的方式。同時為了做到并發,也增加了一些輔助類,如:TreeBin、Traverser等內部類。

繼承關系

? ? ? ?ConcurrentHashMap除了和HashMap一樣繼承了AbstractMap,同時它也實現了一個叫ConcurrentMap的接口,這個接口的作用主要是為Map提供線程安全以及原子性的保證。另外不同于HashMap,它沒有實現Cloneable接口,所以如果涉及對象復制,需要額外考慮其他方式。其他的基本都與HashMap一致了。

初始化容量和擴容機制

? ? ? ?對于初始容量的設置,默認加載因子以及擴容的方式,ConcurrentHashMap采用的方案與HashMap的機制是一模一樣,所以對于HashMap的那一套在這里是通用的,它的內部結構也是一個Node數組,并且它的Node類的內部定義幾乎與HashMap的一致,不同的是此時的Node中代表Value的val和指向下一個Node節點的引用next是volatile修飾的,這個也是為了在高并發情況下,為不同線程間數據可見性而考慮的。而且不僅僅是這兩個字段,在整個類中,除去靜態常量,其余的變量幾乎全部都用volatile修飾。

? ? ? ?如果在創建ConcurrentHashMap對象時,我們手動傳入了capacity值,這里它不是像HashMap那樣直接對傳入的capacity值進行計算求取最近的2的指數值,而是會將傳入的initialCapacity進行如下運算:

tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)

? ? ? ?而這里的tableSizeFor方法就是根據傳入的值,對capacity進行不斷位移以及按位或的操作,最終求出一個合適的2的指數值。即:如果創建ConcurrentHashMap對象時,如果指定了capacity,在實際創建最大容量時,會先對傳入的capacity擴大3倍并加1,再根據此時的值再此進行求取最近的不小于該值的2的指數冪值。

幾個重要的屬性

? ? ? ?前面HashMap中介紹過的幾個重要屬性這里就不再重復了,這里就重點提一下sizeCtl屬性,它的作用很大,用途比較多,根據不同的值,可以分為以下幾種情況:

  • 負數代表正在進行初始化或擴容操作

  • -1代表正在初始化

  • -N表示當前有N - 1個線程正在進行擴容

  • 整數或0代表hash還沒有被初始化,此時它的值表示的是初始化大小或者是下一次擴容的大小,有點類似于前面介紹過的threshold(閾值)的概念,它的值始終是當前ConcurrentHashMap容量的0.75倍,與loadFactor正好相對應。

//下面兩個值主要是用于與hash值進行比較時使用,判斷當前節點類型
static final int MOVED     = -1; // hash值是-1,表示這是一個forwardNode節點
static final int TREEBIN   = -2; // hash值是-2  表示這時一個TreeBin節點</pre>

MOVED和TREEBIN在容器擴容,遍歷以及存放元素的時候,有很重要的作用。

核心類

Node

? ? ? ?跟HashMap一樣,它是包裝了K-V鍵值對的類,前面說過,它的整體設計思路跟HashMap幾乎一樣,只是它的value和next屬性使用了volatile修飾,保證了在并發環境下線程間的可見性,同時比較有意思的是它的setValue方法:

public final V setValue(V value) {
 throw new UnsupportedOperationException();
}

? ? ? ?可以看到,它不允許直接使用setValue方法對Node中的value屬性進行修改,如果這么做,會直接拋出異常。這個方法在HashMap中是可以正常使用的。同時,相較于HashMap,它又新增了一個find方法,注釋上解釋它的功能主要是為了輔助map.get方法,在子類中會進行重寫。

TreeNode

? ? ? ?當Node的鏈表結構過長時(一般是為8個元素),HashMap就是將其轉換成紅黑樹形式,而轉換的基礎就是直接借助TreeNode,但是ConcurrentHashMap中卻不是直接使用TreeNode,它是將這些TreeNode節點放在TreeBin對象中,然后由TreeBin完成紅黑樹的包裝,而且這里的TreeNode是繼承自ConcurrentHashMap中的Node類。

TreeBin

? ? ? ?TreeBin的作用很明確,它的構造函數就一個,接收的參數就是TreeNode,它對TreeNode進行包裝,甚至于當鏈表轉換成樹結構的時候,哪怕它的根節點也是TreeBin,并非HashMap中的TreeNode,所以可以說:ConcurrentHashMap中,如果數組中某個數組位置上的結構轉變成了樹結構,那么存儲在數組中的實際元素就是TreeBin對象。而對于其他沒有轉換成樹結構的位置(鏈表長度仍然在8個以內),仍然是原本的Node類型。

ForwardingNode

? ? ? ?一個用于連接兩個table的節點類,這個類在進行擴容的時候有很大的作用,它內部包含了一個nextTable引用,類型是Node類型,而且它的key、value以及next都是null,hash值為-1,后面在介紹擴容的時候會說道它的作用。

CAS無鎖化

UnSafe靜態代碼塊

? ? ? ?在我當前使用的java10.0.2版本中,源碼的6373行到6403行這段代碼是無鎖化的靜態語句塊部分,它里面利用了jdk.internal.misc.Unsafe類對一些重要屬性的修改采用CAS操作,大大提高了程序的性能,因為沒有鎖的開銷問題,許多鎖帶來的問題也就不存在了。

三個無鎖化的核心方法
//獲取tab數組中i位置上的Node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
 return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}
//利用CAS設置tab數組中i位置上的Node節點,這里就是典型的CAS操作,設置值的時候,傳入待修改的值v和比較值c
//在修改時,將c的值與內存中的值比較,只有相等時,才將該位置上的Node設置為傳入的v,否則修改失敗
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
 Node<K,V> c, Node<K,V> v) {
 return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
//顧名思義,這個方法就是利用CAS操作,將tab數組中的i位置設置為傳入的v
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
 U.putObjectRelease(tab, ((long)i << ASHIFT) + ABASE, v);
}

正是有了上面這三個核心操作,才保證了ConcurrentHashMap的線程安全的特性。

初始化initTable

? ? ? ?在構建ConcurrentHashMap對象的時候,它的構造方法采用的策略和HashMap中的大致是一樣,在構造方法中其實并沒有對具體的存儲數組進行指定,只是簡單初始化了一些必要的參數指標,具體的table的初始化都是放在插入元素時進行的,在插入前會對table進行null值判斷。

private final Node<K,V>[] initTable() {
  Node<K,V>[] tab; int sc;
  while ((tab = table) == null || tab.length == 0) {
    //根據前面的介紹,此時sizeCtl若小于0,表示有其他線程正在進行初始化操作
    //此時當前線程放棄初始化,自旋直至其他線程初始化結束
    if ((sc = sizeCtl) < 0)
      Thread.yield(); 
    else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
      //此時表示當前線程得到了初始化權限,此時將sizeCtl設置為-1,阻止其他線程進入
      try {
        if ((tab = table) == null || tab.length == 0) {
          //DEFAULT_CAPACITY = 16
          int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
          @SuppressWarnings("unchecked")
          Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
          table = tab = nt;
          //此時n是當前table容器的大小,n>>>2實際上就等于0.25n
          //所以sc = 0.75n
          sc = n - (n >>> 2);
        }
      } finally {
//修改sizeCtl的值為當前容器大小的0.75倍值
        sizeCtl = sc;
      }
      break;
    }
}
return tab;
}

擴容transfer方法

? ? ? ?由于這個方法過長,這里就不再貼它的源碼了,強烈建議琢磨一下源代碼,它里面的涉及到的設計思路還是比較精妙的,它的大致擴容思路是這樣的:

  • 開始的時候它會進行一次CPU的可用線程的計算,根據可用線程數目和 Map 數組的長度,平均分配處理當前待擴容的“桶”。默認的是每個線程需要處理16個“桶”,所以換句話說,如果當前map的容量為16的時候,那么擴容階段就只有一個線程在擴容。

  • 它在擴容時需要用到一個額外的數組空間nextTable,它的大小是原table的兩倍,一旦擴容完成,原來的table就會被新的nextTable所取代。

  • 擴容后,就是將原來數組中的內容復制到新的nextTable數組中去,這里的復制轉移并不是簡單的copy,它是有策略的

  • 通過前面的分析我們知道,數組中的元素存在不同的情況,既有Node(此時說明是鏈表結構),也會有TreeBin(鏈表過長轉換成了紅黑樹)以及null(這部分是還未存放元素的“桶”)。

  • 首先在進行遍歷復制的時候,原數組中null的位置在新的數組中同樣位置上會插入一個占位符forwarNode節點,當其他線程在遍歷到當前節點是forwardNode類型,就直接跳過該節點,繼續向后遍歷。

  • 剩下的鏈表和紅黑樹結構中,在復制到新的數組中時會對其進行拆分,拆分的規則是將當前節點的hash值與length進行取余操作,假如在原數組中的位置是index,那么根據取余的結果,如果為0,就在新數組的index位置防止,否則不為0的話,就將其放在index+n的位置,這里的n一般就是16。

? ? ? ?這樣原來數組中的鏈表和紅黑樹部分就都各自被拆分成了兩份存儲在新的數組中,原來的null位置依然為null,沒有任何變化,這樣就完成了數組的擴容操作。下面一份關于擴容的示意圖:

首先我們現在假設當前map的容量為16:

concurrentHashMap01.png

其余數組中的內容暫時不用考慮,單獨看這里的index為1和4的位置上的內容,假設其他位置上的內容都是null,那么擴容后,數組的容量就會變成32,然后1位置上的藍色節點會組成一個新的鏈表,放在新數組中的1位置上,而1位置上的黃色節點會組成新的鏈表放在新數組的17位置上。同樣的4位置上此時鏈表長高度達到8,應為樹結構,但是為了方便表示,這里也將其畫成了鏈表結構,在拆分后,藍色黃色節點各自組成新的鏈表,且長度減到了4,重新變成了鏈表結構,如果拆分后鏈表長度仍然過長,擴容后仍然會保持紅黑樹結構。

concurrentHashMap02.png

put方法存放元素

? ? ? ?首先需要明確的是,ConcurrentHashMap中put是不能存放key或者value為null的元素的,否則會直接拋出空指針異常,這一點有別于HashMap。另外因為ConcurrentHashMap可以運行于多線程環境中,所以它的put方法邏輯比較復雜,簡單來說,它的put方法的主要邏輯就是:

  • 首先根據傳入的key計算對應的table中的位置index

  • 判斷table[index]當前位置中是否存在元素,如果沒有元素,直接放入,沒有加鎖操作

  • 如果當前位置上已經存在元素了,節點上鎖,然后依次遍歷鏈表節點,如果遇到了key和hash都是一致的元素,就更新這個位置上的value,注意這里的上鎖的節點可以理解為hash值相同組成的鏈表的頭結點。

  • 如果一致遍歷到節點鏈的末尾,都沒有找到key和hash都相同的元素,那么就可以認為它是一個插入操作,此時就把這個節點插入到鏈表末尾。

  • 如果table[index]位置上的內容為樹節點,就按照樹的方式是插入節點

  • 在插入結束后,如果是鏈表結構,需要判斷當前鏈表長度是否達到了8,如果是,還需要將其轉換成紅黑樹。

  • 最后同步更新一下當前map中的元素數量

? ? ? ?可以看到,新版本的ConcurrentHashMap中,put時鎖住的是Node節點,而不是像之前JDK1.6和1.7那樣鎖住整個segment。而且在鎖住Node之前的操作也是線程安全的,它的線程安全就依托于前面介紹過的三個核心的CAS方法。

size屬性

? ? ? ?因為可能存在高并發的情況,所以不像HashMap那樣直接調用size方法就可以準確獲取當前map中的元素個數,在高并發下,可能存在當我們需要獲取size值的時候,其他線程正在往map中寫數據,我們不能像虛擬機的垃圾回收一樣,在統計時“Stop The World”,所以得到的size值其實并不是一個精確的值。對于這個大概的size值的獲取,ConcurrentHashMap也是利用了一些策略才得到的,并非直接返回size屬性值。

輔助的內部類和變量
@jdk.internal.vm.annotation.Contended static final class CounterCell {
 volatile long value;
 CounterCell(long x) { value = x; }
}
//它是一個基于計數器的值,其實也就是存放的是map中的元素個數,使用CAS更新
//但是它并不是強制的等于當前map中元素的個數
private transient volatile long baseCount;
//當調整大小、創建CounterCells時用于CAS自旋鎖操作
private transient volatile int cellsBusy;
//計數表,當非空的時候,它的容量是2的冪
private transient volatile CounterCell[] counterCells;
size方法
public int size() {
 long n = sumCount();
 return ((n < 0L) ? 0 :
 (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
 (int)n);
}
//JDK1.8開始新增的一個方法,它與size方法很類似,而且從它的注釋上來看,很推薦使用它來代替size方法
public long mappingCount() {
 long n = sumCount();
 return (n < 0L) ? 0L : n; // ignore transient negative values
}
//無論是size還是mappingCount,都沒有直接返回baseCount的值
//而是在baseCount的基礎上繼續進行了計算,即便如此,它得到的仍然是一個估計值
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;
}
addCount方法

? ? ? ?前面在介紹put方法的時候,在最后一步說到“同步更新一下當前map中的元素數量”,這句話在put方法的代碼中其實就是調用addCount方法,而這個方法會做兩件事情:利用CAS對baseCount的值進行更新,然后判斷此時是否需要擴容。

ConcurrentHashMap的問題

size的準確性

? ? ? ?上面說過,它的size方法得到的值并不是一個準確的值,但是在大多數情況下,這個值是可以保證的,只有在極端并發環境下才有可能出現不一致的情況,所以我們在使用時,不能依賴于size方法來確定map中精確的元素個數,應當有適當誤差。并且現在也更加推薦了mappingCount方法來代替size方法使用。

數據覆蓋問題

? ? ? ?當我們在高并發環境下,純粹put或者get操作,其實是沒有問題的,但是當我們調用get之后,在下次調用put方法之前,如果有其他線程也對該map調用了put方法,那么后續在調用put方法的時候,就有可能把剛才其他線程填入的值覆蓋掉,即使ConcurrentHashMap中使用CAS操作,仍然不可能完全避免這種情況。但是這中屬于具體編碼問題,控制合理的話,編碼人員可以避免這樣做,只要稍加注意,可以采用一些額外的手段保證它的一致性。

空值問題

? ? ? ?一定不能使用ConcurrentHashMap的put方法放入一個key或value為null的元素,否者直接NullPointerException。

LinkedHashMap

原理

可以看以下它的定義:

public class LinkedHashMap<K,V>
 extends HashMap<K,V>
 implements Map<K,V>

? ? ? ?從它的定義中可以明顯的看出,它就是HashMap的子類,所以它的一切都是基于HashMap的,查看它的源代碼可以看到,它的初始化,擴容,元素的存放與獲取底部都是基于HashMap的那一套原理,所以這里就不再繼續介紹了,但是它有一個HashMap沒有的特點,就是雙向鏈表,簡單來說就是:它的本身的存儲結構依然采用HashMap的那套數組加鏈表的方式,但是它的節點內部又多維護了兩個引用before和after,before指向當前元素之前放入的元素,next指向緊接著放入的元素引用,所以它們的引用關系與放入Map中的元素順序有關,也就是說它除了之前介紹的每個“桶”內部的鏈表結構,桶與桶之間的不同節點也有一個引用在維護,并且是雙向的鏈表結構,這樣整體的感覺就是:

LinkedHashMap01.png

概括起來就是:它的主體存儲結構仍然是HashMap的那套邏輯,只是在HashMap的基礎上,每個節點又多維護了一份之前存入的元素引用以及之后存入的元素引用,這樣每個節點內部算起來實際上有三個引用(HashMap本身就有一個鏈表引用,主要是hash值相同的元素之間的引用,然后又有了這個新增的兩個引用)。

LinkedHashMap02.png

特點

? ? ? ?從它的結構可以看出,HashMap所擁有的特性它都是存在的,同時因為加入了雙向鏈表的維護,所以它是一個有序的Map,前面說過,HashMap是不保證有序的,也就是它遍歷的結果可能與它放入的順序不是一致的(不保證結果有序性,并不是一定是無序的),而LinkedHashMap的結果必定是有序的,所以如果需要使用有序的Map場景,可以考慮使用LinkedHashMap。

使用場景

? ? ? ?前面介紹過的Map的使用時機這里都完全可以適用于LinkedHashMap,此外它還可以保證結果的有序性,所以如果對遍歷的有序性有要求,可以使用它。

? ? ? ?另外:它還可以用來實現LRU(Least recently used, 最近最少使用)算法,這是因為它內部有一個removeEldestEntry方法,這個方法的返回值為boolean類型,如果我們需要實現LRU,可以繼承自LinkedHashMap,重寫它的方法,此時,如果需要使用實現LRU,它有一個屬性必須設置為true,那就是:accessOrder,只有它為true的時候,雙向鏈表中的元素就會按照訪問先后順序排列。這里有一個簡單使用的例子:

public class LRU<K, V> extends LinkedHashMap<K, V> implements Map<K, V> {
   public LRU(int initialCapacity, float loadFactor, boolean accessOrder) {
   super(initialCapacity, loadFactor, accessOrder);
 }
 @Override
 protected boolean removeEldestEntry(Entry<K, V> eldest) {
   //保證map中元素個數
   return size() > 6;
 }
 public static void main(String[] args) {
   LRU<Character, Integer> lru = new LRU<Character, Integer>(
     16, 0.75f, true);
   String s = "abcdefghijk";
   for (int i = 0, len = s.length(); i < len; i++) {
     lru.put(s.charAt(i), i);
   }
   System.out.println(lru); //{f=5, g=6, h=7, i=8, j=9, k=10}
 }
}

? ? ? ?最終得到的結果可以看到,map中始終都是只包含6個元素,如果過多,之前插入的節點都會被拋棄,拋棄的都是最近最少使用的節點。

存在的問題

? ? ? ?總體來說,前面說道到的HashMap的存在的問題在這里仍然是存在的,它不是線程安全的,而且它的存儲開銷比HashMap更大,這個也好理解,畢竟它又多了一個雙向鏈表的維護,所以無論是復雜度還是維護成本都會高一些。

ConcurrentSkipListMap

介紹

? ? ? ?最后再來看一個基于跳表的數據結構,它是一個支持排序和并發的Map,是線程安全的,這種跳表的結構我們正常開發中很少用到,所以對于我而言,它是一個知識盲點,下面就簡單介紹一下這個跳表。

原理

? ? ? ?在介紹跳表之前,首先需要知道傳統的鏈表結構是什么樣子,傳統的鏈表是一個線性結構,如果我們需要查詢某個元素,需要從鏈表的頭部挨個遍歷訪問,然后找出目標元素。所以,鏈表結構的查詢需要O(n)的時間,它與鏈表長度緊密相關。而跳表就是基于傳統鏈表上做的一次改進,它是采用空間換時間的方式來提高查詢效率,這里以一個最簡單的方式來描述一下它的數據結構,實際情況中它的結構會比下面的圖示復雜一點,后面會介紹:

SkipListMap1.png

上面的結構就是最簡單的一種跳表的結構,首先需要明確的是:跳表天然是有序的,所以上面的鏈表結構是有序的。它除了正常的鏈表結構(有一個引用指向下一個節點)外,每個節點多了一個引用,用于指向下下個節點,這里,如果我們查詢一個值為12的元素,具體查詢步驟就是:

SkipListMap2.png
  1. 首先12與1比較,比1大,向后找到6節點

  2. 12與6比較,比6節點大,向后找到9節點

  3. 12與9比較,比9節點大,向后找到17節點,說明12在9節點與17節點之間

  4. 12與9比較,比9節點大,向后找到12節點,即為目標節點

? ? ? ?可以看到,它的查詢是跳躍式進行的,跳過了中間的3和7節點,所以它查詢的時間復雜度為O(n/2)。但是前面也說過了,這只是最簡單的一種方式,實際情況中,一個鏈表節點的內部可能包含的不僅僅只有兩個引用(下一個節點+下下個節點),每個節點內部到底會帶有多少個后續節點的引用是隨機生成的,所以實際情況可能是這樣:

SkipListMap3.png

每個節點中擁有的后續節點的引用個數不定,這是一種概率均衡技術,而不是強制性均衡約束,所以對于節點的插入和刪除比傳統的平衡樹算法更為簡潔高效。

查找

SkipListMap4.png

例如查找值為12的元素,就會按照紅色箭頭上的數字步驟查詢即可,這里就不再贅述了。

插入

  1. 找到需要插入的位置

  2. 申請新的節點

  3. 調整指針

SkipListMap5.png

上圖的解釋如下:

? ? ? ?假設我們這里需要插入一個值為15的節點,最后找到的位置就是上圖中紅色圓圈的位置,然后申請新的節點,將節點調整指針,放入12的后面即可,這里有一個技巧:使用一個update數組保存將要插入位置之前的節點直接引用,這里就是上圖中紅色線框框住的三個引用,因為在插入節點時,只有這三個引用可能涉及到指針的調整。調整后的情況即為:

SkipListMap6.png

刪除

? ? ? ?刪除操作其實和插入很類似,找到節點,將其刪除,然后調整指針即完成整個刪除操作,插入邏輯中用到的update數組技巧在這里仍然適用。

Java中的ConcurrentSkipListMap實現

在java中,跳躍表是具有層級結構的,即所謂的level,整體結構大致如下:

SkipListMap7.png

跳表的結構具備以下特征:

  • 最底層包含所有節點的一個有序的鏈表

  • 每一層都是一個有序的鏈表

  • 每個節點都有兩個指針,一個指向右側節點(沒有則為空),一個指向下層節點(沒有則為空)

  • 必備一個頭節點指向最高層的第一個節點,通過它可以遍歷整張表,如上圖中的左上角的藍色節點BASE_HEADER

在新增節點時,假設此時我們加入一個值為80的節點,它是通過如下步驟來添加的:

  1. 首先,它會將80節點加入level1中的鏈表中
SkipListMap8.png
  1. 根據概率算法,計算處一個level值,這里假設為4,然后根據跳表算法描述,構建新的索引
SkipListMap9.png
  1. 將各個索引層次上的節點連接
SkipListMap10.png

其他

目前常用的key-value數據結構有三種:Hash表、紅黑樹以及SkipList,它們各自有著不同的優缺點(不考慮刪除操作):

  • Hash表:插入、查找最快,為O(1),數據的有序化需要顯式的排序操作

  • 紅黑樹:插入、查找為O(logN),但是常數項較小,如果采用無鎖化實現,復雜度很高,一般需要加鎖,數據天然有序

  • SkipList:插入、查找為O(n),常數項比紅黑樹要大,底層結構為鏈表,可無鎖實現,數據天然有序

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,197評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,415評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,104評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,884評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,647評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,130評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,208評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,366評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,887評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,737評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,478評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,174評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,586評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,827評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,608評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,914評論 2 372

推薦閱讀更多精彩內容

  • 轉載自:https://halfrost.com/go_map_chapter_one/ https://half...
    HuJay閱讀 6,169評論 1 5
  • 轉載 HashMap主要用來存放鍵值對,它基于哈希表的Map接口實現,是常用的Java集合之一。與HashTabl...
    Snail127閱讀 205評論 0 0
  • 初始化下載管理器 添加下載 下載數據管理 創建請求 Get Post Delegate 下載數據本地化 創建任務 ...
    Carden閱讀 233評論 0 0
  • 抱孩子外出,總能聽到一些關于孩子的評論,在別人看到的是孩子外表,而我確了解每個不同外表之下傾注的關心和在這個小生命...
    暖陽_a8b4閱讀 195評論 0 0