【问题标题】:Why String.equals() doesn't use hashCode() equality check under the hood?为什么 String.equals() 在底层不使用 hashCode() 相等检查?
【发布时间】:2019-01-06 21:59:47
【问题描述】:

为什么不首先在 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 我感觉我的测量方法可能是错误的,所以欢迎任何 cmets。此外,哈希值保存在 String 中,这也可能会产生一些误导性的结果...

UPD1:正如@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% 的加速。

UPD2 正如@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() 慢。这具有整体意义(正如下面 cmets 中提到的@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 类的源代码。

【问题讨论】:

  • 我对这里使用的基准测试工具一无所知,但我认为它不应该有所作为,或者甚至可能更昂贵地预先检查哈希值。字符串散列函数 afaik 需要字符串的完整迭代,然后 equals 检查可能需要 second 迭代。你会做一个完整的迭代只是为了看看你是否需要做另一个迭代。除非之前已经计算过哈希。
  • 因为相同的哈希码只会确定它们可能相等。 System.out.println("FB".hashCode() == "Ea".hashCode());,然后您必须进一步测试以确定它们是否真的存在。
  • @ArtsiomChapialiou:这是不正确的。 Java 中引用类型的语义对于可变或不可变对象的行为没有不同。使用= 运算符never 的赋值操作会创建一个新对象(装箱转换除外,此处不适用)。 str1 = string1 表示您现在有两个 variables 都引用 same String 对象。所有属于java.lang.Object 子类型的类型都是引用类型。 String 类型的变量不是字符串,而是字符串的引用。
  • @ArtsiomChapialiou:我不会在这里和你争论。我建议你打开一个关于字符串复制的新问题。您有一个根本的误解:您混淆了 assignmentmutation。我很乐意写一个答案来解释实际发生的事情,但是这个评论空间太小了。
  • 为了使情况更加复杂,jvm(s) 通常具有字符串等于代码的内在函数。这意味着 jvm 基本上在平台优化版本中“交换”,而不是您在那里看到的代码。如果在 cpu 上可用,它就开始使用 SSE 4.2 指令,例如早在 2009 年左右。见bugs.openjdk.java.net/browse/JDK-6761600

标签: java string equals hashcode


【解决方案1】:

您调用hashCode() 数千次(使用jmh)的性能测试没有意义,因为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 个字段中计算出来的。

【讨论】:

  • 您能否详细说明或提供一个链接以进一步研究该行:as it is usually uses a short-circuit evaluation.好吗?
  • @PaulNikonowicz 我相信他的意思是除非必要,否则不会完成字段(或字符串情况下的字符)的整个迭代;它使用了提前退出。 Here,见if (v1[i++] != v2[j++]) return false;这一行。
  • @PaulNikonowicz 让我们以 String 类为例 - 如果第一个位置的字符不同,则循环遍历所有剩余字符是没有意义的 - equals() 返回 false - 字符串不相等。而hashCode() 计算哈希码需要遍历所有字符。
  • @AdamSiemion 感谢您指出已兑现的哈希值。我认为 jmh 默认为每个 Benchmark 方法调用创建新参数。看起来Level.Invocation) 可能有助于做到这一点,只是有点害怕 Javadoc... 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 = 字符串2; }
  • 正确的检查是:if (hash!=0 &amp;&amp; anotherString.hash!=0 &amp;&amp; hash==anotherString.hash)
【解决方案2】:

我同意比较hashCode(仅在已经计算过的情况下)看起来可能提高性能,因为String 对象是不可变的。

注意事项:

  1. 提升仅适用于 hashCode 已存在(已计算)的情况。如果尚未计算 hashCode,则计算可能需要比比较 2 个字符串的字符更长的时间。这是因为,在比较时,我们可以在看到差异时立即停止。例如,当比较“aaxxxxxxxxxx”和“aazzzzzzzzzzzz”时,如果字符串不相等,我们可以在第二个字符之后停止。但是计算hashCode 需要遍历所有字符。

  2. 也许作者的决定是基于有关如何使用字符串的统计数据。他们可能已经看到hashCode 的额外比较可能会减慢系统速度。

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

  3. hash 字段可能会在多个线程中同时针对同一个对象进行计算,尤其是在 equals() 使用它的情况下。这是需要考虑的。

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

如果能有一种方法来调整和测量它会很好(字符串是最终的)。也许使用一些字节码操作或使用不同的类加载器......

以下是建议的代码调整(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;
}

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2012-04-27
    • 2013-03-15
    • 1970-01-01
    • 2018-04-03
    相关资源
    最近更新 更多