字符串:为什么indexOf比contains快得多?

9

如果后者只是对前者的包装,为什么indexOfcontains快得多?

以下是Java API中的代码:

public boolean contains(CharSequence s) {
     return indexOf(s.toString()) > -1;
}

在这个线程中,所选答案展示了一个简短的测试,展示了它们之间的差异。
在这个线程中,所选答案声明附加方法调用的开销并不重要。那么,为什么会有区别呢? 请阅读我的编辑: 几乎所有人都认为微基准测试存在缺陷。奇怪的是,它正好反映了我的用例。
实际上,我一开始就没有怀疑在我的用例中indexOfcontains更快,我只是想知道为什么。
我的意图从来不是编写基准测试!我只是在搜索最有效的方法来测试一个字符串是否包含另一个字符串(对于我的应用程序,这与基准测试无关而是“现实生活中的情况”)。

3
答案选错了。它执行了一个不正确的微基准测试,使得contains()方法看起来比实际上慢。 - Kayaman
@Kayaman,你能指定一个正确的基准吗? - Willi Mentzel
1
在这里查看CoronA的答案 here - sandbo00
1
https://dev59.com/hHRB5IYBdhLWcg3wz6UK - Andy Turner
3个回答

17

contains 方法的实现方式如下:

public boolean contains(CharSequence s) {
    return indexOf(s.toString()) > -1;
}
这意味着如果CharSequence s不是一个java.lang.String,那么调用s.toString()将导致分配和初始化一个新的字符串实例,因此可能会更慢。如果s是一个字符串,则不应该有任何可测量的差异。
PS:这里的测试有缺陷: https://dev59.com/QWMl5IYBdhLWcg3wgnSl#18340277 Java最初以“解释”模式执行,这相当缓慢,当它检测到一段代码被执行多次时,会将其编译为本地代码以加快速度(请阅读JIT编译)。
正如你所看到的,contains内部调用indexOf,这意味着indexOf最终将被编译为本地代码。因此,当他测试indexOf(请注意他在测试contains之后进行测试)时,indexOf可能已经被编译为本地代码。这就是时间差异的原因。尝试反转那些测试的顺序-先测试indexOf,然后测试contains,我敢打赌你会看到完全相反的结果。

JMH来拯救

Benchmark                            Mode  Cnt   Score   Error   Units
StringSearchBenchmark.testContains  thrpt  500  22,071 ± 0,269  ops/us
StringSearchBenchmark.testIndexOf   thrpt  500  22,654 ± 0,233  ops/us

正如您所看到的,差异微不足道,可能是由于额外的方法调用(indexOf() + toString())和系统负载造成的。

源代码:

@Fork(1)
@State(Scope.Benchmark)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Measurement(iterations = 500, time = 50, timeUnit = TimeUnit.MILLISECONDS)
@Warmup(iterations = 10)
@BenchmarkMode(Mode.Throughput)
public class StringSearchBenchmark {
    private static final String STATE = "absdefghijklmnopqrstuvwxyzabsdefghijklmnopqrstuvwxyzabsdefghijklmnopqrstuvwxyzabsdefghijklmnopqrstuvwxyz";
    private static final String SEARCH_TERM = "abcd";

    @Benchmark
    public void testContains(Blackhole sink) {
        sink.consume(STATE.contains(SEARCH_TERM));
    }

    @Benchmark
    public void testIndexOf(Blackhole sink) {
        sink.consume(STATE.indexOf(SEARCH_TERM));
    }
}

即使我交换indexOf和contains的顺序,indexOf仍然更快。在我链接的基准测试中,传递的值也已经是一个字符串!或者"test"在内部是一个CharSequence吗? - Willi Mentzel
无论基准测试是否有缺陷...它正是我应用程序所需要的使用情况 :D - Willi Mentzel
然后进行适当的微基准测试。查看JMH和我的答案中的基准测试。 - Svetlin Zarev
我不明白为什么我的应用程序还是一样工作。需要遍历数组并测试元素是否包含子字符串。请解释一下。 - Willi Mentzel
@SventlinZarev 请进一步解释!就像我说的,基准测试符合我的实际使用情况...它怎么可能不准确呢?我理解其他所有内容(解释..本地),但在我的程序中我也没有选择。JVM会按照自己的意愿进行操作。 - Willi Mentzel
显示剩余6条评论

4

正如其他人所说,这个基准测试非常有缺陷 - Java 代码的性能测试并不像那样工作。您必须预热它,以确保所有类都已加载和解析,所有对象都已加载到内存中,并且已完成通过 HotSpot 编译为本地代码等任何编译操作。只在 main 方法中运行一次代码的天真基准测试不会真正起作用。更好的选择是使用类似 JMH 的东西。给定以下测试:

package com.stackoverflow.example;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Measurement(time = 250, timeUnit = TimeUnit.MILLISECONDS)    
public class MyBenchmark {

    private static final String[] names = new String[]{"jack", "jackson", "jason", "jadifu"};

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(MyBenchmark.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }

    @Benchmark
    public void contains() {
        names[0].contains("ja");
    }

    @Benchmark
    public void containsExplicit() {
        names[0].indexOf("ja".toString());
    }

    @Benchmark
    public void indexOf() {
        names[0].indexOf("ja");
    }

    @Benchmark
    public void matches() {
        names[0].matches(".*ja.*");
    }
}

我得到了以下结果:
Benchmark                      Mode  Cnt     Score    Error   Units
MyBenchmark.contains          thrpt   20   219.770 ±  2.032  ops/us
MyBenchmark.containsExplicit  thrpt   20  1820.024 ± 20.583  ops/us
MyBenchmark.indexOf           thrpt   20  1828.234 ± 18.744  ops/us
MyBenchmark.matches           thrpt   20     3.933 ±  0.052  ops/us

现在,这非常有趣,因为它仍然表明containsindexOf慢得多。但是,如果我稍微改变一下测试,如下所示:

@Benchmark
public void contains() {
    assert names[0].contains("ja");
}

@Benchmark
public void containsExplicit() {
    assert names[0].indexOf("ja".toString()) == 0;
}

@Benchmark
public void indexOf() {
    assert names[0].indexOf("ja") == 0;
}

@Benchmark
public void matches() {
   assert names[0].matches(".*ja.*");
}

我得到了以下结果:
Benchmark                      Mode  Cnt    Score   Error   Units
MyBenchmark.contains          thrpt   20  220.480 ± 1.266  ops/us
MyBenchmark.containsExplicit  thrpt   20  219.962 ± 2.329  ops/us
MyBenchmark.indexOf           thrpt   20  219.706 ± 2.401  ops/us
MyBenchmark.matches           thrpt   20    3.766 ± 0.026  ops/us

在这里,我们使用contains获得了相同的结果,但是indexOf已经变慢以匹配contains。这是非常有趣的结果,为什么会发生这种情况呢?
可能是由于HotSpot认识到indexOf调用的结果从未被检查,而且由于它正在使用final类(String),因此HotSpot可能能够保证调用没有副作用。所以如果我们不关注结果并且调用没有副作用,那我们为什么还要调用它呢?HotSpot能够意识到方法调用是无意义的,并将其完全删除,这可能就是这里发生的事情。这肯定可以解释数量级差异。
但是,为什么对于contains却不能起作用呢?我只能假设这是因为contains接受CharSequence而不是String,CharSequence是一个抽象类,这恰好足以防止HotSpot优化方法调用。
这也表明在Java中进行微基准测试是困难的 - 在优化运行代码的表面下,有很多东西需要处理,一些捷径可能导致非常不准确的基准测试。

1
不应在@Benchmark中使用循环。删除for循环并重新测试。此外,“second”对于这样的测试来说时间范围太大了 - 切换到微秒或毫秒。此外,JVM可能会忽略第二个片段中的断言,并因此优化整个循环,这就是第一个片段中发生的事情。 - Svetlin Zarev
我已经按照你的建议进行了更改。这确实使得代码更易读。我非常确定JVM没有忽略assert语句。你需要使用-ea参数来运行,但是除此之外,assert似乎可以强制HotSpot不要优化整个调用。 - ipsi
@ipsi 真的很有趣!但是为什么要使用indexOf("ja".toString())... "ja"已经是一个字符串了吗? - Willi Mentzel
这是contains方法在内部的工作方式,但我怀疑它在这里并没有做太多事情。编译器或HotSpot将优化调用的那部分。必须再对代码进行一些调整以防止该优化。 - ipsi

1

indexOf是Hotspot JVM内在方法的示例。这意味着java.lang.String中的Java代码根本不用于此方法。有此方法的特殊本机版本。您可以在此处找到此类方法的列表:do_intrinsic(_indexOf


但是它包含调用同一方法的内容 :) 因此从这个角度来看,不应该有任何区别。 - Svetlin Zarev
但是你还需要在内部进行一次额外的调用和比较操作。 - sibnick
是的,但如果你看一下我的基准测试,你会发现性能下降是可以忽略不计的,并不像问题标题所暗示的那样“显著”。 - Svetlin Zarev

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