Статьи

Топ 10 простых оптимизаций производительности в Java

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

Но что  такое  масштабирование, и как мы можем быть уверены, что  можем  масштабировать?

Различные аспекты масштабирования

Упомянутый выше обман главным образом связан с масштабированием  нагрузки , то есть для того, чтобы система, которая работает на 1 пользователя, также работала хорошо для 10 пользователей, или 100 пользователей, или миллионов. В идеале ваша система должна быть как можно более «не имеющей состояния», чтобы оставшиеся несколько состояний могли быть перенесены и преобразованы на любом процессоре в вашей сети. Когда загрузка является вашей проблемой, задержка, вероятно, нет, поэтому все в порядке, если отдельные запросы занимают 50-100 мс. Это часто также называют  масштабированием

Абсолютно другой аспект масштабирования связан с масштабированием  производительности , т. Е. Чтобы убедиться, что алгоритм, который работает для 1 фрагмента информации, также будет хорошо работать для 10 частей, или 100 частей, или миллионов. Реализуется ли этот тип масштабирования лучше всего в  Big O Notation . Задержка является убийцей при масштабировании производительности. Вы хотите сделать все возможное, чтобы сохранить все расчеты на одной машине. Это часто также называют  расширением

Если бы было что-то вроде бесплатного ланча ( нет ), мы могли бы бесконечно комбинировать масштабирование. Во всяком случае, сегодня мы рассмотрим несколько очень простых способов улучшить ситуацию с точки зрения производительности.

Обозначение Big O

Java 7 — х  ForkJoinPool , а также Java — 8 в  параллельной Stream  помощи parallelising материал, который является большим при развертывании программы Java на многоядерной процессорной машине. Преимущество такого параллелизма по сравнению с масштабированием на разных машинах в вашей сети заключается в том, что вы можете почти полностью устранить эффекты задержки, поскольку все ядра могут получать доступ к одной и той же памяти.

Но не обманывайтесь эффектом параллелизма! Помните следующие две вещи:

  • Параллелизм пожирает ваши ядра. Это отлично подходит для пакетной обработки, но кошмар для асинхронных серверов (таких как HTTP). Есть веские причины, по которым мы использовали однопоточную модель сервлета в последние десятилетия. Так что параллелизм помогает только при расширении.
  • Параллелизм не влияет на нотацию Big O вашего алгоритма  . Если ваш алгоритм работает  O(n log n), и вы позволяете этому алгоритму работать на  c ядрах, у вас все еще будет  O(n log n / c) алгоритм, как  c незначительная константа в сложности вашего алгоритма. Вы сэкономите время настенных часов, но не уменьшите сложность!

Конечно, лучший способ повысить производительность — это уменьшить сложность алгоритма. Убийца — это достижение  O(1) или квази- O(1)конечно, например, HashMap поиск. Но это не всегда возможно, не говоря уже о легкости.

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

алгоритм

Общая сложность алгоритма  O(N3), или,  O(N x O x P) если мы хотим иметь дело с отдельными порядками. Однако при профилировании этого кода вы можете найти забавный сценарий:

  • В вашем окне разработки левая ветвь ( N -> M -> Heavy operation) — это единственная ветвь, которую вы можете видеть в своем профилировщике, потому что значения для  O и  P малы в данных примера разработки.
  • На производстве, однако, правильная ветвь ( N -> O -> P -> Easy operation или также  NOPE ) действительно вызывает проблемы. Ваша рабочая группа могла бы выяснить это с помощью  AppDynamicsDynaTrace или какого-либо подобного программного обеспечения.

Без производственных данных вы можете быстро сделать выводы и оптимизировать «тяжелую работу». Вы отправляете в производство, и ваше исправление не имеет никакого эффекта.

Не существует золотых правил для оптимизации, кроме фактов, которые:

  • Хорошо спроектированное приложение гораздо проще оптимизировать
  • Преждевременная оптимизация не решит никаких проблем с производительностью, но сделает ваше приложение менее разработанным, что, в свою очередь, затруднит оптимизацию.

Достаточно теории. Давайте предположим, что вы нашли правильную ветвь, чтобы быть проблемой. Вполне может быть , что очень легкая операция взрывает в производстве, так как это называется много и много раз (если  NOи  P большие). Пожалуйста, прочтите эту статью в контексте проблемы на конечном узле неизбежного  O(N3) алгоритма. Эти оптимизации не помогут вам масштабироваться. Они помогут вам сэкономить день вашего клиента, отложив сложное улучшение общего алгоритма на потом!

Вот 10 самых простых оптимизаций производительности в Java:

1. Используйте StringBuilder

Это должно быть по умолчанию почти во всем коде Java. Старайтесь избегать  +оператора. Конечно, вы можете утверждать, что это просто синтаксический сахар для StringBuilder любого, как в:

String x = "a" + args.length + "b";

… который компилируется в

 0  new java.lang.StringBuilder [16]
 3  dup
 4  ldc <String "a"> [18]
 6  invokespecial java.lang.StringBuilder(java.lang.String) [20]
 9  aload_0 [args]
10  arraylength
11  invokevirtual java.lang.StringBuilder.append(int) : java.lang.StringBuilder [23]
14  ldc <String "b"> [27]
16  invokevirtual java.lang.StringBuilder.append(java.lang.String) : java.lang.StringBuilder [29]
19  invokevirtual java.lang.StringBuilder.toString() : java.lang.String [32]
22  astore_1 [x]

Но что произойдет, если позже вам понадобится изменить строку с дополнительными частями?

String x = "a" + args.length + "b";
 
if (args.length == 1)
    x = x + args[0];

Теперь у вас будет секунда  StringBuilder, которая просто излишне потребляет память из вашей кучи, оказывая давление на ваш GC. Напишите это вместо:

StringBuilder x = new StringBuilder("a");
x.append(args.length);
x.append("b");
 
if (args.length == 1);
    x.append(args[0]);

навынос

В приведенном выше примере это, вероятно, совершенно не имеет значения, если вы используете явные  StringBuilder экземпляры, или если вы полагаетесь на компилятор Java, создающий для вас неявные экземпляры. Но помните, мы в  филиале NOPE . Каждый цикл ЦП, который мы тратим на такую ​​глупость, как GC или выделение  StringBuilderемкости по умолчанию, мы тратим N x O x Pвремя впустую  .

Как правило, всегда используйте  StringBuilder вместо  + оператора. И если вы можете, сохраните  StringBuilder ссылку на несколько методов, если ваш  String сложнее построить. Это то, что   делает jOOQ , когда вы генерируете сложный оператор SQL. Есть только один,  StringBuilder который «пересекает» весь ваш SQL  AST (абстрактное синтаксическое дерево)

И для того, чтобы выкрикнуть вслух, если у вас еще есть  StringBuffer ссылки, замените их на  StringBuilder. Вам действительно никогда не нужно синхронизироваться на создаваемой строке.

2. Избегайте регулярных выражений

Регулярные выражения  относительно  дешевы и удобны. Но если вы находитесь в ветке N.OPE , они о худшем, что вы можете сделать. Если вам абсолютно необходимо использовать регулярные выражения в разделах кода, требующих Pattern большого объема вычислений, по крайней мере кэшируйте  ссылку вместо того, чтобы постоянно компилировать ее заново:

static final Pattern HEAVY_REGEX = 
    Pattern.compile("(((X)*Y)*Z)*");

Но если ваше регулярное выражение действительно глупо, как

String[] parts = ipAddress.split("\\.");

… Тогда вам действительно лучше прибегнуть к обычным  char[] или основанным на индексах манипуляциям. Например, этот совершенно нечитаемый цикл делает то же самое:

int length = ipAddress.length();
int offset = 0;
int part = 0;
for (int i = 0; i < length; i++) {
    if (i == length - 1 || 
            ipAddress.charAt(i + 1) == '.') {
        parts[part] = 
            ipAddress.substring(offset, i + 1);
        part++;
        offset = i + 2;
    }
}

… что также показывает, почему не следует делать преждевременную оптимизацию По сравнению с  split() версией это невозможно.

Задача: умные из ваших читателей могут найти  еще более быстрые алгоритмы.

навынос

Регулярные выражения полезны, но они имеют свою цену. Если вы глубоко в  ветке NOPE , вы должны избегать регулярных выражений любой ценой. Остерегайтесь разнообразных методов JDK String, которые используют регулярные выражения, такие как  String.replaceAll(), или  String.split().

 Вместо этого используйте популярную библиотеку, например  Apache Commons Lang , для манипулирования строками.

3. Не используйте итератор ()

Теперь этот совет на самом деле не для общих случаев использования, а применим только в глубине  ветви NOPE . Тем не менее, вы должны подумать об этом. Написание циклов foreach в стиле Java-5 удобно. Вы можете просто полностью забыть о внутреннем цикле и написать:

for (String value : strings) {
    // Do something useful here
}

Однако каждый раз, когда вы запускаете этот цикл, если он  strings есть  Iterable, вы создаете новый  Iterator экземпляр. Если вы используете  ArrayList, это будет выделение объекта с 3  ints в вашей куче:

private class Itr implements Iterator<E> {
    int cursor;
    int lastRet = -1;
    int expectedModCount = modCount;
    // ...

Вместо этого вы можете записать следующий эквивалентный цикл и «тратить» только одно int значение в стеке, что очень дешево:

int size = strings.size();
for (int i = 0; i < size; i++) {
    String value : strings.get(i);
    // Do something useful here
}

навынос

Итераторы, Iterable и цикл foreach чрезвычайно полезны с точки зрения удобочитаемости и читаемости, а также с точки зрения разработки API. Однако они создают небольшой новый экземпляр в куче для каждой отдельной итерации. Если вы выполняете эту итерацию много раз, вы должны избегать создания этого бесполезного экземпляра и вместо этого записывать итерации на основе индекса.

4. Не вызывайте этот метод

Некоторые методы просто дороги. В нашем   примере ветки NOPE такого метода у нас нет, но у вас вполне может быть такой. Давайте предположим, что вашему драйверу JDBC нужно пройти невероятные проблемы, чтобы вычислить значение  ResultSet.wasNull(). Ваш доморощенный код SQL-фреймворка может выглядеть так:

if (type == Integer.class) {
    result = (T) wasNull(rs, 
        Integer.valueOf(rs.getInt(index)));
}
 
// And then...
static final <T> T wasNull(ResultSet rs, T value) 
throws SQLException {
    return rs.wasNull() ? null : value;
}

Эта логика теперь будет вызываться  ResultSet.wasNull() каждый раз, когда  вы получаете результат  intиз набора результатов. Но  getInt() контракт гласит:

Возвращает: значение столбца; если значение равно SQL NULL, возвращаемое значение равно 0

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

static final <T extends Number> T wasNull(
    ResultSet rs, T value
) 
throws SQLException {
    return (value == null || 
           (value.intValue() == 0 && rs.wasNull())) 
        ? null : value;
}

Итак, это не просто:

навынос

Не называйте дорогие методы в алгоритме «конечными узлами», а вместо этого кэшируйте вызов или избегайте его, если это позволяет контракт метода.

5. Используйте примитивы и стек

Приведенный выше пример из  jOOQ , который использует много дженериков, и , таким образом, вынуждены использовать обертку для типов  byteshortintи  long — по крайней мере , до дженериков будет specialisable в Java 10 и проекта Валгаллу . Но у вас может не быть этого ограничения в вашем коде, поэтому вы должны принять все меры для замены:

// Goes to the heap
Integer i = 817598;

… этим:

// Stays on the stack
int i = 817598;

Ситуация ухудшается, когда вы используете массивы:

// Three heap objects!
Integer[] i = { 1337, 424242 };

… этим

// One heap object.
int[] i = { 1337, 424242 };

навынос

Когда вы глубоко в своей  ветке NOPE , вы должны быть крайне осторожны с использованием типов оболочки. Скорее всего, вы создадите большое давление на свой сборщик мусора, который должен постоянно вставлять, чтобы навести порядок.

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

Trove4j , который поставляется с LGPL, int[] является  отличной библиотекой для примитивных коллекций, которые немного сложнее, чем в среднем  .

исключение

Существует исключение из этого правила:  boolean и  byte иметь достаточно несколько значений кэшировать целиком в JDK. Ты можешь написать:

Boolean a1 = true; // ... syntax sugar for:
Boolean a2 = Boolean.valueOf(true);
 
Byte b1 = (byte) 123; // ... syntax sugar for:
Byte b2 = Byte.valueOf((byte) 123);

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

Но только если вы автоматически их упаковываете или звоните  TheType.valueOf(), а не когда вы вызываете конструктор!

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

Этот факт также может помочь вам написать изощренную шутливую шутку Первоапрельского для ваших коллег.

От кучи

Конечно, вы также можете поэкспериментировать с библиотеками вне кучи, хотя это скорее стратегическое решение, а не локальная оптимизация.

Интересная статья на эту тему Питера Лоури и Бена Коттона: OpenJDK и HashMap… Безопасное обучение старой собаки Новые (вне кучи!) Трюки

6. Избегайте рекурсии

Современные функциональные языки программирования, такие как Scala, поощряют использование рекурсии, поскольку они предлагают средства  оптимизации алгоритмов хвостовой рекурсии обратно в итеративные . Если ваш язык поддерживает такие оптимизации, у вас все может быть в порядке. Но даже в этом случае малейшее изменение алгоритма может привести к появлению ветви, которая не позволит вашей рекурсии быть хвостовой рекурсией. Надеюсь, компилятор обнаружит это! В противном случае вы могли бы тратить много кадров стека на что-то, что могло быть реализовано с использованием только нескольких локальных переменных.

навынос

Об этом особо нечего сказать, кроме: Всегда предпочитайте итерацию, а не рекурсию, когда вы глубоко в  ветке NOPE

7. Используйте entrySet ()

Если вы хотите выполнить итерацию через a  Map, и вам нужны и ключи,  и  значения, у вас должна быть очень веская причина написать следующее:

for (K key : map.keySet()) {
    V value : map.get(key);
}

… а не следующее:

for (Entry<K, V> entry : map.entrySet()) {
    K key = entry.getKey();
    V value = entry.getValue();
}

Когда вы находитесь в  ветке NOPE , вам все равно следует опасаться карт, потому что множество  O(1) операций доступа к карте по-прежнему остается большим количеством операций. И доступ тоже не бесплатный. Но, по крайней мере, если вы не можете обойтись без карт, используйте  entrySet() для их итерации! В  Map.Entry любом случае экземпляр есть, вам нужно только получить к нему доступ.

навынос

Всегда используйте,  entrySet() когда вам нужны ключи и значения во время итерации карты.

8. Используйте EnumSet или EnumMap

В некоторых случаях количество возможных ключей на карте известно заранее, например, при использовании карты конфигурации. Если это число относительно мало, вам следует подумать об использовании  EnumSet или  EnumMapвместо обычного  HashSet или  HashMap вместо него. Это легко объяснить, посмотрев на  EnumMap.put():

private transient Object[] vals;
 
public V put(K key, V value) {
    // ...
    int index = key.ordinal();
    vals[index] = maskNull(value);
    // ...
}

Суть этой реализации заключается в том, что у нас есть массив индексированных значений, а не хеш-таблица. При вставке нового значения все, что нам нужно сделать для поиска записи карты, это запросить у enum его постоянный порядковый номер, который генерируется компилятором Java для каждого типа enum. Если это глобальная карта конфигурации (т. Е. Только один экземпляр), то увеличенная скорость доступа поможет  EnumMap значительно опередить  HashMap, что может использовать немного меньше динамической памяти, но которое придется запускать  hashCode() и  equals() на каждом ключе.

навынос

Enum и  EnumMap очень близкие друзья. Всякий раз, когда вы используете enum-подобные структуры в качестве ключей, подумайте над тем, чтобы сделать эти структуры перечислениями и использовать их в качестве ключей  EnumMap.

9. Оптимизируйте методы hashCode () и equals ()

Если вы не можете использовать  EnumMap, по крайней мере, оптимизируйте ваши  hashCode() и equals() методы. Хороший  hashCode() метод очень важен, потому что он предотвратит дальнейшие вызовы гораздо более дорогих, так  equals() как он будет генерировать более четкие хэш-блоки для набора экземпляров.

В каждой иерархии классов у вас могут быть популярные и простые объекты. Давайте посмотрим на org.jooq.Table реализации jOOQ  .

Самая простая и быстрая реализация  hashCode() этого:

// AbstractTable, a common Table base implementation:
 
@Override
public int hashCode() {
 
    // [#1938] This is a much more efficient hashCode()
    // implementation compared to that of standard
    // QueryParts
    return name.hashCode();
}

… Где  name просто имя таблицы. Мы даже не рассматриваем схему или любое другое свойство таблицы, поскольку имена таблиц обычно достаточно различны в базе данных. Кроме того,  name это строка, поэтому она уже имеет кэшированное  hashCode() значение внутри.

Комментарий важен, потому что  AbstractTable расширяет AbstractQueryPart, что является общей базовой реализацией для любого   элемента AST (абстрактного синтаксического дерева) . Общий элемент AST не имеет никаких свойств, поэтому он не может делать какие-либо предположения оптимизированной hashCode() реализацией. Таким образом, переопределенный метод выглядит так:

// AbstractQueryPart, a common AST element
// base implementation:
 
@Override
public int hashCode() {
    // This is a working default implementation. 
    // It should be overridden by concrete subclasses,
    // to improve performance
    return create().renderInlined(this).hashCode();
}

Другими словами, весь рабочий процесс рендеринга SQL должен запускаться для вычисления хэш-кода общего элемента AST.

Вещи становятся более интересными с equals()

// AbstractTable, a common Table base implementation:
 
@Override
public boolean equals(Object that) {
    if (this == that) {
        return true;
    }
 
    // [#2144] Non-equality can be decided early, 
    // without executing the rather expensive
    // implementation of AbstractQueryPart.equals()
    if (that instanceof AbstractTable) {
        if (StringUtils.equals(name, 
            (((AbstractTable<?>) that).name))) {
            return super.equals(that);
        }
 
        return false;
    }
 
    return false;
}

Первое:  всегда  (не только в  ветке NOPE ) прерывать каждый  equals()метод рано, если:

  • this == argument
  • this "incompatible type" argument

Обратите внимание, что последнее условие включает  argument == null, если вы используете instanceof для проверки совместимости типов. Мы уже писали об этом в  10 тонких рекомендациях по кодированию Java .

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

  • com.example.generated.Tables.MY_TABLE
  • DSL.tableByName("MY_OTHER_TABLE")

Если  argument не может  быть равным  this, и если мы можем проверить это легко, давайте сделаем это и прервемся, если проверка не удалась. Если проверка прошла успешно, мы все равно можем перейти к более дорогой реализации  super. Учитывая, что большинство объектов в юниверсе не равны, мы собираемся сэкономить много времени процессора, сокращая этот метод.

некоторые объекты более равны, чем другие

В случае jOOQ большинство экземпляров в действительности являются  таблицами, сгенерированными генератором исходного кода jOOQequals() реализация которого еще более оптимизирована. Десятки других типов таблиц (производные таблицы, табличные функции, таблицы массивов, объединенные таблицы, сводные таблицы, общие выражения таблиц и т. Д.) Могут сохранять свою «простую» реализацию.

10. Думайте в наборах, не в отдельных элементах

Наконец, что не менее важно, есть вещь, которая не связана с Java, но применима к любому языку. Кроме того, мы уезжаем в  Неа ветвь ,  как этот совет мог бы только помочь вам перейти от  O(N3) к  O(n log n), или что — то в этом роде.

К сожалению, многие программисты думают в терминах простых локальных алгоритмов. Они решают проблему шаг за шагом, ветви за веткой, петли за петлей, метод за методом. Это императивный и / или функциональный стиль программирования. Хотя все более легко моделировать «большую картину» при переходе от чисто императивного к объектно-ориентированному (все еще обязательному) функциональному программированию, во всех этих стилях отсутствует то, что есть только в SQL, R и аналогичных языках:

Декларативное программирование.

В SQL ( и нам это нравится, так как это блог jOOQ ) вы можете объявить результат, который вы хотите получить из своей базы данных, без каких-либо алгоритмических последствий. Затем база данных может принять во внимание все доступные метаданные ( например, ограничения, ключи, индексы и т. Д. ), Чтобы выяснить наилучший возможный алгоритм.

Теоретически, это была основная идея  SQL и реляционного исчисления с самого начала. На практике поставщики SQL внедряют высокоэффективные  CBO (Оптимизаторы на основе затрат)  только с последнего десятилетия, так что оставайтесь с нами в 2010-х годах, когда SQL наконец раскроет весь свой потенциал (это было время!)

Но вам не нужно делать SQL, чтобы думать в наборах. Наборы / коллекции / сумки / списки доступны на всех языках и в библиотеках. Основным преимуществом использования множеств является тот факт, что ваши алгоритмы станут намного более краткими. Намного легче написать:

SomeSet INTERSECT SomeOtherSet

скорее, чем:

// Pre-Java 8
Set result = new HashSet();
for (Object candidate : someSet)
    if (someOtherSet.contains(candidate))
        result.add(candidate);
 
// Even Java 8 doesn't really help
someSet.stream()
       .filter(someOtherSet::contains)
       .collect(Collectors.toSet());

Некоторые могут утверждать, что функциональное программирование и Java 8 помогут вам писать более простые и лаконичные алгоритмы. Это не обязательно правда. Вы можете перевести свой императивный цикл Java-7 в функциональную коллекцию потока Java-8, но вы все еще пишете тот же алгоритм. Написание выражения в стиле SQL отличается. Это…

SomeSet INTERSECT SomeOtherSet

… Может быть реализовано 1000 способами с помощью механизма реализации. Как мы узнали сегодня, возможно, было бы разумно EnumSet автоматически преобразовать два набора перед выполнением  INTERSECT операции. Возможно, мы можем распараллелить это,  INTERSECT не делая низкоуровневых звонковStream.parallel()

Вывод

В этой статье мы поговорили об оптимизации, выполненной в  ветви NOPE , т. Е. Глубоко в алгоритме высокой сложности. В нашем случае, будучи  разработчиками jOOQ , мы заинтересованы в оптимизации нашей генерации SQL:

  • Каждый запрос генерируется только на одном StringBuilder
  • Наш шаблонизатор фактически анализирует символы вместо использования регулярных выражений
  • Мы используем массивы везде, где можем, особенно при переборе слушателей
  • Мы избегаем методов JDBC, которые нам не нужно вызывать
  • и т.д…

jOOQ находится в «нижней части пищевой цепи», потому что это (второй) последний API, который вызывается приложениями наших клиентов перед тем, как вызов покидает JVM для входа в СУБД. Нахождение в нижней части пищевой цепочки означает, что каждая строка кода, которая выполняется в jOOQ, может называться  N x O x P  раз, поэтому мы должны с нетерпением оптимизировать.

Ваша бизнес-логика не глубоко в  ветви NOPE . Но ваша собственная, доморощенная логика инфраструктуры может быть (пользовательские платформы SQL, пользовательские библиотеки и т. Д.). Они должны быть рассмотрены в соответствии с правилами, которые мы видели сегодня. Например, используя  Java Mission Control  или любой другой профилировщик.