Как стать автором
Обновить

Ах, эти строки

Время на прочтение23 мин
Количество просмотров7.5K

Это текстовая версия моего доклада "Ах, эти строки" на конференции JPoint-2020.
Дабы не тратить время читателей зря, сразу расставим все точки над "ё".


О чём статья?


Статья об эффективном (или не очень) использовании строк.


Для кого статья?


Статья для разработчиков, занимающихся производительностью, и им сочувствующих.


Откуда всё это?


Что-то выловлено в коде проекта, что-то — во фреймворках и библиотеках.


Что, чем и на чём измеряли?


  • код бенчмарков и результаты прогонов доступны на ГитХабе
  • для измерения использовался JMH 1.23
  • замеры проводились на рабочей машине с Intel Core i7-7700 (сами по себе цифры не важны, важны соотношения между ними и выявленные закономерности)
  • по умолчанию использовался JDK 11, но также 8 и 14 (явно прописаны на соответствующих страницах)
  • режим бенчмарков: среднее время выполнения + расход памяти (меньше значит лучше)

Пакет java.lang и его обитатели


Работающие с явой знают, что java.lang — это ядро языка и если вам понадобится внести туда изменения, то протолкнуть их очень непросто, т. к. язык консервативный и для любого даже самого полезного улучшения необходимы железные доказательства того, что
а) это точно ничего не сломает
б) это действительно нужно


Но есть класс неподвластный консерватизму: java.lang.String. Ниже список последних предложений по его улучшению (или улучшению работы с ним), многие из них уже реализованы:


  • JEP 192: String Deduplication in G1
  • JEP 250: Store Interned Strings in CDS Archives
  • JEP 254: Compact Strings
  • JEP 280: IndifyString Concatenation
  • JEP 326: Raw String Literals (Preview)
  • JEP 355: Text Blocks (Preview)
  • JEP 348: Compiler Intrinsics for Java SE APIs (в основном это пока про String.format())

Обратите внимание на сжатые строки — они затрагивают основы основ — если сравнить содержимое java.lang.String в "восьмёрке" и в "девятке", то изменилось решительно всё.


Почему именно строки


Повышенное внимание разработчиков платформы к строкам тоже понятно и подробно изложено в классическом докладе Катехизис java.lang.String. Вкратце:


  • они [строки] везде
  • они съедают весомую часть кучи
  • они [при неправильном использовании] могут ухудшить производительность
  • когда им хорошо, то хорошо всем

Подходы к прокачиванию производительности


Когда мы улучшаем производительность, то становимся перед выбором:


  • наращивать объём исполняемого
  • уменьшать / переупорядочивать объём исполняемого

Первый подход только на первый взгляд выглядит нелогичным, на деле же это не что иное как кэширование: приложению очень часто всё равно, откуда берётся то или иное значение, важна лишь его правильность. Значит можно прикрутить к приложению кэш, хранить там значения дорогих вычислений и переиспользовать их. Когда мы говорим про строки, то тут есть сразу два похожих механизма: интернирование и дедуплицирование (JEP 192). Подчёркиваю красным: это не кэширование в привычном смысле (хотя иногда может быть похоже как две капли воды).


Поскольку большинство из нас — это рядовые разработчики и влезть в плюсовый адъ внутри ВМ и сделать там что-то осмысленное могут не только лишь все (мало кто может это сделать), то для нас более перспективным является второй подход — делать меньше получая больше. О нём и поговорим.


Основная проблема строк


Проистекает она из JLS 15.18.1 и означает, что [почти] любое преобразование строки порождает новую строку, поэтому зачастую для получения прироста нужно лишь избавиться от ненужных преобразований, при чём многие из них легко формализуются и обращаются в правила статического анализатора.


Отсюда плавно вытекает стратегия и тактика улучшения работы со строками:


  • стратегия: не использовать строки там, где это возможно
  • тактика: по возможности избегать преобразования строк

Ключи и словари


Строки очень часто используются в качестве ключей (внезапно). Причиной является набор уникальных свойств, доступный прямо из коробки:


  • строки неизменяемы
  • строки определяют equals() / hashCode()
  • строки кэшируют хэш-код
  • строки сериализуемы и реализуют java.lang.Comparable (их можно класть в TreeMap)
  • строки особо реализуют Object.equals()
  • любой объект можно представить в виде строки вызвав obj.toString()

Многие знают, что набор постоянных строк-ключей можно представить в виде перечисления, что даёт возможность отказаться от HashMap в пользу EnumMap:


Map<String, ?> map = new HashMap<>();

class Constants {
  static final String MarginLeft = "margl";
  static final String MarginRight = "margr";
  static final String MarginTop = "margt";
  static final String MarginBottom = "margb";
}

легко заменяется на


Map<String, ?> map = new EnumMap<>(Constants.class);

enum Constants {
  MarginLeft,
  MarginRight,
  MarginTop,
  MarginBottom
}

что даёт более легковесный и быстрый словарь:


@Benchmark
public Object hm() {
  var map = new HashMap<>();
  map.put(Constants.MarginLeft, 1);
  map.put(Constants.MarginRight, 2);
  map.put(Constants.MarginTop, 3);
  map.put(Constants.MarginBottom, 4);
  return map;
}

@Benchmark
public Object em() {
  var map = new EnumMap<>(ConstantsEnum.class);
  map.put(ConstantsEnum.MarginLeft, 1);
  map.put(ConstantsEnum.MarginRight, 2);
  map.put(ConstantsEnum.MarginTop, 3);
  map.put(ConstantsEnum.MarginBottom, 4);
  return map;
}

Прирост ощутим:


                               Mode    Score    Error   Units

enumMap                        avgt   23.487 ±  0.694   ns/op
hashMap                        avgt   67.480 ±  2.395   ns/op

enumMap:·gc.alloc.rate.norm    avgt   72.000 ±  0.001    B/op
hashMap:·gc.alloc.rate.norm    avgt  256.000 ±  0.001    B/op

Перебор также пойдёт бодрее:


@Benchmark
public void hashMap(Data data, Blackhole bh) {
  Map<String, Integer> map = data.hashMap;

  for (String key : data.hashMapKeySet) {
    bh.consume(map.get(key));
  }
}

@Benchmark
public void enumMap(Data data, Blackhole bh) {
  Map<ConstantsEnum, Integer> map = data.enumMap;

  for (ConstantsEnum key : data.enumMapKeySet) {
    bh.consume(map.get(key));
  }
}

что даёт


                               Mode    Score    Error   Units

enumMap                        avgt   36.397 ±  3.080   ns/op
hashMap                        avgt   55.652 ±  4.375   ns/op

В сложных случаях так не получается:


// org.springframework.aop.framework.CglibAopProxy

Map<String, Integer> map = new HashMap<>();

getCallbacks(Class<?> rootClass) {
  Method[] methods = rootClass.getMethods();
  for (intx = 0; x < methods.length; x++) {
    map.put(methods[x].toString(), x);          // <------
  }
}

// зеркальный метод
accept(Method method) {
  String key = method.toString();
  // key используется тут в качестве ключа
}

Проблема понятна: вызов java.lang.reflect.Method.toString() порождает новую строку. Много ли теряем?


@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MethodToStringBenchmark {
  private Method method;

  @Setup
  public void setup() throws Exception {
    method = getClass().getMethod("toString");
  }

  @Benchmark
  public String methodToString() { return method.toString(); }
}

Это простейший случай — вызов method.toString() возвращает строку:


"public java.lang.String java.lang.Object.toString()"

а стоит это удовольствие немало:


                                       Mode  Score  Error   Units

methodToString                         avgt   85,4 ±  1,3   ns/op
methodToString:·gc.alloc.rate.norm     avgt  344,0 ±  0,0    B/op

Если мы провернём это в более жизненном ключе, например:


public class MethodToStringBenchmark {
  private Method method;

  @Setup
  public void setup() throws Exception {
    method = getClass().getMethod("getInstance");
  }

  @Benchmark
  public String methodToString() { return method.toString(); }

  MethodToStringBenchmark getInstance() throws ArrayIndexOutOfBoundsException {
    return null;
  }
}

то расценки существенно вырастут:


                                       Mode     Score    Error   Units

methodToString                         avgt   199.765 ±  3.807   ns/op
methodToString:·gc.alloc.rate.norm     avgt  1126.400 ±  9.817    B/op

ведь возвращается уже более внушительная строка:


"public tsypanov.strings.reflection.MethodToStringBenchmark tsypanov.strings.reflection.MethodToStringBenchmark.getInstance() throws java.lang.ArrayIndexOutOfBoundsException"

На первый взгляд всё безнадёжно, ведь никаких enum-ов не напасёшься на все проксируемые методы. Давайте лучше присмотримся к самому классу java.lang.reflect.Method. Уже поверхностный осмотр показывает, что он вполне может быть ключом вместо строки:


  • реализует equals() / hashCode()
  • неизменяемый *

Почему неизменяемый со звёздочкой?


не торопитесь открывать, подумайте

Всё из-за него:


public final class Method extends Executable {
  @Override
  @CallerSensitive
  public void setAccessible(boolean flag) {
      AccessibleObject.checkPermission();
      if (flag) checkCanSetAccessible(Reflection.getCallerClass());
      setAccessible0(flag);
  }
}

Это тот самый случай, когда теория запрещает использовать объект этого класса в качестве ключа, ведь у него есть изменяющий состояние метод, а крестьянская смекалка говорит "Можно!". Ведь сколько бы мы ни дёргали за ручку Method.setAccessible() поведение его equals()/hashCode() не изменится.


Есть и недостатки:


  • java.lang.reflect.Method не реализует Comparable
  • хэш-код объекта Method не равен хэш-коду строки (и он не кэшируется)

В данном случае нам важно только положить пару "ключ-значение" в словарь и получить значение по ключу, следовательно меняем String на Method.


Будет ли толк от нашей заплатки в боевом приложении? Проверим на примере, который и подтолкнул меня покопаться в CglibAopProxy:


@Configuration
public class AspectConfig {

  @Bean
  ServiceAspect serviceAspect() { return new ServiceAspect(); }

  @Bean
  @Scope(BeanDefinition.SCOPE_PROTOTYPE)
  AspectedService aspectedService() { return new AspectedServiceImpl(); }

  @Bean
  AbstractAutoProxyCreator proxyCreator() {
    var factory = new AnnotationAwareAspectJAutoProxyCreator();
    factory.setProxyTargetClass(true);
    factory.setFrozen(true);           // <--- обратите внимание
    return factory;
  }
}

Небольшое пояснение: у нас есть некий компонент-прототип (это нужно, чтобы хранить состояние) с 1 методом, обёрнутым в 1 аспект. Поскольку в нашем случае мы знаем, что цепочка проброса неизменна, то её можно "заморозить", что позволяет "Спрингу" выполнить под капотом некоторые оптимизации (см. документацию).


Вычислим стоимость создания этого бина:


@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class AspectPrototypeBenchmark {
  private AnnotationConfigApplicationContext context;

  @Setup
  public void setUp() {
    context = new AnnotationConfigApplicationContext(AspectConfig.class);
  }

  @Benchmark
  public AspectedService getAdvisedBean() {
    return context.getBean(AspectedService.class);
  }

  @TearDown
  public void closeContext() { context.close(); }
}

Имеем:


                                       Mode      Score     Error   Units

before
getAdvisedBean                         avgt     14.024 ±   0.164   us/op
getAdvisedBean:·gc.alloc.rate.norm     avgt  10983.307 ±  14.193    B/op

after
getAdvisedBean                         avgt      8.150 ±   0.202   us/op
getAdvisedBean:·gc.alloc.rate.norm     avgt   7133.664 ±   5.594    B/op

Неплохо, как для такого простого изменения.


З.Ы. Обратите внимание, что этот бенчмарк лежит в другом репозитории, где собраны бенчмарки для "Спринга".


Составные ключи


В JDK есть класс ObjectStreamClass, использующийся при сериализации, в нём — вложенный класс FieldReflectorKey, а там внутри проблема.


public class ObjectStreamClass implements Serializable {

  private static class Caches {
    static final ConcurrentMap<FieldReflectorKey, Reference<?>> reflectors =
            new ConcurrentHashMap<>();
  }

  private static class FieldReflectorKey extends WeakReference<Class<?>> {
    private final String sigs;
    private final int hash;
    private final boolean nullClass;

    // ...
}

Фамилия имя и отчество виновного известны: JDK-6996807 FieldReflectorKey hash code computation can be improved. Уже из заголовка понятно: вычисление хэш-кода стоит неоправданно дорого. Больное место находится в конструкторе:


FieldReflectorKey(Class<?> cl, ObjectStreamField[] fields,
                    ReferenceQueue<Class<?>> queue)
{
  super(cl, queue);
  nullClass = (cl == null);
  StringBuilder sbuf = new StringBuilder();  // <---- !!!
  for (int i = 0; i < fields.length; i++) {
    ObjectStreamField f = fields[i];
    sbuf.append(f.getName()).append(f.getSignature());
  }
  sigs = sbuf.toString();
  hash = System.identityHashCode(cl) + sigs.hashCode();
}

После внесения изменений получаем:


FieldReflectorKey(Class<?> cl, ObjectStreamField[] fields,
                  ReferenceQueue<Class<?>> queue)
{
  super(cl, queue);
  nullClass = (cl == null);
  sigs = new String[2 * fields.length];
  for (int i = 0, j = 0; i < fields.length; i++) {
    ObjectStreamField f = fields[i];
    sigs[j++] = f.getName();
    sigs[j++] = f.getSignature();
  }
  hash = System.identityHashCode(cl) + Arrays.hashCode(sigs);
}

Теперь вместо цельной строки создаётся массив, заполняемый именами и сигнатурами классов, что даёт небольшой прирост:


SPECjvm2008:serial improves a little bit with this patch, and the allocation rate is down ~5%.

Ровно та же проблема была выловлена в "Спринге" в o.s.context.support.StaticMessageSource:


public class StaticMessageSource extends AbstractMessageSource {
  private final Map<String, String> messages = new HashMap<>();

  @Override
  protected String resolveCodeWithoutArguments(String code, Locale locale) {
    return this.messages.get(code + '_' + locale.toString());
  }

  public void addMessage(String code, Locale locale, String msg) {
    // ...
    this.messages.put(code + '_' + locale.toString(), msg);
  }
}

Измерить производительность можно с помощью бенчмарка:


private final String code = "code1";
private final Locale locale = Locale.getDefault();

@Benchmark
public Object concatenation(Data data) {
  return data.stringObjectMap.get(data.code + '_' + data.locale);
}

Что даёт


concatenation                          avgt     53.241 ±   1.494   ns/op
concatenation:·gc.alloc.rate.norm      avgt    120.000 ±   0.001    B/op

Решение — составной ключ, который может быть представлен в виде отдельного класса


@EqualsHashCode
@RequiredArgsConstructor
private static final class Key {
  private final String code;
  private final Locale locale;
}

списка:


Arrays.asList(code, locale);
// или в старших JDK
List.of(code, locale)

или даже записи (если вы красавчик и у вас Java 14)


private static record KeyRec(String code, Locale locale) {}

Рассмотрим их показатели:


                                       Mode      Score     Error   Units

compositeKey                           avgt      6.065 ±   0.415   ns/op
concatenation                          avgt     53.241 ±   1.494   ns/op
list                                   avgt     31.001 ±   1.621   ns/op

compositeKey:·gc.alloc.rate.norm       avgt     ≈ 10⁻⁶              B/op
concatenation:·gc.alloc.rate.norm      avgt    120.000 ±   0.001    B/op
list:·gc.alloc.rate.norm               avgt     80.000 ±   0.001    B/op

Обратите внимание, что на создание 1 составного ключа мы выделили 0 байт, т. е. анализ области видимости отработал на отлично (чего не скажешь о списке), поэтому я предложил именно такой вариант. Однако Ёрген Холлер, занимавшийся этим изменением рассудил иначе. На первый взгляд, решение несколько странное, ведь последовательный поиск в двух словарях очевидно дороже:


                                       Mode      Score     Error   Units

compositeKey                           avgt      6.065 ±   0.415   ns/op
mapInMap                               avgt      9.330 ±   1.010   ns/op

mapInMap:·gc.alloc.rate.norm           avgt     ≈ 10⁻⁵              B/op
compositeKey:·gc.alloc.rate.norm       avgt     ≈ 10⁻⁶              B/op

Но дороже он будет только при действенном анализе области видимости, а с этим иногда напряжёнка:


этот же бенчмарк на JDK 14
                                       Mode      Score     Error   Units

compositeKey                           avgt      7.803 ±   0.647   ns/op
mapInMap                               avgt      9.330 ±   1.010   ns/op
record                                 avgt     13.240 ±   0.691   ns/op
list                                   avgt     37.316 ±   6.355   ns/op
concatenation                          avgt     69.781 ±   7.604   ns/op

compositeKey:·gc.alloc.rate.norm       avgt     24.001 ±   0.001    B/op
mapInMap:·gc.alloc.rate.norm           avgt     ≈ 10⁻⁵              B/op
record:·gc.alloc.rate.norm             avgt     24.001 ±   0.001    B/op
list:·gc.alloc.rate.norm               avgt    105.602 ±   9.786    B/op
concatenation:·gc.alloc.rate.norm      avgt    144.004 ±   0.001    B/op

Оп-па! А ключ-то составной теперь создаётся в куче! Мораль сей басни такова: скаляризация и анализ области видимости — очень хрупкие вещи, которые не прописаны в спецификации языка и ВМ, и которые нам никто не обещает.


Вывод для разработчика простой: склейка строк для создания ключа — это плохо, ибо


  • требует относительно много времени и доппамяти
  • при частом обращении может стать узким местом

Выход есть – отдельный класс для составного ключа (в крайнем случае подойдёт массив или Arrays.asList() / List.of()).


Склеивание строк


Когда речь заходит о склейке мы часто задаём неправильный вопрос: какой способ самый лучший? Правильный вопрос, ПМСМ, звучит так: а нужно ли вообще их клеить? Для примера рассмотрим часть метода org.springframework.core.ResolvableType.toString():


StringBuilder result = new StringBuilder(this.resolved.getName());
if (hasGenerics()) {
  result.append('<');
  result.append(StringUtils.arrayToDelimitedString(getGenerics(), ", "));
  result.append('>');
}
return result.toString();

Переберём все возможные исполнения, благо их аж целых 2:
1) hasGenerics() возвращается истину и мы честно клеим строки
2) hasGenerics() возвращается ложь и мы переливаем значение this.resolved.getName() в StringBuilder, а оттуда — снова в строку


Очевидно, что во втором случае (он наиболее частый, т. к. большинство бинов нетипизированы) на выходе мы получим ту же строку, что и из this.resolved.getName(), поэтому код можно упростить, повысив одновременно его производительность:


if (hasGenerics()) {
  return this.resolved.getName() 
    + '<'
    + StringUtils.arrayToDelimitedString(getGenerics(), ", ")
    + '>';
}
return this.resolved.getName();

Обратите внимание: после внесения StringBuilder-а внутрь условного блока мы можем отказаться от него в пользу + (об этом чуть ниже).


Склейка: если всё-таки нужно


Рассмотрим задачу преобразования массива байт в шестнадцатеричный вид. Решение следующее:


private static String bytesToHexString(byte[] bytes) {
  StringBuilder sb = new StringBuilder();
  for (int i = 0; i < bytes.length; i++) {
    sb.append(Integer.toString((bytes[i] & 0xff) + 0x100, 16).substring(1));
  }
  return sb.toString();
}

Вопиющая неэффективность метода bytesToHexString очевидна даже новичку: преобразование байта в строку, взятие подстроки, добавление в StringBuilder. На этом варианте останавливаться не будем (хотя он и был выловлен в коде двух проектов). Существует похожий (и тоже неэффективный) вариант решения задачи (взят из статьи про p6spy):


public String toHexString(byte[] bytes) {
  StringBuilder sb = new StringBuilder();
  for (byte b : bytes) {
    int temp = (int) b & 0xFF;
    sb.append(HEX_CHARS[temp / 16]);
    sb.append(HEX_CHARS[temp % 16]);
  }
  return sb.toString();
}

При первом же рассмотрении разработчик наверняка обратит внимание на создание StringBuilder-а конструктором по умолчанию, хотя нам известно количество проходов по циклу, а также тот факт, что при каждом проходе добавляются два знака шестнадцатеричного алфавита. Вырисовывается очевидное улучшение:


public String toHexStringPatched(byte[] bytes) {
  StringBuilder sb = new StringBuilder(bytes.length * 2);
  for (byte b : bytes) {
    int temp = (int) b & 0xFF;
    sb.append(HEX_CHARS[temp / 16]);
    sb.append(HEX_CHARS[temp % 16]);
  }
  return sb.toString();
}

Если мы прогоним через оба метода 1 Мб данных, то обнаружим, что второй даёт существенную экономию памяти при незначительном приросте по времени:


original                          avgt        4167,950 ±     82,704   us/op
patched                           avgt        3972,118 ±     34,817   us/op

original:·gc.alloc.rate.norm      avgt    13631776,184 ±      0,005    B/op
patched:·gc.alloc.rate.norm       avgt     8388664,173 ±      0,002    B/op

Оказывается, что львиную долю занимает проверка выхода за пределы массива и доступ к самому массиву:


@Override
public AbstractStringBuilder append(char c) {
  ensureCapacityInternal(count + 1);
  value[count++] = c;
  return this;
}

Обратите внимание: несмотря на точно заданный размер хранилища и известное количество проходов не существует никакого механизма, который мог бы подсказать исполнению, что проверки доступа можно выбросить. Поэтому правильным решением является отказ от StringBuilder-а в пользу голого массива:


public String toHexString(byte[] bytes) {
  char[] result = new char[bytes.length * 2];
  int idx = 0;
  for (byte b : bytes) {
    int temp = (int) b & 0xFF;
    result[idx++] = HEX_CHARS[temp / 16];
    result[idx++] = HEX_CHARS[temp % 16];
  }
  return new String(result);
}

И вот теперь мы получим существенный прирост:


original                          avgt        4167,950 ±     82,704   us/op
patched                           avgt        3972,118 ±     34,817   us/op
chars                             avgt        1377,829 ±      4,861   us/op

original:·gc.alloc.rate.norm      avgt    13631776,184 ±      0,005    B/op
patched:·gc.alloc.rate.norm       avgt     8388664,173 ±      0,002    B/op
chars:·gc.alloc.rate.norm         avgt     6291512,057 ±      0,001    B/op

Любопытно, что если запустить этот же код на старших версиях JDK, то неожиданно возникает просадка по памяти:


original                          avgt        3813,358 ±     75,014   us/op
patched                           avgt        3733,343 ±     90,589   us/op
chars                             avgt        1377,829 ±      4,861   us/op

original:·gc.alloc.rate.norm      avgt     6816056,159 ±      0,005    B/op
patched:·gc.alloc.rate.norm       avgt     4194360,157 ±      0,006    B/op
chars:·gc.alloc.rate.norm         avgt     6291512,057 ±      0,001    B/op   <----

Совершенно не очевидно для нас показатель потребления памяти для массива вернулся на прежний уровень. Причина в реализации работы со сжатыми строками:


abstract class AbstractStringBuilder implements Appendable, CharSequence {
  byte[] value;

  public AbstractStringBuilder append(char c) {
    this.ensureCapacityInternal(this.count + 1);
    if (this.isLatin1() && StringLatin1.canEncode(c)) {
      this.value[this.count++] = (byte)c;                     // <-----
    } else {
      // ...
    }
    return this;
  }
}

Если на вход StringBuilder.append(char) пришел знак, входящий в множество ASCII (а шестнадцатеричный алфавит входит туда по умолчанию), то его старший байт усекается, а младший кладётся в хранилище. Если же используется голый массив, то в него всегда кладётся полновесный char о двух байтах. Поэтому если у вас на проекте JDK 9 и выше, то шестнадцатеричный алфавит нужно объявлять как массив байт, а char[] менять на byte[].


Вывод для разработчика: иногда склейка строк сводится к задаче о буферизации с известными узкими местами:


  • проверкой выхода за пределы хранилища
  • расширением хранилища
  • переносом данных при расширении

Универсальное решение: семь раз отмерь — один раз отрежь.


Необходимо отметить, что выше описан редкий случай — известно количество проходов и объём данных, записываемых на каждом проходе. Обычно же имеем дело со следующими разновидностями:


// сложение 
String str = s1 + s2 + s3;

// склеивание цепочкой
String str = new StringBuilder().append(str1).append(str2).append(str3).toString();

// склеивание путём раздельного добавления
StringBuilder sb = new StringBuilder();
sb.append(str1);
sb.append(str2);
sb.append(str3);
String str = sb.toString();

Измерим производительность с помощью бенчмарка:


private final String str1 = "1".repeat(10);
private final String str2 = "2".repeat(10);
private final String str3 = "3".repeat(10);
private final String str4 = "4".repeat(10);
private final String str5 = "5".repeat(10);

@Benchmark public String concatenation() { /*...*/ }
@Benchmark public String chainedAppend() { /*...*/ }
@Benchmark public String newLineAppend() { /*...*/ }

Ожидаемо побеждает сложение, в затылок ему дышит склеивание цепочкой:


                                    Mode     Score     Error   Units

chainedAppend                       avgt    33,973 ±   0,974   ns/op
concatenation                       avgt    36,189 ±   1,260   ns/op
newLineAppend                       avgt    71,083 ±   5,180   ns/op

chainedAppend:·gc.alloc.rate.norm   avgt    96,000 ±   0,001    B/op
concatenation:·gc.alloc.rate.norm   avgt    96,000 ±   0,001    B/op
newLineAppend:·gc.alloc.rate.norm   avgt   272,000 ±   0,001    B/op

Из этого давно уже сделан простой и очевидный вывод: в большинстве случаев скрипач StringBuilder не нужен, сложение строк будет и проще, и производительнее. Дело в интринзификации: исполнение распознаёт подобные цепочки и обнаружив, что размер складываемых строк известен, прямо выделяет нужный объём памяти и переносит данные без использования StringBuilder-а. Логичное и несложное улучшение.


Теперь поставим вопрос иначе. Предположим, у нас есть цепочка сложения / StringBuilder.append() и логика приложения заставляет разорвать её:


StringBuilder sb = new StringBuilder()
        .append(str1)
        .append(str2)
        .append(str3);

if (smth) sb.append(str4);

return sb.append(str5).toString();

Ухудшится ли производительность и если да, то будет ли она зависеть от точки разрыва? Оказывается, что одного единственного разрыва достаточно для слома интринзификации, а без неё мы откатываемся к раздельному добавлению:


                                    Mode     Score     Error   Units

chainedAppend                       avgt    33,973 ±   0,974   ns/op
concatenation                       avgt    36,189 ±   1,260   ns/op
newLineAppend                       avgt    71,083 ±   5,180   ns/op
tornAppend                          avgt    66,261 ±   2,095   ns/op

chainedAppend:·gc.alloc.rate.norm   avgt    96,000 ±   0,001    B/op
concatenation:·gc.alloc.rate.norm   avgt    96,000 ±   0,001    B/op
newLineAppend:·gc.alloc.rate.norm   avgt   272,000 ±   0,001    B/op
tornAppend:·gc.alloc.rate.norm      avgt   272,000 ±   0,001    B/op

Это подводит нас уже к менее очевидному выводу: сшивание цепочки даёт конские приросты и позволяет упростить код (вспомните пример с ResolvableType.toString()). Рассмотрим часть встроенного в "Спринг" профилировщика:


// o.s.a.interceptor.AbstractMonitoringInterceptor

String createInvocationTraceName(MethodInvocation invocation) {
  StringBuilder sb = new StringBuilder(getPrefix());                    // < ----
  Method method = invocation.getMethod();
  Class<?> clazz = method.getDeclaringClass();
  if (logTargetClassInvocation && clazz.isInstance(invocation.getThis())) {
    clazz = invocation.getThis().getClass();
  }
  sb.append(clazz.getName());
  sb.append('.').append(method.getName());
  sb.append(getSuffix());
  return sb.toString();
}

Обратите внимание: между объявлением переменной sb и её использованием находится другой код, а значит объявление можно сместить:


String createInvocationTraceName(MethodInvocation invocation) {
  Method method = invocation.getMethod();
  Class<?> clazz = method.getDeclaringClass();
  if (logTargetClassInvocation && clazz.isInstance(invocation.getThis())) {
    clazz = invocation.getThis().getClass();
  }
  StringBuilder sb = new StringBuilder(getPrefix());                    // < ----
  sb.append(clazz.getName());
  sb.append('.').append(method.getName());
  sb.append(getSuffix());
  return sb.toString();
}

Тут же "Идея" поможет нам всё это ужать и сделать совсем красиво:


protected String createInvocationTraceName(MethodInvocation invocation) {
  Method method = invocation.getMethod();
  Class<?> clazz = method.getDeclaringClass();
  if (logTargetClassInvocation && clazz.isInstance(invocation.getThis())) {
    clazz = invocation.getThis().getClass();
  }
  return getPrefix() + clazz.getName() + '.' + method.getName() + getSuffix();
}

Этот код не только проще и выразительнее, но и должен быть производительнее. Проверим:


Гладко вписано в бумаги, да забыли про овраги...
                                Mode      Score     Error   Units

before                          avgt     97,273 ±   0,974   ns/op
after                           avgt     89,089 ±   1,260   ns/op

before:·gc.alloc.rate.norm      avgt    728,000 ±   0,001    B/op
after:·gc.alloc.rate.norm       avgt    728,000 ±   0,001    B/op

Ёлки-палки, нормально же общались! Буквально пол-страницы назад в похожем как две капли воды коде ровно такой же финт ушами давал хороший прирост, а тут что-то пошло нет так. Чтобы разобраться давайте выведем наименьший пример, на котором проблема воспроизведётся:


@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(jvmArgsAppend = {"-Xms2g", "-Xmx2g", "-XX:+UseParallelGC"})
public class BrokenConcatenationBenchmark {

  @Benchmark
  public String slow(Data data) {
    Class<? extends Data> clazz = data.clazz;
    return "class " + clazz.getName();
  }

  @Benchmark
  public String fast(Data data) {
    Class<? extends Data> clazz = data.clazz;
    String clazzName = clazz.getName();
    return "class " + clazzName;
  }

  @State(Scope.Thread)
  public static class Data {
    Class<? extends Data> clazz = getClass();

    @Setup
    // explicitly load name via Class.getName0()
    public void setup() { clazz.getName(); }          <---- обратите внимание
  }
}

Внешне этот пример очень похож на JDK-8043677. Теперь метод Class.getName():


public String getName() {
  String name = this.name;
  if (name == null) {
    this.name = name = this.getName0();
  }
  return name;
}

private native String getName0();

Это обычный ленивый геттер: при первом обращении полю присваивается значение, дальше оно только возвращается. Теперь вспомним, что мы явно вызываем этот метод в setup(), иными словами во время прогона никаких сторонних эффектов уже быть не может. Тем не менее, просадка по производительности стабильно воспроизводится.


Признаюсь, я не нашел объяснения самостоятельно, поэтому задал вопрос на StackOverflow. На выручку пришел apangin, за что ему огромная благодарность. Дело тут вот в чём:


Виртуальная машина собирает статистику исполнения байт-кода. Если один и тот же код исполняется в разных контекстах, то итоговый профиль объединяет в себе статистику по каждому из них. Это называется отравление профиля.
Очевидно, что Class.getName() вызывается не только из кода бенчмарка. И ещё до того, как JIT начинает компиляцию бенчмарка, он уже знает, что условие
if (name == null) {
this.name = name = getName0();
}


было удовлетворено множество раз. По крайней мере, количество вхождений внутрь условного блока оказалось достаточным, чтобы эта ветвь исполнения стала статистически значимой. Поэтому компилятор не может исключить её, что ломает склейку строк.

По ссылке есть пример достижения ровно такого же эффекта без использования нативного метода. В нашем случае нужно извлечь выражение Class.getName() в отдельную переменную.


И вот теперь у нас есть желаемый прирост:


                                Mode      Score     Error   Units

before                          avgt     97,273 ±   0,974   ns/op
after                           avgt     13,301 ±   0,411   ns/op

before:·gc.alloc.rate.norm      avgt    728,000 ±   0,001    B/op
after:·gc.alloc.rate.norm       avgt    280,000 ±   0,001    B/op

Выводы для разработчика:


  • сшивание цепочки = упрощение кода + производительность
  • с старых изданиях JDK (<9) держи в уме угловые случаи

Склейка: среди if-ов как среди рифов


Наш следующий гость — библиотека ASM, использующаяся для работы с байт-кодом. Рассмотрим один из методов класса org.objectweb.asm.Type:


void appendDescriptor(final Class<?> clazz, final StringBuilder sb) {
  String name = clazz.getName();
  for (int i = 0; i < name.length(); ++i) {
    char car = name.charAt(i);
    sb.append(car == '.' ? '/' : car);
  }
  sb.append(';');
}

Имеем уже описанную выше проблему: данные складываются в хранилище познаково, что медленно, т. к. каждый StringBuilder.append(char) означает отдельную проверку выхода за пределы массива и доступ к нему. Чтобы обратить это в массовое добавление, нужно выразить алгоритм одним словом. И это слово — замена, ведь точка заменяется косой чертой, а все прочие знаки остаются без изменений. Значит мы можем переписать код так:


void appendDescriptor(final Class<?> clazz, final StringBuilder sb) {
  sb.append(clazz.getName().replace('.', '/'));
}

Теперь нужно проверить: выиграем или нет. Ведь у изменённого варианта есть недостаток: при одном единственном вхождении искомого знака String.replace(char, char) создаёт новую строку, что требует времени и доппамяти (чего не наблюдалось в прежнем издании).
Прогоним бенчмарк для класса java.lang.String:


@State(Scope.Thread)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(value = Mode.AverageTime)
@Fork(jvmArgsAppend = {"-Xms2g", "-Xmx2g"})
public class CharacterReplaceBenchmark {
  private final Class<?> klass = String.class;

  @Benchmark
  public StringBuilder manualReplace() {
    return ineffective(klass, new StringBuilder());
  }

  @Benchmark
  public StringBuilder stringReplace() {
    return effective(klass, new StringBuilder());
  }
}

Итог неоднозначен:


                                     Mode     Score     Error   Units

manualReplace                        avgt    43,312 ±   1,767   ns/op
stringReplace                        avgt    30,741 ±   3,247   ns/op

manualReplace:·gc.alloc.rate.norm    avgt    56,000 ±   0,001    B/op
stringReplace:·gc.alloc.rate.norm    avgt   112,000 ±   0,001    B/op

С одной стороны имеем существенный выигрыш по времени, с другой — двукратную просадку по памяти. Если вместо java.lang.String полю klass присвоить значение


private final Class<?> klass = CharacterReplaceBenchmark.class;

то результат снова удивит:


                                     Mode     Score     Error   Units

manualReplace                        avgt   160,336 ±   2,628   ns/op
stringReplace                        avgt    67,258 ±   1,535   ns/op

manualReplace:·gc.alloc.rate.norm    avgt   200,000 ±   0,001    B/op
stringReplace:·gc.alloc.rate.norm    avgt   240,000 ±   0,001    B/op

Время выполнения сократится более чем в 2,5 раза, при этом разница в потреблении памяти составит всего 20%. Если же имя класса будет ещё длиннее


private final Class<?> klass = org.springframework.objenesis.instantiator.perc.PercSerializationInstantiator.class;

то String.replace(char, char) будет выигрывать как по времени, так и по памяти:


                                     Mode     Score     Error   Units

manualReplace                        avgt   212,368 ±   3,370   ns/op
stringReplace                        avgt    75,503 ±   1,028   ns/op

manualReplace:·gc.alloc.rate.norm    avgt   360,000 ±   0,001    B/op
stringReplace:·gc.alloc.rate.norm    avgt   272,000 ±   0,001    B/op

Причина в том, что StringBuilder выделяет место с запасом, а из-за отсутствия механизма предсказания совокупного объёма расширение массива всегда происходит по одной и той же формуле независимо от того, сколько данных осталось записать:


// java.lang.AbstractStringBuilder

private int newCapacity(int minCapacity) {
  // overflow-conscious code
  int newCapacity = (value.length << 1) + 2;
  if (newCapacity - minCapacity < 0) {
    newCapacity = minCapacity;
  }
  return newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0
          ? hugeCapacity(minCapacity)
          : newCapacity;
}

Поэтому в примере выше потребление памяти выглядит следующим образом:


java.lang.String                        16 знаков – 16 ячеек
t.s.b.s.CharacterReplaceBenchmark       58 знаков – 70 ячеек
o.s.o.i.p.PercSerializationInstantiator 77 знаков – 142 ячейки

В худшем случае только около половины массива заполнено живыми данными, а всё остальное — пустые ячейки.
Несмотря на всё сказанное выше, существуют случаи, когда познаковый перебор и замена оказываются более выгодными:


// com.intellij.internal.statistic.beans.ConvertUsagesUtil

char c = text.charAt(i);
switch (c) {
  case GROUP_SEPARATOR:
  case GROUPS_SEPARATOR:
  case GROUP_VALUE_SEPARATOR:
  case '\'':
  case '\"':
  case '=' :
    escaped.append(' ');
    break;
  default:
    escaped.append(c);
    break;
}

Если переписать это с использованием String.replace(char, char), то получится следующая цепочка:


return text
  .replace(GROUP_SEPARATOR, ' ')
  .replace(GROUPS_SEPARATOR, ' ')
  .replace(GROUP_VALUE_SEPARATOR, ' ')
  .replace('\'', ' ')
  .replace('\"', ' ')
  .replace('=' , ' ');

Здесь в худшем случае (есть хотя бы 1 вхождение каждого искомого знака) мы получим 6 новых строк и 6 полных переборов. Множественные поиск/замена относительно редки, но иногда встречаются:



Выводы для разработчика:


  • неразрывное действие лучше, чем цикл
  • разовое выделение памяти быстрее, чем многократное
  • массовые операции в 99 случаях из 100 выигрывают у одиночных
  • из любого правила бывают исключения в 1 случае из 100

StringJoiner: склеивание через разделитель


Смотревшие доклад lany Java 9-14: Маленькие оптимизации знают про JDK-8054221, а именно улучшенную реализацию StringJoiner-а:


// было
public final class StringJoiner {
  private final String prefix;
  private final String delimiter;
  private final String suffix;
  private StringBuilder value;
}

// стало
public final class StringJoiner {
  private final String prefix;
  private final String delimiter;
  private final String suffix;

  private String[] elts;

  private int size;
  private int len;
}

Самое праздничное во всём этом: StringBuilder.toString():


char[] chars = new char[len + addLen];
int k = getChars(prefix, chars, 0);
if (size > 0) {
  k += getChars(elts[0], chars, k);
  for (int i = 1; i < size; i++) {
    k += getChars(delimiter, chars, k);
    k += getChars(elts[i], chars, k);
  }
}
k += getChars(suffix, chars, k);
return new String(chars);

Зная подробности реализации можно использовать StringJoiner в задаче склеивания множества строк без разделителя:


StringBuilder pathBuilder = new StringBuilder();
for (PathComponent pathComponent : pathComponents) {
  pathBuilder.append(pathComponent.getPath());
}
return pathBuilder.toString();

лёгким движением руки превращается в


StringJoiner pathBuilder = new StringJoiner("");
for (PathComponent pathComponent : pathComponents) {
    pathBuilder.add(pathComponent.getPath());
}
return pathBuilder.toString();

что даёт прирост на больших объёмах данных, особенно для нелатинских строк:


                         latin  length    Mode     Score    Error   Units

sb                        true      10    avgt     122,2 ±    5,0   ns/op
sb                        true     100    avgt     463,5 ±   42,6   ns/op
sb                        true    1000    avgt    3446,6 ±  109,1   ns/op

sj                        true      10    avgt     141,1 ±    5,3   ns/op
sj                        true     100    avgt     356,0 ±    6,9   ns/op
sj                        true    1000    avgt    2522,1 ±  287,7   ns/op

sb                       false      10    avgt     229,8 ±   14,7   ns/op
sb                       false     100    avgt     932,4 ±    8,7   ns/op
sb                       false    1000    avgt    7456,4 ±  527,2   ns/op

sj                       false      10    avgt     192,6 ±   70,8   ns/op
sj                       false     100    avgt     577,7 ±   60,3   ns/op
sj                       false    1000    avgt    3541,9 ±  135,0   ns/op

sb:·gc.alloc.rate.norm    true      10    avgt     512,0 ±    0,0    B/op
sb:·gc.alloc.rate.norm    true     100    avgt    4376,0 ±    0,0    B/op
sb:·gc.alloc.rate.norm    true    1000    avgt   41280,0 ±    0,0    B/op

sj:·gc.alloc.rate.norm    true      10    avgt     536,0 ±   14,9    B/op
sj:·gc.alloc.rate.norm    true     100    avgt    3232,0 ±   12,2    B/op
sj:·gc.alloc.rate.norm    true    1000    avgt   30232,0 ±   12,2    B/op

sb:·gc.alloc.rate.norm   false      10    avgt    1083,2 ±    7,3    B/op
sb:·gc.alloc.rate.norm   false     100    avgt    9744,0 ±    0,0    B/op
sb:·gc.alloc.rate.norm   false    1000    avgt   93448,0 ±    0,0    B/op

sj:·gc.alloc.rate.norm   false      10    avgt     768,0 ±   12,2    B/op
sj:·gc.alloc.rate.norm   false     100    avgt    5264,0 ±    0,0    B/op
sj:·gc.alloc.rate.norm   false    1000    avgt   50264,0 ±    0,0    B/op

Теперь посмотрите на код и постарайтесь найти в нём недоработку:


char[] chars = new char[len + addLen];
int k = getChars(prefix, chars, 0);
if (size > 0) {
  k += getChars(elts[0], chars, k);
  for (int i = 1; i < size; i++) {
    k += getChars(delimiter, chars, k);
    k += getChars(elts[i], chars, k);
  }
}
k += getChars(suffix, chars, k);
return new String(chars);

Ответ
char[] chars = new char[len + addLen];     // почему char[], а не byte[] ?!!
int k = getChars(prefix, chars, 0);
if (size > 0) {
  k += getChars(elts[0], chars, k);
  for (int i = 1; i < size; i++) {
    k += getChars(delimiter, chars, k);
    k += getChars(elts[i], chars, k);
  }
}
k += getChars(suffix, chars, k);
return new String(chars);

А вот это уже совсем другая история. Если совсем коротко, то причина в пакете: StringJoiner лежит в java.util, а весь функционал связанный со сжатыми строками — в java.lang. Поэтому внутри StringBuider-а массив байтов, а в StringJoiner всё ещё char[]. О попытках это исправить я подробно писал в прошлой статье.


Выводы:


  • старайтесь избегать выражений вроде map.get(/* new String */) / map.put(/* new String */)
  • составной ключ вида "_" + smth почти всегда можно заменить
  • при буферизации обращайте внимание на объём данных и размер буфера
  • клейте строки через «+», зачастую StringBuilder не нужен
  • одиночные преобразования почти всегда проигрывают массовым
  • помните о StringJoiner-e и используйте его для типовых задач

Пишите свои примеры в комментариях, будем разбирать.

Теги:
Хабы:
Всего голосов 14: ↑13 и ↓1+15
Комментарии5

Публикации

Истории

Работа

Java разработчик
347 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань