Ещё больше строковых оптимизаций

?v=1

В продолжение своей предыдущей статьи о строках (напоминаю, это была текстовая версия доклада на конференции JPoint-2020) решил дописать ещё одну заметку со строковыми оптимизациями, обнаруженными уже после вёрстки презентации (первые две есть на видео в самом конце, показывал их прямо из «Идеи»).

Снова StringBuilder.append (char)

На сцене снова «Спринг», а именно o.s.u.StringUtils.deleteAny(String, String):

// org.springframework.util.StringUtils

public static String deleteAny(String inString, String charsToDelete) {
  if (!hasLength(inString) || !hasLength(charsToDelete)) {
    return inString;
  }

  StringBuilder sb = new StringBuilder(inString.length());
  for (int i = 0; i < inString.length(); i++) {
    char c = inString.charAt(i);
    if (charsToDelete.indexOf(c) == -1) {
      sb.append(c);
    }
  }
  return sb.toString();
}

В разделе «Склейка: если всё-таки нужно» рассматривая StringBuilder.append(char) я отметил невозможность оптимизации компилятором проверок внутри этого метода даже в счётном цикле с заранее известным количеством проходов. Выходом станет использование массива.

Этот же подход хорошо ложится на случаи, в которых длина преобразованной строки может быть меньше или равна исходной. Действительно, какой бы аргумент не передавался в deleteAny, длина возвращаемой строки никогда не превысит длину исходной. Следовательно, можно незначительно усложнив код избавиться от SB:

public static String deleteAny(String inString, String charsToDelete) {
  if (!hasLength(inString) || !hasLength(charsToDelete)) {
    return inString;
  }
  
  int lastCharIndex = 0;
  char[] result = new char[inString.length()];
  for (int i = 0; i < inString.length(); i++) {
    char c = inString.charAt(i);
    if (charsToDelete.indexOf(c) == -1) {
      result[lastCharIndex++] = c;
    }
  }
  return new String(result, 0, lastCharIndex);
}

Переменная lastCharIndex устраняет возможные «пустоты» в массиве при пропуске одного и более знаков.

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

Benchmark                       Mode     Score     Error   Units

original                        avgt    90.203 ±   4.317   ns/op
patched                         avgt    25.391 ±   1.118   ns/op

original:·gc.alloc.rate.norm    avgt   104.000 ±   0.001    B/op
patched:·gc.alloc.rate.norm     avgt   104.000 ±   0.001    B/op

Но и это ещё не всё.

Уже после моего коммита наблюдательный пользователь Johnny Lim сообразил, что если после завершения перебора значение переменной lastCharIndex равно длине исходной строки, то ни одной замены не сделано, а значит новую строку создавать не нужно. Итого:

public static String deleteAny(String inString, String charsToDelete) {
  if (!hasLength(inString) || !hasLength(charsToDelete)) {
    return inString;
  }
  
  int lastCharIndex = 0;
  char[] result = new char[inString.length()];
  for (int i = 0; i < inString.length(); i++) {
    char c = inString.charAt(i);
    if (charsToDelete.indexOf(c) == -1) {
      result[lastCharIndex++] = c;
    }
  }
  if (lastCharIndex == inString.length()) {   // С - сообразительность
    return inString;
  }
  return new String(result, 0, lastCharIndex);
}

Похожий подход использован тут.

Коварный StringBuilder.append (Object)

Следующий пример намного менее очевиден:

// org.springframework.http.ContentDisposition

private static String escapeQuotationsInFilename(String filename) {
  if (filename.indexOf('"') == -1 && filename.indexOf('\\') == -1) {
    return filename;
  }
  boolean escaped = false;
  StringBuilder sb = new StringBuilder();
  for (char c : filename.toCharArray()) {
    sb.append((c == '"' && !escaped) ? "\\\"" : c);    // <----
    escaped = (!escaped && c == '\\');
  }
  // Remove backslash at the end..
  if (escaped) {
    sb.deleteCharAt(sb.length() - 1);
  }
  return sb.toString();
}

Больное место находится в строке 10, а заключение звучит так: поскольку тернарный оператор возвращает либо String, либо char, то аргументом метода StringBuilder.append() фактически является Object. И всё бы ничего, да преобразование знака в объект происходит с помощью String.valueOf() и мы на ровном месте получаем множество мелкого мусора по схеме:

Character.valueOf(c) 
  -> StringBuilder.append(Object) 
    -> String.valueOf()
      -> Character.toString()

Отдельно доставляет реализация Character.toString:

Осторожно, не ушибите лицо ладонью

public final class Character {

  private final char value;

  public String toString() {
    char buf[] = {value};
    return String.valueOf(buf);
  }
}

Зачем было оборачивать знак в массив? Тайна сия велика… Как бы то ни было, это уже исправлено.

Таким образом, достаточно избавиться от тернарного оператора:

for (int i = 0; i < filename.length() ; i++) {
  char c = filename.charAt(i);
  if (!escaped && c == '"') {
    sb.append("\\\"");    // append(String)
  }
  else {
    sb.append(c);         // append(char)
  }
  escaped = (!escaped && c == '\\');
}

И вновь очень простое изменение приносит впечатляющий прирост (бенчмарк по ссылке):

JDK 8

Benchmark                             latin   len   Score          Units

appendCovariant                        true    10   180.2 ± 10.3   ns/op
appendExact                            true    10    68.5 ±  1.4   ns/op

appendCovariant                       false    10   177.7 ±  4.4   ns/op
appendExact                           false    10    67.7 ±  1.3   ns/op

appendCovariant:·gc.alloc.rate.norm    true    10   688.0 ±  0.0    B/op
appendExact:·gc.alloc.rate.norm        true    10   112.0 ±  0.0    B/op

appendCovariant:·gc.alloc.rate.norm   false    10   816.0 ±  0.0    B/op
appendExact:·gc.alloc.rate.norm       false    10   112.0 ±  0.0    B/op

JDK 14

Benchmark                             latin   len    Score         Units

appendCovariant                        true    10    228.8 ± 18.6  ns/op
appendExact                            true    10     57.9 ±  2.6  ns/op

appendCovariant                       false    10    292.8 ± 12.4  ns/op
appendExact                           false    10     90.2 ±  2.2  ns/op

appendCovariant:·gc.alloc.rate.norm    true    10    688.0 ±  0.0   B/op
appendExact:·gc.alloc.rate.norm        true    10    112.0 ±  0.0   B/op

appendCovariant:·gc.alloc.rate.norm   false    10   1096.0 ±  0.0   B/op
appendExact:·gc.alloc.rate.norm       false    10    200.0 ±  0.0   B/op

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

Ответ

Не знаю, не проверял :)
Оставляю этот вопрос энтузиастам в качестве домашнего задания :)

Коварный String.substring

Давно известно, что метод String.substring всегда возвращает новую строку, и тем не менее в задачах на «выкусывание» он всё ещё пользуется незаслуженной популярностью:

// org.springframework.web.util.UrlPathHelper

/**
 * Sanitize the given path replacing "//" with "/"
 */
private String getSanitizedPath(String path) {
  String sanitized = path;
  while (true) {
    int idx = sanitized.indexOf("//");
    if (idx < 0) {
      break;
    }
    else {
      sanitized = sanitized.substring(0, idx) + sanitized.substring(idx + 1);
    }
  }
  return sanitized;
}

Здесь даже если исполнение каким-то чудом сможет распознать шаблон склеивания строк и подставить StringBuilder этот метод всё равно останется чертовски неэффективным.

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

private static String getSanitizedPath(String path) {
  int index = path.indexOf("//");
  if (index >= 0) {
    StringBuilder sanitized = new StringBuilder(path);
    while (index != -1) {
      sanitized.deleteCharAt(index);
      index = sanitized.indexOf("//", index); //не начинай сначала ;)
    }
    return sanitized.toString();
  }
  return path;
}

В некоторых случаях использование StringBuilder.deleteCharAt(int) позволяет существенно облегчить понимание кода:

// org.springframework.web.util.UrlPathHelper

private String removeSemicolonContentInternal(String requestUri) {
  int semicolonIndex = requestUri.indexOf(';');
  while (semicolonIndex != -1) {
    int slashIndex = requestUri.indexOf('/', semicolonIndex);
    String start = requestUri.substring(0, semicolonIndex);
    requestUri = (slashIndex != -1) ? start + requestUri.substring(slashIndex) : start;
    semicolonIndex = requestUri.indexOf(';', semicolonIndex);
  }
  return requestUri;
}

Логика здесь довольно запутанная, но на высоком уровне метод удаляет содержимое, выделенное ; внутри ссылки, превращая строку вроде /foo;f=F;o=O;o=O/bar;b=B;a=A;r=R в /foo/bar;b=B;a=A;r=R.

Можно избавиться от взятия подстроки и склейки переписав метод вот так:

private static String removeSemicolonContentInternal(String requestUri) {
  int semicolonIndex = requestUri.indexOf(';');
  if (semicolonIndex == -1) {
    return requestUri;
  }
  StringBuilder sb = new StringBuilder(requestUri);
  while (semicolonIndex != -1) {
    int slashIdx = sb.indexOf("/", semicolonIndex + 1);
    if (slashIdx == -1) {
      return sb.substring(0, semicolonIndex);
    }
    sb.delete(semicolonIndex, slashIdx);
    semicolonIndex = sb.indexOf(";", semicolonIndex);
  }
  return sb.toString();
}

На первый взгляд, кода стало больше: 12 строк превратились в 16. С другой стороны, он стал выразительнее и проще для понимания, что идёт приятной добавкой к улучшенной производительности.

Все изменения по теме, а также бенчмарк можно увидеть здесь.

Мопед не мой

В этом разделе описано улучшение, автором которого я не являюсь, но которое представляет интерес.

// org.springframework.util.StringUtils

public static String trimLeadingWhitespace(String str) {
  if (!hasLength(str)) {
    return str;
  }
  StringBuilder sb = new StringBuilder(str);
  while (sb.length() > 0 && Character.isWhitespace(sb.charAt(0))) {
    sb.deleteCharAt(0);
  }
  return sb.toString();
}

Чтобы улучшить этот код нужно вновь понять его высокоуровневый смысл (к счастью он полностью соответствует имени метода). Код удаляет все пробелы в начале строки, а значит вместо познакового удаления можно взять подстроку начиная от первого знака-непробела:

public static String trimLeadingWhitespace(String str) {
  if (!hasLength(str)) {
    return str;
  }
  int idx = 0;
  while (idx < str.length() && Character.isWhitespace(str.charAt(idx))) {
    idx++;
  }
  return str.substring(idx);
}

Этот код значительно быстрее первоначальной версии, а также потребляет меньше памяти, ведь в худшем случае создаётся только один объект (в лучшем при idx = 0 метод str.substring()вернёт строку, на которой он был вызван).

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

Мелочь

Если в коде есть что-то вроде

char[] chars;
//...
return new String(chars, 0, chars.length);

то это всегда можно переписать в виде

char[] chars;
//...
return new String(chars);

Производительность это сильно не улучшит, однако, перефразируя рекламу моющего средства из 90-х, «если не видно разницы, то зачем писать больше?» :)

Заключение

На этом всё, надеюсь примеры были вам интересны и полезны. Описывайте свои улучшения в комментариях, самые интересные добавлю в статью. До новых встреч!

© Habrahabr.ru