為什麼Java String雜湊乘數為31?

二進位制之路發表於2018-09-23

前面簡單介紹了[ 經典的Times 33 雜湊演算法 ],這篇我們通過分析Java 1.8 String類的雜湊演算法,繼續聊聊對乘數的選擇。

String類的hashCode()原始碼

/** Cache the hash code for the string */
private int hash;

/** 
Returns a hash code for this string. The hash code for a String object is computed as 
 s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
using int arithmetic, where s[i] is the ith character of the string, 
n is the length of the string, and ^ indicates exponentiation. 
(The hash value of the empty string is zero.) 
*/
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}
複製程式碼

可以看到,String的雜湊演算法也是採用了Times 33的思路,只不過乘數選擇了31。

其中

  • hash預設值為0.
  • 判斷h == 0是為了快取雜湊值.
  • 判斷value.length > 0是因為空字串的雜湊值為0.

用資料說話

前一篇我們提到:

這個神奇的數字33,為什麼用來計算雜湊的效果會比其他許多常數(無論是否為質數)更有效,並沒有人給過足夠充分的解釋。因此,Ralf S. Engelschall嘗試通過自己的方法解釋其原因。通過對1到256中的每個數字進行測試,發現偶數的雜湊效果非常差,根據用不了。而剩下的128個奇數,除了1之外,效果都差不多。這些奇數在分佈上都表現不錯,對雜湊表的填充覆蓋大概在86%。

從雜湊效果來看(Chi^2應該是指卡方分佈),雖然33並不一定是最好的數值。但17、31、33、63、127和129等相對其他的奇數的一個很明顯的優勢是,由於這些奇數與16、32、64、128只相差1,可以通過移位(如1 << 4 = 16)和加減1來代替乘法,速度更快

那麼接下來,我們通過實驗資料,來看看偶數、奇數,以及17、31、33、63、127和129等這些神奇數字的雜湊效果,來驗證Ralf S. Engelschall的說法。

環境準備

個人筆記本,Windows 7作業系統,酷睿i5雙核64位CPU。

測試資料:CentOS Linux release 7.5.1804的/usr/share/dict/words字典檔案對應的所有單詞。

由於CentOS上找不到該字典檔案,通過yum -y install words進行了安裝。

/usr/share/dict/words共有479828個單詞,該檔案連結的原始檔案為linux.words。

計算衝突率與雜湊耗時

測試程式碼

/**
 * 以1-256為乘數,分別計算/usr/share/dict/words所有單詞的雜湊衝突率、總耗時.
 * 
 * @throws IOException
 */
@Test
public void testHash() throws IOException {
	List<String> words = getWords();
	
	System.out.println();
	System.out.println("multiplier, conflictSize, conflictRate, timeCost, listSize, minHash, maxHash");
	for (int i = 1; i <=256; i++) {
		computeConflictRate(words, i);
	}
}

/**
 * 讀取/usr/share/dict/words所有單詞
 * 
 * @return
 * @throws IOException
 */
private List<String> getWords() throws IOException {
	// read file
	InputStream is = HashConflictTester.class.getClassLoader().getResourceAsStream("linux.words");
	List<String> lines = IOUtils.readLines(is, "UTF-8");
	return lines;
}

/**
 * 計算衝突率
 * 
 * @param lines
 */
private void computeConflictRate(List<String> lines, int multiplier) {
	// compute hash
	long startTime = System.currentTimeMillis();
	List<Integer> hashList = computeHashes(lines, multiplier);
	long timeCost = System.currentTimeMillis() - startTime;
	
	// find max and min hash
	Comparator<Integer> comparator = (x,y) -> x > y ? 1 : (x < y ? -1 : 0);
	int maxHash = hashList.parallelStream().max(comparator).get();
	int minHash = hashList.parallelStream().min(comparator).get();
	
	// hash set
	Set<Integer> hashSet = hashList.parallelStream().collect(Collectors.toSet());
	
	int conflictSize = lines.size() - hashSet.size();
	float conflictRate = conflictSize * 1.0f / lines.size();
	System.out.println(String.format("%s, %s, %s, %s, %s, %s, %s", multiplier, conflictSize, conflictRate, timeCost, lines.size(), minHash, maxHash));
}

/**
 * 根據乘數計算hash值
 * 
 * @param lines
 * @param multiplier
 * @return
 */
private List<Integer> computeHashes(List<String> lines, int multiplier) {
	Function<String, Integer> hashFunction = x -> {
		int hash = 0;
		for (int i = 0; i < x.length(); i++) {
			hash = (multiplier * hash) + x.charAt(i);
		}
		return hash;
	};
	return lines.parallelStream().map(hashFunction).collect(Collectors.toList());
}
複製程式碼

執行測試方法testHash(),稍等片刻後,我們將得到一份測試報告。

雜湊衝突率降序排序

通過對雜湊衝突率進行降序排序,得到下面的結果。

結果分析

  • 偶數的衝突率基本都很高,只有少數例外。
  • 較小的乘數,衝突率也比較高,如1至20。
  • 乘數1、2、256的分佈不均勻。Java雜湊值為32位int型別,取值範圍為[-2147483648,2147483647]。

雜湊衝突率降序排序

雜湊耗時降序排序

我們再對衝突數量為1000以內的乘數進行分析,通過對執行耗時進行降序排序,得到下面的結果。

分析17、31、33、63、127和129

  • 17在上一輪已經出局。
  • 63執行計算耗時比較長。
  • 31、33的衝突率分別為0.13%、0.14%,執行耗時分別為10、11,實時基本相當
  • 127、129的衝突率分別為0.01%、0.004%,執行耗時分別為9、10

總體上看,129執行耗時低,衝突率也是最小的,似乎先擇它更為合適?

雜湊耗時降序排序

雜湊分佈情況

將整個雜湊空間[-2147483648,2147483647]分為128個分割槽,分別統計每個分割槽的雜湊值數量,以此來觀察各個乘數的分佈情況。每個分割槽的雜湊桶位為2^32 / 128 = 33554432。

之所以通過分割槽來統計,主要是因為單詞數太多,嘗試過畫成圖表後密密麻麻的,無法直觀的觀察對比。

計算雜湊分佈程式碼

@Test
public void testHashDistribution() throws IOException {
	int[] multipliers = {2, 17, 31, 33, 63, 127, 73, 133, 237, 161};
	List<String> words = getWords();
	for (int multiplier : multipliers) {
		List<Integer> hashList = computeHashes(words, multiplier);
		Map<Integer, Integer> hashMap = partition(hashList);
		System.out.println("\n" + multiplier + "\n,count");
		hashMap.forEach((x, y) -> System.out.println(x + "," + y));
	}
}

/**
 * 將整個雜湊空間等分成128份,統計每個空間內的雜湊值數量
 * 
 * @param hashs
 */
public static Map<Integer, Integer> partition(List<Integer> hashs) {
	// step = 2^32 / 128 = 33554432
	final int step = 33554432;
	List<Integer> nums = new ArrayList<>();
	Map<Integer, Integer> statistics = new LinkedHashMap<>();
	int start = 0;
	for (long i = Integer.MIN_VALUE; i <= Integer.MAX_VALUE; i += step) {
		final long min = i;
		final long max = min + step;
		int num = (int) hashs.parallelStream().filter(x -> x >= min && x < max).count();

		statistics.put(start++, num);
		nums.add(num);
	}

	// 為了防止計算出錯,這裡驗證一下
	int hashNum = nums.stream().reduce((x, y) -> x + y).get();
	assert hashNum == hashs.size();

	return statistics;
}
複製程式碼

生成資料之後,儲存文字為csv字尾,通過Excel開啟。再通過Excel的圖表功能,選擇柱狀圖,生成以下圖表。

乘數2

乘數2

乘數17

乘數17

乘數31

乘數31

乘數33

乘數33

乘數73

乘數73

乘數127

乘數127

乘數133

乘數133

乘數161

乘數161

乘數237

乘數237

除了2和17,其他數字的分佈基本都比較均勻。

總結

現在我們基本瞭解了Java String類的雜湊乘數選擇31的原因了,主要有以下幾點。

  • 31是奇素數。
  • 雜湊分佈較為均勻。偶數的衝突率基本都很高,只有少數例外。較小的乘數,衝突率也比較高,如1至20
  • 雜湊計算速度快。可用移位和減法來代替乘法。現代的VM可以自動完成這種優化,如31 * i = (i << 5) - i
  • 31和33的計算速度和雜湊分佈基本一致,整體表現好,選擇它們就很自然了。

當參與雜湊計算的項有很多個時,越大的乘數就越有可能出現結果溢位,從而丟失資訊。我想這也是原因之一吧。

儘管從測試結果來看,比31、33大的奇數整體表現有更好的選擇。然而31、33不僅整體表現好,而且32的移位操作是最少的,理論上來講計算速度應該是最快的。

最後說明一下,我通過另外兩臺Linux伺服器進行測試對比,發現結果基本一致。但以上測試方法不是很嚴謹,與實際生產執行可能存在偏差,結果僅供參考。

幾個常用實現選項

values chosen to initialize h and a for some of the popular implementations

其中

  • INITIAL_VALUE:雜湊初始值。Java String的初始值hash=0。
  • a:雜湊乘數。Java String的雜湊乘數為31。

參考

stackoverflow.com/questions/2…

segmentfault.com/a/119000001…

en.wikipedia.org/wiki/Univer…

《Effective Java中文版本》第2版

個人公眾號

更多文章,請關注公眾號:二進位制之路

二進位制之路

相關文章