HashMap底層原理及jdk1.8原始碼解讀
一、前言
寫在前面:小編碼字收集資料花了一天的時間整理出來,對你有幫助一鍵三連走一波哈,謝謝啦!!
HashMap在我們日常開發中可謂經常遇到,HashMap 原始碼和底層原理在現在面試中是必問的。所以我們要掌握一下,也是作為兩年開發經驗必備的知識點!HashMap基於Map介面實現,元素以 <Key,Value>
的方式儲存,並且允許使用null 鍵和null值,因為key不允許重複,因此只能有一個鍵為null,另外HashMap是 無序的、執行緒不安全
的。
HashMap繼承類圖 快捷鍵Ctrl+alt+U
二、儲存結構介紹
Jdk7.0之前 陣列 + 連結串列
Jdk8.0開始 陣列 + 連結串列 + 二叉樹
連結串列內元素個數>8個 由連結串列轉成二叉樹
連結串列內元素個數<6個 由二叉樹轉成連結串列
紅黑樹,是一個自平衡的二叉搜尋樹,因此可以使查詢的時間複雜度降為 O(logn)
連結串列的長度特別長的時候,查詢效率將直線下降,查詢的時間複雜度為 O(n)
Jdk1.7中連結串列新元素新增到連結串列的頭結點,先加到連結串列的頭節點,再移到陣列下標位置。簡稱 頭插法(併發時可能出現死迴圈)
Jdk1.8中連結串列新元素新增到連結串列的尾結點。簡稱 尾插法(解決了頭插法死迴圈)
底層結構例項圖
三、原始碼分析之常用變數解讀
/** * 預設初始容量 - 必須是 2 的冪。 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * 最大容量,如果一個更高的值被任何一個帶引數的建構函式隱式指定使用。必須是 2 <= 1<<30 的冪。 * 簡單理解為:最大容量2的30次冪,如果帶容量也會轉化為2的次冪 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 建構函式中未指定時使用的負載因子。經過官方測試測出減少hash碰撞的最佳值 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 使用紅黑樹的計數閾值,超過8則轉化為紅黑樹 */ static final int TREEIFY_THRESHOLD = 8; /** * 使用紅黑樹的計數閾值,低於6則轉化為連結串列 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 連結串列轉化為紅黑樹,除了有閾值的限制,還有另外一個限制,需要陣列容量至少達到64,才會轉化為紅黑樹。 * 這是為了避免,陣列擴容和樹化閾值之間的衝突。 */ static final int MIN_TREEIFY_CAPACITY = 64; /** * 在首次使用時初始化,並根據需要調整大小。分配時,長度始終是 2 的冪。 * (我們還在某些操作中允許長度為零,以允許當前不需要的引導機制) */ transient Node<K,V>[] table; /** * 儲存快取的 entrySet(),也就是存放的鍵值對 */ transient Set<Map.Entry<K,V>> entrySet; /** * 此對映中包含的鍵值對映的數量,就是陣列的大小 */ transient int size; /** * 記錄集合的結構變化和操作次數(例如remove一次進行modCount++)。 * 該欄位用於使 HashMap 的 Collection-views 上的迭代器快速失敗。如果失敗也就是我們常見的CME * (ConcurrentModificationException)異常 */ transient int modCount; /** * 陣列擴容的大小 * 計算方法 capacity * load factor 容量 * 載入因子 * @serial */ int threshold; /** * 雜湊表的負載因子。 * @serial */ final float loadFactor;
四、原始碼分析之構造方法解讀
/** * 構造一個具有指定初始容量和負載因子的構造方法 * 不建議使用這個構造方法,載入因子經過官方測試是效率最好的,所以為了效率應該保持預設 */ public HashMap(int initialCapacity, float loadFactor) { // 引數為負數會丟擲IllegalArgumentException異常 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; // 初始化的引數預設如果不是2的次冪,直接給你轉化為2的次冪 // 傳的引數為11,會自動轉化為比引數大的最近的2的次冪的值,也就是16。 // 我們後面會詳細說這個方法怎麼進行轉化的 this.threshold = tableSizeFor(initialCapacity); } /** * 構造一個只有指定初始容量的方法 */ public HashMap(int initialCapacity) { // 我們會發現還是呼叫上面兩個引數的構造方法,自動幫我們設定了載入因子 this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** * 構造一個具有預設初始容量(16) 和預設負載因子(0.75)的構造方法 */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } /** * 指定map集合,轉化為HashMap的構造方法 */ public HashMap(Map<? extends K, ? extends V> m) { // 使用預設載入因子 this.loadFactor = DEFAULT_LOAD_FACTOR; //呼叫PutMapEntries()來完成HashMap的初始化賦值過程 putMapEntries(m, false); } /** * 將傳入的子Map中的全部元素逐個新增到HashMap中 */ final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { // 獲得引數Map的大小,並賦值給s int s = m.size(); // 判斷大小是否大於0 if (s > 0) { // 證明有元素來插入HashMap // 判斷table是否已經初始化 如果table=null一般就是建構函式來呼叫的putMapEntries,或者構造後還沒放過任何元素 if (table == null) { // pre-size // 如果未初始化,則計算HashMap的最小需要的容量(即容量剛好不大於擴容閾值)。這裡Map的大小s就被當作HashMap的擴容閾值,然後用傳入Map的大小除以負載因子就能得到對應的HashMap的容量大小(當前m的大小 / 負載因子 = HashMap容量) // ((float)s / loadFactor)但這樣會算出小數來,但作為容量就必須向上取整,所以這裡要加1。此時ft可以臨時看作HashMap容量大小 float ft = ((float)s / loadFactor) + 1.0F; // 判斷ft是否超過最大容量大小,小於則用ft,大於則取最大容量 int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 暫時存放到擴容閾值上,tableSizeFor會把t重新計算到2的次冪給擴容陣列大小 if (t > threshold) threshold = tableSizeFor(t); } // 如果當前Map已經初始化,且這個map中的元素個數大於擴容的閥值就得擴容 // 這種情況屬於預先擴大容量,再put元素 else if (s > threshold) // 後面展開說 resize(); // 遍歷map,將map中的key和value都新增到HashMap中 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); // 呼叫HashMap的put方法的具體實現方法putVal來對資料進行存放。該方法的具體細節在後面會進行講解 // putVal可能也會觸發resize putVal(hash(key), key, value, false, evict); } } }
五、原始碼分析之常用方法解讀
1、tableSizeFor方法解讀
/** * 返回給定目標容量的 2 次方。 */ static final int tableSizeFor(int cap) { // cap = 10 int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; // 小於0就是1,如果大於0在判斷是不是超過最大容量就是n=15+1 = 16,超過就按最大容量 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
以 cap為10
為例:(右移規則為:無符號位右移)
10的二進位制為: 0000 0000 0000 0000 0000 0000 0000 1010
執行 int n = cap - 1;
二進位制結果為: 0000 0000 0000 0000 0000 0000 0000 1001
執行 n |= n >>> 1;(先進行右移,結果和原來的數進行或運算[==有1則1==])
0000 0000 0000 0000 0000 0000 0000 1001
0000 0000 0000 0000 0000 0000 0000 0100 n>>1結果
————————————————————
0000 0000 0000 0000 0000 0000 0000 1101 n |= n >> 1 結果
二進位制結果為: 0000 0000 0000 0000 0000 0000 0000 1101
執行 n |= n >>> 2;
0000 0000 0000 0000 0000 0000 0000 1101
0000 0000 0000 0000 0000 0000 0000 0011 n>>2結果
————————————————————
0000 0000 0000 0000 0000 0000 0000 1111 n |= n >> 2 結果
二進位制結果為: 0000 0000 0000 0000 0000 0000 0000 1111
執行 n |= n >>> 4;
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0000 0000 n>>4結果
————————————————————
0000 0000 0000 0000 0000 0000 0000 1111 n |= n >> 4 結果
二進位制結果為: 0000 0000 0000 0000 0000 0000 0000 1111
執行 n |= n >>> 8;
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0000 0000 n>>8結果
————————————————————
0000 0000 0000 0000 0000 0000 0000 1111 n |= n >> 8 結果
二進位制結果為: 0000 0000 0000 0000 0000 0000 0000 1111
執行 n |= n >>> 16;
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0000 0000 n>>16結果
————————————————————
0000 0000 0000 0000 0000 0000 0000 1111 n |= n >> 16 結果
二進位制結果為: 0000 0000 0000 0000 0000 0000 0000 1111
我們會發現,當數小的的時候進行到4時就不會變了我們得到的值為15,即輸入10,經過此方法後得到 15
。遇到大的數才會明顯,大家可以找個大的數字進行試試就是先 右移
在進行 或運算
。最後進行三門運算進行 +1
操作,最後結果為 16=2^4
2、hash方法解讀
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
先判斷key是否為空,若為空則返回0。上面說了HashMap僅支援一個key為null的。若非空,則先計算key的hashCode值,賦值給h,然後把h右移16位,並與原來的h進行異或處理( 相同為1,不同為0
)。
註釋進行谷歌翻譯:
計算 key.hashCode() 並傳播(XOR)更高位的雜湊降低。 由於該表使用二次冪掩碼,因此僅在當前掩碼之上位變化的雜湊集將始終發生衝突。 (已知的例子是在小表中儲存連續整數的 Float 鍵集。)因此,我們應用了一種變換,將 高位的影響向下傳播
。 在位擴充套件的速度、實用性和質量之間存在折衷。 因為許多常見的雜湊集已經合理分佈(所以不要從傳播中受益),並且因為我們使用樹來處理 bin 中的大量衝突,我們只是以最簡單的方式對一些 移位的位進行異或
,以 減少系統損失
, 以及 合併最高位的影響
,否則由於表邊界,這些最高位將永遠不會用於索引計算。
總結: 使用最簡易的方式,及減少系統損失又減少了hash碰撞
。
3、put方法解讀
/** * 將指定的值與此對映中的指定鍵相關聯。即當前key應該存放在陣列的哪個下標位置 * 如果對映先前包含鍵的對映,則替換舊的值。 */ public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * 這才是真正的儲存方法 * @param hash 經過hash運算後的key * @param key 你要新增的key * @param value 你要新增的value * @param onlyIfAbsent 如果為真,則不要更改現有值,本次為FALSE,可以替換更改 * @param evict 如果為 false,則表處於建立模式。我們為true */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 判斷table是否為空 if ((tab = table) == null || (n = tab.length) == 0) // 如果空的話,會先呼叫resize擴容,resize我們後面展開講解 n = (tab = resize()).length; // 把通過hash得到的值與陣列大小-1進行與運算,這個運算就可以實現取模運算,而且位運算還有個好處,就是速度比較快。 // 得到key所對應的陣列的節點,然後把該陣列節點賦值給p,然後判斷這個位置是不是有元素 if ((p = tab[i = (n - 1) & hash]) == null) // key、value包裝成newNode節點,直接新增到此位置。 tab[i] = newNode(hash, key, value, null); // 如果當前陣列下標位置已經有元素了,又分為三種情況。 else { Node<K,V> e; K k; // 當前位置元素的hash值等於傳過來的hash,並且他們的key值也相等 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 則把p賦值給e,後續需要新值把舊值替換 e = p; // 來到這裡說明該節點的key與原來的key不同,則看該節點是紅黑樹,還是連結串列 else if (p instanceof TreeNode) // 如果是紅黑樹,則通過紅黑樹的方式,把key-value存到紅黑樹中。後面再講解這個方法 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 來到這裡說明結構為連結串列,把key-value使用尾插法插到連結串列尾。 // jdk1.7 連結串列是頭插入法,在併發擴容時會造成死迴圈 // jdk1.8 就把頭插入法換成了尾插入法,雖然效率上有點稍微降低一些,但是不會出現死迴圈 for (int binCount = 0; ; ++binCount) { // 遍歷該連結串列,知道知道下一個節點為null。 if ((e = p.next) == null) { // 說明到連結串列尾部,然後把尾部的next指向新生成的物件 p.next = newNode(hash, key, value, null); // 如果連結串列的長度大於等於8 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 則連結串列轉化成為紅黑樹 後面再補充 treeifyBin(tab, hash); break; } // 如果在連結串列中找到了相同key的話,直接退出迴圈 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 說明發生了碰撞,e代表的是舊值,需要替換為新值 if (e != null) { // existing mapping for key V oldValue = e.value; // 判斷是不是允許覆蓋舊值,和舊值是否為空 if (!onlyIfAbsent || oldValue == null) // 把舊值替換 e.value = value; // hashmap沒有任何實現,我們先不考慮 afterNodeAccess(e); // 返回新值 return oldValue; } } // fail-fast機制每次對結構改變進行+1 ++modCount; // 判斷HashMap中的存的資料大小,如果大於陣列長度*0.75,就要進行擴容 if (++size > threshold) resize(); // 也是一個空的實現 afterNodeInsertion(evict); return null; }
4、resize方法解讀
看不清檢視原連結: 高清圖連結
/** * 初始化或者是將table大小加倍,如果為空,則按threshold分配空間, * 否則加倍後,每個容器中的元素在新table中要麼呆在原索引處,要麼有一個2的次冪的位移 */ final Node<K,V>[] resize() { // 原來的資料 Node<K,V>[] oldTab = table; // 獲取原來的陣列大小 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 舊陣列的擴容閾值,注意看,這裡取的是當前物件的 threshold 值,下邊的第2種情況會用到。 int oldThr = threshold; // 初始化新陣列的容量和閾值 int newCap, newThr = 0; // 1.當舊陣列的容量大於0時,說明在這之前肯定呼叫過 resize擴容過一次,才會導致舊容量不為0。 if (oldCap > 0) { // 容量達到最大 if (oldCap >= MAXIMUM_CAPACITY) { // 擴容的大小設定為上限 threshold = Integer.MAX_VALUE; // 直接返回預設原來的,無法在擴了 return oldTab; } // 如果舊容量是在16到上限之間 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 新陣列的容量和閾值都擴大原來的2倍 newThr = oldThr << 1; // double threshold } // 2. 到這裡 oldCap <= 0, oldThr(threshold) > 0,這就是 map 初始化的時候, // 第一次呼叫 resize的情況 else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; // 3. 到這裡,說明 oldCap 和 oldThr 都是小於等於0的。也說明我們的map是通過預設無參構造來建立的 else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY;// 16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);// 12 } // 只有經過2.才會進入 if (newThr == 0) { float ft = (float)newCap * loadFactor; // 把計算後的ft符合大小,則賦值newThr newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 最後得到要擴容的大小 threshold = newThr; // 用於抑制編譯器產生警告資訊 @SuppressWarnings({"rawtypes","unchecked"}) // 在建構函式時,並沒有建立陣列,在第一次呼叫put方法,導致resize的時候,才會把陣列創建出來。 // 這是為了延遲載入,提高效率。 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; // 如果第一個元素的下一個元素為null,說明連結串列只有一個 if (e.next == null) // 則直接用它的hash值和新陣列的容量取模就行(這樣運算效率高),得到新的下標位置 newTab[e.hash & (newCap - 1)] = e; // 如果是紅黑樹 else if (e instanceof TreeNode) // 則拆分紅黑樹,這個小編就不帶著往下深究了,感興趣可以自己點進去看看 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 說明是連結串列且長度大於1 else { // preserve order // 連結串列舊位置的頭尾節點 Node<K,V> loHead = null, loTail = null; // 連結串列新位置的頭尾節點 Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 這裡小編不太明白了,只能參考大佬的講解,還是有點懵逼,等有時間懂了再來重新梳理 do { next = e.next; // 如果當前元素的hash值和oldCap做與運算為0,則原位置不變 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 不為0則把資料移動到新位置 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; }
5、get和containsKey方法解讀
/** * 根據key獲取對應的value */ public V get(Object key) { Node<K,V> e; // 呼叫後存在就獲取元素的value返回 return (e = getNode(hash(key), key)) == null ? null : e.value; } /** * 判斷key是否在map中存在 */ public boolean containsKey(Object key) { // 呼叫方法存在及不為null return getNode(hash(key), key) != null; } /** * 我們發現底層都是getNode來進行幹活的 */ final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 判斷陣列不能為空,然後取到當前hash值計算出來的下標位置的第一個元素 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 如果hash值和key都相等並不為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) // 則以紅黑樹的查詢方式進行獲取到我們想要的key對應的值 return ((TreeNode<K,V>)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); } } // 找不到key對應的值,返回null return null; }
6、remove方法解讀
/** * 如果key存在,就把元素刪除 */ public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } /** * 刪除方法的具體實現 * @param hash 經過hash運算後的key * @param key 你要移除的key * @param value 你要移除的value * @param matchValue 如果為真,則僅在值相等時刪除,我們為FALSE,key相同即刪除 * @param movable 如果為假,則在刪除時不要移動其他節點,我們為TRUE,刪除移動其他節點 */ final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; // 判斷table不為空,連結串列不為空 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; // 陣列中的第一個節點就是我們要刪除的節點 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 要刪除的節點給node node = p; // 第一個不是,並且後面還有節點 else if ((e = p.next) != null) { // 如果是紅黑樹 if (p instanceof TreeNode) // 在紅黑樹中找到返回 node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { // 遍歷連結串列 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { // 找到要刪除的node node = e; break; } p = e; } while ((e = e.next) != null); } } // 這裡說明我們要刪除的節點已找到 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 如果為紅黑樹,就按紅黑樹進行刪除 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); //我們要刪除的是頭節點 else if (node == p) tab[index] = node.next;// 由於刪除的是首節點,那麼直接將節點陣列對應位置指向到第二個節點即可 //不是頭節點,將當前節點指向刪除節點的下一個節點 else p.next = node.next;// p的下一個節點指向到node的下一個節點即可把node從連結串列中刪除了 // 操作+1 ++modCount; // map大小-1 --size; // 空的實現 afterNodeRemoval(node); // 返回刪除的節點 return node; } } return null; }
六、總結
Has和Map得底層還是很多的,裡面用的一些邏輯和演算法,都是很牛皮的、耐人琢磨的。oracle裡的人才還是厲害啊,你看都看不明白,人家就能設計出來,實現出來。真的是你我皆為螻蟻,只不過是一個個搬磚工具人呀。哈哈,對自己的一個自嘲哈,共勉!!看到這裡了,點個贊吧,小編碼字實屬不易呀!!謝謝了!!
環境大家關注小編的微信公眾號!謝謝大家!
推廣自己網站時間到了!!!
- 執行緒池底層原理詳解與原始碼分析
- 30分鐘掌握 Webpack
- 線性迴歸大結局(嶺(Ridge)、 Lasso迴歸原理、公式推導),你想要的這裡都有
- 【前端必會】webpack loader 到底是什麼
- 中心化決議管理——雲端分析
- HashMap底層原理及jdk1.8原始碼解讀
- 詳解JS中 call 方法的實現
- 列印 Logger 日誌時,需不需要再封裝一下工具類?
- 初識設計模式 - 代理模式
- 密碼學奇妙之旅、01 CFB密文反饋模式、AES標準、Golang程式碼
- Springboot之 Mybatis 多資料來源實現
- CAS核心思想、底層實現
- 面試突擊86:SpringBoot 事務不回滾?怎麼解決?
- 基於electron vue element構建專案模板之【打包篇】
- MiniWord .NET Word模板引擎,藉由Word模板和資料簡單、快速生成檔案。
- 認識執行緒,初始併發
- 1-VSCode搭建GD32開發環境
- 初識設計模式 - 原型模式
- 執行緒安全問題的產生條件、解決方式
- 2>&1到底是什麼意思?