昨天在知乎上看到了一個問題如何看待代碼中濫用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
- 因爲底層桶的大小length是2^n ,
- 利用二進制
&
的特點 ,可以快速的達成取模的目的(%
取模比&
運算複雜)
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
- 單純對Put操作來講 ,和memory的實驗類似 ,越是底層越是簡單的結構 ,效率越高像TreeMap耗時是數組的1000倍
- 但對於一些後續操作 ,排序/get來說 ,TreeMap/HashMap則不知道高到哪裏去
- 所以瞭解集合類的差異 ,各自的優缺點 ,合理的使用纔是最重要的