image Привет, Хаброжители!

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

Узнайте, как Java работает на уровне байт-кода. Освойте ценные приемы конкурентного выполнения и оптимизации быстродействия, а еще ключевые методы сборки, тестирования и развертывания. Также рассмотрите альтернативные языки для JVM – Kotlin и Clojure. Изучив материал, вы будете выделяться на фоне других разработчиков!
Для кого эта книга
Задача книги «Java для опытных разработчиков», 2-е изд., — сделать вас Java-разработчиком следующего десятилетия и оживить вашу страсть к языку и платформе. Попутно вы познакомитесь с новыми возможностями Java и освежите представления о важнейших методологиях современной разработки ПО (таких, как разработка через тестирование и контейнерное развертывание), а также заглянете в мир альтернативных языков для JVM.

Эта книга прежде всего ориентируется на Java-разработчиков, которые хотят обновить свои представления о языке и платформе. Если вам хотелось бы синхронизировать свои знания с современной версией Java, то эта книга для вас.

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

  • Часть I «От 8 к 11 и дальше» (главы 1–3) состоит из трех глав, в которых рассматриваются новейшие версии Java. По умолчанию в книге используется синтаксис и семантика Java 11, а когда в особых случаях встречается более поздний синтаксис, это оговаривается отдельно.
  • Часть II «Что там внутри?» (главы 4–7) дает возможность заглянуть «под капот» Java. Пословица гласит, что прежде чем нарушать правила, стоит их хотя бы узнать. В этих главах показано, как сначала овладеть правилами языка программирования Java, а затем нарушать их.
  • Часть III «Другие языки на JVM» (главы 8–10) посвящена многоязычному программированию на JVM. Главу 8 можно считать обязательной, потому что она готовит почву для дальнейшего материала, рассматривая классификацию и использование альтернативных языков.
  • В следующих двух главах рассматривается похожий на Java язык с объектно- ориентированной и функциональной парадигмой (Kotlin) и полноценный функциональный язык (Clojure). Эти главы можно читать независимо друг от друга, хотя, если у вас мало опыта в функциональном программировании, то лучше осваивать их по порядку.
  • Часть IV «Сборка и развертывание» (главы 11–14) описывает процессы сборки, развертывания и тестирования в том виде, в каком они применяются в современных проектах. Предполагается, что у читателей есть хотя бы минимальное представление о модульном тестировании, например в той форме, которая реализована в JUnit.
  • Часть V «Передовые рубежи Java» (главы 15–18) развивает темы, представленные ранее, и углубляется в мир функционального программирования, конкурентного выполнения и внутреннего устройства платформы. И хотя главы можно читать по отдельности, в некоторых разделах мы предполагаем, что вы уже прочли предыдущие главы или другим образом познакомились с теми или иными темами.

Прагматичный подход к анализу производительности


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

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

  • Какие объективно наблюдаемые аспекты кода вы оцениваете.
  • Как измерить эти наблюдаемые аспекты.
  • Какие цели поставлены для наблюдаемых аспектов.
  • Как определить, что оптимизация производительности завершена.
  • Какие максимальные издержки (в отношении затрат времени разработчиков и дополнительной сложности кода) допускает оптимизация быстродействия.
  • Чем нельзя жертвовать при оптимизации.
Самое важное, о чем неоднократно говорится в этой главе, — необходимы объективные измерения. Если вы не измеряете хотя бы один наблюдаемый аспект — это не анализ производительности.

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

Понимайте, что измеряете


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

ПОДСКАЗКА


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

Всегда связывайте измерения, цели и выводы с одной или несколькими базовыми метриками, упомянутыми выше. Вот некоторые типичные наблюдаемые аспекты, которые могут стать хорошими целями для оптимизации производительности:
  • Среднее время выполнения метода handleRequest() (после разогрева).
  • 90-й процентиль сквозной задержки системы с десятью одновременными клиентами.
  • Деградация времени отклика с ростом количества одновременных пользователей от 1 до 1000.
Это все величины, которые можно объективно измерить — и, возможно, оптимизировать. Чтобы результаты изменений были точными и полезными, необходимы базовые знания статистики.

Понимать, что вы измеряете, и убеждаться, что ваши измерения точны, — хороший первый шаг. Однако нечеткие или изменчивые цели часто мешают получить полезный результат, в том числе в оптимизации производительности. Стоит оперировать целями производительности, которые относятся к категории, известной как SMART (от «Specific, Measurable, Agreed, Relevant, Time-boxed», то есть «конкретные, измеримые, согласованные, актуальные и ограниченные
во времени»).

Как выполнять измерения


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

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

ПРИМЕЧАНИЕ


Также существует инструментарий JVMTI (JVM Tool Interface), с помощью которого можно создавать очень изощренные средства контроля производительности, но у него есть свои недостатки: прежде всего JVMTI требует использовать низкоуровневый код, что усугубляет сложность и ухудшает безопасность написанных на нем средств.

Прямые измерения


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

long t0 = System.currentTimeMillis();
methodToBeMeasured();
long t1 = System.currentTimeMillis();
long elapsed = t1 - t0;
System.out.println("methodToBeMeasured выполнялся "+ elapsed +" мс");

Этот код выводит информацию о том, сколько времени заняло выполнение methodToBeMeasured(), с точностью до миллисекунды. Неудобно здесь то, что этот код приходится добавлять в кодовую базу, а с ростом количества измерений станет трудно ориентироваться в потоке данных.

Есть и другие проблемы: например, что произойдет, если methodToBeMeasured() выполняется быстрее миллисекунды? Как мы увидим позднее в этой главе, также приходится учитывать возможные эффекты «холодного старта»: JIT-компиляция может привести к тому, что после нескольких первых запусков метода он будет выполняться быстрее.

Существуют и менее очевидные нюансы: currentTimeMillis() требует вызова
низкоуровневого метода и системного вызова, чтобы обратиться к системным часам. Эти вызовы не только сами занимают время, но и могут вытеснить код из конвейеров выполнения, а это приведет к дополнительному ухудшению быстродействия, которого не было бы без кода измерения.

Автоматический измерительный анализ во время загрузки класса


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

Один из примеров такого рода — автоматический измерительный анализ методов. При таком подходе methodToBeMeasured() загружается специальным загрузчиком классов, который добавляет в начало и в конец метода байт-код, регистрирующий моменты времени входа и выхода из метода. Как правило, эти данные обычно записываются в общую структуру, к которой обращаются другие потоки, — обычно чтобы либо записать вывод в журнал, либо связаться с сетевым сервером, который обрабатывает «сырые» данные.

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

ПРИМЕЧАНИЕ


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

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

Установите цели по быстродействию


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

  • Сократить 90-й процентиль сквозной задержки на 20 % при десяти одновременно работающих пользователях.
  • Сократить среднюю задержку handleRequest() на 40 %.
В более сложных случаях цель может состоять из нескольких взаимосвязанных критериев производительности. Имейте в виду, что чем меньше связаны друг с другом показатели, которые вы измеряете и оптимизируете, тем сложнее может оказаться ваша задача. Оптимизация по одному показателю может отрицательно отразиться на другом.

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

Слишком часто разработчики увлекаются анализом и не останавливаются, чтобы прояснить свои цели.

Знайте, когда остановиться


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

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

Другой важный фактор — сколько усилий уходит на то, чтобы оптимизировать редко используемые пути в коде. Оптимизировать код, на который приходится 1 % или менее от времени выполнения программы, — это почти всегда напрасный труд, хотя на удивление много разработчиков этим занимаются.

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

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

Учитывайте цену повышения быстродействия


Оптимизация производительности никогда не обходится даром. Цена бывает разной:

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

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

Часто полезно представлять себе максимальные затраты, на которые вы готовы ради повышения производительности. Например, это могут быть ограничения по времени, которое тратится на оптимизацию, или предельное количество дополнительных классов или строк кода. Допустим, разработчик может решить, что на оптимизацию можно потратить не более недели или что оптимизированные классы не должны увеличиться более чем на 100 % (то есть в два раза относительно исходного размера).

Преждевременная оптимизация опасна


Одна из самых знаменитых цитат про оптимизацию принадлежит Дональду Кнуту (статья Structured Programming with go to Statements, «Структурное программирование с инструкциями go to» из журнала «Computing Surveys», вып. 6, № 4 (декабрь 1974)):
Программисты тратят огромное количество времени на размышления или беспокойство о скорости некритичных частей своих программ, и эти попытки повысить эффективность на самом деле оказывают огромное негативное влияние… Преждевременная оптимизация — корень всех зол.

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

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

В следующем фрагменте кода мы проверяем, делает ли объект log что-нибудь полезное с отладочным сообщением. Если подсистема ведения журнала не настроена для отладочного вывода, то этот код не будет создавать сообщение, что сэкономит затраты на вызов currentTimeMillis() и на конструирование объекта StringBuilder, в котором хранится сообщение:

if (log.isDebugEnabled()) {
    log.debug("Бесполезное сообщение от : "+ System.currentTimeMillis());
}

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

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

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

Чтобы разобраться, придется кратко погрузиться в аппаратную часть.

Что пошло не так? Почему мы должны этим заниматься?


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

Когда же все пошло не по плану? Почему тактовые частоты не растут в прежнем темпе? И что озадачивает еще сильнее, почему компьютер с чипом на 3 ГГц не кажется более быстрым, чем компьютер с чипом на 2 ГГц? Откуда взялась эта тенденция — перекладывать на разработчиков ответственность за производительность?

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

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

Закон Мура


Закон Мура назван в честь Гордона Мура, одного из основателей Intel. Самая распространенная формулировка закона выглядит так: «Максимальное количество транзисторов на микросхеме, производство которой экономически оправданно, удваивается приблизительно каждые два года».

Закон, который на самом деле представляет собой эмпирическое наблюдение о тенденциях в производстве компьютерных процессоров, основан на статье Мура 1965 года, в которой он давал прогноз на 10 лет, то есть до 1975 года.

То, что закон продолжал действовать гораздо дольше, — воистину поразительно. На рис. 7.2 представлены данные о реальных микропроцессорах из разных семейств (прежде всего Intel x86) с 1980 года до новейшего (на момент написания этой книги в 2021 году) Apple Silicon. (Данные взяты из Википедии и незначительно отредактированы для ясности.) График показывает взаимосвязь между количеством транзисторов на микросхемах и датой их выпуска.

image

График является логарифмически-линейным, то есть каждое деление оси y обозначает 10-кратное увеличение. Как видите, данные выстроились практически на прямой линии, а через каждые 6–7 лет эта линия пересекает очередной вертикальный уровень. В этом проявляется закон Мура, потому что увеличение в 10 раз за 6–7 лет — это примерно то же самое, что удвоение за каждые два года.

Еще раз обратите внимание, что ось y на графике — логарифмическая. Например, массовая микросхема Intel, произведенная в 2005 году, содержала около 100 миллионов транзисторов, и это приблизительно в 100 раз больше, чем содержала микросхема, произведенная в 1990 году.

Очень важно, что в законе Мура речь идет именно о количестве транзисторов. Это основное положение, которое объясняет, почему одного лишь этого закона недостаточно, чтобы разработчики продолжали решать свои проблемы за счет производителей оборудования (см. Герб Саттер (Herb Sutter), The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software, «Бесплатный сыр закончился. Фундаментальный поворот к конкурентности в ПО», «Dr. Dobb’s Journal», № 30 (2005), с. 202–210).

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

ПРИМЕЧАНИЕ


Количество транзисторов — не то же самое, что тактовая частота. И даже все еще распространенное мнение о том, что чем выше тактовая частота, тем лучше производительность, — это чрезмерное упрощение.

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

Иерархия задержек памяти


Чтобы компьютерный процессор работал, ему нужны данные. Если нет данных, которые можно обрабатывать, то тактовая частота процессора становится несущественной: процессору приходится просто ожидать, выполняя пустую операцию (NOP) и, по сути, простаивая, пока не поступят данные.

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

  1. Где находится ближайшая копия данных, которые нужны ядру процессора, чтобы продолжать работу?
  2. Насколько быстро данные можно перенести туда, где ядро может их использовать?
Перечислим основные места размещения данных (в так называемой архитектуре фон Неймана, самой распространенной из используемых):

  • Регистры — области памяти, которые находятся непосредственно на процессоре и готовы к немедленному использованию. Инструкции процессора обращаются к этой памяти напрямую.
  • Оперативная память — обычно DRAM (динамическая память с произвольным доступом). Время доступа для этой памяти составляет около 50 нс (но позднее мы подробнее расскажем, как кэши процессора помогают сократить эту задержку).
  • Твердотельные диски, или SSD, — обращение к этим дискам занимает не более
    0,1 мс, но они все еще остаются дороже, чем традиционные жесткие диски.
  • Жесткий диск — обращение к диску и загрузка запрошенных данных в
    основную память занимает около 5 мс.

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

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

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

Кэши обозначаются L1 и L2 (на некоторых машинах бывает еще L3). Чем меньше цифра, тем ближе кэш физически находится к ядру и тем быстрее он работает. Мы подробнее поговорим про кэши в разделе 7.6 (посвященном JIT-компиляции), где будет приведен пример, который показывает, насколько существенно кэш L1 влияет на выполнение кода. На рис. 7.3 показана относительная скорость доступа к кэшам и другим областям памяти.



Наряду с добавлением кэшей в 1990-х и начале 2000-х широко применялась другая практика: чтобы преодолеть задержку памяти, процессоры оснащались все более сложной функциональностью. Изощренные аппаратные механизмы, такие как параллелизм на уровне команд (ILP) и многопоточность на уровне микросхемы (CMT), использовались для того, чтобы процессор продолжал своевременно получать данные даже в условиях расширяющегося разрыва между возможностями процессора и задержкой памяти.

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

Тема производительности в обозримой перспективе тесно связана с конкурентным выполнением: один из основных способов повышения общей производительности системы заключается в том, чтобы использовать больше ядер. В этом случае, даже если одно ядро ожидает данные, остальные тем временем могут продолжать работать (но не забывайте о влиянии закона Амдала, который рассматривался в главе 5). Эта связь настолько важна, что мы повторим еще раз:

  • Практически все современные процессоры являются многоядерными.
  • Производительность и конкурентность тесно связаны друг с другом.

Мы лишь в самых общих чертах описали принципы компьютерных архитектур, которые имеют отношение к программированию на Java. Любознательному читателю, который захочет узнать больше, стоит обратиться к специализированной литературе, например Хеннесси и др. (Hennessy et al.) «Computer Architecture: A Quantitative Approach, 6th edition» (Morgan Kaufmann, 2017). Аппаратные факторы касаются не только программирования на Java, однако управляемость JVM создает дополнительные сложности. Рассмотрим их в следующем разделе.

Почему так сложно оптимизировать производительность кода на Java?


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

Такое смещение акцентов мешает рассматривать систему в целом, потому что управляемая исполнительная среда оказывается «черным ящиком» для разработчика. Альтернативный подход состоит в том, чтобы отказаться от всех преимуществ управляемой исполнительной среды, как, например, поступают программисты на C/C++, которым приходится практически все делать самостоятельно. В этом случае ОС обеспечивает только простейшие возможности например рудиментарное планирование потоков, работа с которым всегда отнимает значительно больше времени, чем отдельная оптимизация производительности.

Вот некоторые важные аспекты платформы Java, которые усложняют оптимизацию:

  • Планирование потоков.
  • Сборка мусора (GC).
  • JIT-компиляция.

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

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

Роль времени в оптимизации производительности


Чтобы оптимизировать производительность, необходимо знать, как интерпретировать данные, собранные в процессе выполнения кода. А для этого, в свою очередь, важно понимать ограничения, которые присущи любым измерениям времени на платформе.

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

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

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

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

Интерпретация измерений
Если интервал указан как 5945 нс с погрешностью до 1 нс, однако получен от таймера с точностью 1 мкс, истинное значение на самом деле лежит в интервале 3945–7945 нс (с вероятностью 95%). Остерегайтесь показателей производительности, которые выглядят слишком точными; всегда проверяйте как точность, так и погрешность измерений.

Гранулярность
Истинная гранулярность системы определяется частотой самого быстрого таймера — скорее всего, это таймер прерываний (в диапазоне 10 нс). Иногда эта характеристика называется различимостью (distinguishability) и описывается как кратчайший интервал между двумя событиями, о которых можно определенно сказать, что они произошли «достаточно близко по времени, но не одновременно».

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

Хронометраж в распределенных сетевых конфигурациях


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

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

Вспомним самое важное о хронометраже в Java:

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

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

Промахи кэша


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

В листинге 7.1 мы перебираем 2-мегабайтный массив и выводим время, которое тратится на выполнение одного из двух циклов. Первый цикл увеличивает на 1 каждый 16-й элемент массива int[]. 64 байта почти всегда укладываются в строку кэша L1 (а тип int в Java состоит из 4 байт), так что код по одному разу обращается к каждой строке кэша.

Обратите внимание: чтобы получить точные результаты, необходимо провести разогрев кода, чтобы JVM откомпилировала интересующие вас методы. Мы подробнее рассмотрим разогрев JIT-компилятора позднее в этой главе.


Вторая функция touchEveryItem() увеличивает каждый байт в масиве, так что она выполняет в 16 раз больше работы, чем touchEveryLine(). Однако посмотрим на результат на типичном ноутбуке:

Строка: 487481 ns ; Элемент: 452421
Строка: 425039 ns ; Элемент: 428397
Строка: 415447 ns ; Элемент: 395332
Строка: 372815 ns ; Элемент: 397519
Строка: 366305 ns ; Элемент: 375376
Строка: 332249 ns ; Элемент: 330512

Результаты показывают, что touchEveryItem() выполняется не в 16 раз дольше, чем touchEveryLine(). Всему виной время передачи данных из основной памяти в кэш процессора — оно доминирует в общем профиле производительности.

В функциях touchEveryLine() и touchEveryItem() одинаковое количество операций чтения строк кэша, а время передачи данных заметно превалирует над тактами, которые тратятся непосредственно на модификацию данных.

ПРИМЕЧАНИЕ


Этот пример поднимает важный вопрос: необходимо сформировать хотя бы общее понимание (или ментальную модель) того, как процессор на самом деле расходует свое время.

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

Об авторах
Бен Эванс — обладатель статуса Java Champion и главный сеньор-разработчик в Red Hat. Раньше он был ведущим архитектором систем измерительного контроля в New Relic, а также одним из основателей jClarity — стартапа, который разрабатывал средства повышения производительности и был приобретен компанией Microsoft. Бен также работал старшим специалистом по архитектуре в отделе биржевых деривативов в Deutsche Bank и старшим техническим инструктором в Morgan Stanley. Он шесть лет состоял в комитете Java Community Process Executive Committee, участвуя в разработке новых стандартов Java.

Бен — автор шести книг, включая «Optimizing Java»1 и новые издания «Java in a Nutsell»2, а также технических статей, которые ежемесячно читают тысячи разработчиков. Он регулярно выступает на конференциях и проводит обучение для организаций по всему миру по таким темам, как платформа Java, системная архитектура, быстродействие и конкурентность.

Джейсон Кларк — главный инженер и архитектор в New Relic, где он работал над множеством проектов: от библиотек измерительного контроля Ruby до платформ координации контейнеров. Ранее он был архитектором в WebMD, где занимался веб-службами на базе .NET.

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

Мартин Фербург — главный менеджер по разработке ПО в инженерной группе Java компании Microsoft и один из руководителей группы London Java User Group (LJC), где он участвовал в создании AdoptOpenJDK (сейчас Eclipse Adoptium) — ведущего дистрибутива OpenJDK, который не находится под управлением Oracle. Мартин — соавтор первого издания этой книги и член многих комитетов по стандартизации Java (JCP, Jakarta EE и т. д.).

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

» Оглавление
» Отрывок

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Для Хаброжителей скидка 25% по купону — Java

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


  1. tuxi
    17.06.2024 13:45

    У вас оплата российской банковской картой (сбер) не срабатывает, 2 раза пробовал.


    1. ph_piter Автор
      17.06.2024 13:45

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


      1. tuxi
        17.06.2024 13:45

        с 3й попытки прошла оплата.


  1. idsmokedried
    17.06.2024 13:45

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