Почему String.equals намного медленнее для неидентичных (но равных) объектов String?

Я вникаю в вопрос о том, что String.equals() действительно так плохо, и, пытаясь сделать некоторый бенчмаркинг, натолкнулся на некоторые неожиданные результаты.

Используя jmh, я написал простой тест (код и pom в конце), который показывает, сколько раз функция может быть запущена за 1 секунду.

Benchmark                                Mode  Samples          Score   Score error  Units
c.s.SimpleBenchmark.testEqualsIntern    thrpt        5  698910949.710  47115846.650  ops/s
c.s.SimpleBenchmark.testEqualsNew       thrpt        5     529118.774     21164.872  ops/s
c.s.SimpleBenchmark.testIsEmpty         thrpt        5  470846539.546  19922172.099  ops/s

Это фактор 1300x между testEqualsIntern и testEqualsNew, что, откровенно говоря, довольно удивительно для меня.

В коде String.equals() есть тест для одного и того же объекта, который довольно быстро удаляет одинаковые (интернированные в этом случае) строковые объекты, Я просто испытываю большие трудности, полагая, что дополнительный код, который, по-видимому, означает перемещение массива размером 1 для двух тестов и сравнение элементов, - это большая часть производительности.

Я также поставил тест с другим простым вызовом метода в String, чтобы убедиться, что я не вижу что-то слишком сумасшедшее.

package com.shagie;

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;

public class SimpleBenchmark {
    public final static int ITERATIONS = 1000;
    public final static String EMPTY = "";
    public final static String NEW_EMPTY = new String("");

    @Benchmark
    public int testEqualsIntern() {
        int count = 0;
        String str = EMPTY;

        for(int i = 0; i < ITERATIONS; i++) {
            if(str.equals(EMPTY)) {
                count++;
            }
        }
        return count;
    }

    @Benchmark
    public int testEqualsNew() {
        int count = 0;
        String str = NEW_EMPTY;

        for(int i = 0; i < ITERATIONS; i++) {
            if(str.equals(EMPTY)) {
                count++;
            }
        }
        return count;
    }

    @Benchmark
    public int testIsEmpty() {
        int count = 0;
        String str = NEW_EMPTY;

        for(int i = 0; i < ITERATIONS; i++) {
            if(str.isEmpty()) {
                count++;
            }
        }
        return count;
    }


    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
          .include(".*" + SimpleBenchmark.class.getSimpleName() + ".*")
          .warmupIterations(5)
          .measurementIterations(5)
          .forks(1)
          .build();

        new Runner(opt).run();
    }
}

.pom для maven (чтобы быстро настроить его, если вы хотите воспроизвести это):

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.shagie</groupId>
    <artifactId>bench</artifactId>
    <version>1.0</version>
    <packaging>jar</packaging>

    <name>String Benchmarks with JMH</name>

    <prerequisites>
        <maven>3.0</maven>
    </prerequisites>

    <dependencies>
        <dependency>
            <groupId>org.openjdk.jmh</groupId>
            <artifactId>jmh-core</artifactId>
            <version>${jmh.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjdk.jmh</groupId>
            <artifactId>jmh-generator-annprocess</artifactId>
            <version>${jmh.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <jmh.version>0.9.5</jmh.version>
        <javac.target>1.6</javac.target>
        <uberjar.name>benchmarks</uberjar.name>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.1</version>
                <configuration>
                    <compilerVersion>${javac.target}</compilerVersion>
                    <source>${javac.target}</source>
                    <target>${javac.target}</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>2.2</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <finalName>${uberjar.name}</finalName>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>org.openjdk.jmh.Main</mainClass>
                                </transformer>
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
        <pluginManagement>
            <plugins>
                <plugin>
                    <artifactId>maven-clean-plugin</artifactId>
                    <version>2.5</version>
                </plugin>
                <plugin>
                    <artifactId>maven-deploy-plugin</artifactId>
                    <version>2.8.1</version>
                </plugin>
                <plugin>
                    <artifactId>maven-install-plugin</artifactId>
                    <version>2.5.1</version>
                </plugin>
                <plugin>
                    <artifactId>maven-jar-plugin</artifactId>
                    <version>2.4</version>
                </plugin>
                <plugin>
                    <artifactId>maven-javadoc-plugin</artifactId>
                    <version>2.9.1</version>
                </plugin>
                <plugin>
                    <artifactId>maven-resources-plugin</artifactId>
                    <version>2.6</version>
                </plugin>
                <plugin>
                    <artifactId>maven-site-plugin</artifactId>
                    <version>3.3</version>
                </plugin>
                <plugin>
                    <artifactId>maven-source-plugin</artifactId>
                    <version>2.2.1</version>
                </plugin>
                <plugin>
                    <artifactId>maven-surefire-plugin</artifactId>
                    <version>2.17</version>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>

</project>

Это было сгенерировано автоматически (соответствующие настройки для группы и артефакта):

$ mvn archetype:generate \
          -DinteractiveMode=false \
          -DarchetypeGroupId=org.openjdk.jmh \
          -DarchetypeArtifactId=jmh-java-benchmark-archetype \
          -DgroupId=org.sample \
          -DartifactId=test \
          -Dversion=1.0

Запуск тестов:

$ mvn clean install
$ java -jar target/benchmarks.jar ".*SimpleBenchmark.*" -wi 5 -i 5 -f 1

Как будет, версия Java будет работать под:

$ java -version
java version "1.6.0_65"
Java(TM) SE Runtime Environment (build 1.6.0_65-b14-462-11M4609)
Java HotSpot(TM) 64-Bit Server VM (build 20.65-b04-462, mixed mode)

Аппаратное обеспечение (которое может поставить под вопрос) - OS X, 10.9.4 на процессоре Intel Xeon.

Ответ 1

Тестирование равенства с новой строкой не имеет смехотворной производительности. Эффект, который вы видите, просто то, что Hotspot способен оптимизировать цикл в одном случае, но не в другом.

Здесь дамп сборки hotspot testEqualsIntern из OpenJDK 7 (IcedTea7 2.1.7) (7u3-2.1.7-1) 64-битный сервер, показывающий результат без петли (аналогичный код генерируется для testIsEmpty):

Decoding compiled method 0x00007fb360a1a0d0:
Code:
[Entry Point]
[Constants]
  # {method} 'testEqualsIntern' '()I' in 'Test'
  #           [sp+0x20]  (sp of caller)
  0x00007fb360a1a200: mov    0x8(%rsi),%r10d
  0x00007fb360a1a204: cmp    %r10,%rax
  0x00007fb360a1a207: jne    0x00007fb3609f38a0  ;   {runtime_call}
  0x00007fb360a1a20d: data32 xchg %ax,%ax
[Verified Entry Point]
  0x00007fb360a1a210: push   %rbp
  0x00007fb360a1a211: sub    $0x10,%rsp
  0x00007fb360a1a215: nop                       ;*synchronization entry
                                                ; - Test::[email protected] (line 8)
  0x00007fb360a1a216: mov    $0x3e8,%eax
  0x00007fb360a1a21b: add    $0x10,%rsp
  0x00007fb360a1a21f: pop    %rbp
  0x00007fb360a1a220: test   %eax,0x6232dda(%rip)        # 0x00007fb366c4d000
                                                ;   {poll_return}
  0x00007fb360a1a226: retq

Когда вы сравниваете 1000 итераций одной вещи с 1 итерацией другой, неудивительно, что результаты отличаются в 1000 раз.

Я выполнил тот же тест после добавления четырех нулей в Итерации, и, как и ожидалось, testEqualsIntern занял одинаково долгое время, пока testEqualsNew был слишком медленным, чтобы ждать.

Ответ 2

Очень легко писать ошибочные микро-тесты... и вы попадаете в ловушку.

Единственный способ узнать, что произойдет, - посмотреть на код сборки. Вы должны проверить самостоятельно, если получившийся код будет тем, что вы ожидали, или если возникла какая-то нежелательная магия. Попробуем сделать это вместе. Вы должны использовать addProfile(LinuxPerfAsmProfiler.class) для просмотра кода сборки.

Что такое код сборки для testEqualsIntern:

....[Hottest Region 1]..............................................................................
[0x7fb9e11acda0:0x7fb9e11acdc8] in org.sample.generated.MyBenchmark_testEqualsIntern::testEqualsIntern_thrpt_jmhLoop

                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 103)
                  0x00007fb9e11acd82: movzbl 0x94(%rdx),%r11d   ;*getfield isDone
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 105)
                  0x00007fb9e11acd8a: mov    $0x2,%ebp
                  0x00007fb9e11acd8f: test   %r11d,%r11d
                  0x00007fb9e11acd92: jne    0x00007fb9e11acdcc  ;*ifeq
                                                                 ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 105)
                  0x00007fb9e11acd94: nopl   0x0(%rax,%rax,1)
                  0x00007fb9e11acd9c: xchg   %ax,%ax            ;*aload
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 103)
6.50%    3.37%    0x00007fb9e11acda0: mov    0xb0(%rdi),%r11d   ;*getfield i1
                                                                ; - org.openjdk.jmh.infra.Blackhole::[email protected] (line 350)
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 103)
0.06%    0.05%    0x00007fb9e11acda7: mov    0xb4(%rdi),%r10d   ;*getfield i2
                                                                ; - org.openjdk.jmh.infra.Blackhole::[email protected] (line 350)
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 103)
0.06%    0.09%    0x00007fb9e11acdae: cmp    $0x3e8,%r10d
0.03%             0x00007fb9e11acdb5: je     0x00007fb9e11acdf1  ;*return
                                                                ; - org.openjdk.jmh.infra.Blackhole::[email protected] (line 354)
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 103)
48.85%   44.47%    0x00007fb9e11acdb7: movzbl 0x94(%rdx),%ecx    ;*getfield isDone
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 105)
0.33%    0.62%    0x00007fb9e11acdbe: add    $0x1,%rbp          ; OopMap{r9=Oop rbx=Oop rdi=Oop rdx=Oop off=226}
                                                                ;*ifeq
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 105)
0.03%    0.05%    0x00007fb9e11acdc2: test   %eax,0x16543238(%rip)        # 0x00007fb9f76f0000
                                                                ;   {poll}
42.31%   49.43%    0x00007fb9e11acdc8: test   %ecx,%ecx
                   0x00007fb9e11acdca: je     0x00007fb9e11acda0  ;*aload_2
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 106)
                  0x00007fb9e11acdcc: mov    $0x7fb9f706fe40,%r10
                  0x00007fb9e11acdd6: callq  *%r10              ;*invokestatic nanoTime
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 106)
                  0x00007fb9e11acdd9: mov    %rbp,0x10(%rbx)    ;*putfield operations
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 108)
                  0x00007fb9e11acddd: mov    %rax,0x28(%rbx)    ;*putfield stopTime
                                                                ; - org.sample.generated.MyBenchmark_testEqualsIntern::[email protected] (line 106)
....................................................................................................

Как вы, возможно, знаете, JMH берет ваш контрольный код и вставляет его в свой собственный цикл измерения. Вы можете легко просмотреть сгенерированный код, просмотрев папку target/generated-sources. Вы должны знать, как выглядит этот код, чтобы сравнить его с сборкой.

Интересная часть здесь:

public void testEqualsIntern_avgt_jmhLoop(InfraControl control, RawResults result, MyBenchmark_1_jmh l_mybenchmark0_0, Blackhole_1_jmh l_blackhole1_1) throws Throwable {
    long operations = 0;
    long realTime = 0;
    result.startTime = System.nanoTime();
    do {
        l_blackhole1_1.consume(l_mybenchmark0_0.testEqualsIntern());
        operations++;
    } while(!control.isDone);
    result.stopTime = System.nanoTime();
    result.realTime = realTime;
    result.operations = operations;
}

Хорошо, вы видите этот приятный цикл do/while, который выполняет две вещи:

  • вызов функции
  • вызывать потребление для предотвращения нежелательной оптимизации Hotspot?

Теперь вернемся к сборке. Попробуйте найти в нем три операции (цикл, потребление и код). Ты можешь?

Вы можете видеть цикл JMH, это 0x00007fb9e11acdb7: movzbl 0x94(%rdx),%ecx ;*getfield isDone и следующий переход.

Вы можете видеть черную дыру, она от 0x00007fb9e11acda0 до 0x00007fb9e11acdb5:

Но где ваш код? Его нет. Вы не следовали рекомендациям JMH, и вы позволили Hotspot удалить ваш код. Вы сравниваете NOOP. Кстати, вы когда-нибудь пытались сравнить NOOP? Это хорошо, когда вы видите рядом с этим рядом, вы знаете, что вам нужно быть очень осторожным.

Вы можете сделать тот же анализ для второго эталона. Я не читал его код сборки тщательно, но вы сможете определить, что ваш цикл for и call равны. Вы можете снова прочитать образцы JMH, чтобы избежать такой проблемы.

TL; DR Написание правильных тестов на микро /nano невероятно сложно, и вы должны дважды проверить, что вы знаете, что вы измерили. Сборка - единственный путь. Смотрите все презентации и читайте все сообщения блога от Алексея, чтобы узнать больше. Он отлично справляется. И, наконец, такие измерения практически всегда бесполезны в реальной жизни, но являются хорошим инструментом обучения.

Ответ 3

Объяснение представляется (в первом случае, intern() 'd one) JVM может проверить ссылочное равенство, которое является прямым численным сравнением.

В противоположность этому, тест для неосновного равенства (равенства значений) должен итерировать последовательности символов двух строк (строк). Ваши наблюдаемые результаты не столь значительны, как вы думаете. Существует JIT и другие оптимизации, и производительность, скорее всего, улучшится на практике (поскольку не каждая String равна, и она может быть короткой, когда она не является).

Наконец, микро-контрольные показатели, как известно, ненадежны. Но вы нашли оптимизацию производительности, встроенную в JVM по дизайну. Проверка ссылочного равенства намного быстрее.

Ответ 4

public int testEqualsIntern() {
    int count = 0;
    String str = EMPTY;

    for(int i = 0; i < ITERATIONS; i++) {
        if(str.equals(EMPTY)) {
            count++;
        }
    }
    return count;
}

здесь str.equals(EMPTY) сначала проверит равенство на == и вернет true, так как обе str и EMPTY имеют одинаковые ссылки и находятся в пуле строк, и операция будет быстрее, но в случае

public int testEqualsNew() {
    int count = 0;
    String str = NEW_EMPTY;

    for(int i = 0; i < ITERATIONS; i++) {
        if(str.equals(EMPTY)) {
            count++;
        }
    }
    return count;
}

EMPTY string находится в пуле строк, а NEW_EMPTY не является частью пула, и обе имеют разные ссылки, так как EMPTY - это литеральные константы, а NEW_EMPTY - нет. поэтому equals() сначала попытается сравнить равенство на ==, который вернет false, поскольку оба имеют разные ссылки, и он проверит содержимое, поэтому в этом случае equals() займет больше времени.