为什么String.equals()方法在内部不使用hashCode()进行相等性检查?

3

为什么不在equals()方法中使用hashCode()来预先检查相等性呢?

快速测试草案:

@Fork(value = 1)
@Warmup(time = 1)
@Measurement(time = 1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)

public class Main {

  @Param({
    "o", // differ size
    "oooooooooooooooooo1", // same size, differ last symbol
    "oooooooooooooooooo2" // same content
  })
  String string1;

  @Param({
    "oooooooooooooooooo2"
  })
  String string2;

  @Benchmark
  public void stringEquals(Blackhole bh) {
    bh.consume(string1.equals(string2));
  }

  @Benchmark
  public void myEquals(Blackhole bh) {
    bh.consume(myEquals(string1, string2));
  }

  boolean myEquals(String str1, String str2){
    if (str1.hashCode()==str2.hashCode()) {
      return str1.equals(str2);
    }
    return false;
  }
}

结果:

Benchmark                    (string1)            (string2)  Mode  Cnt   Score   Error  Units
Main.myEquals                        o  oooooooooooooooooo2  avgt    5   5.552 ± 0.094  ns/op
Main.myEquals      oooooooooooooooooo1  oooooooooooooooooo2  avgt    5   5.626 ± 0.173  ns/op
Main.myEquals      oooooooooooooooooo2  oooooooooooooooooo2  avgt    5  14.347 ± 0.234  ns/op
Main.stringEquals                    o  oooooooooooooooooo2  avgt    5   6.441 ± 1.076  ns/op
Main.stringEquals  oooooooooooooooooo1  oooooooooooooooooo2  avgt    5  13.596 ± 0.348  ns/op
Main.stringEquals  oooooooooooooooooo2  oooooooooooooooooo2  avgt    5  13.663 ± 0.126  ns/op

如您所见,对于“大小相同,最后一个符号不同”的情况,我们获得了很好的加速。

我认为在String.equals()的实现中,hashCode()的相等性检查应该替换为length()的相等性检查,因为它们需要相同的时间:

  @Benchmark
  public void emptyTest(Blackhole bh) {
    bh.consume(0);
  }

  @Benchmark
  public void stringLength(Blackhole bh) {
    bh.consume(string2.length());
  }

  @Benchmark
  public void stringHashCode(Blackhole bh) {
    bh.consume(string2.hashCode());
  }

Benchmark                      (string2)  Mode  Cnt  Score   Error  Units
Main.emptyTest       oooooooooooooooooo2  avgt    5  3.702 ± 0.086  ns/op
Main.stringHashCode  oooooooooooooooooo2  avgt    5  4.832 ± 0.421  ns/op
Main.stringLength    oooooooooooooooooo2  avgt    5  5.175 ± 0.156  ns/op

PS 我觉得我的测量方法可能有误,所以欢迎任何评论。此外,哈希保存在String内部,这也可能产生一些误导性的结果...

更新1: 正如@AdamSiemion提到的,我们需要在每次调用基准测试方法时重新创建字符串,以避免哈希码的缓存:

  String str1, str2;

  @Setup(value = Level.Invocation)
  public void setup(){
    str1 = string1;
    str2 = string2;
  }

  @Benchmark
  public void stringEquals(Blackhole bh) {
    bh.consume(str1.equals(str2));
  }

  @Benchmark
  public void myEquals(Blackhole bh) {
    bh.consume(myEquals(str1, str2));
  }

Benchmark                    (string1)            (string2)  Mode  Cnt   Score   Error  Units
Main.myEquals                        o  oooooooooooooooooo2  avgt    5  29.417 ± 1.430  ns/op
Main.myEquals      oooooooooooooooooo1  oooooooooooooooooo2  avgt    5  29.635 ± 2.053  ns/op
Main.myEquals      oooooooooooooooooo2  oooooooooooooooooo2  avgt    5  37.628 ± 0.974  ns/op
Main.stringEquals                    o  oooooooooooooooooo2  avgt    5  29.905 ± 2.530  ns/op
Main.stringEquals  oooooooooooooooooo1  oooooooooooooooooo2  avgt    5  38.090 ± 2.933  ns/op
Main.stringEquals  oooooooooooooooooo2  oooooooooooooooooo2  avgt    5  36.966 ± 1.642  ns/op

所以,对于“大小相同,最后一个符号不同”的情况,我们仍然有近30%的加速。

更新2 正如@DanielPryden提到的,str1 = string1不会创建新的字符串。因此我们需要明确地这样做:

  @Setup(value = Level.Invocation)
  public void setup(){
    str1 = new String(string1);
    str2 = new String(string2);
  }

Benchmark                    (string1)            (string2)  Mode  Cnt   Score   Error  Units
Main.myEquals                        o  oooooooooooooooooo2  avgt    5  61.662 ± 3.068  ns/op
Main.myEquals      oooooooooooooooooo1  oooooooooooooooooo2  avgt    5  85.761 ± 7.766  ns/op
Main.myEquals      oooooooooooooooooo2  oooooooooooooooooo2  avgt    5  92.156 ± 8.851  ns/op
Main.stringEquals                    o  oooooooooooooooooo2  avgt    5  30.789 ± 0.731  ns/op
Main.stringEquals  oooooooooooooooooo1  oooooooooooooooooo2  avgt    5  38.602 ± 1.212  ns/op
Main.stringEquals  oooooooooooooooooo2  oooooooooooooooooo2  avgt    5  38.921 ± 1.816  ns/op

所以,现在我们得到了预期的结果:使用hashCode()总是比equals()慢。这是有道理的(正如@Carcigenicate在下面的评论中提到的):hashCode()需要完全遍历char[]来生成哈希值。我曾经认为可能是hashCode()底层的某些内在机制使其更快,但事实并非如此。
因此,如果检查预先计算的hash是否存在并进行比较,则仍然可以加速equals()
public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length
           // new code begins
            && (hash==0 || anotherString.hash==0 || hash==anotherString.hash)) {
           // new code ends
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

当检查 hash 字段时,如果字符串相等,我们会遇到一些轻微的减速,但是对于长度相同但内容不同且已经预先计算哈希值的字符串,将获得加速效果。

不幸的是,由于无法更改 String 类的源代码,因此我无法进行测试。


3
我对这里使用的基准测试工具一无所知,但我认为预先检查哈希值可能没什么必要,甚至可能会更加昂贵。据我所知,字符串哈希函数需要对整个字符串进行完整迭代,然后 equals 检查可能会需要第二次迭代。你将会进行一次完整的迭代,只是为了看是否需要执行另一个迭代,除非该哈希值已经被预先计算过。 - Carcigenicate
2
因为相同的哈希码只能表明它们可能是相等的。System.out.println("FB".hashCode() == "Ea".hashCode());,然后您需要进一步测试以确定它们是否实际上相等。 - Elliott Frisch
2
@ArtsiomChapialiou:那是不正确的。在Java中,引用类型的语义对于可变或不可变对象的行为没有区别。使用=运算符的赋值操作永远不会创建一个新对象(除了装箱转换,这里不适用)。str1 = string1意味着您现在有两个变量都引用同一个字符串对象。所有的子类型都属于java.lang.Object的类型都是引用类型。类型为String的变量不是一个字符串,而是一个指向字符串的引用。 - Daniel Pryden
2
@ArtsiomChapialiou:我不打算在这里和你争论。我建议你开一个新的问题来探讨字符串复制。你有一个基本的误解:你混淆了赋值变异。我很乐意写一个答案解释实际发生的情况,但是这个评论空间太小了。 - Daniel Pryden
2
为了进一步复杂化情况,JVM通常会针对String equals代码使用内置函数。这意味着JVM基本上会“交换”平台优化版本,而不是您在那里看到的代码。例如,它从2009年开始就开始使用CPU上可用的SSE 4.2指令。请参见https://bugs.openjdk.java.net/browse/JDK-6761600。 - Mattias Isegran Bergander
显示剩余11条评论
2个回答

1

您使用jmh调用hashCode()数千次来进行性能测试是没有意义的,因为String哈希码已被缓存:

/** Cache the hash code for the string */
private int hash; // Default to 0

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哈希码,调用hashCode()几乎没有任何成本——与大多数Java类相反,它们每次调用hashCode()时都会重新计算哈希码。

通常equals()hashCode()更快,因为它通常使用短路评估。例如,如果您有一个包含10个字段的调用,并且两个提供的实例的第一个字段的值不同,equals()将不会检查剩余的9个字段,而hashCode()(通常)将从所有10个字段计算出来。


你能详细说明或提供一个链接,以便进一步研究关于这行代码的内容吗:因为它通常使用短路评估。谢谢! - Paul Nikonowicz
2
@PaulNikonowicz 我相信他的意思是,除非必要,否则不会完成字段(或字符串情况下的字符)的整个迭代;它使用了早期退出。在这里,请看到行if (v1[i++] != v2[j++]) return false; - Carcigenicate
1
@PaulNikonowicz,让我们以String类为例 - 如果第一个位置的字符不同,则没有必要循环遍历所有剩余的字符 - equals()返回false - 字符串不相等。而hashCode()计算哈希码需要循环遍历所有字符。 - Adam Siemion
@AdamSiemion 感谢您指出缓存哈希的问题。我以为jmh默认为每个基准方法调用创建新参数。似乎Level.Invocation)可能有助于解决这个问题,只是有点害怕它的Javadoc... <code> String str1, str2; @Benchmark public void stringEquals(Blackhole bh) { bh.consume(str1.equals(str2)); } @Benchmark public void myEquals(Blackhole bh) { bh.consume(myEquals(str1, str2)); } @Setup(value = Level.Invocation) public void setup(){ str1 = string1; str2 = string2; }</code> - Artsiom Chapialiou
1
正确的检查应该是:if (hash!=0 && anotherString.hash!=0 && hash==anotherString.hash) - Artsiom Chapialiou
显示剩余3条评论

1
我认为,如果已经计算了哈希码,比较hashCode似乎可以提高性能,因为String对象是不可变的。
考虑因素:
  1. 增强只有在已经存在hashCode(已被计算)时才会生效。如果hashCode尚未计算,则计算时间可能比比较两个字符串的字符还要长。因为在比较时,我们可以在看到差异后立即停止。例如,当比较“aaxxxxxxxxxx”和“aazzzzzzzzzzz”时,如果字符串不相等,我们可以在第二个字符后停止。但是计算hashCode需要遍历所有字符。

  2. 也许编写者的决定是基于关于字符串使用方式的统计数据。他们可能已经发现,hashCode的附加比较可能会减慢系统速度。

    例如,如果大多数字符串都在哈希映射/表中使用,则hashCode已经被比较和使用。剩下要比较的所有字符串具有相同的hashCode,因此无需再次比较hashCode

  3. 对于同一对象,可能会在多个线程中同时计算hash字段,特别是如果equals()使用它。这需要考虑到。

  4. 另一个考虑因素是内存使用情况。也许JVM中有优化,如果int字段为零,则不使用内存?一旦它不为零,可能会增加内存消耗吗?

希望能够有一种方法来调整和衡量这个(String是final的)。也许可以使用一些字节码操作或使用不同的类加载器...

以下是所建议的代码调整(OpenJDK和Oracle看起来相同):

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }

    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {

            // THE HASHCODE TWEAK
            if (hash != 0 &&
                anotherString.hash != 0 &&
                hash == anotherString.hash)
            {
                return true;
            }

            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接