如何看待代碼中濫用HashMap?-知乎問題讀後感和相關研究

昨天在知乎上看到了一個問題如何看待代碼中濫用HashMap? .日常工程中使用HashMap確實挺多的 ,簡單方便快捷(至少感覺上是這樣) ,但越是簡單好用的東西 ,底層封裝的越複雜 .

跟進去看了一下 ,朱文彬老師進行了比較直觀的對比實驗 ,我也查閱了其他的資料 ,最後把這個實驗扒下來運行了 .

資料 HashMap的原理研究


1.HashMap的結構 ,數組Col[對應HashCode] + 鏈表Row[對應數據節點Entry]

transient Node<K,V>[] table


2.設置初始容量(桶/數組的數量 ,默認16) ,負載因子(判定Map滿的條件 ,默認0.75)

    public HashMap(int initialCapacity, float loadFactor) {
        //初始容量不能<0
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: "
                    + initialCapacity);
        //初始容量不能 > 最大容量值,HashMap的最大容量值爲2^30
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //負載因子不能 < 0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: "
                    + loadFactor);

        // 計算出大於 initialCapacity 的最小2^n值。
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        //設置HashMap的容量極限,當HashMap的容量達到該極限時就會進行擴容操作
        threshold = (int) (capacity * loadFactor);
        //初始化table數組
        table = new Entry[capacity];
        init();
    }

3.put方法

    public V put(K key, V value) {
         //當key爲null,調用putForNullKey方法,保存null與table第一個位置中,這是HashMap允許爲null的原因
         if (key == null)
             return putForNullKey(value);
         //計算key的hash值
         int hash = hash(key.hashCode());
         ------(1)
         //計算key hash 值在 table 數組中的位置
         int i = indexFor(hash, table.length);
         ------(2)
         //從i出開始迭代 e,找到 key 保存的位置
         for (Entry<K, V> e = table[i]; e != null; e = e.next) {
             Object k;
             //判斷該條鏈上是否有hash值相同的(key相同)
             //若存在相同,則直接覆蓋value,返回舊value
             if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                 V oldValue = e.value;    //舊值 = 新值
                 e.value = value;
                 e.recordAccess(this);
                 return oldValue;     //返回舊值
             }
         }
         //修改次數增加1
         modCount++;
         //將key、value添加至i位置處
         addEntry(hash, key, value, i);
         return null;
     }

3.1計算hash值/查詢對應的數組列表位置

    static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    static int indexFor(int h, int length) {
        return h & (length - 1);
    }

HashMap通過數組加鏈表 ,如何均勻分佈數據?

  • 排布太緊鏈表會很長 ,查詢效率會變低(順序查詢)
  • 排布太鬆數組會很大 ,浪費很多空間

hash+indexFor

  • hash是純數學計算
  • 合理分佈數據需要取模 ,indexFor是特殊的”取模”運算
    • 因爲底層桶的大小length是2^n ,length-1 -> 111...111(2進制)
    • 10110 & 1111 (22 & 15) -> 00110 (6)
    • 22%16 = 6
  • 利用二進制&的特點 ,可以快速的達成取模的目的(%取模比&運算複雜)

3.2添加節點/相同key替換

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //獲取bucketIndex處的Entry
        Entry<K, V> e = table[bucketIndex];
        //將新創建的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry 
        table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
        //若HashMap中元素的個數超過極限了,則容量擴大兩倍
        if (size++ >= threshold)
            resize(2 * table.length);
    }

4.get操作

    public V get(Object key) {
        // 若爲null,調用getForNullKey方法返回相對應的value
        if (key == null)
            return getForNullKey();
        // 根據該 key 的 hashCode 值計算它的 hash 碼  
        int hash = hash(key.hashCode());
        // 取出 table 數組中指定索引處的值
        for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
            Object k;
            //若搜索的key與查找的key相同,則返回相對應的value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
    }

5.擴容機制

隨着HashMap中的元素增加,hash衝突的機率也就變高,
因爲數組的長度是固定的。爲了提高查詢的效率,要對數組進行擴容(元素超過 大小length*負載因子loadFactor),
而在HashMap數組擴容之後,最消耗性能的點就出現了:
原數組中的數據必須重新計算其在新數組中的位置,並put,這就是resize

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        //如果當前的數組長度已經達到最大值,則不在進行調整
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        //根據傳入參數的長度定義新的數組
        Entry[] newTable = new Entry[newCapacity];
        //按照新的規則,將舊數組中的元素轉移到新數組中
        transfer(newTable);
        table = newTable;
        //更新臨界值
        threshold = (int)(newCapacity * loadFactor);
    }
    //舊數組中元素往新數組中遷移
    void transfer(Entry[] newTable) {
        //舊數組
        Entry[] src = table;
        //新數組長度
        int newCapacity = newTable.length;
        //遍歷舊數組
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);//放在新數組中的index位置
                    e.next = newTable[i];//實現鏈表結構,新加入的放在鏈頭,之前的的數據放在鏈尾
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }

    }

6.線程安全

HashMap是線程不安全的 ,因此在多線程應用時 ,可以考慮使用:

  • HashTable(同步鎖整個table數組 ,效率較低)
  • Collections.synchronizedMap(對每一個方法增加了synchronized ,但並不保證put/get/contain之間的同步)
  • ConcurrentHashMap(同步鎖每次只鎖一個桶 ,可以多線程同時讀寫不同桶 ,也保證了put/get同一個桶的同步)

實驗一 Map/List/數組的內存佔用情況

package sourceCode.javaSE;

import static sourceCode.objMemoryUtil.ObjMemoryCostUtil.*;

//import static -> 靜態導入 ,導入全部(*)或指定的靜態方法 ,可以直接使用 ,不用加System.這樣的前綴名
import static java.lang.System.out;

/**
 * 作者:朱文彬
 * 鏈接:https://www.zhihu.com/question/28119895/answer/40494358
 * 來源:知乎
 * 著作權歸作者所有,轉載請聯繫作者獲得授權。
 * <p>
 * 對各種map佔用的內存大小進行研究
 */
public class HashMapMemoryTest {

    static void printSize(Object o) {
        out.printf("類型:%s,佔用內存:%.2f MB\n", o.getClass().getSimpleName(), deepSizeOf(o) / 1024D / 1024D);
    }

    public static void main(String[] args) throws Throwable {

        int size = 30000;

        java.util.Map<Object, Object> javaUtilHashMap = new java.util.HashMap<>();
        for (int i = 0; i < size; javaUtilHashMap.put(i, i), i++) {
        }

        /**
         * Java集合框架Koloboke ,目前的版本主要是替換java.util.HashSet和java.util.HashMap
         *
         * Koloboke對每個entry使用了更少的內存
         * Koloboke目標是把鍵和值存儲在同一行高速緩存中
         * 所有的方法都經過了實現優化,而不是像AbstractSet類或AbstractMap類那樣委託給框架類(Skeleton Class)
         */
        net.openhft.koloboke.collect.map.hash.HashIntIntMap openHftHashIntIntMap = net.openhft.koloboke.collect.map.hash.HashIntIntMaps.newUpdatableMap();
        for (int i = 0; i < size; openHftHashIntIntMap.put(i, i), i++) {
        }

        java.util.ArrayList<Object> javaUtilArrayList = new java.util.ArrayList<>();
        for (int i = 0; i < size; javaUtilArrayList.add(i), i++) {
        }

        Integer[] objectArray = new Integer[size];
        for (int i = 0; i < size; objectArray[i] = i, i++) {
        }

        /**
         * hppc - High Performance Primitive Collections for Java
         * 對Java的原始集合類型如映射map、集合set、堆棧stack、列表list、隊列deque等進行了擴展,提供了更佳的內存利用率,帶來了更好的性能。
         */
        com.carrotsearch.hppc.IntArrayList hppcArrayList = new com.carrotsearch.hppc.IntArrayList();
        for (int i = 0; i < size; hppcArrayList.add(i), i++) {
        }

        int[] primitiveArray = new int[size];
        for (int i = 0; i < size; primitiveArray[i] = i, i++) {
        }

        out.println("java.vm.name=" + System.getProperty("java.vm.name"));
        out.println("java.vm.version=" + System.getProperty("java.vm.version"));
        out.println("容器元素總數:" + size);

        printSize(javaUtilHashMap);
        printSize(openHftHashIntIntMap);
        printSize(javaUtilArrayList);
        printSize(hppcArrayList);
        printSize(primitiveArray);
        printSize(objectArray);  
    }
}
容器元素總數:30000
類型:HashMap,佔用內存:2.08 MB
類型:UpdatableLHashParallelKVIntIntMap,佔用內存:0.50 MB
類型:ArrayList,佔用內存:0.58 MB
類型:IntArrayList,佔用內存:0.17 MB
類型:int[],佔用內存:0.11 MB
類型:Integer[],佔用內存:0.57 MB

內存差異的原因是:

 1. hash中爲避免退化爲數組(如openhft的實現可以退化爲數組)或者鏈表(java.util.HashMap可能退化爲鏈表)使用的空槽
 2. java.util.HashMap.Entry的額外佔用的內存,用於維持鏈表、內存對齊等
 3. 對象內存佔用:在HotSpot 64位jdk中,一個java.lang.Integer佔用16字節,一個引用佔用4字節,總共20字節,而一個int只佔用4字節

結果分析 :

 1. 處理 `大數據量的默認類型` 時 ,使用個性化的集合類可以減少類型推斷 ,節省拆裝箱的內存
 2. 數組是較爲底層的 ,內存使用上最少 ,但可支持的操作也很少 ,查詢效率也不那麼好
 3. 但面對數量巨大的key和簡單的value來說 ,使用數組太耗費時間
 4. `內存優化` 和 `搜索優化` 不可調和

補充:deepSizeOf(o) - 利用Instrumentation檢測JVM對象大小

朱老師並未在知乎上發佈sizeOf工具類的代碼 ,因此我在網上找了一個替代的工具類:如何準確計算Java對象的大小 - 博客園aprogramer .

文章內使用了java.lang.instrument.Instrumentation : Java Instrumentation指的是可以用獨立於應用程序之外的代理(agent)程序來監測和協助運行在JVM上的應用程序 ,監測和協助包括但不限於獲取JVM運行時狀態,替換和修改類定義等 .

最後參考Java對象佔用內存大小的計算方法 ,完整的計算Map和內部引用的所有成員的大小

package sourceCode.objMemoryUtil;

/*
 * @(#)MemoryCalculator.java    1.0 2010-11-8
 *
 * Copyright 2010 Richard Chen([email protected]) All Rights Reserved.
 * PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 */

import java.lang.instrument.Instrumentation;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.*;

/**
 * 利用Instrumentation去檢測JVM中的情況 ,還可以分析JVM中加載的所有對象等
 * 1.編寫premain函數 ,作爲JVM啓動時的回調函數 ,注入Instrumentation實例到工具類中
 * 2.編寫MANIFEST.MF ,指定Premain-Class的位置
 * 3.單獨打包工具類和MANIFEST
 * 4.在使用入口程序設置VM-operation : -javaagent:target/objMemoryUtil.jar ,指定代理的工具類jar
 */
public class ObjMemoryCostUtil {
    /**
     * JVM將在啓動時通過{@link #premain}初始化此成員變量.
     */
    private static Instrumentation instrumentation = null;


    /**
     * JVM在初始化後在調用應用程序main方法前將調用本方法, 本方法中可以寫任何main方法中可寫的代碼.
     *
     * @param agentArgs 命令行傳進行來的代理參數, 內部需自行解析.
     * @param inst      JVM注入的句柄.
     */
    public static void premain(String agentArgs, Instrumentation inst) {
        instrumentation = inst;
    }

    /**
     * 計算實例本身佔用的內存大小. 注意:
     * 1. 多次調用可能結果不一樣, 主要跟實例的狀態有關
     * 2. 實例中成員變量如果是reference類型, 則reference所指向的實例佔用內存大小不統計在內 (只計算基本類型的成員)
     *
     * @param obj 待計算內存佔用大小的實例.
     * @return 內存佔用大小, 單位爲byte.
     */
    public static long shallowSizeOf(Object obj) {
        if (instrumentation == null) {
            throw new IllegalStateException("Instrumentation initialize failed");
        }
        if (isSharedObj(obj)) {
            return 0;
        }
        return instrumentation.getObjectSize(obj);
    }

    /**
     * 計算實例佔用的內存大小, 含其成員變量所引用的實例, 遞歸計算.
     *
     * @param obj 待計算內存佔用大小的實例.
     * @return 內存佔用大小, 單位爲byte.
     */
    public static long deepSizeOf(Object obj) {
        Map calculated = new IdentityHashMap();
        Stack unCalculated = new Stack();
        unCalculated.push(obj);
        long result = 0;
        do {
            result += doSizeOf(unCalculated, calculated);
        } while (!unCalculated.isEmpty());
        return result;
    }

    /**
     * 判斷obj是否是共享對象. 有些對象, 如interned Strings, Boolean.FALSE和Integer#valueOf()等.
     *
     * @param obj 待判斷的對象.
     * @return true, 是共享對象, 否則返回false.
     */
    private static boolean isSharedObj(Object obj) {
        if (obj instanceof Comparable) {
            if (obj instanceof Enum) {
                return true;
            } else if (obj instanceof String) {
                return (obj == ((String) obj).intern());
            } else if (obj instanceof Boolean) {
                return (obj == Boolean.TRUE || obj == Boolean.FALSE);
            } else if (obj instanceof Integer) {
                return (obj == Integer.valueOf((Integer) obj));
            } else if (obj instanceof Short) {
                return (obj == Short.valueOf((Short) obj));
            } else if (obj instanceof Byte) {
                return (obj == Byte.valueOf((Byte) obj));
            } else if (obj instanceof Long) {
                return (obj == Long.valueOf((Long) obj));
            } else if (obj instanceof Character) {
                return (obj == Character.valueOf((Character) obj));
            }
        }
        return false;
    }

    /**
     * 確認是否需計算obj的內存佔用, 部分情況下無需計算.
     *
     * @param obj        待判斷的對象.
     * @param calculated 已計算過的對象.
     * @return true, 意指無需計算, 否則返回false.
     */
    private static boolean isEscaped(Object obj, Map calculated) {
        return obj == null || calculated.containsKey(obj)
                || isSharedObj(obj);
    }

    /**
     * 計算棧頂對象本身的內存佔用.
     *
     * @param unCalculated 待計算內存佔用的對象棧.
     * @param calculated   對象圖譜中已計算過的對象.
     * @return 棧頂對象本身的內存佔用, 單位爲byte.
     */
    private static long doSizeOf(Stack unCalculated, Map calculated) {
        Object obj = unCalculated.pop();
        if (isEscaped(obj, calculated)) {
            return 0;
        }
        Class clazz = obj.getClass();
        if (clazz.isArray()) {
            doArraySizeOf(clazz, obj, unCalculated);
        } else {
            while (clazz != null) {
                Field[] fields = clazz.getDeclaredFields();
                for (Field field : fields) {
                    if (!Modifier.isStatic(field.getModifiers())
                            && !field.getType().isPrimitive()) {
                        field.setAccessible(true);
                        try {
                            unCalculated.add(field.get(obj));
                        } catch (IllegalAccessException ex) {
                            throw new RuntimeException(ex);
                        }
                    }
                }
                clazz = clazz.getSuperclass();
            }
        }
        calculated.put(obj, null);
        return shallowSizeOf(obj);
    }

    /**
     * 將數組中的所有元素加入到待計算內存佔用的棧中, 等待處理.
     *
     * @param arrayClazz   數組的型別.
     * @param array        數組實例.
     * @param unCalculated 待計算內存佔用的對象棧.
     */
    private static void doArraySizeOf(Class arrayClazz, Object array,
                                      Stack unCalculated) {
        if (!arrayClazz.getComponentType().isPrimitive()) {
            int length = Array.getLength(array);
            for (int i = 0; i < length; i++) {
                unCalculated.add(Array.get(array, i));
            }
        }
    }
}

Instrumentation使用方式

  • 編寫MANIFEST.MF ,指定Premain-Class的位置
Manifest-Version: 1.0
Premain-Class: sourceCode.objMemoryUtil.ObjMemoryCostUtil
Created-By: 1.6.0_29
  • 單獨打包ObjMemoryCostUtil類 ,並將MANIFEST加入到Jar(粗淺的學習了下maven打包)
<plugin>
    <artifactId>maven-jar-plugin</artifactId>
    <executions>
        <execution>
            <id>objMemoryCostUtil</id>
            <goals>
                <goal>jar</goal>
            </goals>
                <phase>package</phase>
            <configuration>
                <finalName>objMemoryUtil</finalName>
                <includes>
                    <include>**/objMemoryUtil/**</include>
                </includes>
                <archive>
                    <manifestFile>src/main/java/sourceCode/objMemoryUtil/MANIFEST.MF</manifestFile>
                    <manifest><addClasspath>true</addClasspath></manifest>
                </archive>
            </configuration>
        </execution>
    </executions>
</plugin>
  • 在使用的Main函數中設置VM參數 ,指定jar包位置 -javaagent:target/objMemoryUtil.jar

這裏寫圖片描述

  • 運行即可

實驗二 Map/List/數組的put性能

這個實現就是通過插入數據 ,計算插入時間

package sourceCode.javaSE;

import java.util.Collections;

import static java.lang.Math.*;
import static java.lang.System.*;
import static java.util.Arrays.*;

/**
 * 作者:朱文彬
 * 鏈接:https://www.zhihu.com/question/28119895/answer/40494358
 * 來源:知乎
 * 著作權歸作者所有,轉載請聯繫作者獲得授權。
 * <p>
 * 對各種map存取時間進行研究
 */

public class HashMapCPUTimeTest {

    /**
     * 計算各集合put操作的時間 ,可以設置重複次數取平均
     *
     * @param type 對象類型
     * @param r    線程所做的操作
     */
    static void printTime(Class type, Runnable r) {
        double time = timeCall(r, 30);
        char[] rpad = "                                    ".toCharArray();
        type.getSimpleName().getChars(0, type.getSimpleName().length(), rpad, 0);
        out.printf("類型:%s \t 耗時:%.2g s\n", new String(rpad), time);
    }

    /**
     * 根據重複次數 ,計算所花時間的平均值
     *
     * @param call   目標線程
     * @param repeat 重複次數
     * @return
     */
    public static double timeCall(Runnable call, int repeat) {
        double[] a = new double[repeat];
        setAll(a, i -> timeCall(call));
        if (repeat > 7) { //重複次數>7 ,只對中間的60%數據計算平均
            sort(a);
            int i = round(repeat * 0.2f);
            return stream(a, i, repeat - i).average().getAsDouble();
        }
        if (repeat > 3) { //重複次數>3 ,去掉一個最高分一個最低分 ,剩下的取平均
            sort(a);
            return stream(a, 1, repeat - 1).average().getAsDouble();
        }
        return stream(a).average().getAsDouble();
    }

    /**
     * 啓動線程 ,執行put操作
     *
     * @param call
     * @return
     */
    public static double timeCall(Runnable call) {
        long startA = nanoTime();//System.nanoTime提供基於系統的相對精確的時間 ,類似秒錶
        long start = nanoTime();
        try {
            call.run();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return 1E-9d * (max(0, nanoTime() - start - (start - startA))); //1E-9d :1乘以10的-9次方
    }


    public static void main(String[] args) throws Throwable {
        int size = 1000000;
        out.println("java.vm.name=" + System.getProperty("java.vm.name"));
        out.println("java.vm.version=" + System.getProperty("java.vm.version"));
        out.println("容器元素總數:" + size);

        printTime(java.util.HashMap.class, () -> {
            java.util.Map<Object, Object> javaUtilHashMap = new java.util.HashMap<>();
            for (int i = 0; i < size; javaUtilHashMap.put(i, i), i++) {
            }
        });

        printTime(java.util.LinkedHashMap.class, () -> {
            java.util.Map<Object, Object> javaUtilLinkedHashMap = new java.util.LinkedHashMap<>();
            for (int i = 0; i < size; javaUtilLinkedHashMap.put(i, i), i++) {
            }
        });

        printTime(java.util.concurrent.ConcurrentHashMap.class, () -> {
            java.util.Map<Object, Object> javaUtilLinkedHashMap = new java.util.concurrent.ConcurrentHashMap<>();
            for (int i = 0; i < size; javaUtilLinkedHashMap.put(i, i), i++) {
            }
        });

        printTime(Collections.synchronizedMap(new java.util.concurrent.ConcurrentHashMap()).getClass(), () -> {
            java.util.Map<Object, Object> javaUtilLinkedHashMap = Collections.synchronizedMap(new java.util.concurrent.ConcurrentHashMap());
            for (int i = 0; i < size; javaUtilLinkedHashMap.put(i, i), i++) {
            }
        });

        printTime(java.util.TreeMap.class, () -> {
            java.util.Map<Object, Object> javaUtilTreeMap = new java.util.TreeMap<>();
            for (int i = 0; i < size; javaUtilTreeMap.put(i, i), i++) {
            }
        });

        printTime(net.openhft.koloboke.collect.map.hash.HashIntIntMaps.newUpdatableMap().getClass(), () -> {
            net.openhft.koloboke.collect.map.hash.HashIntIntMap openHftHashIntIntMap = net.openhft.koloboke.collect.map.hash.HashIntIntMaps.newUpdatableMap();
            for (int i = 0; i < size; openHftHashIntIntMap.put(i, i), i++) {
            }
        });

        printTime(java.util.ArrayList.class, () -> {
            java.util.ArrayList<Object> javaUtilArrayList = new java.util.ArrayList<>();
            for (int i = 0; i < size; javaUtilArrayList.add(i), i++) {
            }
        });

        printTime(Integer[].class, () -> {
            Integer[] objectArray = new Integer[size];
            for (int i = 0; i < size; objectArray[i] = i, i++) {
            }
        });

        printTime(com.carrotsearch.hppc.IntArrayList.class, () -> {
            com.carrotsearch.hppc.IntArrayList hppcArrayList = new com.carrotsearch.hppc.IntArrayList();
            for (int i = 0; i < size; hppcArrayList.add(i), i++) {
            }
        });

        printTime(int[].class, () -> {
            int[] primitiveArray = new int[size];
            for (int i = 0; i < size; primitiveArray[i] = i, i++) {
            }
        });

    }
}

容器元素總數:1000000
類型:HashMap 耗時:0.028 s
類型:LinkedHashMap 耗時:0.025 s
類型:ConcurrentHashMap 耗時:0.10 s
類型:SynchronizedMap 耗時:0.091 s
類型:TreeMap 耗時:0.25 s
類型:UpdatableLHashParallelKVIntIntMap 耗時:0.048 s
類型:ArrayList 耗時:0.0063 s
類型:Integer[] 耗時:0.0031 s
類型:IntArrayList 耗時:0.0033 s
類型:int[] 耗時:0.00064 s

  1. 單純對Put操作來講 ,和memory的實驗類似 ,越是底層越是簡單的結構 ,效率越高像TreeMap耗時是數組的1000倍
  2. 但對於一些後續操作 ,排序/get來說 ,TreeMap/HashMap則不知道高到哪裏去
  3. 所以瞭解集合類的差異 ,各自的優缺點 ,合理的使用纔是最重要的
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章