为什么String的hashCode()不缓存0?


75

我在Java 6的String源代码中注意到,hashCode仅缓存除0以外的值。以下代码段显示了性能差异:

public class Main{
   static void test(String s) {
      long start = System.currentTimeMillis();
      for (int i = 0; i < 10000000; i++) {
         s.hashCode();
      }
      System.out.format("Took %d ms.%n", System.currentTimeMillis() - start);
   }
   public static void main(String[] args) {
      String z = "Allocator redistricts; strict allocator redistricts strictly.";
      test(z);
      test(z.toUpperCase());
   }
}

在ideone.com中运行此命令可获得以下输出:

Took 1470 ms.
Took 58 ms.

所以我的问题是:

  • 为什么String的hashCode()不缓存0?
  • Java字符串散列为0的概率是多少?
  • 避免每次哈希值都为0的字符串每次都重新计算哈希值的性能损失的最佳方法是什么?
  • 这是缓存值的最佳实践方法吗?(即除了一个缓存所有?)

为了您的娱乐,这里的每一行都是一个散列为0的字符串:

pollinating sandboxes
amusement & hemophilias
schoolworks = perversive
electrolysissweeteners.net
constitutionalunstableness.net
grinnerslaphappier.org
BLEACHINGFEMININELY.NET
WWW.BUMRACEGOERS.ORG
WWW.RACCOONPRUDENTIALS.NET
Microcomputers: the unredeemed lollipop...
Incentively, my dear, I don't tessellate a derangement.
A person who never yodelled an apology, never preened vocalizing transsexuals.

6
大声笑!+1是übergeek方式拖钓的一个好例子!
Mike Nakis 2013年

Answers:


57

您不用担心。这是考虑此问题的一种方法。

假设您有一个应用程序什么都不做,只是整年都在哈希字符串周围。假设它需要全部存储在内存中的一千个字符串,以循环方式在它们上反复调用hashCode(),经过一百万次,然后再获取一千个新字符串并再次执行。

并假设字符串的哈希码为零的可能性实际上远大于1/2 ^ 32。我敢肯定,这是有点更大的1/2 ^ 32,但让我们说这是比差很多,像1 / ^ 16(平方根!现在这是一个差很多!)。

在这种情况下,与其他任何人相比,Oracle工程师在改进这些字符串的哈希码的缓存方式方面将受益更多。因此,您写信给他们,并要求他们修复它。他们发挥了魔力,因此只要s.hashCode()为零,它就会立即返回(甚至是第一次!100%的改善!)。可以说,在任何其他情况下,他们这样做都不会降低性能。

万岁!现在您的应用程序...让我们看看...快0.0015%!

过去需要花费一整天的时间现在只需23小时,57分钟和48秒!

并且请记住,我们设置了场景以使怀疑的所有可能好处,通常到了可笑的程度。

这对您来说值得吗?

编辑:自从几个小时前发布此消息以来,我让我的一个处理器疯狂运行以寻找具有零哈希码的两个单词的短语。到目前为止,它提出了:甲壳动物zorillo,计时记述schtoff,挫伤性的回廊状,creashaks Organzine,鼓木boulderhead,可进行电分析的且难以理解的。这是在大约2 ^ 35的可能性中进行的,因此,通过完美的分配,我们希望仅看到8。显然,到完成时,我们的数量将是原来的几倍,但不会更多。更重要的是,我现在提出了一些有趣的乐队名称/专辑名称!没有公平的偷窃!


2
这是一个非常实际的论点。但是出于好奇,这种缓存机制在其他地方也很常见吗?也就是说,如果尝试缓存所有值都需要一个额外的标志,那么最佳做法是只牺牲一个值以使其不可缓存?
polygenelubricants 2010年

2
我敢肯定我已经使用了这一技巧一两次。当然,与大多数类相比,对String类的要求非常不同。非常合适的用户名btw :)
Kevin Bourrillion,2010年

20
是的,我的用户名证明我最近对String的hashCode()非常着迷。约书亚·布洛赫(Joshua Bloch)在2007年7月23日的Google Tech Talk视频中说,他在10分钟内发现了(200,000)^ 2个单词对中的“多基因润滑剂”。我利用了哈希函数的属性,仅用了几秒钟就可以在O(N)中进行操作。例如,以下字符串也散列为MIN_VALUE: "And so my fellow mismanagements: ask not what your newsdealer can sugarcoat for you -- ask what you can sugarcoat for your newsdealer."
polygenelubricants 2010年

6
如果字符串来自用户,则可能性接近1。您知道有人会尝试。
锑2012年

1
我想对于多基因
润滑剂

24

它使用0表示“我尚未计算出哈希码”。替代方法是使用单独的布尔标志,这将占用更多内存。(或者,当然完全不缓存哈希码。)

我不希望许多字符串散列为0。可以说,对于哈希例程故意避免0(例如,将0的哈希转换为1,并对其进行缓存)是有意义的。这会增加冲突,但避免重新哈希。不过,现在这样做已经太迟了,因为已明确记录了String hashCode算法。

至于总体上这是否是个好主意:这是一种肯定有效的缓存机制,并且通过更改来避免重新哈希以哈希0结尾的值可能会更好(请参见编辑)。我个人很想看看最初使Sun认为值得这样做的数据-它为每个创建的字符串占用了额外的4个字节,但是通常对其进行哈希处理的次数很少或很少,并且唯一的好处是对哈希进行了一次以上的字符串。

编辑:正如KevinB在其他地方的评论中指出的那样,上面的“避免0”建议很可能会产生净成本,因为它可以帮助非常罕见的情况,但是每次哈希计算需要进行额外的比较。


我刚刚添加了一个最佳实践标签和第四个问题,使之成为设计问题。应该这样吗?每次调用该方法时,保存O(n)的非零概率是否应该工作(由于Strings和hashCode()是Java的基本组成部分,因此将被称为大量)证明额外的O(1)存储空间是合理的吗?还是通常只缓存除一个值之外的所有值而不具有标志的最佳实践?
polygenelubricants 2010年

1
@Stephen C:假设哈希分布完美。我不知道String使用的情况是否如此。
乔恩·斯基特

1
“我不希望许多字符串散列为0”。好吧,除非故意选择了字符串。
Tom Hawtin-大头钉

1
“好吧,除非故意选择了字符串。”“”,可能是Java世界中最常见的字符串(甚至知道将多少个字符串初始化为“”并且从未更改过,对吗?)和“” .hashCode ()为0。例如,我看不到使用“”作为映射键的许多用例,但是我敢肯定它会发生,因此这可能是不成比例的昂贵。就是说,“”。hashCode()基本上只是执行从0到0的循环,所以我认为它不会很慢...即使是这样,谁在乎(请参阅Kevin的回答)
Cowan

1
@Sergio:是的。例如,"aaaaaa".hashCode()返回-1425372064。
乔恩·斯基特

19

我认为到目前为止还没有其他答案很重要:存在零值,以便hashCode缓存机制可以在多线程环境中稳定运行。

如果您有两个变量,例如cachedHashCode本身和一个isHashCodeCalculated布尔值(用于指示是否已计算cachedHashCode),则需要线程同步才能使事情在多线程环境中工作。同步将对性能造成不利影响,尤其是因为字符串通常在多个线程中重用。

我对Java内存模型的理解有些粗略,但是大致情况是这样的:

  1. 当多个线程访问一个变量(例如缓存的hashCode)时,不能保证每个线程都会看到最新的值。如果变量从零开始,则A更新它(将其设置为非零值),然后线程B不久后读取它,线程B仍然可以看到零值。

  2. 从多个线程访问共享值(没有同步)还有另一个问题-您最终可能会尝试使用仅部分初始化的对象(构造对象不是原子过程)。诸如longs和doubles之类的64位基元的多线程读写也不一定是原子的,因此,如果两个线程尝试读取和更改long或double的值,则一个线程可能最终会看到一些奇怪且部分设置的东西。还是类似的东西。如果您尝试同时使用两个变量(例如cachedHashCode和isHashCodeCalculated),也会遇到类似的问题-线程很容易出现,可以看到其中一个变量的最新版本,但可以看到另一个变量的旧版本。

  3. 解决这些多线程问题的通常方法是使用同步。例如,您可以将对缓存的hashCode的所有访问都放在一个同步块中,或者可以使用volatile关键字(尽管要小心一点,因为语义有些混乱)。

  4. 但是,同步会使事情变慢。像字符串hashCode这样的东西的坏主意。字符串在HashMaps中经常用作键,因此您需要hashCode方法才能良好地执行,包括在多线程环境中。

  5. 32位或更少的Java原语(例如int)是特殊的。与长(64位值)不同,可以确保不会读取int(32位)的部分初始化值。当您读取不同步的int时,您不能确定将获得最新的设置值,但是可以确定获取的值是线程或线程在某个时候明确设置的值。另一个线程。

java.lang.String中的hashCode缓存机制设置为依赖上面的第5点。通过查看java.lang.String.hashCode()的源,您可能会更好地理解它。基本上,在多个线程一次调用hashCode的情况下,hashCode可能最终会被多次计算(如果计算出的值为零,或者如果多个线程一次调用hashCode并且都看到零缓存值),但是您可以确保hashCode ()将始终返回相同的值。因此,它很健壮,并且性能也很高(因为在多线程环境中没有同步可成为瓶颈)。

就像我说的那样,我对Java内存模型的理解有些粗略,但是我很确定自己已经掌握了上述要点。最终,这是缓存哈希码而无需同步开销的非常聪明的习惯用法。


您不一定需要同步-正如您所提到的,有些东西像volatile。尽管确实需要谨慎对待volatile,但我认为可以肯定地说String类的作者很可能知道如何正确使用它,或者有合适的人来咨询。我同意你的意思……但是我仍然不太相信完全值得缓存,而且系统中每个字符串的内存开销仍然存在:(
Jon Skeet 2010年

1
据我了解,volatile是同步的一种形式,只是它的开销要比synced关键字少。我找到了此链接cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html,其中部分解释了String哈希码中使用的惯用法。我自己更喜欢它-我想我实际上将开始更多地使用它:)尽管我非常感谢您对内存的观点-这在某些方面可能是个问题。BTW String.intern()是为什么多线程性能对字符串很重要的原因-JVM可以在内部重新使用它们。
MB。

1
这可能是将零视为特殊值的一个很好的理由,但是这并不是让哈希函数返回不可缓存的值的一个很好的理由。在哈希函数中包含类似以下内容if (computedHash != 0) return computedHash; else return «some other function»;是否会有任何困难?即使另一个函数只是简单地将字符串中第一个字符的ASCII值加上字符串中最后一个字符的ASCII值的991倍,再加上1234567890,也不会使分布变得太多。
2012年

if (computedHash != 0) return computedHash; else return «some other function»;实际上什么hashCode功能,只需小心应用,如果周围它是由多个线程调用会发生什么。您可以看一下源代码。除了多线程,它只是意味着,如果计算出的哈希码为零(无论如何不太可能),则每次调用该函数时都会重新计算哈希码。
MB。

我同意第一点。@supercat花了一段时间,但他们解决了这个问题。
尤金(Eugene)

8

由于实现将缓存值0解释为“尚未初始化的缓存值”,因此未缓存0。另一种选择是使用java.lang.Integer,其中null表示该值尚未缓存。但是,这将意味着额外的存储开销。

关于将字符串的哈希码计算为0的概率,我会说该概率非常低,并且可能在以下情况下发生:

  • 字符串为空(尽管每次都重新计算此哈希码实际上是O(1))。
  • 发生溢出,最终计算出的哈希码为0(e.g. Integer.MAX_VALUE + h(c1) + h(c2) + ... h(cn) == 0)。
  • 该字符串仅包含Unicode字符0。这不太可能,因为这是一个控制字符,除了“纸带世界”(!)之外,没有其他意义:

维基百科

代码0(ASCII代码名称NUL)是一种特殊情况。在纸带中,就是没有孔的情况。将其视为填充字符很方便,而没有其他含义


如果您使用本机代码new File(“ C:\\ CONFIG.SYS \ u0000ignored”)。isFile()== true,那么在我的Windows计算机上,\ u0000仍然有效。它是各种安全问题的根源。对于大多数应用程序,请过滤此字符!
Thomas Jung

@Thomas Jung如果必须查看文件路径,请首先对其进行规范化(当然,白名单字符不会被列入黑名单)。即使那样也无法帮助您克服符号链接。
Tom Hawtin-大头钉

1
请注意,如果您有非NUL字符,则字符串必须为6或7个字符长,然后才能具有零哈希码。
Tom Hawtin-大头钉

6

事实证明,这是一个与安全漏洞有关的好问题。

“在对字符串进行哈希处理时,Java还将哈希值缓存在hash属性中,但前提是结果不为零。因此,目标值零对于攻击者而言特别有趣,因为它可以防止缓存并强制重新哈希。”



2

十年后,情况发生了变化。老实说,我真不敢相信(但是我内心的怪胎非常高兴)。

正如您已经指出的String::hashCode,有些字符串可能存在某些位置,zero并且没有缓存(可以做到这一点)。很多人争辩(包括在此问答中)为什么在中没有添加字段java.lang.String,例如:,hashAlreadyComputed然后简单地使用它。问题很明显:每个String实例都有多余的空间。有BTW一个理由 java-9引入compact Strings很简单,因为许多基准测试表明,在大多数应用程序中,这是一个相当(过度)使用的类。增加更多空间?决定是:否。特别是由于最小的可能的加法是1 byte,而不是1 bit(对于32 bit JMV,额外的空间本来是8 bytes :1表示标志,7表示对齐)。

因此,Compact String出现了java-9,如果您仔细看(或关心),他们确实在中添加了一个字段java.lang.Stringcoder。我不是只是反对吗?它不是那么容易。紧凑字符串的重要性似乎超出了“多余空间”的论点。同样重要的是要说多余的空间32 bits VM仅是重要的(因为对齐没有间隙)。相反,在jdk-8布局中java.lang.String是:

java.lang.String object internals:
 OFFSET  SIZE     TYPE DESCRIPTION                           VALUE
  0    12          (object header)                           N/A
 12     4   char[] String.value                              N/A
 16     4      int String.hash                               N/A
 20     4          (loss due to the next object alignment)
 Instance size: 24 bytes
 Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

注意一个重要的事情:

Space losses : ... 4 bytes total.

因为每个java对象都是对齐的(多少取决于JVM和某些启动标志UseCompressedOops,例如),String所以之间有一个缺口4 bytes,未使用。因此,在添加时coder1 byte 无需添加额外的空间即可。这样,之后 Compact String添加s,布局已更改:

java.lang.String object internals:
 OFFSET  SIZE     TYPE DESCRIPTION                           VALUE
  0    12          (object header)                           N/A
 12     4   byte[] String.value                              N/A
 16     4      int String.hash                               N/A
 20     1     byte String.coder                              N/A
 21     3          (loss due to the next object alignment)
 Instance size: 24 bytes
 Space losses: 0 bytes internal + 3 bytes external = 3 bytes total

coder1 byte了,差距缩小了3 bytes。因此,“损坏”已经发生jdk-9。因为-有32 bits JVM增加,8 bytes : 1 coder + 7 gap64 bit JVM-没有增加,因此coder从差距中占据了一些空间。

现在,jdk-13他们决定利用that gap,因为它仍然存在。让我提醒您,具有零hashCode的String的概率为40亿分之一;仍然有人说:那又怎样?让我们解决这个问题!Voilá:jdk-13布局java.lang.String

java.lang.String object internals:
OFFSET  SIZE      TYPE DESCRIPTION                            VALUE
  0    12           (object header)                           N/A
 12     4    byte[] String.value                              N/A
 16     4       int String.hash                               N/A
 20     1      byte String.coder                              N/A
 21     1   boolean String.hashIsZero                         N/A
 22     2           (loss due to the next object alignment)
 Instance size: 24 bytes
 Space losses: 0 bytes internal + 2 bytes external = 2 bytes total

这里是: boolean String.hashIsZero。它在代码库中:

public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value)
                       : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;
        }
    }
    return h;
}

等待!h == 0 hashIsZero田野?那不应该这样命名:hashAlreadyComputed?为什么实现不符合以下要求:

    @Override
    public int hashCode(){
        if(!hashCodeComputed){
            // or any other sane computation
            hash = 42;
            hashCodeComputed = true;
        }

        return hash;
    }

即使我阅读了源代码下的注释:

    // The hash or hashIsZero fields are subject to a benign data race,
    // making it crucial to ensure that any observable result of the
    // calculation in this method stays correct under any possible read of
    // these fields. Necessary restrictions to allow this to be correct
    // without explicit memory fences or similar concurrency primitives is
    // that we can ever only write to one of these two fields for a given
    // String instance, and that the computation is idempotent and derived
    // from immutable state

只有在阅读这篇文章后,才有意义。有点棘手,但这一次只写一个,在上面的讨论中有很多细节。


0
  • 为什么String的hashCode()不缓存0?

保留值为零,表示“未缓存哈希码”。

  • Java字符串散列为0的概率是多少?

根据Javadoc,字符串哈希码的公式为:

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

使用int算术,哪里s[i]是字符串的第i个字符,n的长度。(在特殊情况下,空字符串的哈希值定义为零。)

我的直觉是,上面的哈希码函数在值范围内均匀分布了字符串哈希int值。均匀分布,这意味着随机生成的String散列为零的概率为2 ^ 32中的1。

  • 避免每次哈希值都为0的字符串每次都重新计算哈希值的性能损失的最佳方法是什么?

最好的策略是忽略该问题。如果您重复哈希相同的String值,则您的算法会有些奇怪。

  • 这是缓存值的最佳实践方法吗?(即除了一个缓存所有?)

这是时间与空间的权衡。AFAIK,替代品是:

  • cached向每个String对象添加一个标志,使每个Java String都需要一个额外的单词。

  • 使用hash成员的最高位作为缓存标志。这样,您可以缓存所有哈希值,但是只有一半的可能的String哈希值。

  • 根本不要在字符串上缓存哈希码。

我认为Java设计师对Strings进行了正确的调用,而且我确信他们已经进行了广泛的分析,从而证实了他们所做决定的正确性。但是,它并没有跟随,这将始终是处理缓存的最佳方法。

(请注意,有两个“公用”字符串值散列为零;空字符串和仅由NUL字符组成的字符串。但是,计算这些值的散列码的成本与计算散列码的成本相比很小)。典型String值的哈希码。)


我不相信2 ^ 32中的1是正确的:对于较短的字符串,哈希码将在[0,Integer.MAX_VALUE]范围内;对于任何足以导致溢出的字符串,哈希码将在以下范围内:[ Integer.MIN_VALUE,Integer.MAX_VALUE]。因此,对于随机生成的字符串(并假设使用均匀分布的哈希算法),分布并不完全均匀;哈希码为正数或零数的可能性比负数为高。
亚当斯基

hashCode算法会很快导致整数溢出,Adamski。以几个随机的例子为例,看起来6个单词字符就足够了-但是我认为您的推理是正确的,这确实会导致向正的散列值倾斜(随着您的字符串变长,它会降低)
oxbow_lakes 2010年

随机生成的字符串具有随机长度和随机字符。
斯蒂芬·C

@Stephen:随机长度是我的确切观点:对于包含随机字符的随机长度字符串的完全均匀分布,由于较短的字符串不会引起溢出,因此会有更多的字符串散列为> = 0 。
Adamski

您忽略了我在答案中列出的选项:添加“ if(hash == 0)hash = 1;” 在算法的末尾。这样,您就不会丢失普通哈希值的一半,而不会丢失一半。
乔恩·斯基特

0

好吧,它保持为0,因为如果长度为零,则无论如何最终将为零。

很快就可以确定len为零,因此哈希码也必须为零。

因此,供您进行代码审查!这就是Java 8的全部荣耀:

 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;
    }

如您所见,如果字符串为空,它将始终返回快速零:

  if (h == 0 && value.length > 0) ...

0

“避免0”的建议似乎适合作为最佳实践,因为它有助于在写之前进行分支操作的微薄成本,从而解决一个真正的问题(在可能由攻击者提供的可构造情况下严重的意外性能下降)。如果仅有的东西进入设置的哈希值到特殊的调整值,则可以执行一些剩余的“意外性能降低”。但这至少是2倍的降级,而不是无限的。

当然,String的实现不能更改,但是不需要使该问题永久存在。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.