Java编程高级Java开发人员

在什么具体的原子性保证下,**String.hashCode()** 在其内部哈希缓存被多个线程并发填充的情况下安全地省略了**volatile**修饰符?

用 Hintsage AI 助手通过面试

回答

问题的历史

JSR 133规范(Java 5)发布之前,Java内存模型缺乏正式的发生顺序规则,使得无害的数据竞争变得危险。String 一直是一个性能关键的不可变类,广泛用于HashMap操作。早期的JDK版本引入了懒哈希缓存,以避免对大字符串反复计算哈希值。省略hash字段的volatile修饰符是一个有意的优化,早于现代并发原语,依赖于计算的幂等性和Java 5中JLS添加的特定原子性保证。

问题

当多个线程同时在新创建的String上调用hashCode()时,它们可能会同时观察到hash字段的默认值0。没有同步机制的情况下,这会创建一个数据竞争,多个线程可能在相同时间计算哈希值并尝试写回。挑战在于确保没有线程观察到部分写入(撕裂)的哈希值或不一致的状态,同时避免与每次hashCode()调用相关联的volatile读写的高昂内存屏障成本。

解决方案

该解决方案依赖于两个基本的JMM属性。首先,Java语言规范(§17.7)保证对32位原始值(int)的写入是原子性的,防止字拆分。其次,String构造函数通过其final value 字段建立了发生顺序关系,确保任何接收该引用的线程可以完全看到支持数组。由于哈希计算是该不可变、已安全发布数据的纯函数,对缓存的填充竞争是无害的。如果线程读取到过时的0,它简单地重新计算相同的值;如果读取到缓存值,则使用该值。原子写入确保该值要么被完全观察到,要么不被观察到,绝不会损坏。

public int hashCode() { int h = hash; // 非volatile读取:可能看到0或缓存值 if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // 原子写入:32位赋值不可分割 } return h; }

生活中的情况

我们设计了一个高吞吐量的摄入服务,每秒处理数百万条CSV记录。每条记录为ConcurrentHashMap缓存生成多个String键。性能分析显示,**hashCode()**计算消耗了15%的CPU时间,原因在于大型字符串键。

解决方案A:volatile哈希字段。 我们考虑在自定义String包装器中为hash字段添加volatile。优点包括在所有内核间的即时可见性和严格的顺序一致性。然而,缺点也很严重:JMH基准测试显示,由于每次映射操作的缓存一致性流量和内存屏障成本,吞吐量下降了400%。

解决方案B:同步hashCode()。 我们测试了对计算进行同步。优点是简单且绝对正确。缺点是灾难性的争用;在32个线程的情况下,延迟从每次操作2纳秒飙升至800纳秒,因为线程排队等待监视器。

解决方案C:无害竞争(当前实现)。 我们保留了未同步的幂等缓存。优点是零同步开销并与核心数量完美可扩展。缺点是理论上的:在首次访问时,如果线程竞争,可能导致偶尔的冗余计算。我们选择了解决方案C,因为重新计算哈希(缓存未命中)的成本与缓存一致性协议(volatile)或争用(synchronized)的成本相比微不足道。

结果: 系统在每核心每秒维持250万次操作,而**hashCode()**并未出现在前100个热门方法中,验证了无害的数据竞争是该不可变数据结构的正确架构权衡。

候选人常常忽视的内容

为什么没有volatile不会违反创建字符串的线程与计算其哈希值的线程之间的发生顺序关系?

发生顺序关系实际上是由String对象本身的安全发布建立的,而不是哈希字段。当一个String被构造时,它的final value 字段保证了支持数组内容对任何接收引用的线程是可见的。hash字段只是一个缓存;观察到其默认值0是一个有效的程序状态,只是触发了计算。JMM确保不可变的value数组是一致的,并且由于哈希是纯粹从这些可见数据派生的,因此计算结果不论哪个线程执行均会相同。

是否可以将同样的优化应用于64位长哈希值而不使用volatile?

不可以。JMM只保证在所有架构上对32位原始值(intfloat)的原子性。对于64位原始值(longdouble),规范允许在32位JVM或某些架构上出现字拆分,而无需使用volatile或同步。线程理论上可观察到一个计算哈希的高32位和另一个计算哈希的低32位,从而导致完全错误的非零哈希值,损坏HashMap的桶放置。因此,缓存64位哈希需要volatileAtomicLong

这与单例初始化的破损"双重检查锁定"习惯法有何不同?

关键区别在于安全发布和幂等性。在破损的双重检查锁定中,问题是观察到对尚未完成构造函数的对象的非空引用(引用赋值与构造执行的重排序)。在**String.hashCode()**中,String对象本身已经安全发布并完全构造;hash字段仅仅是懒惰初始化的纯数据缓存。看到0(未初始化)并不是部分构造,而是有效的初始状态。此外,该操作是幂等的——多个线程写入相同的计算值会产生与一个线程写入相同结果,而DCL则需要精确一次的实例创建。