HashMap源碼解析、jdk7和8之后的區別、相關問題分析(多線程擴容帶來的死循環)



一、概覽


HashMap<String, Integer> map = new HashMap<>();

這個語句執行起來,在 jdk1.8 之前,會創建一個長度是 16 的 Entry[] 數組,叫 table,用來存儲鍵值對。

在 jdk 1.8 后,不在這里創建數組了,而是在第一次 put 的時候才會創建數組叫 Node[] table ,用來存儲鍵值對。


二、源碼的成員變量分析


聲明部分

HashMap 實現了 Map 接口,又繼承了 AbstractMap,但是 AbstractMap 也是實現了 Map 接口的,而且很多集合類都是這種實現,這是一個官方失誤造成的冗余,不過一直流傳了下來。

  1. 繼承 AbstractMap ,這個父類作為抽象類,實現了 Map 的很多方法,為了減少直接實現類的工作;
  2. 實現 Cloneable 接口和 Serializable 接口,這個問題在 原型模式 里面說過,就是深拷貝的問題,但是值得注意的是,HashMap 實現這兩個接口,重寫的方法仍然不是深拷貝,而是淺拷貝

屬性部分

2.1 序列號serialVersionUID

序列化默認版本號,不重要。

2.2 默認初始化容量DEFAULT_INITIAL_CAPACITY

集合默認初始化容量,注釋里寫了必須是 2 的冪次方數,默認是 16。

問題 1 : 為什么非要是 2 的次方數呢?

答:第一方面為了均勻分布,第二方面為了擴容的時候重新計算下標值的方便。

這個涉及到了插入元素的時候對每一個 node 的應該在的桶位置的計算:

核心在這個方法里,會根據 (n - 1) & hash 這個公式計算出 ihash 是提前算出的 key 的哈希值,n 則是整個 map 的數組的長度。

那么這個節點應該放在哪個桶,這就是散列的過程,我們當然希望散列的過程是盡量均勻的,而不會出現都算出來進入了 table[] 的同一個位置。那么,可以選擇的方法有取余啊、之類的,這里采用的方法是位運算來實現取余。

就是(n - 1) & hash 這個位運算,2 的冪 -1 都是11111結尾的:


2 進制,所以 2 的幾次方都是 1 00000(很多個 0 的情況),然后 -1, 就會變成 000 11111(很多個1)

那么和 本來計算的具有唯一性的 hash 值相與,

  1. 用高位的 0 把hash 值的高位都置為了 0 ,所以限制在了 table 的下標范圍內。
  2. 保證了 hash 值的盡量散開。


對於第 2 點,如果不是 2 的冪次方,那么 -1 就不會得到 1111 結尾,甚至如果是個基數,-1 后就會變成形如 0000 1110
這樣的偶數,那么相與的結果豈不是永遠都是偶數了?這樣 table 數組就會有一半的位置永遠利用不上的。所以 2 的冪次方以及 -1 的操作,才能保證得到和取模一樣的效果。

因此得出結論,如果 n 是 2 的冪次方,計算出的位置會很均勻,相反則會干擾這個運算,導致計算出的位置不均勻。

第二個方面的原因就是擴容的時候,重新要計算下標值 hash2 的冪次方帶給了好處,下面的擴容部分有詳細說明。

注意到我們初始化 HashMap 的時候可以指定容量。

問題 2 那么如果傳入的容量並不是 2 的次方,怎么辦呢?

從構造方法可以看到,調用指定加載因子和 容量的方法,如果大於最大容量,就會改為最大容量,接着對於容量,調用 tableSizeFor 方法,此時傳入的參數已經肯定是 <= 最大容量的數字了。

tableSizeFor 這個方法會產生一個大於傳入數字的、最小的 2 的冪次方數。

2.3 最大容量MAXIMUM_CAPACITY

最大 hashMap 的容量就是 1 左移 30 位,也就是 2 的 30 次方

2.4 默認加載因子DEFAULT_LOAD_FACTOR

默認加載因子為 0.75 ,也就是說,如果鍵值對超過了當前的容量 * 0.75 ,就會觸發擴容。

問題 為什么是 0.75 而不是別的數呢?

答:如果加載因子越大,對空間的利用更充分,但是查找效率會降低(鏈表長度會越來越長);如果加載因子太小,那么表中的數據將過於稀疏(很多空間還沒用,就開始擴容了),對空間造成嚴重浪費。

其實 0.75 是一個統計的結果,比較理想的值,根據舊版源碼里面的注釋,和概率的泊松分布有關系,當負載因子是 0.75 的情況下,哈希碰撞的概率遵循參數約為 0.5 的泊松分布,因此選擇它是一個折衷的辦法來滿足時間和空間。

2.5 轉樹的閾值TREEIFY_THRESHOLD

默認為 8 ,也就是說一個桶內的鏈表節點數多於 8 的時候,結合數組當前長度會把鏈表轉換為紅黑樹。

問題 為什么是超過 8 就轉為紅黑樹?

答:首先,紅黑樹的節點在內存中是普通鏈表節點方式存儲的 2 倍,成本是比較高的,那么對於太少的節點數目就沒必要轉化,繼續擴容就行了。

結合負載因子 0.75泊松分布結果,每個鏈表有 8 個節點的概率已經到達可以忽略的程度,所以將這個值設置為 8 。為了避免出現惡意的頻繁插入,除此之外還會判斷數組長度是否達到了 64。

所以到這里我個人的理解是:
-> 最開始hashmap的思想就是數組加鏈表;
-> 因為數組里的各個鏈表長度要均勻,所以就有了哈希值的算法,以及適當的擴容,擴容的加載因子定成了 0.75 ;
-> 而擴容只能根據總共的節點數來計算,可能沒來得及擴容的時候還是出現了在同一個鏈表里元素變得很多,所以要轉紅黑樹,而這個數量就根據加載因子結合泊松分布的結果,決定了是8.

2.6 重新退化為鏈表的閾值UNTREEIFY_THRESHOLD

默認為 6, 也就死說如果操作過程發現鏈表的長度小於 6 ,又會把樹退回鏈表。

2.7 轉樹的最小容量

不僅僅是說有鏈表的節點多於 8 就轉換,還要看 table 數組的長度是不是大於 64 ,只有大於 64 了才轉換。為了避免開始的時候,正好一些鍵值對都裝進了一個鏈表里,那只有一個鏈表,還轉了樹,其實沒必要。

還有屬性的第二部分:

第一個是容器 table 存放鍵值對的數組,就是保存鏈表或者樹的數組,可以看到 Node 類型也是實現了 Entry 接口的,在 1.8 之前這個節點是不叫 Node 的,就叫的 Entry,因為就是一個鍵值對,現在換成了 Node,是因為除了普通的鍵值對類型,還可能換成紅黑樹的樹節點TreeNode 類型,所以不是 Entry了。

第二個是保存所有鍵值對的一個 set 集合,是一個存放緩存的;
第三個 size 是整個hashmap 里的鍵值對的數目;
第四個是 modCount 是記錄集合被修改的次數,有助於在多個線程操作的時候報根據一致性保證安全;
第五個 threshold 是擴容的閾值,也就是說大於閾值的時候就開始擴容,也就是 threshold = 當前的 capacity * loadfactor
第六個 loadFactor 也是對應前面的加載因子。


三、源碼的核心方法分析


3.1 構造方法

可以看到,這幾個重載的構造方法做的事就是設置一些參數。

事實上,在 jdk1.8 之后,並不會直接初始化 hashmap,只是進行加載因子、容量參數的相關設定,真正開始將 table 數組空間開辟出來,是在 put 的時候才開始的。

第一個:

public HashMap()

是我們平時最常用的,只是設置了默認加載因子,容量沒有設定,那顯然就是 16

第二個:

public HashMap(int initialCapacity)

為了盡量少擴容,這個構造方法是推薦的,也就是指定 initialCapacity,在這個方法里面直接調用的是

第三個構造方法:

public HashMap(int initialCapacity, float loadFactor)

用指定的初始容量和加載因子,確保在最大范圍內,也調整了 threshold 容量是 2 的冪次方數

這里就是一個問題,把 capcity 調整成 2 的冪次方數,計算 threshold 的時候不應該要乘以 loadfactor 嗎,怎么能直接賦給 threshold 呢?

原因是這里沒有用到 threshold ,還是在 put 的時候才進行 table 數組的初始化的,所以這里就沒有操作。

最后一個構造方法是,將本來的一個 hashmap 放到一個新的 map 里。

3.2 put 和 putVal 方法

put 方法是直接調用了計算 hash 值的方法計算哈希值,然后交給 putVal 方法去做的。

hash 方法就是調用本地的 hashCode 方法再做一個位移操作計算出哈希值。

為什么采用這種右移 16 位再異或的方式計算 hash 值呢?

因為 hashCode 值一般是一個很大的值,如果直接用它的話,實際上在運算的時候碰撞的概率會很高,所以要充分利用這個二進制串的性質:int 類型的數值是 4 個字節的,右移 16 位,再異或可以同時保留高 16 位低 16 位的特征,進行了混合得到的新的數值中,高位與低位的信息都被保留了 。

另外,因為,異或運算能更好的保留各部分的特征,如果采用 & 運算計算出來的值會向 1 靠攏,采用 | 運算計算出來的值會向 0 靠攏, ^ 正好。

最后的目的還是一樣,為了減少哈希沖突。

算出 hash 值后,調用的是 putVal 方法:

傳入哈希值;要插入的 key 和 value;然后兩個布爾變量,onlyIfAbsent 代表當前要插入的 value 是否存在了如果是 true,就不修改;evict 代表這個 hashmap 是否處於創建模式,如果是 false,就是創建模式。

下面是源碼及具體注釋:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;


    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;//調用resize方法初始化tab,驗證了我們說的,構造方法不會創建數組,而是插入的時候創建。

    //這個算法前面也已經講過,就是計算索引,如果p的位置是 null,就在這里放入一個newNode;
    //如果p的位置不是 null,說明這個桶里已經有鏈表或者樹了,就不能直接 new ,而是要遍歷鏈表插入,並同時判斷是不是需要轉樹
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            //已經不是鏈表是紅黑樹了,調用putTreeVal
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //是鏈表,用 for 循環遍歷
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;//如果已經有值,覆蓋,這里用到了onlyIfAbsent
            afterNodeAccess(e);
            return oldValue;
        }
    }

    //增加修改hashMap的次數
    ++modCount;
    //如果已經達到了閾值,就要擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

這里面涉及到的步驟主要如下:

  1. 調用 resize 方法初始化 table 數組,jdk1.8 后確實是到 put 的時候才會初始化數組;

  2. hash 值計算出在數組里應該在的索引;

  3. 如果索引位置是 null,就直接放入一個新節點,也就是 Node 對象;

  4. 如果不是 null,則要在這個桶里插入:

    1. 如果遇見了一個節點的 hash 值、key值和傳入的這個新的一樣,賦值給 e 這個節點;
    2. instanceof 判斷是否為 TreeNode 類型,也就是說如果這個桶里已經不是鏈表而是紅黑樹了,就調用 putTreeVal 方法;
    3. 如果不是,那就要遍歷這個鏈表,同理,遍歷的過程如果也找到了一個階段的 hash 值、key 值和傳入的一樣,賦值給 e 這個節點,否則遍歷到最后,把一個 Node 對象插到鏈表末尾,插完后鏈表長度已經大於閾值,就要轉樹。
  5. 結束插入的動作后,前面的 e 一旦被賦值過了,說明是有一樣的 key 出現,那么就說明不用插入新節點,而是替代舊的 val

這里面涉及到的 resize 、putTreeVal 和 treeifyBin 也是比較復雜的方法,下來進行介紹。

3.3 treeifyBin 方法

轉換為樹的方法

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;

    //如果數組的長度還沒有達到 64 ,就不轉樹,只是擴容。
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
        
    //如果 e 不為空,那么遍歷整個鏈表,把每個節點都換成具有prev和next兩個指針的樹節點
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        //結束后要開始把一個普通的樹(此時其實嚴格上說是一個雙鏈表的形態)轉化成紅黑樹
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

treeify 里面調用了各種左旋啊、右旋啊,平衡啊,各種很復雜的紅黑樹操作方法,這里不再深入。

3.4 resize 擴容方法

問題:什么時候會擴容?

從前面成員變量的解釋和插入元素,已經能總結出兩種擴容的情況:

  1. 當鍵值對的元素個數(也就是鍵值對的個數,size)超過了 數組長度*負載因子(0.75)的時候,擴容;
  2. 當其中某一個鏈表的元素個數達到 8 個,並且數組長度沒有達到 64 ,則擴容而不轉紅黑樹。

擴容每次都會把數組的長度擴到 2 倍,並且之后還要把每個元素的下標重新計算,這樣的開銷是很大的。

值得注意的是,重新計算下標值的方法 和第一次的計算方法一樣,這樣很簡便且巧妙:

  • 首先,仍然使用 (n - 1) & hash 這個式子計算索引,但是顯然有重新計算的時候,變化的是 n-1,有些就不會在原位置了;
  • n 的變化入手,因為是 2 倍擴容,而數組長度本身也設置是 2 的冪次,在二進制位上來說,新算出來的 n-1 只是相比舊的 n-1 左移了一位;
比如 16-1 = 15,就是  1 0000 - 1 =  0 1111;
新的 32-1 = 31,就是 10 0000 - 1 = 01 1111;
  • 那么這個值再和 hash 相與運算,節點要么在原來位置,要么在原位置+舊的容量的位置,也就是在最高位加上了一個原來的容量;
  • 這樣計算的時候就不用頻繁的再計算,而是用一個加法就直接定位到要挪動的地方。

上面講過的為什么長度設置 2 的冪次,這里也能作為一個優勢的解釋。

源碼如下:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;

    int newCap, newThr = 0;//新的容量和新的閾值

    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; //這里把新的閾值和新的邊界值都*2
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }


    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;

    //創建新數組
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;


    if (oldTab != null) {
        //for循環就開始把所有舊的節點都放到新數組里
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;//如果這個位置本來就只有一個元素,還用舊方法計算位置
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);//如果是樹節點,拆分
                else { 
                    //是鏈表,保持順序,用do-while循環進行新的位置安排
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {//用hash和oldCap的與結果,拆分鏈表
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }else {//用hash和oldCap的與結果,拆分鏈表
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;//還放在原來索引位置
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;//放在新索引位置,就是加上 oldCap 
                    }
                }
            }
        }
    }
    return newTab;
}

3.5 remove 和 removeNode 刪除方法

remove 直接調用的 removeNode 方法,類似於前面的 put 調用 putVal 。

注意 remove 根據 key 的時候肯定默認那個對應的 value 也是要刪除的,所以 matchValue 置為 false,意思就是不用看 value

removeNode 的整體思路比較常規,就是我們能想到的:

  1. 如果本身 hashmap 不為空,且 hash 值對應的索引位置不為空,才去某一個桶里找並刪除;

    1. 在遍歷查找的過程里,分成對於鏈表節點和樹節點的查找,就是根據 key 來比較的;
    2. 找到之后,根據 matchValue 判斷要不要刪除,刪除的過程就是用之前找到的那個位置,然后指針操作就可。
  2. 否則,直接返回 null

3.6 get 和 getNode 方法

get 也只直接調用了 getNode 方法:

這里面的代碼就和 remove 方法的前半部分幾乎一樣,也就是找到指定的 key 的位置,並返回對應的 value

3.7 HashMap的遍歷

HashMap 本身維護了一個 keySet 的 Set,拿到所有的 key 。(顯然維護 value 是沒辦法的,因為 key 都是唯一的),但這種方法不推薦,因為拿到 key 后再去找 value又是對 map 的遍歷。

Set<String> keys = map.keySet();
for (String key: keys){
    System.out.println(key + map.get(key));//根據key得到value
}

也可以拿到所有的 value 需要用 Collection 來接收:

Collection<Integer> values = map.values();
for (Integer v: values){
    System.out.println(v);
}

也可以獲取到所有的鍵值對Entry 的 Set 集合,然后拿到對應的迭代器進行遍歷:

Set<Map.Entry<String,Integer>> entries = map.entrySet();
Iterator<Map.Entry<String,Integer>> iterator = entries.iterator();

while (iterator.hasNext()){
    Map.Entry<String,Integer> entry = iterator.next();
    System.out.println(entry.getKey()+entry.getValue());//得到key和value
}

jdk 1.8 之后,還增加了一個 forEach 方法,可以接口里的這個方法本身也是通過第二種方法實現的,在HashMap 里重寫了這個方法,變成了對 table 數組的遍歷,使用的時候,用 lambda 表達式傳入泛型就可以。

map.forEach((key,value)->{
    System.out.println(key + value);
});

這種方法其實用到的也屬於設計模式的代理模式


四、總結 jdk 1.7 和 1.8 之后關於 HashMap 的區別


4.1 數據結構的使用

  • 1.7 :單鏈表
  • 1.8 :單鏈表,如果鏈表長度>8且數組長度已經>64,轉為紅黑樹

關於數組本身,1.7 是一個 Entry 類型的數組,1.8是一個 Node 類型。

4.2 什么時候擴容?

1.7 擴容時機

  • 擴容只有一種情況。利用了兩個信息:
  1. 數組長度 * 加載因子。加載因子默認情況是 0.75 ,等鍵值對個數 size 達到了數組長度 * 加載因子
  2. 產生哈希沖突,當前插入的時候數組的這個位置已經不為空了。

擴容后,添加元素。

1.8 的擴容時機

先添加元素,再看是否需要擴容。

  • 擴容的第一種情況。

數組長度 * 加載因子。加載因子默認情況是 0.75 ,等鍵值對個數 size 達到了數組長度 * 加載因子(這點判斷是一樣的)

  • 擴容的第二種情況。

當其中某一個鏈表的元素個數達到 8 個,走到轉樹節點的方法里,但是又發現數組長度沒有達到 64 ,則擴容而不轉紅黑樹。

4.3 擴容的實現

1.7 擴容的實現

  • 數組長度 * 2 操作;
  • 然后用一個 transfer 方法進行數據遷移,transfer 里,對單向鏈表進行一個一個 hash 重新計算並且安排,采用頭插法來安排單向鏈表,把節點都安排好。

但是如果多線程的情況下,有別的線程先完成了擴容操作,這個時候鏈表的重新挪動已經導致節點位置的變化,切換回這個線程的時候,繼續改變鏈表指針就可能會產生環,然后這個線程死循環。

具體就是 7 的擴容方法在遷移的時候采用的是頭插法,那么比如兩個元素 ab一個鏈表,線程1和2都發現要擴容,就會去調用transfer方法:

  1. 1 先讀取了 e 是 a,next 是 b,但是沒來得及繼續操作就掛起了;
  2. 2 開始讀取,並采用頭插法就是遍歷ab,先把a移到新數組的位置,此時a.next = null;繼續遍歷到 b,b移到新位置,b.next = a;(形成了 b->a)
  3. 這時候切換到了線程 1 執行,本來已經再循環里面記錄了 e 和 e.next 了,然而這時本來數組都變新的了,所以修改的時候計算位置啥的還是這個新數組里,不會變,因為計算的肯定是一樣的, a.next = b,而前面就修改過了b.next = a,這樣已經是環了,那么線程 1 繼續while,一直next,死循環。

1.8 擴容的實現

因為是先插入,再擴容,所以插入的時候對於鏈表就是一個尾插法。

然后如果達到了擴容的條件,也就先進行數組長度 * 2 操作,直接在 resize 方法里完成數據遷移,這里因為數據結構已經有鏈表+紅黑樹兩種情況:

  1. 如果是鏈表,把單鏈表進行數據遷移,充分利用與運算,將單鏈表針對不同情況拆斷,放到新數組的不同位置;
  2. 如果是紅黑樹,樹節點里維護了相當於雙向鏈表的指針,重新處理,如果處理之后發現樹的節點(雙向鏈表)小於等於 6 ,還會再操作把樹又轉換為單鏈表。

但是如果在多線程的情況下,不會形成環鏈表,但是可能會丟失數據,因為會覆蓋到一樣的新位置。

4.4 為什么HashMap線程不安全

  1. put、get 等等核心方法在多線程情況下,都會出現修改的覆蓋,數據不一致等等問題。比如多個線程 put 先后的問題,會導致結果覆蓋,如果一個 put 一個get,也可能會因為調度問題獲取到錯誤的結果。
  2. 正如上面具體分析過的死循環問題,在多線程擴容的時候,1.7的 hashmap 因為采用頭插法進行擴容之后的重新節點分配,可能會出現死循環;
  3. 因為 Hashmap 的迭代器是 fast-fail iterator,所以多線程一邊寫操作一邊遍歷,會出現 ConcurrentModificationException 並發讀寫異常。


免責聲明!

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



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