Существует популярное заблуждение о том, что если не нравится garbage collection, то надо писать не на Java, а на C/C++. Последние три года я занимался написанием low latency кода на Java для торговли валютой, и мне приходилось всячески избегать создания лишних объектов. В итоге я сформулировал для себя несколько простых правил, как свести аллокации в Java если не до нуля, то до некого разумного минимума, не прибегая к ручному управлению памятью. Возможно, кому-то из сообщества это тоже будет полезно.


Зачем вообще избегать создания мусора


О том, какие есть GC и как их настраивать говорилось и писалось много. Но в конечном счете как ни настраивай GC — код, который мусорит, будет работать субоптимально. Всегда возникает компромисс между throughput и latency. Становится невозможно улучшить одно не ухудшив другое. Как правило накладные расходы GC измеряют изучая логи — по ним можно понять в какие моменты были паузы и сколько времени они занимали. Однако в логах GC содержится далеко не вся информация об этих накладных расходах. Объект, созданный потоком, автоматически помещается в L1 кэш ядра процессора, на котором выполняется данный поток. Это приводит к вытеснению оттуда других потенциально полезных данных. При большом количестве аллокаций полезные данные могут быть вытеснены и из L3 кэша. Когда в следующий раз поток будет обращаться к этим данным произойдет кэш мисс, что приведет к задержкам в исполнении программы. Более того, так как L3 кэш является общим для всех ядер в пределах одного процессора, мусорящий поток будет выталкивать из L3 кэша данные и других потоков/приложений, и уже они будут сталкиваться с лишними дорогостоящими кэш миссами, даже если сами они написаны на голом С и мусор не создают. Никакие настройки никаких garbage collector’ов (ни C4, ни ZGC) не помогут справиться с этой проблемой. Единственный способ улучшить ситуацию в целом — это не создавать лишние объекты без надобности. Java в отличие от C++ не имеет богатого арсенала механизмов работы с памятью, но тем не менее есть ряд способов, позволяющих свести аллокации к минимуму. О них и пойдет речь.


Лирическое отступление

Разумеется, не нужно писать весь код в стиле garbage free. Фишка языка Java как раз в том, что можно сильно упростить себе жизнь, убирая только основные источники мусора. Можно также не заниматься safe memory reclamation при написании lock-free алгоритмов. Если некий код выполняется только один раз при старте приложения, то он может аллоцировать сколько угодно, и это не страшно. Ну и разумеется, основной рабочий инструмент при избавлении от лишнего мусора — это allocation profiler.


Использование примитивных типов


Самое простое, что можно сделать во многих случаях — это использовать примитивные типы вместо объектных. В JVM есть ряд оптимизаций, позволяющих свести к минимуму накладные расходы объектных типов, например кэширование маленьких значений целочисленных типов и инлайнинг простых классов. Но на эти оптимизации не всегда стоит полагаться, потому что они могут и не отработать: целочисленное значение может быть не быть закешированным, а инлайнинг может не произойти. Более того, при работе с условным Integer’ом мы вынуждены переходить по ссылке, что потенциально приводит к кэш миссу. Так же у всех объектов есть заголовки, которые занимают лишнее место в кэше, вытесняя оттуда другие данные. Давайте считать: примитивный int занимает 4 байта. Объектный Integer занимает 16 байт + размер ссылки на этот Integer 4 байта минимум (в случае compressed oops). В сумме получается, что Integer занимает в пять (!) раз больше места, чем int. Поэтому лучше собственноручно использовать именно примитивные типы. Приведу несколько примеров.


Пример 1. Обычные вычисления


Допустим, у нас есть обычная функция, которая просто что-то считает.


Integer getValue(Integer a, Integer b, Integer c) {
   return (a + b) / c;
}

Такой код скорее всего заинлайнится (и метод и классы) и не приведет к лишним аллокациям, но быть уверенным в этом нельзя. Даже если это произойдет, останется проблема с тем, что отсюда может вылететь NullPointerException. JVM так или иначе должна будет либо вставлять проверки на null под капотом, либо каким-то образом понять из контекста, что null в качестве аргумента прийти не может. Так или иначе, лучше просто написать этот же код на примитивах.


int getValue(int a, int b, int c) {
   return (a + b) / c;
}

Пример 2. Лямбды


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


void calculate(Consumer<Integer> calculator) {
   int x = System.currentTimeMillis();
   calculator.accept(x);
}

Несмотря на то, что переменная x является примитивом, будет создан объект типа Integer, который будет передан в calculator. Чтобы этого избежать, надо использовать IntConsumer вместо Consumer<Integer>:


void calculate(IntConsumer calculator) {
   int x = System.currentTimeMillis();
   calculator.accept(x);
}

Такой код уже не приведет к созданию лишнего объекта. В java.util.function есть целый набор стандартных интерфейсов, адаптированных для использования примитивных типов: DoubleSupplier, LongFunction и т.д. Ну а если чего-то не хватает, то всегда можно добавить нужный интерфейс с примитивами. Например вместо BiConsumer<Integer, Double> можно использовать самодельный интерфейс.


interface IntDoubleConsumer {
    void accept(int x, double y);
}

Пример 3. Коллекции


Использование примитивного типа может быть затруднено тем, что переменная этого типа лежит в некой коллекции. Предположим, что у нас есть некий List<Integer> и мы хотим узнать, какие числа в нем имеются и посчитать, сколько раз каждое из чисел повторяется. Для этого мы используем HashMap<Integer, Integer>. Код выглядит так:


List<Integer> numbers = new ArrayList<>();
// fill numbers somehow
Map<Integer, Integer> counters = new HashMap<>();
for (Integer x : numbers) {
    counters.compute(x, (k, v) -> v == null ? 1 : v + 1);
}

Этот код плох сразу по нескольким параметрам. Во-первых, он использует промежуточную структуру данных, без которой наверняка можно было бы обойтись. Ну да ладно, для простоты будем считать, что этот список потом чего-то понадобится, т.е. совсем его убрать нельзя. Во-вторых, в обоих местах используются объектный Integer вместо примитивного int. В-третьих, происходит множество аллокаций в методе compute. В четвертых, происходит аллокация итератора. Эта аллокация скорее всего заинлайнится, но тем не менее. Как превратить этот код в garbage free код? Нужно просто использовать коллекцию на примитивах из некой сторонней библиотеки. Есть целый ряд библиотек, содержащих такие коллекции. Следующий кусок кода использует библиотеку agrona.


IntArrayList numbers = new IntArrayList();
// fill numbers somehow
Int2IntCounterMap counters = new Int2IntCounterMap(0);
for (int i = 0; i < numbers.size(); i++) {
    counters.incrementAndGet(numbers.getInt(i));
}

Объекты, которые тут создаются, это две коллекции и два int[], которые находятся внутри этих коллекций. Обе коллекции можно переиспользовать, вызвав у них метод clear(). Используя коллекции на примитивах мы не усложнили наш код (и даже упростили, убрав метод compute со сложной лямбдой внутри него) и получили следующие дополнительные бонусы по сравнению с использованием стандартных коллекций:


  1. Практически полное отсутствие аллокаций. Если коллекции переиспользовать, то аллокаций не будет вовсе.
  2. Существенная экономия памяти (IntArrayList занимает примерно в пять раз меньше места, чем ArrayList<Integer>. Как уже говорилось, мы заботимся именно об экономном использовании кэшей процессора, а не о RAM.
  3. Последовательный доступ к памяти. На тему того, почему это важно, написано много, так что я не буду на этом останавливаться. Вот пара статей: Martin Thompson и Ulrich Drepper.

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


Mutable объекты


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


Небольшое отступление

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


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


class IntPair {
   int x;
   int y;
}

IntPair divide(int value, int divisor) {
   IntPair result = new IntPair();
   result.x = value / divisor;
   result.y = value % divisor;
   return result;
}

Как можно избавиться от аллокации в этом случае? Правильно, передать IntPair в качестве аргумента и записать туда результат. В этом случае надо написать подробный javadoc, а еще лучше использовать некую конвенцию для названий переменных, куда записывается результат. Например, их можно начинать с префикса out. Garbage free код в этом случае будет выглядеть так:


void divide(int value, int divisor, IntPair outResult) {
   outResult.x = value / divisor;
   outResult.y = value % divisor;
}

Хочу заметить, что метод divide не должен нигде сохранять ссылку на pair или передавать ее в методы, которые это могут сделать, иначе у нас могут появиться большие проблемы. Как мы видим, mutable объектами пользоваться сложнее, чем примитивными типами, поэтому если есть возможность использовать примитивы, то лучше так и поступить. По факту, в нашем примере мы перенесли проблему с аллокацией изнутри метода divide наружу. Во всех местах, где мы вызываем этот метод мы должны будем иметь некую пустышку IntPair, которую будем передавать в divide. Зачастую достаточно хранить эту пустышку в final поле объекта, откуда мы вызываем метод divide. Приведу надуманный пример: предположим, что наша программа занимается только тем, что получает по сети поток чисел, делит их и отправляет результат в тот же сокет.


class SocketListener {
   private final IntPair pair = new IntPair();
   private final BufferedReader in;
   private final PrintWriter out;

   SocketListener(final Socket socket) throws IOException {
       in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
       out = new PrintWriter(socket.getOutputStream(), true);
   }

   void listenSocket() throws IOException {
       while (true) {
           int value = in.read();
           int divisor = in.read();
           divide(value, divisor, pair);
           out.print(pair.x);
           out.print(pair.y);
       }
   }
}

Для лаконичности я не стал писать “лишний” код по обработке ошибок, корректному завершению работы программы и т.д. Основная идея этого куска кода заключается в том, что используемый нами объект IntPair создается один раз и сохраняется в final поле.


Объектные пулы


Когда мы пользуемся mutable объектами мы должны сначала откуда-то взять пустой объект, потом записать в него нужные нам данные, попользоваться ими где-то, а затем вернуть объект “на место”. В вышеописанном примере объект всегда был “на месте”, т.е. в final поле. К сожалению, это не всегда получается сделать простым образом. Например, мы можем заранее не знать, сколько именно объектов нам понадобится. В этом случае нам на помощь приходят объектные пулы. Когда нам становится нужен пустой объект, мы достаем его из объектного пула, а когда он перестает быть нужен, мы его туда возвращаем. Если в пуле нет свободного объекта, то пул создает новый объект. Это уже по факту является ручным управлением памятью со всеми вытекающими последствиями. К этому способу желательно не прибегать, если есть возможность пользоваться предыдущими способами. Что может пойти не так?


  • Мы можем забыть вернуть объект в пул, и тогда создастся мусор ("memory leak"). Это небольшая проблема — слегка просядет производительность, но отработает GC и программа продолжит работать.
  • Мы можем вернуть объект в пул, но сохранить на него ссылку где-то. Потом кто-то другой достанет объект из пула, и в этот момент в нашей программе уже будут две ссылки на один и тот же объект. Это классическая проблема use-after-free. Это сложно дебажить, т.к. в отличие от C++ программа не упадет с сегфолтом, а продолжит неправильно работать.

Для того чтобы уменьшить вероятность совершения описанных выше ошибок можно использовать стандартную конструкцию try-with-resources. Выглядеть это может так:


public interface Storage<T> {
   T get();

   void dispose(T object);
}

class IntPair implements AutoCloseable {
    private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new);
    int x;
    int y;

    private IntPair() {}

    public static IntPair create()
    {
        return STORAGE.get();
    }

    @Override
    public void close()
    {
        STORAGE.dispose(this);
    }
}

Метод divide может выглядеть так:


IntPair divide(int value, int divisor) {
    IntPair result = IntPair.create();
    result.x = value / divisor;
    result.y = value % divisor;
    return result;
}

А метод listenSocket вот так:


void listenSocket() throws IOException {
    while (true) {
        int value = in.read();
        int divisor = in.read();
        try (IntPair pair = divide(value, divisor)) {
            out.print(pair.x);
            out.print(pair.y);
        }
    }
}

В IDE как правило можно настроить подсвечивание всех случаев использования AutoCloseable объектов вне try-with-resources блока. Но это не стопроцентный вариант, т.к. подсвечивание в IDE может быть просто выключено. Поэтому есть еще один способ гарантировать возврат объекта в пул — инверсия контроля. Приведу пример:


class IntPair implements AutoCloseable {
    private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new);
    int x;
    int y;

    private IntPair() {}

    private static void apply(Consumer<IntPair> consumer)
    {
        try(IntPair pair = STORAGE.get()) {
            consumer.accept(pair);
        }
    }

    @Override
    public void close()
    {
        STORAGE.dispose(this);
    }
}

В этом случае мы в принципе не можем получить доступ к объекту класса IntPair снаружи. К сожалению, этот способ тоже работает не всегда. Например, он не будет работать в случае если один поток достает объекты из пула и кладет в некую очередь, а другой поток достает их из очереди и возвращает в пул.


Очевидно, что если мы храним в пуле не самописные объекты, а какие-то библиотечные, которые не имплементируют AutoCloseable, то вариант с try-with-resources тоже не прокатит.


Дополнительной проблемой здесь является многопоточность. Реализация объектного пула должна быть очень быстрой, чего довольно сложно добиться. Медленный пул может принести больше вреда для производительности, чем пользы. В свою очередь аллокация новых объектов в TLAB происходит очень быстро, гораздо быстрее, чем malloc в C. Написание быстрого объектного пула — это отдельная тема, которую я бы сейчас не хотел развивать. Скажу только, что хороших "готовых" реализаций я не видел.


Вместо заключения


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


Update:


Да, вспомнил еще один способ для тех, кто не боится побитовых сдвигов: упаковывание нескольких маленьких примитивных типов в один большой. Предположим, что нам надо вернуть два int’а. В этом конкретном случае можно не использовать объект IntPair, а вернуть один long, первые 4 байта в котором будут соответствовать первому int’у, а вторые 4 байта — второму. Код может выглядеть так:


long combine(int left, int right)
{
   return ((long)left << Integer.SIZE) | (long)right & 0xFFFFFFFFL;
}

int getLeft(long value)
{
   return (int)(value >>> Integer.SIZE);
}

int getRight(long value)
{
   return (int)value;
}

long divide(int value, int divisor) {
    int x = value / divisor;
    int y = value % divisor;
    return combine(left, right);
}

void listenSocket() throws IOException {
    while (true) {
        int value = in.read();
        int divisor = in.read();
        long xy = divide(value, divisor);
        out.print(getLeft(xy));
        out.print(getRight(xy));
    }
}

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

Комментарии (52)


  1. FlashLight13
    14.01.2019 15:07
    -2

    Много ли кейсов где подобные оптимизации (обязательное приведение Integer -> int, геморрой с кастомными коллекциями и тд) могут реально дать ощутимый бус производительности?

    На тему изменяемых (mutable) объектов было бы круто упомянуть про то, что работа с неизменяемыми (immutable) объектами будет оптимизирована на уровне языка

    Вообще, люто не хватает сравнения перформанса, HashMap и Int2IntCounterMap, на каких-то +- реальных примерах, потому что сейчас проблема кажется несколько надуманной


    1. Diaboliko
      14.01.2019 22:35

      Помимо описанного профита для работы кэша процессора, стоит еще упомянуть очевидную нативную поддержку. Сумма примитивов — ~1 операция. Сумма программных типов — несколько проверок и, возможно, кастомный размер + кастомная математика. Ваш кэп.


    1. vmescheryakov
      14.01.2019 23:11

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


    1. shalomman
      15.01.2019 09:20

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

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


      1. Rhombus
        15.01.2019 12:32

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

        А их не надо очищать, такие объекты должны жить пока живет JVM, в этом-то и смысл.


    1. Berkof
      16.01.2019 11:20

      Вам не сравнение перформанса нужно, а простой алгоритм:
      1) поймите для себя — тормозит ли ваше приложение. Если не тормозит вообще, то и проблемы нет… При этом приложение может жрать 16 ядер и потреблять 60 гигов хипа и запросы минутами обрабатывать, но пользователя это абсолютно устраивает (например — запросы приходят от крона ночью, а сервак один фиг в это время простаивает)… А может приложение отрабатывать за 300мс на 200мб хипа, но очень сильно дофига тормозить, т.к. это высокоскоростная торговля.
      2) Если тормозит — запустите профилировщик (тот же jfr отлично справляется) и посмотрите на горячие методы… Может там сетевой активности много или где-то пузырьковая сортировка вручную написана.
      3) Если видите, что всё «ровно» и, особенно, если операции с картами занимают заметный процент времени (или GC часто и с аппетитом работает, сжирая хотя-бы 5% CPU) — попробуйте поменять мапу, добавить ForkJoinPool на много потоков, добавить кэши и вот эту вот всю стандартную фигню из зелёной зоны оптимизации приложений.
      4) Если не помогло и это — обратитесь к специалисту


  1. kzhyg
    14.01.2019 15:35
    +1

    Ожидал найти что-нибудь новое, а тут всё те же байки из 1998 про объектные обёртки.


    1. tmaxx
      14.01.2019 19:25

      В чем конкретно заключаются байки?

      Обертки приводят к аллокации памяти (обычно). Аллокация приводит к GC (периодически) и залипанию приложения на пару миллисекунд. В некоторых (очень специфичных) приложениях это залипание неприемлемо.

      Что из вышеперечисленного неправда?


    1. vmescheryakov
      14.01.2019 22:34

      Да, никаких новых/экзотических фич языка тут нет, обычная Java. Я даже Unsafe ни разу не упомянул. Идея поста в том, чтобы показать как выглядит стиль garbage free программирования. В таком стиле написано довольно мало библиотек. И нужно это далеко не во всех ситуациях. Тем не менее, такой стиль является насущной необходимостью в случае если SLA для worst case latency на весь сетевой стек и бизнес логику системы составляет десятки микросекунд.


  1. slava_k
    14.01.2019 17:13
    +2

    Спасибо большое что поделились опытом.

    Со мной не обязательно соглашаться, но почти все приемы в статье так или иначе это полумеры, отсрочка неизбежного. Если приложение/сервис многопоточный, то как не выкручивайся, но все равно где-то потечет либо свой код, либо внутри чьей-то библиотеки, потоки будут загаживать друг друга и будет miss-hell, сборщик может вести себя непредсказуемо и прочие прелести. Можно продолжать бороться, понимая стоимость такой борьбы как в цене разработчиков, так и в цене потенциальных ошибок.

    В итоге я пришел к схеме, когда приложение/сервис приходится дробить на типы критичности кода в «деньгах». То, что менее критично, как в плане low latency внутри приложения и связь с внешним миром, так и в плане быстроты реакции систем мани-менеджмента, внутреннего «клиринга» активов по портфелям — остается на Scala/Java под дополнительным мониторингом, как внутренним (надежный код, доп. логи в несколько типов хранилищ, кросс-мониторинг между такими хранилищами), так и внешним (мониторинг процессов, работоспособность/доступность портов, тестирование правильности работы firewalld/iptables и различных AC-систем на их основе). То, что критично для принятия решения менее минуты — где возможно, переписывается на С++ и Rust (очень аккуратно).

    Как бы ни хотелось единообразия технологического стека, но, увы, при тестировании и моделировании этот порог в минуту очень важен (не только для HFT) и для многих торговых алгоритмов поведения стоимость реакции на нестандартное поведение/обработку ошибок почти всегда по деньгам превышает комиссии/своп/депозитарий по торговым инструментам. А для тех торговых систем, где по изначальной идее доходность сильно зажата и имеет смысл только на большом объеме средств (market making, вынужденные тесты ликвидности для поквартальной переоценки балансов хеджфондов) — такая дополнительная обработка ошибок и нестандартных ситуаций, а также соответствующая экономия уже становится сравнима с доходностью таких систем.

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

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

    В итоге безобидная ТС с простеньким на вид алгоритмом принятия решений в 1-2к строк обрастает «броней» от внешнего мира и внутренним иммунитетом. И вот уже 150к+ строк, несколько языков, свои сервера для обслуживания как самой системы, так и надежности систем мониторинга и оповещения по различным степеням критичности событий.


  1. sergey-gornostaev
    14.01.2019 17:49

    Может быть я чего-то не учитываю, но не разумнее ли вместо объектных пулов использовать direct buffer?


    1. Sap_ru
      14.01.2019 22:26

      К direct buffer ОЧЕНЬ неудобно обращаться, а работа с обычными объектами очень хорошо оптимизируется JVM.


    1. vmescheryakov
      14.01.2019 22:27
      +1

      Это отличный подход, так можно делать. Пользуясь случаем не могу не прорекламировать отличный инструмент для этого: SBE. К сожалению, это не всегда удобно. Пример: объект, который мы хотим переиспользовать хранит ссылки на другие объекты. В этом случае придется придумывать разные варианты, которые могут быть менее предпочтительны. 1) Сериализовать в этот же буфер объект, на который ссылаемся, и работать с локальной копией. Это иногда имеет смысл, но не всегда, особенно если состояние объекта может меняться извне. 2) Можно хранить объекты, на которые мы ссылаемся, в неком массиве/списке, а в буфер записывать только индекс в этом массиве. В этом случае у нас нет ссылки на сам этот массив/список в буфере, и мы должны либо делать его статическим, либо ссылка на него должна быть доступна из контекста.


  1. vmescheryakov
    14.01.2019 22:51

    Да, описанных мер не достаточно. По факту, приходится отказываться от использования на критическом пути практически всех привычных для большинства разработчиков библиотек. Конечно, это сильно ограничивает. Мы тоже пришли к тому, что весь критический путь вынесен в одну или несколько JVM, в которых мусорить практически запрещено. Вся остальная обвязка, не критичная к latency, вынесена в отдельные JVM. Там можно и мусорить, и блокироваться сколько влезет.
    P.S. Минута — очень длинный промежуток времени. Это уже территория high throughput.


    1. Sap_ru
      14.01.2019 23:05

      А не проще разрешить GC, которые вносят более-мнее предсказуемые задержки и тонко настроить их через ключи (рекомендуемое/максимальное время блокировки и т.п.) и потом ещё и принудительно запускать, когда задачи позволяют.
      У там совсем всегда что-ли миллисекунды гарантировать нужно? Тогда уж нужно другой язык искать.
      У меня промышленный софт отлично через Java управляется в приличном realtime.


      1. vmescheryakov
        14.01.2019 23:17

        Наш ориентир по latency — 50 мкс. Насчет другого языка — да, можно писать на нативном языке. Но у java есть неоспоримые плюсы: удобные фреймворки для тестирования кода, удобные IDE для разработки, куча библиотек для всего чего угодно, которыми вполне можно пользоваться вне критического пути, высокая скорость разработки. По факту обо всех тех же вещах придется думать и при программировании на условных плюсах. malloc на критическом пути в любом случае не вызывается, так что придется в подобных вещах упражняться.


        1. Sap_ru
          14.01.2019 23:20

          Ну, тогда Java не ваш язык же! Хотя, если вам нужно среднее значение, то, мне кажется, что оно достижимо тонкой настройкой сборщика мусора и минимальными извращениями с экономией памяти. Правда, это от количества объектов и общего количества используемой памяти зависит.


          1. gubber
            15.01.2019 13:27

            Ну, тогда Java не ваш язык же!

            Ну с чего же вдруг так? На Java есть высокопроизводительные системы. И качественно работают. На JavaOne ребята выступали, которые на Java писали биржевой аггрегатор.
            И ни чего. Работало с нужными им временными задержками. Просто готовить надо уметь.


        1. Rhombus
          15.01.2019 08:26

          50 мкс это среднее, максимум или какой-то перцентиль?


          1. vmescheryakov Автор
            15.01.2019 21:42

            Если быть точным, наш текущий таргет: 50мкс — медиана, 100мкс — 99.99%.


  1. vmescheryakov
    14.01.2019 23:28

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


  1. Iqorek
    14.01.2019 23:58

    Ваши исследования подтверждены бенчмарками или вы предполагаете, что если будет меньше аллокаций, то будет работать быстрей?
    Почему спрашиваю, когда то проверял скорость объектного пула для небольших буферов в 200-300 байт против создания новых, итого пул работал не на много, на около 5%, но медленней.


    1. RainM
      15.01.2019 00:44

      тут вопрос не в том, что медленнее, а в том, что предсказуемей, т.е. если на критическом пути market date'ы плодить объекты, то GC рано или поздно случится. Получается мы теряем некоторый throughput ради предсказуемого правого хвоста распределения.


  1. tmaxx
    15.01.2019 00:45

    IO ещё нехило аллоцирует, конкретно — HashSet внутри Selector’a


    Из способов борьбы я знаю: хак из Агроны (подменяющий этот HashSet через reflection), отказ от селектора (если сокетов не очень много), написание своей JNI-обёртки над epoll и сокетами.


    Используете что-то из этого?


    1. vmescheryakov
      15.01.2019 08:01

      Мы не напрямую с IO работаем, так что впервые про это слышу. Но скорее всего наша либа использует что-то в этом роде.


    1. tbl
      15.01.2019 08:32

      Reflection — такой себе вариант: существенное жертвование производительностью в method.invoke ради уменьшения количества аллокаций в memory heap.


      Method.invoke в числодробилках дает существенную просадку, которая хорошо видна в профилировщике. У меня на алгоритмах обхода достаточно большого ациклического графа (количество нод > 10 000, количество ребер > 1 000 000) отказ от вызова method.invoke на каждой ноде дал суммарный прирост производительности ~30% (даже с увеличенной нагрузкой на гц)


      1. tmaxx
        15.01.2019 10:23
        +1

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


  1. RainM
    15.01.2019 00:46

    Интересный момент, как влияет использование try-with-resources на скорость работы сгенерированного кода. В С++ компиляторах раньше при включении исключений некоторые оптимизации просто отключались.


    1. Cerberuser
      15.01.2019 04:49

      Отключение при включении исключений. А неплохая скороговорка получается...


  1. Rhombus
    15.01.2019 08:02

    Вот неплохой доклад на эту тему youtu.be/BD9cRbxWQx8


  1. ViceCily
    15.01.2019 08:37

    Не думали о более радикальных подходах, ломающих стереотипы? Epsilon GC. Далее, как это применить для ваших нужд, подумайте самостоятельно.


  1. dim2r
    15.01.2019 10:34

    1)Изза того, что данные перемещаются в памяти при сборке мусора, то заранее не известен адрес, где объект располагает данные. Каждый раз, когда вы обращаетесь к данным, то происходит не прямое обращение к памяти, а сначала вычисляется адрес данных.

    Вот интересно было бы понять, — когда происходит вычисление адреса и когда нет. Сколько тактов занимает вычисление адреса? В каких случаях происходит прямое обращение, как в С++?

    2) Есть подозрения, что если переместить сборщик мусора в режим ядра ring0, то можно делать более эффективный сбор мусора, так как некоторые фишки реализованы на уровне оборудования. Например dirty bit на странице памяти реализован на уровне оборудования и говорит, было ли изменение куска памяти или нет.


    1. tmaxx
      15.01.2019 16:48

      >> 1)Изза того, что данные перемещаются в памяти при сборке мусора, то заранее не известен адрес, где объект располагает данные. Каждый раз, когда вы обращаетесь к данным, то происходит не прямое обращение к памяти, а сначала вычисляется адрес данных.

      Это не так, начиная где-то с Java 1.4 (а может и раньше).
      Объектная ссылка всегда содержит прямой указатель на объект. Когда GC проходит по графу объектов, он также обновляет ссылки (кроме ссылок из мусора, естественно).


  1. prs123
    15.01.2019 10:45

    Возможно немного оффтоп, но очень уж интересно. Почему в Java типичные коллекшены или списки (не встроенные массивы) используют обьектные Integer, а не примитивные?


    1. rkfg
      15.01.2019 11:53

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


  1. rkfg
    15.01.2019 11:52

    Столкнулся с необходимостью уменьшить мусор, когда работал с OpenCV на Android. Там применяется нативная библиотека, которая биндится через JNI, соответственно, управление ресурсами полностью ручное. Хип на андроиде не очень большой, а изображения с камеры и при обработке довольно крупные, так что рассчитывать на GC уже нельзя. В частности, был такой баг: если телефон положить на стол камерой вниз, программа падала через секунд 20, а при обычной эксплуатации работала намного дольше. Из-за полностью чёрного кадра скорость работы возрастала в разы (с 2-3 FPS до 30, максимум для камеры), и память быстро исчерпывалась, программа иногда молча, а иногда с трейсом, закрывалась (падала по OOM в нативном коде).


    Дошёл сам до того же решения, что и автор тут советует — все матрицы в final-полях, делаю .release() как только массив перестаёт быть нужен. Интересно, что сам по себе .release() вовсе не спасает от OOM, если продолжать на каждый кадр создавать новые объекты. А вот с переиспользованием всё работает как надо. Немного страшновато было лишь то, что обработка шла в отдельном потоке, дабы не затормаживать отрисовку картинки с камеры, а строгой синхронизации никакой по сути не было. Только future, который отмечал, что распознавание завершено, так что можно загрузить следующую картинку и запустить новый таск (а я не знаю, как правила happens-before работают с нативной памятью, вдруг задание переменной из одного потока не успеет «протечь» в другой?). Но вроде проблем так и не возникло.


  1. lagranzh
    15.01.2019 12:22

    для хранения вспомогательных объектов типа IntPair удобно пользовать ThreadLocal.
    проблемы с многопоточностью отпадают. я так думаю.


    1. Rhombus
      15.01.2019 13:42

      ThreadLocal вносит свои накладные расходы, лучше уж держать всю логику в одном потоке.


      1. lagranzh
        15.01.2019 17:39

        я не знаю как это имплементировано в java, но по идее, доступ к TSS не должен быть дороже, чем просто доступ к памяти. если я не ошибаюсь, конечно.


        1. vmescheryakov Автор
          15.01.2019 21:50

          ThreadLocal в java — это по факту нечто вроде Map<ThreadID, Object>, там довольно увесистая логика с отрабатывает каждый раз.


          1. Throwable
            16.01.2019 10:59
            +1

            Это не совсем так. В текущей имплементации ThreadLocals реально хранятся локально в каждом треде как Thread.threadLocals: Map<ThreadLocal, Object>. Доступ к ним действительно очень быстрый, однако есть проблемы с очисткой — если не был въявную вызван ThreadLocal.remove(), то слот может болтаться в Thread-е до следующего рехэша.


            1. vmescheryakov Автор
              16.01.2019 11:59

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


  1. tsypanov
    15.01.2019 12:32

    Добрый день, действительно ли использование объектных пулов даёт в вашем проекте ощутимый прирост производительности? Вопрос задаю в связи с всплывшим в памяти докладом Алексея Кудрявцева "Computer Science ещё жива", в котором утверждается, что от объектных пулов он отказался после переезда на "восьмёрку".


    Вот тут этот момент в докладе: https://youtu.be/Ra2RSsyO4XU?t=2097


  1. ValDubrava
    15.01.2019 13:43

    Довольно распространенная ошибка, когда пытаются использовать для задачи неподходящий язык/фреймворк/платформу. Например, пытаются демонизировать PHP, пытаются на C писать сложную многопоточную бизнес логику или, наоборот, на Java пытаются написать low latency код. Не говоря уже о том, что вы тут чуть ли не для realtime OS оптимизации предлагаете. И тут кроме GC еще вытесняющая многозадачность ОС вносит свои корректировки в том числе и в кеш (поправьте меня пожалуйста, если я тут заблуждаюсь). В итоге, во-первых, хочу сказать, что надо набраться смелости и признать, как бы вы не любили Java, но эту задачу лучше отдать коллегам пишущим на С/С++. А во-вторых — очень прошу людей, пишущих на Java высоконагруженные приложения — не применять никакую из описанных в статье практик! Это не про нас! Наша задача писать простой и надежный код, который будет стабильно работать на большом числе потоков, ядер, на большом числе CPU и отдельных узлов. И наши с вами оптимизации — убрать N^2 запросов к БД. Настроить правильные индексы. Избежать лишних синхронизаций (и тем более volatile) и т.п.


    1. Rhombus
      15.01.2019 15:50

      C++ никак не поможет против вытесняющей многозадачности. Решение этой проблемы совершенно одинаковое, что на C++, что на java.
      И не надо путать высоконагруженные и low-latency приложения.


  1. ShinRa
    15.01.2019 15:00

    void divide(int value, int divisor, IntPair outResult) {
       outResult.x = value / divisor;
       outResult.y = value % divisor;
    }

    Очень плохой пример, т.к. функция, которая изменяет входной аргумент, должна отдавать ссылку на измененный объект, а не просто изменять без возврата. String transform(String text).
    (по «Чистому коду»)
    Код пишется для людей в первую очередь, а не для машины.


    1. Rhombus
      15.01.2019 15:50

      Код пишется для людей в первую очередь, а не для машины.


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


  1. naushniki
    15.01.2019 21:54

    Спасибо, познавательная статья!
    Какое время отклика требовалось в системах, над которыми вы работали?


    1. vmescheryakov Автор
      15.01.2019 21:56

      По факту все что меньше 1мс требует примерно такого подхода.


  1. LMSn
    15.01.2019 21:54

    А вы не задумывались о правильности выбора технологического стека под вашу задачу? Основные проблемы, которые вы описываете, в родственном Java дотнете решаются использованием структур. Там вы можете не ограничиваться примитивами, а писать свои типы, которыми не управляет GC в общем случае.


    1. vmescheryakov Автор
      15.01.2019 22:01

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


      1. Sap_ru
        17.01.2019 00:16

        Если нужен Linux, то хуже Java и, главное, нет стабильности. Что-то где-то обмновил и вся картина поменялась.