Programming
Java
8 May

Мины под производительностью ждут своего часа

В этой статье я расскажу о минах, заложенных под производительность, а также об их обнаружении (желательно ещё до взрыва) и обезвреживании.


Картинка для привлечения внимания

image


Что такое мина?


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


String concat(String... strings) {
  String result = "";
  for (String str : strings) {
    result += str;
  }
  return result;
}

Даже начинающим разработчикам известно, что строки неизменяемы, а склеивание их в цикле не означает дописывание данных в хвост уже существующей строки, а создание новой строки при каждом проходе. Если вы оплошали, то не расстраивайтесь — "Идея" вас сразу же предупредит об опасности, а "Сонар" наверняка завалит вашу сборку.


А вот этот код привлечёт куда меньше внимания, да и "Идея" (до версии 2018.2) будет молчать:


Long total = 0L;
List<Long> totals = query.getResultList();

for (Long element : totals) {
  total += element == null ? 0 : element;
}

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


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


На деле не всё так просто. Возьмём бенчмарк и попробуем сложить числа указанным способом:


Внезапно, вышло очень и очень недёшево (здесь и далее JDK 11, если явно не указано иное)
                (size)   Mode  Cnt     Score   Error  Units
wrapper             10   avgt  100      23,5 ±   0,1  ns/op
wrapper            100   avgt  100     352,3 ±   2,1  ns/op
wrapper           1000   avgt  100    4424,5 ±  25,2  ns/op

wrapper             10   avgt  100         0 ±     0   B/op
wrapper            100   avgt  100      1872 ±     0   B/op
wrapper           1000   avgt  100     23472 ±     0   B/op

Сравните с простым типом:


primitive           10   avgt  100       6,4 ±   0,0  ns/op
primitive          100   avgt  100      39,8 ±   0,1  ns/op
primitive         1000   avgt  100     252,5 ±   1,3  ns/op

primitive           10   avgt  100         0 ±     0   B/op
primitive          100   avgt  100         0 ±     0   B/op
primitive         1000   avgt  100         0 ±     0   B/op

Отсюда выводим одно из определений мины под производительностью — это код, который не бросается в глаза, не обнаруживается (по крайней мере в то время, когда вы с ним столкнулись) статическими анализаторами, но может тормозить в некоторых использованиях. В нашем случае пока сумма не превышает 127 объекты берутся из кэша и Long всего в 4 раза медленнее чем long. Однако уже для массива размером 100 скорость ниже почти в 10 раз.


Большие мелочи


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


Предположим, у нас есть код:


// org.springframework.data.convert.CustomConversions$ConversionTargetsCache

Map<Object, TypeInformation<?>> cache = new ConcurrentHashMap<>();

private TypeInformation<?> getFromCacheOrCreate(Alias alias) {
  TypeInformation<?> info = cache.get(alias);
  if (info == null) {
    info = getAlias.apply(alias);
    cache.put(alias, info);
  }
  return info;
}

На что похожа логика метода?


Не спешите подсматривать, подумайте

Это же ConcurrentHashMap::computeIfAbsent!


У нас "восьмёрка" и мы можем круто улучшить код: 6 строк заменить одной, сделав код короче и проще для понимания. Кстати, знатоки многопоточности наверняка укажут на ещё одно улучшение, которое несёт с собой ConcurrentHashMap::computeIfAbsent, но о нём чуть позже ;)


Претворим в жизнь великую мысль:


// org.springframework.data.convert.CustomConversions$ConversionTargetsCache

Map<Object, TypeInformation<?>> cache = new ConcurrentHashMap<>();

private TypeInformation<?> getFromCacheOrCreate(Alias alias) {
  return cache.computeIfAbsent(alias, getAlias);
}

Собрались, запустились, прослезились

Чтобы увидеть полный размер нажмите правой клавишей по рисунку и выберите "Открыть изображение в новой вкладке"
image


Пока приложение работало с одним потоком — всё было более-менее хорошо. Потоков стало больше и стало ощутимо хуже. Выяснилось, что ConcurrentHashMap::computeIfAbsent блокируется, при чём даже в том случае, когда ключ уже добавлен в словарь. И это стало причиной вполне себе бага в Спринг Дата Монго.


Убедиться в этом можно с помощью простого измерения ("восьмёрка"). Вот его вывод:


Benchmark        Mode  Cnt    Score    Error  Units

1 thread

computeIfAbsent  avgt   20   19,405 ±  0,411  ns/op
getAndPut        avgt   20    4,578 ±  0,045  ns/op

2 threads

computeIfAbsent  avgt   20   66,492 ±  2,036  ns/op
getAndPut        avgt   20    4,454 ±  0,110  ns/op

4 threads

computeIfAbsent  avgt   20  155,975 ±  8,850  ns/op
getAndPut        avgt   20    5,616 ±  2,073  ns/op

6 threads

computeIfAbsent  avgt   20  203,188 ± 10,547  ns/op
getAndPut        avgt   20    7,024 ±  0,456  ns/op

8 threads

computeIfAbsent  avgt   20  302,036 ± 31,702  ns/op
getAndPut        avgt   20    7,990 ±  0,144  ns/op

Можно ли это однозначно считать ошибкой разработчиков? По моему скромному мнению — нет, нельзя. В документации сказано:


Some attempted update operations on this map by other threads may be blocked while computation is in progress, so the computation should be short and simple, and must not attempt to update any other mappings of this map

Иными словами ConcurrentHashMap::computeIfAbsent закрывает от внешнего мира ячейку, содержащую ключ (в отличие от ConcurrentHashMap::get), что в общем случае верно, так как позволяет увернуться от гонки при одновременном вызове метода из разных потоков, когда ключ ещё не добавлен.


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


В более свежих изданиях (>8) ConcurrentHashMap::computeIfAbsent стал шустрее:


JDK 11
Benchmark        Mode  Cnt   Score   Error  Units

1 thread 

computeIfAbsent  avgt   20   6,983 ± 0,066  ns/op
getAndPut        avgt   20   5,291 ± 1,220  ns/op

2 threads

computeIfAbsent  avgt   20   7,173 ± 0,249  ns/op
getAndPut        avgt   20   5,118 ± 0,395  ns/op

4 threads

computeIfAbsent  avgt   20   7,991 ± 0,447  ns/op
getAndPut        avgt   20   5,270 ± 0,366  ns/op

6 threads

computeIfAbsent  avgt   20  11,919 ± 0,865  ns/op
getAndPut        avgt   20   7,249 ± 0,199  ns/op

8 threads

computeIfAbsent  avgt   20  14,360 ± 0,892  ns/op
getAndPut        avgt   20   8,511 ± 0,229  ns/op

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


Почему я написал 'почти не изменилось'

Не всегда ConcurrentHashMap::computeIfAbsent взаимозаменяем с выражением getAndPut, потому что ConcurrentHashMap::computeIfAbsent является атомарной операцией. В этом же коде


private TypeInformation<?> getFromCacheOrCreate(Alias alias) {
  TypeInformation<?> info = cache.get(alias);
  if (info == null) {
    info = getAlias.apply(alias);
    cache.put(alias, info);
  }
  return info;
}

из-за отсутствия внешней синхронизации появляется гонка. Если функция, передаваемая в ConcurrentHashMap::computeIfAbsent для заданного ключа всегда возвращает одно и то же значения, то это "безопасная" гонка, самое большее, что нам грозит — это вычисление одного и того же значения 2 и более раз. Если же таких гарантий нет, то механическая замена чревата поломкой приложения. Будьте осторожны!


Эти руки ничего не меняли


Бывает и так, что код вообще не меняется, но внезапно начинает тормозить.


Представьте, что перед нами стоит задача переложить элементы массива в коллекцию. Наиболее логичным будет использовать готовый метод Collection::addAll, да вот незадача — он принимает коллекцию:


public interface Collection<E> extends Iterable<E> {
  boolean addAll(Collection<? extends E> c);
}

Самый простой выход — завернуть массив в Arrays::asList. Получится что-то вроде


boolean addItems(Collection<T> collection) {
  T[] items = getArray();
  return collection.addAll(Arrays.asList(items));
}

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


  • заворачивание массива в список (лишний объект)
  • создание итератора (ещё один лишний объект) и проход по нему

В самом деле, в опорной реализации Collection::addAll мы увидим вот это:


public abstract class AbstractCollection<E> implements Collection<E> {

  public boolean addAll(Collection<? extends E> c) {
    boolean modified = false;
    for (E e : c) {
      if (add(e)) 
        modified = true;
    }
    return modified;
  }

}

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


boolean addItems(Collection<T> collection) {
  T[] items = getArray();
  return Collections.addAll(collection, items);
}

Внутри код, вполне справедливо кажущийся более производительным:


public static <T> boolean addAll(Collection<? super T> c, T... elements) {
  boolean result = false;
    for (T element : elements)
      result |= c.add(element);
  return result;
}

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


Наконец, коллеги приводят ultima ratio regum: документацию. А там серым по белому (или зелёным по чёрному) сказано:


/**
 * ...
 * The behavior of this convenience method is identical to that of
 * c.addAll(Arrays.asList(elements)), but this method is likely
 * to run significantly faster under most implementations.           <----
 * @since 1.5
 */
@SafeVarargs
public static <T> boolean addAll(Collection<? super T> c, T... elements) {
  //...
}

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


Убедиться в этом поможет бенчмарк, который мы запустим для HashSet-а на "восьмёрке":


Benchmark          (collection)  (size)  Mode  Cnt     Score   Error  Units

addAll                  HashSet      10  avgt  100     155,2 ±   2,8  ns/op
addAll                  HashSet     100  avgt  100    1884,4 ±  37,4  ns/op
addAll                  HashSet    1000  avgt  100   17917,3 ± 298,8  ns/op

collectionsAddAll       HashSet      10  avgt  100     136,1 ±   0,8  ns/op
collectionsAddAll       HashSet     100  avgt  100    1538,3 ±  31,4  ns/op
collectionsAddAll       HashSet    1000  avgt  100   15168,6 ± 289,4  ns/op

Похоже, что более опытные товарищи оказались правы. Почти.


В более поздних изданиях (например, в 11) блеск утилитного метода несколько потускнеет:


Benchmark          (collection)  (size)  Mode  Cnt     Score   Error  Units

addAll                  HashSet      10  avgt  100     143,1 ±   0,6  ns/op
addAll                  HashSet     100  avgt  100    1738,4 ±   7,3  ns/op
addAll                  HashSet    1000  avgt  100   16853,9 ± 101,0  ns/op

collectionsAddAll       HashSet      10  avgt  100     132,1 ±   1,1  ns/op
collectionsAddAll       HashSet     100  avgt  100    1661,1 ±   7,1  ns/op
collectionsAddAll       HashSet    1000  avgt  100   15450,9 ±  93,9  ns/op

Видно, что ни о каком "значительно быстрее" речь не идёт. А если мы повторим опыт для ArrayList-a, то окажется, что утилитный метод начинает сильно проигрывать (чем дальше — тем сильнее):


Benchmark            (collection)  (size)  Mode  Cnt     Score   Error  Units

JDK 8

addAll                  ArrayList      10  avgt  100      38,5 ±   0,5  ns/op
addAll                  ArrayList     100  avgt  100     188,4 ±   7,0  ns/op
addAll                  ArrayList    1000  avgt  100    1278,8 ±  42,9  ns/op

collectionsAddAll       ArrayList      10  avgt  100      62,7 ±   0,7  ns/op
collectionsAddAll       ArrayList     100  avgt  100     495,1 ±   2,0  ns/op
collectionsAddAll       ArrayList    1000  avgt  100    4892,5 ±  48,0  ns/op

JDK 11

addAll                  ArrayList      10  avgt  100      26,1 ±   0,0  ns/op
addAll                  ArrayList     100  avgt  100     161,1 ±   0,4  ns/op
addAll                  ArrayList    1000  avgt  100    1276,7 ±   3,7  ns/op

collectionsAddAll       ArrayList      10  avgt  100      41,6 ±   0,0  ns/op
collectionsAddAll       ArrayList     100  avgt  100     492,6 ±   1,5  ns/op
collectionsAddAll       ArrayList    1000  avgt  100    6792,7 ± 165,5  ns/op

Ничего неожиданного здесь нет, ArrayList построен вокруг массива, поэтому разработчики дальновидно переопределили метод Collection::addAll:


public boolean addAll(Collection<? extends E> c) {
  Object[] a = c.toArray();
  modCount++;
  int numNew = a.length;
  if (numNew == 0)
    return false;
  Object[] elementData;
  final int s;
  if (numNew > (elementData = this.elementData).length - (s = size))
    elementData = grow(s + numNew);
  System.arraycopy(a, 0, elementData, s, numNew);    <--- переносим данные пачкой
  size = s + numNew;
  return true;
}

Теперь вернёмся к нашим минам. Предположим, что мы всё же приняли предложенное на вычитке решение и оставили этот код:


boolean addItems(Collection<T> collection) {
  T[] items = getArray();
  return Collections.addAll(collection, items);
}

До поры до времени всё хорошо, но после добавления нового функционала метод иногда становится горячим и начинает тормозить. Открываем исходники — код не менялся. Объём данных тот же. А производительность сильно просела. Это ещё одна разновидность мин.


Расчехляем отладчик и находим прекрасное:



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


                                 Java 8     Java 11
                  размер

addAll                10           56,9        25,2    ns/op
collectionsAddAll     10          352,2       142,9    ns/op

addAll               100          159,9        84,3    ns/op
collectionsAddAll    100         4607,1      3964,3    ns/op

addAll              1000         1244,2       760,2    ns/op
collectionsAddAll   1000       355796,9    364677,0    ns/op

На больших массивах разница между Collections::addAll и Collection::addAll составляет скромных 500 раз. Дело в том, что COWList не просто расширяет существующий массив, а создаёт новый при каждом добавлении элементов:


public boolean add(E e) {
  synchronized (lock) {
    Object[] es = getArray();
    int len = es.length;
    es = Arrays.copyOf(es, len + 1);     <---- привет сборщику мусора
    es[len] = e;
    setArray(es);
    return true;
  }
}

Кто виноват?



Основная проблема здесь в том, что метод Collections::addAll принимает интерфейс, при этом у метода addAll нет тела. Нет тела — нет дела, поэтому документация написана исходя из реализации, существующей в AbstractCollection::addAll, которая является обобщённым алгоритмом, применимым для всех коллекций. Это означает, что более конкретные реализации структур данных, находящиеся на более низком уровне абстракции, могут изменять это поведение.


Теперь по-человечески
        Collection::addAll  – верхний уровень
AbstractCollection::addAll  – средний уровень  <--- описано в документации
         ArrayList::addAll
           HashSet::addAll  – нижний уровень   <--- тут может быть что угодно
           COWList::addAll

Ещё об абстракциях


Раз уж мы заговорили об уровнях абстракции, то расскажу об одном примере из жизни.


Давайте сравним эти два способа сохранения энного количество сущностей в базу:


@Transactional
void save(int n) {
  for (int i = 0; i < n; i++) {
    SimpleEntity e = new SimpleEntity();
    repository.save(e);
  }
}

@Transactional
void _save(int n) {
  for (int i = 0; i < n; i++) {
    SimpleEntity e = new SimpleEntity();
    repository.saveAndFlush(e);
  }
}

На первый взгляд, производительность обоих способов не должна особо отличаться, ведь


  • в обоих случаях в базу будет сохранено одинаковое количество сущностей
  • если ключ берётся из последовательности, то количество обращение будет одинаковым
  • объём передаваемых данных одинаков

Обратимся к методу SimpleJpaRepository::saveAndFlush:


@Transactional
public <S extends T> S save(S entity) {
  if (entityInformation.isNew(entity)) {
    em.persist(entity);
  return entity;
  } else {
    return em.merge(entity);
  }
}

@Transactional
public <S extends T> S saveAndFlush(S entity) {
  S result = save(entity);
  flush();
  return result;
}

@Transactional
public void flush() {
  em.flush();
}

Стрёмным местом здесь является метод flush(). Почему стрёмным? Как мне кажется, его раскрытие в интерфейсе JpaRepository было ошибкой разработчиков. Попробую обосновать свою мысль. Обычно этот метод вообще не используется разработчиком, т. к. вызов EntityManager::flush привязан к завершению транзакции, управляемой Спрингом:


// управление сессией передано каркасу

@Transactional
public void method() { <-- скрытый Session::open
  /*.*/
}                      <-- скрытый Session::flush

Обратите внимание: EntityManager — это часть спецификации JPA, реализованный в Хибернейте в виде сессии (интерфейс Session и класс SessionImpl, соответственно). Спринг Дата — это каркас, работающий поверх ORM-а, в данном случае — поверх Хибернейта. Получается, что метод JpaRepository::saveAndFlush даёт нам доступ к нижним уровням API, хотя задача фреймворка как раз в сокрытии низкоуровневых подробностей (ситуация чем-то похожа на историю с Unsafe в JDK).
В нашем случае при использовании JpaRepository::saveAndFlush мы влезаем в низшие слои приложения, ломая тем самым кое-что.


Не торопитесь подсматривать, подумайте самостоятельно

Ломается способность Хибернейта отправлять данные пачками, кратно настройке jdbc.batch_size, которая указывается в application.yml:


spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 500

Работа Хибернейта построена на событиях, поэтому при сохранении 1000 сущностей вот так


@Transactional
void save(int n) {
  for (int i = 0; i < n; i++) {
    SimpleEntity e = new SimpleEntity();
    repository.save(e);
  }
}

при вызове repository.save(e) не происходит мгновенного сохранения. Вместо этого создаётся событие, которое ставится в очередь. По завершению транзакции данные сливаются при помощи EntityManager::flush, который делит вставки/обновления на пачки кратно jdbc.batch_size и создаёт из них запросы. В нашем случае jdbc.batch_size: 500, так что сохранение 1000 сущностей на деле означает лишь 2 запроса.


А вот с ручным сливом сессии на каждом проходе цикла


@Transactional
void _save(int n) {
  for (int i = 0; i < n; i++) {
    SimpleEntity e = new SimpleEntity();
    repository.saveAndFlush(e);
  }
}

очередь очищается и сохранение 1000 сущностей означает 1000 запросов.


Таким образом, вмешательство в низшие слои приложения может легко стать миной, при чём не только миной производительности (см. Unsafe и его неконтролируемое применение).


Насколько сильно это тормозит? Возьмём самый лучший (для нас) случай — БД находится на том же хосте, что и приложение. Мой замер показывает такую картину:


                    (entityCount)  Mode  Cnt    Score   Error  Units
bulkSave                       10    ss  500   16,613 ± 1,714  ms/op
bulkSave                      100    ss  500   31,371 ± 1,453  ms/op
bulkSave                     1000    ss  500   35,687 ± 1,973  ms/op

bulkSaveUsingFlush             10    ss  500   32,653 ± 2,166  ms/op
bulkSaveUsingFlush            100    ss  500   61,983 ± 6,304  ms/op
bulkSaveUsingFlush           1000    ss  500  184,814 ± 6,976  ms/op

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


Таким образом, работая на неправильном уровне абстракции можно легко создать мину замедленного действия. Кстати, в одной из своих предыдущих статей я рассказывал о курьёзной попытке улучшения StringBuilder-a: там меня постигла неудача как раз при попытке влезть в более абстрактный уровень кода.


Границы минных полей


Поиграем в сапёра? Найди мину:


// org.springframework.cache.interceptor.CacheAspectSupport 

Object generateKey(CacheOperationContext ctx, Object result) {
  Object key = ctx.generateKey(result);
  Assert.notNull(key, "Null key ..." + context.metadata.operation);

  // ...
  return key;
}

Нашёл? Сверься с правильным ответом
                                                 |
                                                \ /
// org.springframework.cache.interceptor.CacheAspectSupport 
                 |
                \ /
Object generateKey(CacheOperationContext ctx, Object result) {
  Object key = ctx.generateKey(result);
                                                  |
                                                 \ /
  Assert.notNull(key, "Null key ..." + context.metadata.operation);
  return key;
}

"Издеваетесь?", — воскликнет критик, — "Да там всего-лишь склейка двух строк? Какое это имеет значение в кровавом Э.?" Позвольте обратить ваше внимание на то, что я выделил не только склейку строк, а ещё и имя класс и название метода. Ведь опасность склейки строк не в самой склейке, а в том, что происходит она в методе, создающем ключи для кэша, т. е. в определённых сценариях у нас будет очень много обращений к этому методу, а значит очень много мусорных строк.
Поэтому сообщение об ошибке нужно создавать только тогда, когда эта ошибка действительно будет выброшена:


// org.springframework.cache.interceptor.CacheAspectSupport 

Object generateKey(CacheOperationContext ctx, Object result) {
  Object key = ctx.generateKey(result);
  if (key == null) {
    throw new IAE("Null key ..." + context.metadata.operation);
  }

  // ...
  return key;
}

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


С другой стороны — это та черта, до пересечения которой усложнение кода не даёт значимого (измеримого) улучшения.


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


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


Тот самый сотый случай


Вот код, который приводит в своей статье The volatile read surprise Ницан Вакарт:


@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class LoopyBenchmarks {
  @Param({ "32", "1024", "32768" })
  int size;

  byte[] bunn;

  @Setup
  public void prepare() {
    bunn = new byte[size];
  }

  @Benchmark
  public void goodOldLoop(Blackhole fox) {
    for (int y = 0; y < bunn.length; y++) { // good old C style for (the win?)
      fox.consume(bunn[y]);
    }
  }

  @Benchmark
  public void sweetLoop(Blackhole fox) {
    for (byte bunny : bunn) { // syntactic sugar loop goodness
      fox.consume(bunny);
    }
  }
}

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


Benchmark              (size)     Score  Score error  Units
goodOldLoop               32     46.630        0.097  ns/op
goodOldLoop             1024   1199.338        0.705  ns/op
goodOldLoop            32768  37813.600       56.081  ns/op

sweetLoop                 32     19.304        0.010  ns/op
sweetLoop               1024    475.141        1.227  ns/op
sweetLoop              32768  14295.800       36.071  ns/op

Здесь неопытный разработчик может сделать такой очевидный и подкреплённый бенчмарками вывод: проход по массиву с помощью нового синтаксиса работает быстрее, чем счётный цикл. Это неверный вывод, ведь стоит немного изменить метод goodOldLoop:


@Benchmark
public void goodOldLoopReturns(Blackhole fox) {
  byte[] sunn = bunn; // make a local copy of the field
  for (int y = 0; y < sunn.length; y++) {
    fox.consume(sunn[y]);
  }
}

и его показатели сравняются с показателями "более быстрого" метода sweetLoop:


Benchmark              (size)     Score  Score error  Units
goodOldLoopReturns        32     19.306        0.045  ns/op
goodOldLoopReturns      1024    476.493        1.190  ns/op
goodOldLoopReturns     32768  14292.286       16.046  ns/op

sweetLoop                 32     19.304        0.010  ns/op
sweetLoop               1024    475.141        1.227  ns/op
sweetLoop              32768  14295.800       36.071  ns/op

Дело здесь в коварном Blackhole::consume:


//...
public volatile byte b1, b2;
public volatile BlackholeL2 nullBait = null;

/**
 * Consume object. This call provides a side effect preventing JIT to eliminate dependent computations.
 *
 * @param b object to consume.
 */
public final void consume(byte b) {
  if (b == b1 & b == b2) {
    // SHOULD NEVER HAPPEN
    nullBait.b1 = b; // implicit null pointer exception
  }
}

благодаря которому внутри нашего цикла внезапно нарисовался волатильный доступ, который действует подобно барьеру, запрещая компилятору некоторые перестановки и улучшения. В первой версии метода goodOldLoop компилятор вынужден загружать из памяти поле this.bunn при каждом проходе цикла, а for-each цикл делает это лишь единожды, выполняя неявное сохранение поля в местную переменную (ЕМНИП, в Java Concurrency In Practice это называется "синхронизация на стеке"). Именно этим объясняется разница в показателях.


Критически настроенный читатель скажет: "Какое значение это имеет в повседневной работе? Этот пример искусственный, Blackhole::consume — часть инфраструктуры JMH и в обычной разработке никогда не используется. И вообще, какая разница, как именно перебирать массив в реальном коде?"


Чтобы отразить этот убийственный аргумент давайте внимательно посмотрим на пример:


byte[] bunn;

public void goodOldLoop(Blackhole fox) {
  for (int y = 0; y < bunn.length; y++) {
    fox.consume(bunn[y]);
  }
}

Ничего не напоминает? Точно? Что же, давайте внесём некоторые изменения:


E[] bunn;

public void forEach(Consumer<E> fox) {
  for (int y = 0; y < bunn.length; y++) {
    fox.consume(bunn[y]);
  }
}

Получился Iterable::forEach! И если открыть реализации этого метода для коллекций, работающих поверх массива, то внезапно все они используют либо сахарный цикл, либо сохранение в переменную перед проходом (все исходники из JDK 13):


//ArrayList
public void forEach(Consumer<? super E> action) {
  Objects.requireNonNull(action);
  final int expectedModCount = modCount;
  final Object[] es = elementData;
  final int size = this.size;
  for (int i = 0; modCount == expectedModCount && i < size; i++)
    action.accept(elementAt(es, i));
  if (modCount != expectedModCount)
    throw new ConcurrentModificationException();
}

//Arrays$ArrayList
public void forEach(Consumer<? super E> action) {
  Objects.requireNonNull(action);
  for (E e : a) {
    action.accept(e);
  }
}

//CopyOnWriteArrayList
public void forEach(Consumer<? super E> action) {
  Objects.requireNonNull(action);
  for (Object x : getArray()) {
    @SuppressWarnings("unchecked") E e = (E) x;
    action.accept(e);
  }
}

//ArrayDeque
public void forEach(Consumer<? super E> action) {
  Objects.requireNonNull(action);
  final Object[] es = elements;
  for (int i = head, end = tail, to = (i <= end) ? end : es.length; ; i = 0, to = end) {
    for (; i < to; i++)
      action.accept(elementAt(es, i));
    if (to == end) {
      if (end != tail) throw new ConcurrentModificationException();
      break;
    }
  }
}

А помнить об этом нужно, когда вы реализовываете свою структуру данных или улучшаете уже существующую. Например, не так давно поступило предложение об улучшении Collections.nCopies()::forEach:


@Override
public void forEach(final Consumer<? super E> action) {
  Objects.requireNonNull(action);
  for (int i = 0; i < this.n; i++) {
    action.accept(this.element);
  }
}

На первый взгляд в этом коде нет необходимости выполнять синхронизацию на стеке, т. к. значения полей this.n и this.element записываются только один раз при создании объекта:


private static class CopiesList<E>
  extends AbstractList<E>
  implements RandomAccess, Serializable {

  final int n;
  final E element;

  CopiesList(int n, E e) {
    assert n >= 0;
    this.n = n;
    element = e;
  }

Как оказалось, полагаться на это нельзя, равно как и на @Stable.


Обратите внимание: в 99 случаях из 100 совершенно неважно, как именно вы обходите массив, но всегда есть 1 случай из 100, в котором внутри цикла окажется волатильный доступ и неверное решение станет проблемой производительности. Это ещё один пример мины, срабатывающей только в особых условиях.


В одной из своих статей я показывал похожий пример разрушительного воздействия на производительность связки "цикл и volatile".


Эхо войны


Завершить статью хочу примером древнего улучшения, которое стало миной:


//java.lang.Integer

@HotSpotIntrinsicCandidate
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

Когда-то давно разработчики посчитали, что неплохо было бы кэшировать значения некоторых типов (java.lang.Integer, java.lang.Long, java.lang.Short, java.lang.Byte, java.lang.Character). Более того, это внесли в спецификацию языка, поэтому когда мы пишем


Integer intgr = Integer.valueOf(42);

новый объект не создаётся.


Если мы дерзнём писать вот так:


Integer intgr = new Integer(42);

то все статические анализаторы завопят про ухудшение производительности, ФайндБагз и Сонар будут заваливать сборки до тех пор, пока мы не вернёмся к каноничному Integer::valueOf вместо богомерзкого конструктора.


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


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


+32
13k 93
Comments 21