這個博主居然這樣解讀驗證HashMap源碼

簡介

HashMap在1.8之後通過數組(table)屬性使用單向鏈表 + 紅黑樹的結構組合提高查找效率,於是我大致的畫了下圖:
faker-hashmap-tree.png
後來寫着寫着發現我還是太年輕了,有什麼比親手實踐更值得讓人信服呢?

類圖分析(只標註主要屬性方法)

HashMap類圖

  • Map<K,V>:鍵值映射的基礎接口,提供常用的鍵值映射操作方法的抽象
  • Map.Entry<K,V>:鍵值對條目(單個鍵值)抽象接口
  • AbstractMap<K,V>: 簡單實現了Map接口的部分方法
  • HashMap<K,V>: 基於Map接口的哈希實現
  • HashMap.Node<K,V>:HashMap鍵值對條目鏈表結構的實現類
  • HashMap.TreeNode<K,V>:HashMap鍵值對條目紅黑樹結構的實現類
  • LinkedHashMap<K,V>:基於Map接口的哈希表和鏈表結構實現,與HashMap的主要區別在於鍵值對都是有序的
  • LinkedHashMap.Entry<K,V>:LinkedHashMap鍵值條目鏈表結構的實現類

屬性解析

  • DEFAULT_INITIAL_CAPACITY:默認16,默認初始容量,必須爲2的冪值
  • MAXIMUM_CAPACITY:默認1<<30(即2^29),最大容量值,如果有參構造函數容量值比該值高,則使用該值作爲容量值
  • DEFAULT_LOAD_FACTOR:默認0.75f,構造函數中未指定時使用的負載因子
  • TREEIFY_THRESHOLD:默認8,容器樹化閾值,達到閾值(8)後容器將使用紅黑樹結果存儲數據
  • UNTREEIFY_THRESHOLD:默認6,非樹化閾值,應小於TREEIFY_THRESHOLD
  • MIN_TREEIFY_CAPACITY:默認64,表中節點鏈表結構被樹化的最小表容量值,實際會根據容器大小判斷是隻進行擴容還是進行樹化。
  • loadFactor:哈希表的負載因子
  • threshold:下一次調整大小的容量閾值(capacity * load factor)
  • modCount:對該HashMap進行結構修改的次數。結構修改是指更改HashMap中的映射數目或以其他方式修改其內部結構的修改(如重新哈希)。
  • size:包含的鍵-值映射數
  • table:該表(節點Node數組)在首次使用時初始化,並根據需要調整大小,分配後的長度始終是2的冪。
  • entrySet:用於獲取key-value映射集合Set<Map.Entry<K,V>>,在首次調用entrySet()方法時被初始化

文章使用的術語摻雜了一些個人根據源碼與文檔的理解,需瞭解注意的如下:

  • 表(table數組) = 容器
  • table.length = 容量 != 映射數目
  • table中的節點Node元素 = 桶bin
  • table中的鍵值對節點Node以外的元素都以null填充

方法解析,窺遍HashMap

HashMap的三個構造函數

  1. 無參構造函數
/**
 * 使用默認的初始容量(16)和默認的加載因子(0.75)構造一個空的HashMap。
 * 注:該方法沒有初始化閾值threshold
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
  1. 帶初始容量的構造函數
public HashMap(int initialCapacity) {
    // 初始化容量initialCapacity、負載因子loadFactor=DEFAULT_LOAD_FACTOR(0.75)、閾值threshold
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
  1. 帶初始容量與f負載因子的構造函數
/**
 * 以特定的容量與負載係數構建一個空的HashMap
 *
 * @param  initialCapacity 初始容量
 * @param  loadFactor      負載因子
 * @throws IllegalArgumentException initialCapacity爲負數、負載因子爲負數或非數字時拋錯
 */
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;   // MAXIMUM_CAPACITY = 1 << 30
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

/**
 * 該方法主要用於設置閾值threshold,返回大於或等於參數容量cap的的2次冪,如cap=9、11、12、15、16時都會返回16,cap=5、6、7、8時返回8
 * @param cap 容量參數
 * @return 返回大於或等於參數容量cap的的2次冪
 */
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;
}

put(K, V)添加鍵值

HashMap的三個構造函數可以看出構造HashMap時主要初始化了負載因子loadFactor、table擴容閾值threshold,若是無參構造函數則只初始化閾值,所以一般table的初始化都是在put第一個鍵值對時初始化的。

/**
 *  獲取鍵的哈希值
 * 
 *  @param key
 *  @return key的哈希值
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

/**
 *  創建鏈表節點並設置該節點指向的下一節點
 * 
 *  @param hash  鍵哈希值
 *  @param key   鍵
 *  @param value 值
 *  @param next  新建節點指向的下一節點
 *  @return      鏈表節點
 */
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}

/**
 * 實現Map.put和相關的方法
 *
 * @param hash key的哈希值
 * @param key
 * @param value 
 * @param onlyIfAbsent true則不替換已存在的key值
 * @param evict 標誌表是否處於創建模式
 * @return 返回key之前的值,之前值不存在則返回true
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // 引用屬性table的臨時變量
    Node<K,V>[] tab; 
    // table上的節點引用(p = pointer = 鏈表指針)
    Node<K,V> p; 
    // n用於記錄Node<K,V>[] table長度的臨時變量,i爲key哈希與table.length相與後的table索引index
    int n, i;
    // 1. 若table爲空則初始化鍵值對數組table
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2 
    // 2.1 判斷key哈希值與長度相與運算獲取相應的數組索引值i,判斷table數組i節點是否空,空則直接在該索引放置新節點,跳到`3`;不爲空則該位置上的節點將會形成桶(鏈表|紅黑樹結構)鏈接多個節點
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 2.2 table[i]上的元素節點p不爲空        
    else {
        // e可看作遍歷table的結果節點(e = end),k用於引用p節點的key值
        Node<K,V> e; K k;
        // 2.2.1 判斷節點p與參數的key是否相同,相同即只需執行替換value,e不爲null,跳到`2.2`
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 2.2.2 參數key與table[i]上的key不匹配,若table[i]的節點爲TreeNode,則以TreeNode結構存放新節點,e爲null,跳到`2`
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 2.2.3 參數key與table[i]上的key不匹配,且該節點並非TreeNode,則以鏈式節點存儲
        else {
            // binCount:桶中的元素數目,即Node<K,V>[] table中的Node節點元素內的key-value鍵值對數目
            for (int binCount = 0; ; ++binCount) {
                // p下一節點e爲空節點,則新建節點賦到當前節點的next
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 根據桶中節點數目與容量大小判斷是進行擴容還是進行樹化,若桶中已有的節點數>=8且容量>=64則進行樹化;e爲null,跳到`3`
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // p下一節點e不爲空
                // p下一節點e的key與參數key相同,e不爲null,跳到`2.2`
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // p下一節點e的hash或key屬性與新增的key不同,則把下一節點賦給p繼續進行鏈表遍歷,直到遇到空的節點或hash、key相同的節點
                p = e;
            }
        }
        // 2.2.4 e不爲空,即Node[] table中已含鍵爲參數key的節點,則替換key的舊值
        if (e != null) { // existing mapping for key
           // 獲取參數key對應節點的舊映射值
            V oldValue = e.value;
            // 設置key新的映射值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); // HashMap中爲空實現,主要應用在LinkedHashMap中
            return oldValue;
        }
    }
    // 3. 節點新增成功後的容器相關屬性更改
    // e爲空,則有新的鍵值對節點添加,結構發生改變,結構修改次數自增
    ++modCount;
    // 鍵值對數目自增,並判斷自增後若大於擴容閾值,則調整容量大小
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict); // HashMap中爲空實現,主要應用在LinkedHashMap中
    // 不存在舊節點,返回空
    return null;
}

/**
 * 若表太小,則resize調整表大小;否則將替換bin(桶)中給定哈希值的索引中所有鏈接的節點Node爲TreeNode。
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // tab爲空或tab數組長度 < MIN_TREEIFY_CAPACITY(64),即元素數量不多,只重新調整tab長度(容量),節點維持鏈表結構而無需更改爲紅黑樹結構
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    // 判斷參數hash值在tab數組中相應位置的節點是否不爲空
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        // 將e與e鏈表上鍊接的所有Node節點轉換爲TreeNode節點
        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)
            // 將table轉爲紅黑樹結構
            hd.treeify(tab);
    }
}

void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }

綜上,HashMap添加鍵值對的流程如下:

  1. 判斷table是否爲空,空則調用resize()初始化table數組,一般發生在初次添加鍵值對
  2. 參數key哈希值hash與數組table長度相與獲取到索引值i,根據table[i]節點是否爲空執行不同的操作
  • 2.1 table[i]節點p爲空:在table[i]創建新節點,跳到步驟3
  • 2.2 table[i]節點p不爲空,根據以下不同的情況執行相應的操作
    • 2.2.1 節點p的key、hash與參數的key、hash匹配:即最終操作只需替換原有key的值,將最終節點e設爲p,跳到2.2.4
    • 2.2.2 節點p是一個紅黑樹節點(TreeNode)類型:根據參數創建新的紅黑樹節點TreeNode,根據紅黑樹規則把新節點插入到p節點所在的紅黑樹上
    • 2.2.3 其它情況(即鏈表結構,頭節點key與參數key不同):創建新節點並鏈接到鏈表末尾,若鏈表節點數大於8個,則調用treeifyBin()根據table.length長度進行不同的操作
      • table.length >= 64,將所有的Node鏈表節點轉爲TreeNode紅黑樹節點
      • table.length < 64,調用resize()將table.length左移一位(原lengh乘2),即使鏈表節點數>8依舊保持鏈表結構
    • 2.2.4 設置e節點key新值,返回key舊值
  1. 節點新增成功後的HashMap相關操作屬性更新

實力(例)驗證

爲了簡單直接的顯示HashMap結構,這個博主直接的把HashMap實例裏的核心屬性都print出來了。

  1. table容量<64時,key hashCode相同的節點數>8依舊會保持鏈表結構
public static void stringKey() throws NoSuchFieldException, IllegalAccessException {
    HashMap<String, Integer> map = new HashMap<>(32);
    // 9個hashKey相同的字符串集合,map容量<64時會在添加第9個"20kf"進行擴容而不會樹化,即32則左移一位爲64,集合刪除"20kf"則不會擴容仍爲32
    Set<String> sameHashKeys = Sets.newHashSet("30lG", "31MG", "31Lf", "30kf", "1nlG", "2PLf", "1oLf", "2OlG", "2Okf");
    Field thresholdField = HashMap.class.getDeclaredField("threshold");
    Field tableField = HashMap.class.getDeclaredField("table");
    thresholdField.setAccessible(true);
    tableField.setAccessible(true);
    Random random = new Random();
    IntStream.range(0, 10).forEach(i -> map.put(String.valueOf(i), i));
    sameHashKeys.forEach(key -> map.put(key, random.nextInt(25)));
    Set<Map.Entry<String, Integer>> sets = map.entrySet();
    Map.Entry<String, Integer>[] table = (Map.Entry<String, Integer>[]) tableField.get(map);
    long notNullCount = Arrays.stream(table)
            .filter(Objects::nonNull)
            .count();
    Set<Class> nodeClasses = Arrays.stream(table)
            .filter(Objects::nonNull)
            .map(Object::getClass)
            .collect(Collectors.toSet());
    // 輸出格式化
    String tableString = JSONObject.toJSONString(table)
            .replaceAll(",null", "")
            .replaceAll("null,", "")
            .replaceAll("\\{|}|\"", "")
            .replaceAll(":", "=")
            .replaceAll(",", ", ");
    System.out.println("map.size():" + map.size() + ", table.length: " + table.length
            + ", table node count: " + notNullCount + ", entrySet size: " + sets.size());
    System.err.println("node classes: " + nodeClasses);
    map.remove("30lG");
    System.out.println("table:  " + JSONObject.toJSONString(table));
    System.out.println("evict null table: " + tableString);
    System.out.println("entrySet: " + sets);
}

控制檯輸出:

map.size():19, table.length: 64, table node count: 11, entrySet size: 19
node classes: [class java.util.HashMap$Node]
table:  [null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,{"31MG":22},{"0":0},{"1":1},{"2":2},{"3":3},{"4":4},{"5":5},{"6":6},{"7":7},{"8":8},{"9":9},null,null,null,null,null,null]
evict null table: [30lG=13, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9]
entrySet: [31MG=22, 31Lf=1, 30kf=14, 1nlG=7, 2PLf=13, 1oLf=13, 2OlG=21, 2Okf=16, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9]
  1. table容量>=64時,key hashCode相同的節點數>8後HashMap會樹化
HashMap<String, Integer> map = new HashMap<>(33); // 有參容量自動調整爲64

控制檯輸出:

map.size():19, table.length: 64, table node count: 11, entrySet size: 19
node classes: [class java.util.HashMap$TreeNode, class java.util.HashMap$Node]
table:  [null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,{"30kf":19},{"0":0},{"1":1},{"2":2},{"3":3},{"4":4},{"5":5},{"6":6},{"7":7},{"8":8},{"9":9},null,null,null,null,null,null]
evict null table: [30kf=19, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9]
entrySet: [30kf=19, 31MG=8, 31Lf=16, 1nlG=3, 2PLf=19, 1oLf=20, 2OlG=12, 2Okf=15, 0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9]

可以看到"30kf"是該紅黑樹上的root。

那麼問題來了,添加映射會調整結構(即更改節點Node類型),那麼刪除呢?這個博主有點懶,所以不講源碼給答案:
HashMap刪除節點的過程十分簡單,獲取節點類型->根據節點類型進行相應的刪除操作,若節點是樹節點結構,則判斷該樹的節點數是否較少(源碼文檔標註一般爲2~6),較少則把樹節點TreeNode轉換爲普通節點Node,如上例中刪除最後4個hashCode相同的節點後(即只剩下5個Node)再打印會發現沒有TreeNode了。
調用鏈:hashmap.remove() -> hashmap.removeNode() -> treeNode.removeTreeNode() -> treeNode.untreeify(map)

reszie()調整table容量

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    // table未初始化則設舊容量爲0,構造函數並沒有初始化table,即首次put添加元素時oldCap都爲0
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // oldCap > 0意味着並未首次添加元素時調用方法,而是鍵值對數量>閾值(即size>threshold)時需要擴容調用該方法,可能發生在上文put(K,V)步驟3
    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; // double threshold
    }
    // oldCap=0 & oldThr>0,該條件可能發生在調用了有參構造函數初始化threshold、loadFactor後通過put首次添加鍵值對,並把此時的threshold賦給newCap(有參構造函數處提及有參初始化後threshold是2的冪值)
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // oldCap=0 & oldThr=0,該條件發生在調用了`HashMap`的無參構造函數情況下,容量與閾值皆設爲默認值
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;  // default cap 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // default threshold 12
    }
    if (newThr == 0) {
        // float threshold,計算後的浮點型閾值
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 設置調整後的閾值
    threshold = newThr;
    // 創建新的數組進行鍵值對遷移,所以建議預估好鍵值對數量調用有參構造函數初始化`HashMap`
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // Node單節點遷移到新的數組
                if (e.next == null)
                    // 重新哈希
                    newTab[e.hash & (newCap - 1)] = e;
                // TreeNode拆分爲較高樹與較低樹,若拆分後的樹桶節點數< UNTREEIFY_THRESHOLD = 6,則取消樹化
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 類似樹節點,鏈表拆分爲較高鏈與較低鏈
                    // loHead = Low Head, Lo Tail = Low Tail
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead = High Head, hiTail = High Tail
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            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;
                    }
                }
            }
        }
    }
    return newTab;
}

綜上,HashMap.put(K,V)調用resize()調整大小主要分以下幾種情況:

  • 擴容:oldCap = table.length > 0,resize()結果:
    • table.length = oldCap * 2;
    • threshold = table.length * loadFactor
  • 有參函數初始化:oldCap = table.length = 0 & oldThr = threshold > 0,resize()結果:
    • table.length = threshold;,此處threshold值爲跟有參構造函數運算後的threshold值,運算後的threshold值是比構造函數參數threshold大的最小一個2的冪值
    • threshold = table.length * loadFactor
  • 無參函數初始化:oldCap = table.length = 0 & oldThr = threshold = 0
    • table.length = 16;
    • threshold = 12

容量調整後會再根據table中的節點結構進行相應的操作:

  • 單節點Node無鏈接:重新哈希
  • 樹節點TreeNode:將樹箱中的節點拆分爲較高和較低的樹箱,如果拆分後的樹箱容量<6,則取消樹化;拆分後較低的樹箱放在新table[舊table原索引],較高的樹箱遷移到新table[原索引+原table.length],詳細可看TreeNode.split()源碼
  • 鏈表結構Node:拆分規則遷移與TreeNode類似,低位鏈表head node放在新table[原索引],高位鏈表遷移到新table[原索引+原table.length]

table.length=newCapthreshold調整後會創建新的節點數組進行鍵值對遷移,所以一般建議初始化時配置好容量避免擴容時的遷移損失。

clear()清空映射

clear()只是簡單的把table數組的所有元素置爲null

public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

疑問record & 總結

  1. entrySet()方法返回的明明是一個new EntrySet(),爲什麼卻打印出了完整的HashMap映射?
    EntrySet繼承了AbstractSet類並實現了iterator()方法,AbstractSettoString()方法會調用iterator()方法獲取迭代器,再通過Iterator.next()進行元素的字符串拼接。EntrySet。iterator()返回一個實現了Iterator接口的類EntryIterator,通過EntryIterator.next()可以遍歷獲取HasMap中的所有Node,所以即使HashMap的entrySet屬性沒有初始化但通過entrySet()方法依舊可以獲取HashMap的完整映射。

  2. hashCode相同的key映射數超過8個並不一定就會轉爲紅黑樹結構
    HashMap當同hashCode的節點Node超過8個且table數組容量>=64纔會轉爲紅黑樹結構,否則容量<64時只會進行擴容保存鏈表結構,Result.png:
    在這裏插入圖片描述

  3. resize()進行容量調整並不一定會使每個節點進行重新哈希(rehash),重新哈希只會出現在無鏈接的單節點上

如果看完這篇文章有人問你HashMap的相關結構知識但還是忘了,建議你這樣回答:
%E5%90%83%E8%BF%87%E5%A4%9A%E5%B0%91%E9%9D%A2%E5%8C%85%E7%89%87.jpg
(我一個星期前寫的代碼都不一定記得,你問我這個?)我想起早上的麪包還沒吃,先告辭!

如有筆誤,還請指出

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章