Добрый вечер, коллеги.

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

В этой статье я изложу несколько советов по оптимизации кода на Java. Я специально рассмотрю конкретные операции в реальных программах на Java. Эти советы, в сущности, применимы в конкретных сценариях, требующих высокой производительности, поэтому совершенно нет нужды писать весь код именно в такой манере, поскольку обычно выигрыш в скорости будет мизерным. Однако, на самых жарких участках разница может получиться существенной.

Пользуйтесь профилировщиком!


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

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

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

Прежде чем попытаться перейти к точечной оптимизации конкретного пути исполнения кода, нужно подумать, по какому пути код выполняется сейчас. Иногда избранный подход бывает фундаментально ущербным – например, вы ценой неимоверных усилий и всех мыслимых оптимизаций сможете ускорить этот код на 25%, однако, если изменить подход (подобрать иной алгоритм), выполнение кода может ускориться на порядок и даже более. Зачастую такое случается, когда резко меняются масштабы данных, которые требуется обрабатывать. Бывает несложно написать решение, которое сработает в данном конкретном случае, но для работы с реальными данными оно может оказаться непригодным.

Иногда выход бывает тривиальным – просто изменить структуру, в которой вы храните ваши данные. Вот вам воображаемый пример: если программа обычно обращается к вашим данным в произвольном порядке, а вы храните их в LinkedList, то бывает достаточно переключиться на ArrayList – и код станет выполняться гораздо быстрее. При работе с большими множествами данных и решении задач, где критична производительность, чрезвычайно важно правильно подобрать структуру данных, которая отвечает форме ваших данных и тем операциям, которые над ними осуществляются.

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

Сравнение потоковых API и старого доброго цикла for


Потоки – замечательное нововведение в языке Java, при позволяющее без труда переделать барахлящие фрагменты кода, отказавшись от циклов for в пользу более универсальных многоразовых блоков кода, гарантирующих уверенное выполнение. Однако, за такие удобства приходится платить: при использовании потоков снижается производительность. К счастью, эта цена, по-видимому, не слишком высока. В случае с самыми ходовыми операциями можно получить как ускорение на несколько процентов, так и замедление на 10-30%, однако, этот момент следует иметь в виду.

В 99% случаев снижение производительности при использовании потоков более чем компенсируется благодаря тому, что код становится гораздо яснее. Но в том 1% случаев, когда поток у вас, возможно, будет использоваться в очень активном цикле, стоит задуматься о некоем компромиссе в пользу производительности. Это особенно касается приложений с высокой пропускной способностью, заставляет задуматься о том, что работа с потоковыми API сопряжена с активным выделением памяти (в этой теме на StackOverflow читаем, что каждый новый фильтр отъедает еще 88 байт памяти), поэтому давление на память может возрасти. В таком случае приходится чаще запускать сборщик мусора, что очень негативно сказывается на производительности.

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

Также нужно обращать внимание, в какой именно среде выполняется ваш код. Если речь идет о сильно распараллеленном окружении (например, о сайте), то вряд ли вы ускорите его работу, добавив туда еще один поток. На самом деле, при высоких нагрузках такая ситуация может быть еще порочнее, чем непараллельное исполнение. Дело в том, что, если рабочая нагрузка по природе своей параллельна, то программа наверняка и так максимально эффективно использует оставшиеся ядра процессора – то есть, вы тратите ресурсы на разделение задач, а вычислительной мощности у вас при этом не прибавляется.

Я сделал ряд контрольных замеров. testList – это массив из 100 000 элементов, состоящий из чисел от 1 до 100 000, преобразованных в строки и затем перемешанных.

// ~1 500 оп/с
public void testStream(ArrayState state) {
    List<String> collect = state.testList
            .stream()
            .filter(s -> s.length() > 5)
            .map(s -> "Value: " + s)
            .sorted(String::compareTo)
            .collect(Collectors.toList());
}

// ~1 500 оп/с
public void testFor(ArrayState state) {
    ArrayList<String> results = new ArrayList<>();

    for (int i = 0;i < state.testList.size();i++) {
        String s = state.testList.get(i);

        if (s.length() > 5) {
            results.add("Value: " + s);
        }
    }

    results.sort(String::compareTo);
}

// ~8 000 оп/с
// Обратите внимание: при размере массива от 10 000 элементов и переменной нагрузке на процессор этот код выполнялся втрое медленнее testStream
public void testStreamParrallel(ArrayState state) {
    List<String> collect = state.testList
            .stream()
            .parallel()
            .filter(s -> s.length() > 5)
            .map(s -> "Value: " + s)
            .sorted(String::compareTo)
            .collect(Collectors.toList());
}

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

Передача даты и операции с ней


Нельзя недооценивать издержек, возникающих, например, при парсинге строки с датой в объект даты и при форматировании объекта даты в строку с датой. Представьте себе ситуацию, когда у вас есть список из миллиона объектов (это либо обычные строки, либо некие объекты, представляющие элемент в виде поля данных, подкрепленного строкой) – и весь список нужно откорректировать по заданной дате. В случае, если эта дата представлена в виде строки, потребуется сначала разобрать эту строку, чтобы преобразовать ее в объект Date, обновить объект Date, а затем вновь отформатировать его в виде строки. Если дата уже представлена в виде временной метки Unix (или в виде объекта Date, фактически, представляющего собой просто обертку вокруг временной метки Unix) – то вам останется сделать простую арифметическую операцию, сложение или вычитание.

Мои тесты показывают, что программа выполняется до 500 раз быстрее, если просто оперировать объектом даты, нежели если парсить его, преобразовывать в строку и обратно. Даже если просто исключить этап парсинга, все равно достигается стократное ускорение. Этот пример может показаться надуманным, но, уверен, вам известны случаи, когда значения даты хранились в базе данных в виде строк, а также возвращались в виде строк в откликах API

// ~800 000 оп/c
public void dateParsingWithFormat(DateState state) throws ParseException {
    Date date = state.formatter.parse("20-09-2017 00:00:00");
    date = new Date(date.getTime() + 24 * state.oneHour);

    state.formatter.format(date);
}

// ~3 200 000 оп/с
public void dateLongWithFormat(DateState state) {
    long newTime = state.time + 24 * state.oneHour;

    state.formatter.format(new Date(newTime));
}

// ~400 000 000 оп/с
public long dateLong(DateState state) {
    long newTime = state.time + 24 * state.oneHour;

    return newTime;
}

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

Операции над строками


Манипуляция над строками – это, пожалуй, одна из самых распространенных операций в любой программе. Однако, если выполнять ее неправильно, она может получиться затратной. Именно поэтому я уделяю такое внимание работе со строками в этой статье, посвященной оптимизации Java. Ниже мы рассмотрим один из самых частых подводных камней. Однако, хочу дополнительно подчеркнуть, что такие проблемы проявляются лишь при выполнении самых скоростных фрагментов кода, либо когда приходится иметь дело с существенным количеством строк. В 99% случаев ничего из показанного ниже не случится. Однако, если такая проблема возникнет, она может убийственно сказаться на производительности.

Использование String.format, когда могла бы сработать простая конкатенация


Простейший вызов String.format происходит примерно в 100 раз медленнее, чем при конкатенации значений в строку вручную. Как правило, это приемлемо, поскольку на моей машине мы здесь все равно имеем дело с миллионами операций в секунду. Однако, в случае загруженного цикла, оперирующего миллионами элементов, спад производительности может быть ощутимым.

Однако, есть один случай, когда _следует _использовать именно строковое форматирование, а не конкатенацию даже в среде с высокими требованиями к производительности – я говорю о журналировании отладочной информации. Рассмотрим два вызова, происходящих в таком контексте:

logger.debug("the value is: " + x);
logger.debug("the value is: %d", x);

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

// ~1 300 000 оп/с
public String stringFormat() {
    String foo = "foo";
    String formattedString = String.format("%s = %d", foo, 2);

    return formattedString;
}

// ~115 000 000 оп/с
public String stringConcat() {
    String foo = "foo";
    String concattedString = foo + " = " + 2;

    return concattedString;
}

Неиспользование построителя строк внутри цикла


Если вы не пользуетесь построителем строк внутри цикла, то производительность кода сильно падает. В упрощенной реализации мы наращивали бы строку внутри цикла при помощи оператора +=, прикрепляя таким образом новую часть строки к уже имеющейся. Проблема с данным подходом заключается в том, что при каждой итерации цикла будет выделяться новая строка, а старую строку на каждой итерации придется копировать в новую. Даже сама по себе эта операция затратна, не говоря уже о лишней нагрузке, связанной с дополнительной сборкой мусора, необходимой при создании и отбрасывании такого количества строк. Воспользовавшись StringBuilder, мы ограничим количество операций выделения памяти, что позволит нам сильно повысить производительность. В моих тестах таким образом удавалось ускорить программу более чем в 500 раз. Если при создании построителя строк вы можете, как минимум, достаточно уверенно предположить, какого размера будет результирующая строка, то можно ускорить код еще на 10%, заранее задав корректный размер (в таком случае не придется пересчитывать размер внутреннего буфера и избавиться от операций выделения и копирования).

Также отмечу, что (почти) всегда использую StringBuilder, а не StringBuffer. StringBuffer предназначен для работы в многопоточных средах и именно поэтому оснащен внутренней синхронизацией. Издержки за такую синхронизацию приходится нести даже в однопоточной среде. Если вам требуется наращивать строку данными, поступающими из многих потоков (допустим, в реализации с журналированием) – вот вам одна из немногих ситуаций, когда следует пользоваться именно StringBuffer, а не StringBuilder.

// ~11 операций в секунду
public String stringAppendLoop() {
    String s = "";

    for (int i = 0;i < 10_000;i++) {
        if (s.length() > 0) s += ", ";
        s += "bar";
    }

    return s;
}

// ~7 000 операций в секунду
public String stringAppendBuilderLoop() {
    StringBuilder sb = new StringBuilder();

    for (int i = 0;i < 10_000;i++) {
        if (sb.length() > 0) sb.append(", ");
        sb.append("bar");
    }

    return sb.toString();
}

Использование построителя строк вне цикла


Мне попадались в Интернете рекомендации использовать построитель строк вне цикла – и это даже кажется целесообразным. Однако, мои опыты показали, что на самом деле код при этом выполняется втрое медленнее, чем при += — даже если StringBuilder находится вне цикла. Хотя += в данном контексте и превращается в вызовы StringBuilder, выполняемые javac, код получается гораздо быстрее, чем при непосредственном использовании StringBuilder, что меня удивило.

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

// ~20 000 000 операций в секунду
public String stringAppend() {
    String s = "foo";
    s += ", bar";
    s += ", baz";
    s += ", qux";
    s += ", bar";
    s += ", bar";
    s += ", bar";
    s += ", bar";
    s += ", bar";
    s += ", bar";
    s += ", baz";
    s += ", qux";
    s += ", baz";
    s += ", qux";
    s += ", baz";
    s += ", qux";
    s += ", baz";
    s += ", qux";
    s += ", baz";
    s += ", qux";
    s += ", baz";
    s += ", qux";

    return s;
}

// ~7 000 000 операций в секунду
public String stringAppendBuilder() {
    StringBuilder sb = new StringBuilder();
    sb.append("foo");
    sb.append(", bar");
    sb.append(", bar");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");
    sb.append(", baz");
    sb.append(", qux");

    return sb.toString();
}

Итак, создание строк связано с явственными издержками, поэтому в циклах следует по возможности избегать такой практики. Добиться этого легко – просто используйте StringBuilder внутри цикла.

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

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

Написав эту статью, я собрал zip-архив со всеми упомянутыми здесь данными, и ниже привожу вывод после проверки всех контрольных точек. Все результаты получены на ПК с i5-6500. Код запускался с JDK 1.8.0_144, VM 25.144-b01 на Windows 10



Весь код можно скачать здесь на GitHub.

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


  1. Mingun
    18.05.2018 22:42

    Возможно, в stringAppend компилятор сразу формирует готовую строку и просто возвращает готовый результат. Было бы странно, если такая очевидная оптимизация не делалась бы.


  1. mspain
    19.05.2018 06:19
    +2

    Статью надо было назвать «Открытия java-junior в время испытательного срока»


  1. nonnenmacher
    19.05.2018 06:34

    Если честно, маловато по теме. Можно, например, добавить, что при проверках if нужно наименее затратные проверки ставить вперёд и т.д.


    1. tsvetkovpa
      21.05.2018 00:08

      Скорее нужно ставить вперед те, которые, если сработают, с наибольшей вероятностью сделают последующие ненужными


  1. asympro
    19.05.2018 07:22

    >Если у кого-нибудь есть версии, почему так происходит – поделитесь пожалуйста в комментариях.

    У меня строго диаметральная ситуация:

    Benchmark Mode Cnt Score Error Units
    stringAppend thrpt 6 26,659 0,236 ops/us
    stringAppendBuilder thrpt 6 6,941 0,119 ops/us


  1. Mobilnik
    19.05.2018 08:49
    +1

    Уважаемые ph_piter!

    Если соберетесь действительно издавать перевод книги, пожалуйста, переводите термины аккуратнее, а названия классов не переводите вовсе. Ни разу не сталкивался с великим и могучим «ПОСТРОИТЕЛЕМ СТРОК» :), потребовалось несколько секунд, чтобы понять, что это за зверь.

    А так буду рад видеть вашу книгу.
    Спасибо за статью.


    1. dimkrayan
      19.05.2018 19:09

      плюс, стримы желательно вообще не переводить. Поток — очень не однозначный термин.


  1. Throwable
    19.05.2018 11:51
    +1

    Неужели у кого-то реально проект из-за того, что он неправильно пользовал StringBuilder или stream вместо for? В 95% случаев всегда тормозит база данных, причем в половине случаев решение не такое тривиальное, как просто переписать запросы: требуются серьезные архитектурные модификации, использование кешей и т.д. Кроме того, на продакшне ситуация может сильно отличаться от тестового бенча, поэтому всякие профайлеры тут не помогут. Поэтому совет один — используйте лайв мониторинг и фреймворки типа Metrics, пишите понятный код и не сильно забивайте голову всей этой фигней по поводу stream/for, StringBuilder, etc...


    1. mspain
      19.05.2018 17:08

      Начинающему жабопрогеру полезно знать накладные расходы на каждый объект (что-то в духе 60байт на каждый String, 70байт на Long итд) тк это особо не афишируется в книгах, про существование нестандартных библиотек навроде fastutils или koloboke. А тут какая-то ерунда для 11классников написана.


    1. vsb
      19.05.2018 19:01

      5% оставшихся случаев никто не отменял.


    1. vedenin1980
      19.05.2018 21:10

      у кого-то реально проект из-за того, что он неправильно пользовал StringBuilder или stream вместо for

      У меня было. Просто надо понимать, что если у вас обычный не сильнозагруженный сайт — то вы правы, если что-то и надо оптимизировать, то кривую работу с базой данных, сетью и т.п. Если у вас что-то вроде BigData, где каждую секунду надо переваривать миллиарды строк, как-то их группировать и обрабатывать, то любой чих в коде вроде Integer вместо int разом превращается в лишний сервер за десяток тысяч $.

      пишите понятный код и не сильно забивайте голову всей этой фигней по поводу stream/for, StringBuilder, etc...

      Проекты все-таки бывают разные, представьте, что разработчики Hadoop или Apache Spark не будут забивать голову такой фигней?


      1. panchmp
        20.05.2018 03:08

        тут приведены азы для юниора, а не разработчика Hadoop или Apache Spark


        1. vedenin1980
          20.05.2018 09:46

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