由String.hashCode方法引发的int类型乘法溢出的思考

由String.hashCode方法引发的int类型乘法溢出的思考

  • 一、String.hashCode()
  • 二、问题引出
  • 三、整数在计算机中的表示
  • 四、解释结果
  • 五、参考

文中说明不当的,欢迎指正!
本文主要讨论String.hashCode()的实现以及延伸整数在Java虚拟机表示的问题。

一、String.hashCode()

最近在看String.hashCode()方法源码,首先看下如何计算一个字符串的hash值

/** The value is used for character storage. */  
private final char value[];  //将字符串截成的字符数组  
  
/** Cache the hash code for the string */  
private int hash; // Default to 0 用以缓存计算出的hashcode值  
  
/** 
  * 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.) * * @return a hash code value for this object. */
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { // h等于默认值,且字符串长度>0进入 char val[] = value; // 转字符数组用以计算hash值 for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }

从上面代码我们可以看出,在String类中有个私有实例字段hash表示该串的哈希值,在第一次调用hashCode方法时,此时hash还为默认值0,所以h=hash之后,若字符串长度大于0,则进入第一个判断,该判断里面计算了hash值。该计算就是以31为权,每一位为字符的ASCII值进行运算

结合代码注释,也可看出哈希计算公式:s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1]

为了更方便理解公式,我们可以通过实际的调试来体会。我这里书面上通过例子来讲解下,比如我计算msg="abcd"的hash值,说明如下:

String msg = "abcd";  // 此时value[] = {'a','b','c','d'}

所以,for循环会执行4次
第一次:h = 310 + a = 97
第二次:h = 31
97 + b = 3105
第三次:h = 313105 + c = 96354
第四次:h = 31
96354 + d = 2987074
由以上代码计算可以算出 msg 的hashcode = 2987074,刚好与 System.out.println(new String(“abcd”).hashCode()); 进行验证的结果一致。

接着计算完哈希值h后面的代码“hash = h; ”,会把h赋给hash,这样之后再调用hashCode方法不会进刚才说的判断,便可以直接取hash字段返回。

那么,为什么取31为权?可以参考StackOverflow上的这个问题
主要是因为31是一个奇质数,所以31i=32i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多,而且int在计算机中表示32位有符号整数。

二、问题引出

源码读到这里,一般我们也要有自己的思考。假如我String字符串长度越来越长,那我的hash值不就越来越大,这样hash的意义在哪里?于是我做了个实验看下是不是跟我的疑问一致,我分别试下:

System.out.println(new String("abcde").hashCode());
System.out.println(new String("abcdef").hashCode());

结果得到结果:

92599395
-1424385949

那这个“92599395”我们很好理解,因为上面例子的基础,“abcde”在“abcd”哈希值“2987074”的基础上再进行计算,即 92599395=2987074*31+101。

那么,这个-1424385949是怎么回事呢?它似乎并没有像我想的变的越来越大了。我先不考虑这个负数值怎么来的,正常数学情况下这计算结果应该是什么值?会等于 92599395*31+102=2870581347

细心的话你会发现,2870581347这个数值大小其实已经超过int类型能表示的最大值2147483647。

int的取值范围为:- 2^31次方 到 2^31-1,即-2147483648至2147483647

那么到底发生了什么最后值变了?针对这个问题,《Java虚拟机说明书》中“Java语言编程概念”里对“基本数据类型的变窄转换”的介绍,我对于“2987074 * 31+101”的解释和过程是这样的:
在“92599395 * 31” 时Java发现结果“2870581245”已经超出了int基本数据类型的最大范围“2147483647”,于是作了默认的类型提升(type promotion),中间结果做为long类型存放,然后“2870581245+102=2870581347”返回结果时目标数据类型int不能够容纳下结果,于是根据Java的基础类型的变窄转换(Narrowing primitive conversion)规则,把结果宽于int类型宽度的部分全部丢弃,也就是只取结果的低32位,于是就得到了上面的结果。那最后结果怎么会得到“-1424385949”这样一个负数值呢?这就要先提到整数在计算机中的表示了。

三、整数在计算机中的表示

在Java虚拟机中,整数有byte、short、int、long四种,分别表示8位、16位、32位、64位有符号整数。 整 数 在 计 算 机 中 用 补 码 表 示 , \color{red}{整数在计算机中用补码表示,} 在Java虚拟机中也不例外。在学习补码之前,必须先理解原码和反码。
(此处说明以int为例,因为int最常见,其他同理)
1、原码: 就是符号位上加上数字的二进制表示,以int为例, 第 一 位 表 示 符 号 位 ( 正 数 或 负 数 ) , 其 余 31 位 表 示 该 数 字 的 二 进 制 值 。 \color{red}{第一位表示符号位(正数或负数),其余31位表示该数字的二进制值。} 31
10的原码为:00000000 00000000 00000000 00001010
-10的原码为:10000000 00000000 00000000 00001010
对于原码来说,绝对值相同的正数和负数只有符号位不同。

2、反码:就是在原码的基础上, 符 号 位 不 变 , 其 余 位 取 反 \color{red}{符号位不变,其余位取反} ,以-10为例,其反码为:
11111111 11111111 11111111 11110101

3、补码 负 数 的 补 码 就 是 反 码 加 1 , 正 数 的 补 码 就 是 原 码 本 身 \color{red}{负数的补码就是反码加1,正数的补码就是原码本身} 1,因此:
10的补码为:00000000 00000000 00000000 00001010
-10的原码为:11111111 11111111 11111111 11110110

那么,为什么要使用补码作为计算机内的实际存储值方式,而不用原码呢?
在《实战Java虚拟机》一书中提到:

使用补码作为计算机内的实际存储值方式至少有两个好处:
1、可以统一数字0的 表示,由于0既不是正数,也不是负数,使用原码时符号难以确定。使用原码表示0的话,难以确定,正数和负数的符号位是不同的。但是使用补码,无论把0归入正数或者负数都会得到相同的结果。
2、使用补码可以简化整数的加减法计算,将减法计算视为加法计算,实现减法和加法的完全统一,实现减法和加法的完全统一。

为了更有体会,现使用8位整数说明这个问题,计算-6+5的过程如下:
-6 补码:11111010
5 补码:00000101
直接相加得:11111111
通过计算可知,补码为11111111,首位1表示为负数,那么反码应该为
“11111111-00000001=11111110”,那么再推其原码就是“10000001”,在8位整数下,也就是“-1”;

再举个例子,计算4+6的过程如下:
4 补码:00000100
6 补码:00000110
直接相加“00000100+00000110=00001010”,也就是10进制的“10”。

可以看到,使用补码表示时,只需要将补码简单地相加,即可得到算术加法的正确结果,则无需区别正数或者负数。

四、解释结果

回到问题上来,我们来解释下,为什么“abcdef”的哈希值为“-1424385949”?

我们上面提到计算“92599395 * 31+102”的时候 ,这过程中java做了默认的类型提升(type promotion),中间结果做为long类型存放,然后“2870581245+102=2870581347”返回结果时目标数据类型int不能够容纳下结果(2870581347>2147483647),于是根据Java的基础类型的变窄转换(Narrowing primitive conversion)规则,把结果宽于int类型宽度的部分全部丢弃。

由于相加的两个数都是正数(正数补码=原码),也就是说它们补码相加的结果就是2870581347的补码,加上这个“和”曾经是用long类型存放,那么long类型的这个值“2870581347”的补码是:

00000000 00000000 00000000 00000000 | 10101011 00011001 10011000 01100011
/ / 中 间 加 条 竖 线 表 示 32 位 分 割 \color{green}{ // 中间加条竖线表示32位分割} //线32

然后基于“变窄转换”,我们丢弃结果高于int类型宽度(32)的部分,得到最终结果补码:

10101011 00011001 10011000 01100011

可以看到首位(符号位)为1,为负数,则-1倒推其反码,得:

10101011 00011001 10011000 01100010

最后符号位不变,对反码取反,推其原码,得到最终结果原码:

11010100 11100110 01100111 10011101 = -1010100 11100110 01100111 10011101

而该二进制原码就是10进制的数字“-1424385949”。至此,已经真相了!

五、参考

String源码中hashCode算法
关于Java中String类的hashCode方法
https://bbs.csdn.net/topics/40216116

你可能感兴趣的:(趣味源码)