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

Статья не претендует на полноту или полную непогрешимость. Во-первых, нельзя объять необъятное. Во-вторых, все меняется и проверенные истины могут перестать быть истинами в новых версиях. В сети существует множество статей об  устройствах Java, однако в этой статье в блоге ЛАНИТ я стремился сделать выжимку основных моментов, необходимых для администраторов Java-приложений. Для более глубокого погружения в тот или иной вопрос потребуется обратиться к другим источникам.

Источник

Зачем появилась Java

В середине 1990-х годов очень быстро развивался Web. Javascript еще не было, и в основном пользователи видели статические странички. Не всем это нравилось, хотелось интерактивности. CGI уже существовал, но был крайне медленным и использовался только в тех случаях, когда без него обойтись было нельзя. Многим, в частности, зарождающемуся бизнесу, было тесно в этих рамках — необходимо было найти язык, который отвечал бы ряду условий.

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

  • Не был бы подвержен утечкам памяти, ошибкам сегментации, то есть был бы надежнее, чем С и C++, самые популярные языки для коммерческого программирования в те годы.

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

  • Был бы объектно-ориентированным. Объектно-ориентированное программирование в 1990-е годы  — тренд помощнее блокчейна в 2020-х годах.

На эту роль прекрасно подошел язык Java, который, хотя и не создавался специально под эти требования, неплохо под них подошел. Он может запускаться на любой архитектуре (при условии наличия под эту архитектуру Java-машины), не требуя при этом пересборки, был намного быстрее perl, awk и прочих, используемых для CGI-скриптов, да и писать на нем можно было почти все — backend, frontend (имеются в виду Java-апплеты), десктопные приложения, встраиваемые системы. Выучив Java в 1990-е годы, вы получали очень широкое поле для применения своих навыков.

Java-машина

Источник

Много раз слышал от людей, казалось бы, противоречащие друг другу заявления:

  • «Java — компилируемый язык, я же компилирую его в своей IDE»;

  • «Java — интерпретируемый язык, ведь Java-машина занимается интерпретацией байт-кода, который вы создали компилятором».

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

Есть интерпретаторы типично компилируемого С (tcc), есть компиляторы типично интерпретируемого Python (nuitka). Есть и компилятор Java (gcj), он вам собирает бинарник. Но если мы говорим о конкретной реализации языка, тут ситуация становится несколько более четкой. Поэтому сосредоточимся на Sun'овской Java-машине (ныне — Oracle'овской), и рассмотрим, как она работает.

В случае с ней, Java компилируется в байт-код, который затем интерпретируется этой самой Java-машиной.

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

Кроме самой Java-машины, как правило, с ней поставляется набор библиотек. Этот набор библиотек определяет платформу Java.

1. Самая известная платформа — Java SE (Standard Edition), содержит библиотеки, больше всего подходящие для работы приложений для десктопа.

2. Java EE (Enterprise Edition) включает в себя все библиотеки Java SE, но дополнительно описывает требования к реализации библиотек для приложений уровня бизнеса — связь с БД, другими приложения Java EE и др.

3. Если смотреть в сторону упрощения, есть Java ME (Mobile Edition) — для мобильных приложений на телефонах (и здесь имеются в виду именно мобильные телефоны, а не суперкомпьютеры, которые сейчас большинство носят в карманах), вычислительной мощности которых, как правило, хватает максимум на вычисления, необходимые для организации разговора (т.е. поддержание связи с вышками и сжатие/распаковку звука, передаваемого в сотовую сеть).

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

Организация памяти в Java

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

Heap

Источник

В Java есть два вида типов данных — примитивные и объекты.

Примитивных типов данных в Java очень мало, и все они, как и следует из названия, примитивные — самая большая переменная примитивного типа long имеет размер в 8 байт. Это разного размера числовые типы данных, целочисленные и с плавающей точкой, символьный и булевый. Они, да еще и ссылки на объекты, могут создаваться (если они не являются частью объекта) на стеке, а не в Heap. Благодаря этому, они могут иметь четкое время жизни и не нуждаться в периодической очистке. Проблема с ними, конечно же, в том, что они примитивные. Что-то более-менее серьезное, используя только примитивные объекты, написать очень сложно — даже массив создать нельзя, он уже является в Java объектом.

Если же вы создаете объект в Java — в подавляющем большинстве случаев целиком, и в очень маленьком количестве случаев (JNI) частично, занимает память в области данных, называемой кучей, или Heap. И тут у нас появляются проблемы, связанные с определением времени жизни объекта. Решить ее призваны разного рода сборщики мусора (GC). Про него — ниже.

Как отделить живые объекты от неживых?

Прежде чем удалять объект, необходимо понять, нужен он, или уже нет. Есть несколько способов решения этого вопроса, у всех есть свои недостатки.

В Java эта проблема решается следующим образом: есть некий набор так называемых «корневых» объектов, про который виртуальная машина точно знает, что они живые. В них есть ссылки на другие объекты, в которых есть ссылки на другие — и так далее. Рекурсивно проходя по ним, можно собрать список объектов, на которые есть ссылки, они и считаются живыми. Остальные подлежат удалению. Такой проход в терминологии GC называется Mark, при описании разных GC будет использоваться этот термин.

Подробнее про строение Heap

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

  1. Долгая работа GC. Он должен проходить весь Heap при каждом запуске.

  2. Фрагментация. Представим ситуацию, что у нас Heap размером 1000К, мы создали 1000 объектов по 1К, после этого каждый второй стал ненужным и GC его очистил. Формально, у нас свободно 500К, но объект размером 2К уже создать невозможно — нет промежутка достаточного размера. Теоретически, можно заставить GC дефрагментировать Heap, помещая все объекты друг за другом, и меняя адреса в ссылках на эти объекты (такой шаг в терминологии GC называется Compact), но это добавит еще времени к и без того небыстрой работе нашего примитивного GC.

Поэтому даже самые простые GC в современных JVM, как правило, делят Heap на несколько регионов. Подробнее про них — в разборе каждого GC.

Строение ссылки

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

Надо иметь в виду, что часть команд CPU либо работают очень медленно, или вообще не могут работать с переменными длинной X байт, если их адрес не делится на X. Поэтому, как правило, если переменная имеет размер 2 байта, то она в памяти должна лежать по четному адресу, если 4 — то по адресу, делящегося на 4, и т.д.

Исходя из этого, становится очевидно, что если сделать ссылку по-простому, то есть просто хранить в ней адрес объекта в памяти, как, например, это сделано в ссылках С++ или указателях в С, то, если ваш Heap больше 4 ГБ (2³² байт), ссылки должны занимать 64 бита (8 байт), а не 32 (4 байта), т.к. иначе адреса там не поместятся. Но 4 ГБ для современных приложений — не очень большой Heap.

Здесь разработчики JVM сделали хитрость — они решили все объекты выравнивать по 8 байт (так как это наименьшее общее кратное для всех выравниваний на x86-64). Это значит, что последние три бита в ссылке можно не хранить, они все равно будут равны 0. Благодаря этому, в 32 бита влезут адреса 0-2³⁵ (32 ГБ), а не 0-2³² (4 ГБ).

Поэтому имейте в виду, расширение Heap за 32 ГБ может фактически снизить его вместимость — из-за того, что каждая ссылка, бывшая раньше 32-битной, станет 64-битной.

Различные сборщики мусора

Теперь поподробнее про те GC, что есть в OpenJDK 11. Их все проектировали, исходя из следующих принципов.

  1. Ожидаемое время жизни объекта в большинстве случаев пропорционально тому, сколько объект уже живет. Иными словами, чем дольше объект уже существует, тем больше вероятность того, что он еще просуществует долго. Это происходит потому, что многие объекты создаются на очень короткий срок — для ответа на запрос, например, или для преобразования типов. Есть объекты, которые живут подольше, например, при обработке транзакций, но их меньше. И наконец, есть те объекты, которые создаются один раз в момент запуска программы и живут до ее завершения, всяческие singleton'ы, например. Это значит, что следует отделить только что созданные объекты от старых, и почаще запускать GC на первых.

  2. По закону Парето, 20% работы занимает 80% времени и наоборот. Понятно, что здесь не стоит ориентироваться на конкретные значения, тем не менее, можно поступить двумя способами: запустить GC, который будет работать долго, но вычистит весь мусор; или запустить GC, который отработает быстро, но часть мусора оставит. В первом случае мы получим оптимальную производительность (throughput), но и долгие паузы при выполнении GC. Во втором — сравнительно короткие паузы, но из-за накладных расходов на каждый вызов GC, пострадает общая производительность. Нужно найти равновесие между двумя этими экстремумами. При этом еще и не потратить слишком много памяти на накладные расходы.

  3. Лишнюю память занимать не стоит, но лучше занять лишнюю память, чем лихорадочно отдавать операционной системе едва освободившуюся память, а потом забирать у нее ее снова.

Теперь про каждый подробнее, начиная с самого простого.

Epsilon GC (-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC)

Самый простой GC, который только можно придумать. Он не делает совсем ничего. Когда Heap заканчивается, вы получаете исключение OutOfMemory и дальше делаете, что хотите. Идеально подходит для тех случаев, когда вы четко знаете, сколько вашему приложению нужно объектов, и что оно не создаст ничего лишнего, а если уж создало, то это — ошибка. Это не шутка, такое действительно бывает в embedded-системах.

Serial GC (-XX:+UseSerialGC)

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

Heap делится в нем на три региона: Eden, Survivor, и Tenured. Все новые объекты создаются в Eden, когда он заполняется, по нему проходит фаза Mark, объекты перемещаются в Survivor. Для каждого из объектов в Survivor увеличивается счетчик сборок мусора, которые он пережил. Если он оказывается больше некоторого значения, объект переезжает в Tenured. Это, вкратце (я опустил много деталей), так называемая малая сборка. За счет того, что она проходит только по новым объектам (регион Tenured не чистится), она выполняется быстро (Tenured по умолчанию — ⅔ Heap'а), а за счет того, что происходит копирование, нет проблемы с фрагментацией (не нужна фаза Сompact).

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

Еще необходимо упомянуть, что все время, пока работает этот GC, приложение работать не может — сборка мусора идет в один поток, который блокирует работу всех остальных.

Сборщик настраивается — в частности, можно задать, какая часть каждого региона должна остаться свободной, чтобы JVM отдала память системе, или наоборот, забрала ее. Это опции -XX:MinHeapFreeRatio и -XX:MaxHeapFreeRatio.

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

Эти два недостатка решает следующий доступный нам Garbage Collector.

Parallel GC (-XX:+UseParallelGC)

Источник

Сборщик, который почти аналогичен предыдущему, за исключением следующий моментов.

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

  2. Сборщик мусора сам подбирает размеры регионов.

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

  1. Сборка не занимала больше 1% времени работы CPU (настраивается через опцию -XX:GCTimeRatio).

  2. Сборка не останавливала приложение больше, чем на N миллисекунд, если задан параметр -XX:MaxGCPauseMillis.

  3. Размер Heap был минимальным.

Таким образом, можно видеть, что по умолчанию сборщик подбирает размер Heap так, чтобы GC занимал 1% времени работы приложения — и это именно время работы приложения, т.е. CPU time, а не сколько оно простаивало. Это приводит к интересным ситуациям, когда приложение в простое потребляет больше памяти, чем при нагрузке.

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

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

Поподробнее про него можно почитать здесь.

G1

Источник

Но что же, если мы пишем приложение, которое не должно надолго зависать при сборке мусора? Ладно, если это веб. Там пользователи терпеливые. Но если это локальное приложение? Там, если приложение зависнет больше, чем на две секунды, нормальный пользователь его сразу удалит, а нетерпеливый еще успеет вычислить разработчика, купить билет на самолет, прилететь и настучать разработчику по голове.

Пусть сборщик мусора отнимает побольше ресурсов, только бы работал незаметно для пользователя — с меньшими паузами, подумали разработчики JVM, и так родились сборщики CMS и G1. О первом я не буду рассказывать — он уже помечен как deprecated, и его выкинут из Java, а вот G1 стал сборщиком мусора по умолчанию, и при всей его сложности, о нем стоит рассказать поподробнее.

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

Каждый из этих регионов получает свою роль, — Eden, Surivior, Tenured, но эти роли непостоянные, могут меняться, если будет недостаток в регионах одного типа и избыток других. А еще существуют отдельные регионы для огромных объектов, которые не помещаются в один.

При необходимости собрать мусор GC выбирает на основе своей статистики сборок, которую он ведет, те регионы, в которых:

  • сборка, скорее всего, освободит больше всего памяти (поэтому GC называется G1 — Garbage first);

  • cборка, скорее всего, не займет больше, чем -XX:MaxGCPauseTimeMillis (по умолчанию — 200) миллисекунд.

В случае малой сборки, как и в других GC, очищаются регионы Eden. Большие сборки (так называемые mixed в терминологии G1) затрагивают и регионы со старыми объектами, но G1 так искусно их избегает, что их увидеть можно крайне редко.

Еще нужно упомянуть, что в отличие от предыдущих сборщиков, G1 некоторую часть работы (например, изначальную пометку достижимых объектов) выполняет параллельно с работой приложения, что сильно снижает паузы, но уменьшает throughput, который в случае G1 по умолчанию составляет 90% (сравните с Parallel GC, у которого это значение 99%), то есть можно ожидать, что приложение будет работать в среднем на 9% медленнее. К тому же, статистика, которую он собирает, тоже занимает место, да и фрагментации большое количество регионов добавляют. Эмпирически можно сказать, что с G1-приложения потребляют где-то на 20% больше памяти. Такова цена за недолгие паузы.

Проблему с дополнительным потреблением памяти может несколько сгладить уникальная для G1 особенность — он может находить одинаковые строки и дедуплицировать их в памяти, если включена опция -XX:+G1EnableStringDeduplication. Я пробовал ее на практике, но особого снижения потребления не заметил, хотя все, конечно, зависит от приложения.

Здесь я опять опустил очень много подробностей, о них тут.

ZGC, Shenandoah

G1, конечно, хорош, но можно сделать еще сложнее. В современных JDK есть два новых сборщика мусора — ZGC (production-ready с JDK 15) и Shenandoah (c 12-й Java). Оба ставят грандиозные цели, вроде пауз не более 1 миллисекунды. Подробно про них писать не буду, уж слишком они сложны, да и мы в нашей компании до сих пор используем JDK 11, где их нет. Интересующихся направлю к циклу статей, из которых я почерпнул большую часть информации о сборщиках мусора для написания этой статьи. Она здесь. Там же можно почитать поподробнее и про другие сборщики мусора.

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

Выбор GC по умолчанию

В один момент мы столкнулись с интересной и малодокументированной особенностью Java 11, касающейся выбора алгоритма сборки мусора. Исходно мы предполагали, что по умолчанию, начиная с JVM 9, используется сборщик мусора под названием Garbage first (G1). Этот факт был широко разрекламирован и описан в различных ресурсах, например, здесь. Однако мы заметили, что на некоторых подсистемах у нас используется SerialGC вместо G1. Это далеко не самый лучший выбор, учитывая, что он однопоточный и с жестко заданными регионами для новых и старых объектов. Мы начали исследовать причину такого поведения и нашли ответ только в коде JVM.

Здесь происходит следующее: если JDK считает, что запущена на сервере, (на основании результата вызова функции is_server_class_machine()), то используется G1, если его нет, то ParallelGC, и если его нет, то SerialGC. Если же эта функция возвращает false, то всегда используется SerialGC.

Сама же функция is_server_class_machine()  реализована здесь. Как видим, там очень странная эвристика по определению, сервер это или нет: если доступно 2048-256=1792 МБ памяти и два ядра, то это сервер, иначе — нет. Учитывая, что на тестовых стендах некоторые контейнеры у нас имеют меньше такого объема памяти, в них эта функция возвращала false, и это служило причиной использования SerialGC.

Non-Heap

При этом необходимо не забывать, что не вся память Java-машины подвергается сборке мусора. Те области памяти, в которые код, написанный на Java, не имеет прямого доступа, например, загруженные классы, буферы ввода-вывода, а также те, которые используются JNI, относятся к так называемой Native-памяти и игнорируются сборщиком мусора. Управление памятью там ручное, и код, который ей управляет, либо относится к самой JVM (например, для загруженных классов), или ее обязан чистить программист, если он использует JNI.

Native-память

Ну и всякого рода неименованная память для того, что порождено JNI — буферы, коннекторы к функциям на C и прочее. Тут все зависит от используемых приложений и библиотек. И сам код JVM.

Код

Надо сказать, что вынос данных из Heap — это хорошо, постольку поскольку Garbage Collector не тратит процессорное время на ее очистку, и поэтому проглядывается тенденция по выносу различных объектов из Heap'а в Native-память. Так, в JDK8 появился раздел Metaspace, куда стали попадать метаданные классов. Раньше они находились в области PermGen в Heap.

Еще есть несколько областей кода, где размещается то, что JIT-компилятор собрал в родной для архитектуры код — область CodeHeap (CodeCache в старых версиях JDK). CodeHeap делится на три части.

  • Non-nmethods (объемом в единицы мегабайт) — область для работы самого JIT.

  • Две другие — profiled nmethods и non-profiled nmethods немного похожи на области новых и старых объектов в Heap.

  • В profiled слегка оптимизированные методы, которые могут и не понадобиться еще раз.

  • В non-profiled — полностью оптимизированные, которые живут уже долго.

  • Compressed Class Space — часть Metaspace, доступная по 32-битным указателям.

Стек

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

Поскольку каждый поток может быть занят выполнением своего кода, у каждого потока стек свой, так что увеличение числа живых потоков тоже вызывает увеличение объема потребляемой памяти. По умолчанию размер стека — 1 Мб на каждый поток. Проверяется командой java -XX:+PrintFlagsFinal -version | grep ThreadStackSize.

ClassLoader'ы

Java-приложение состоит из классов. Классы объединяются в пакеты и библиотеки. Все это нужно каким-то образом помещать в память, и если для нативных приложений есть динамический линковщик, то в мире Java этим занимается набор так называемых Class Loader'ов.

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

Вот они, в порядке от самого низкого до самого высокого уровня.

  1. BootStrap ClassLoader — тот самый ClassLoader, который загружает базовые классы JVM, они находятся в файле rt.jar и в каталоге $JAVA_HOME/jre/lib. Там находятся такие классы, которые входят в стандарт J2SE, например, java.lang.String, реализующий строки, или java.lang.Thread, реализующий потоки. Можете сами распаковать и посмотреть.

  2. Extension ClassLoader — чуть выше уровнем, подгружает те классы, которые расширяют функциональность JVM, они находятся в $JAVA_HOME/jre/lib/ext и в каталогах, заданных в -Djava.ext.dirs.

  3. Application ClassLoader — подгружает классы, переданные через переменную $CLASSPATH, или опции -classpath, -cp.

  4. Custom ClassLoader — а это уже программно-определяемые ClassLoader'ы. Их количество и последовательность зависит от вашего сервера приложений, если он есть. Они будут подгружать его модули и сами приложения.

Подгрузка класса происходит следующим образом.

  1. Если класс уже в памяти, то ничего делать не надо.

  2. Если класса в памяти нет, то ClassLoader обращается к ClassLoader'у более низкого уровня с требованием подгрузить класс, то проверяет наличие класса в памяти, если нет, то дергает более низкоуровневый ClassLoader и т.д., пока класс в памяти не найдется, или мы не дойдем до BootStrap ClassLoader.

  3. Если после опроса более низкоуровневых ClassLoader'ов класс так и не нашелся, наш ClassLoader пытается сам подгрузить нужный класс.

Что из этого следует?

  1. Если класс подгружен более высокоуровневым Classloader'ом, то он невидим для более низкоуровневого;

  2. Более высокоуровневые Classloader'ы не могут переопределить классы более низкоуровневых Classloader'ов, но наоборот можно.

Это все стоит иметь в виду, если вы пытаетесь найти причину ClassNotFoundException.

J2EE

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

Когда вы получаете JVM, как правило, в нее входит не только сама Java-машина, а еще и некоторый набор классов (уже упоминавшиеся rt.jar и $JAVA_HOME/jre/lib). Они составляют собой набор J2SE, и их, как правило, достаточно для написания несложных десктопных или серверных приложений. По функциональности это примерно, как стандартная библиотека Python.

Но если необходимо написать приложение уровня предприятия, этих библиотек зачастую не хватает. Поэтому в Java есть слой так называемой Java Enterprise Edition, в котором описаны интерфейсы и требования к серверу приложений, реализация которых, будучи подключённой к приложению, обеспечит разработчикам такие важные вещи, как организация транзакций, возможность взаимодействия с СУБД, удобное создание веб-приложений с помощью сервлетов (JSP), организация логики приложения в Enterprise Java Bean, обмен сообщениями с помощью JMS.

У этого стандарта свое версионирование, не совпадающее с версиями Java, поэтому необходимо внимательно смотреть, версия чего нас интересует.

Стоит сказать, что все это реализуется не самой JVM, и даже не JRE/JDK, в отличие от J2SE. J2EE реализован, частично или полностью, разными серверами приложений. Например, существуют серверы приложений, реализующие JSP, но в которых нет EJB (таким, например, является Tomcat и Jetty), а есть серверы приложений, реализующие все, например GlassFish — это эталонная реализация стандартов J2EE.

У меня нет цели описать все, что входит в этот стандарт, это было бы слишком для такой обзорной статьи. А уж подробное описание тянет на книгу в добрую тысячу страниц. Главное — иметь в виду, что J2EE не реализуется JRE/JDK/JVM.

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


  1. Livefish
    00.00.0000 00:00

    Неплохая статья для систематизации знаний, спасибо!


  1. jimaltair
    00.00.0000 00:00

    Спасибо автору! Интересная и полезная обзорная статья. Почерпнул для себя пару практических моментов, на которые раньше не обращал внимание


  1. Gmugra
    00.00.0000 00:00
    +1

    Спасибо. Полезный и дельный лонгрид.

    Но не могу устоять от пары замечаний.

    1. Java SE - "больше всего подходящие для работы приложений для десктопа". Это, конечно, неверно.
      (Да некоторая часть SDK посвящена поддержке разработки графических интерфейсов, но не более того)
      И более того, сейчас множество серверного софта пишется именно в рамках Java SE. И уровня предприятия в том числе.
      P.S. и, к слову, поддержка баз данных aka JDBC это часть Java SE, а не JEE.

    2. Но так же не верно утверждать что никто не пишет десктопные приложения на Java.
      Собственно весь спектр десктопных приложений для разработки на Java(и не только), на Java SE и написан.
      Все эти Eclipse, IntelliJ IDEA, SmartGit, DBeaver, EditiX и т.д. и т.п. - сотни их, на самом деле.

    3. Serial GC
      Многопоточность не бесплатна. Переключение между потоками - очень дорогая операция для CPU.
      Поэтому, если для JVM доступно очень мало логических процессорных ядер(<=2),то все GC активно использующие дополнительные потоки(а это все кроме Serial GC)
      ничего не дают. В таких условиях, они скорее всего будут работать хуже чем Serial GC. А единственное доступное для JVM ядро - это не такая уж экзотическая история.
      В мире полно одноядерных платформ где JVM вполне применяют. Да и в контейнерах (Docker and co.) такое бывает.
      Поэтому Serial GC живее всех живых не смотря на прямолинейность.

    4. Parallel GC Тут стоило бы сказать что этот GC очень часто лучший выбор для "короткоживущих" процессов. Запустился - отработал - завершился. Например, java приложения запускаемые по СRON. Например, сборки проектов с gradle/maven.
      В общем, сценарий очень не редкий и throughput GC, в таких сценариях, может дать знатнейший выигрыш (вплоть до 10-ков процентов).
      Поэтому он нужен и важен.


    1. okulovas Автор
      00.00.0000 00:00

      Спасибо за детальный комментарий!

      1 и 2. К сожалению, я был неправильно понят из-за некорректно подобранной формулировки.

      > Java SE - "больше всего подходящие для работы приложений для десктопа".

      Это, конечно, неверно.

      Если это понимать как "Java SE — только для десктопа", то конечно нет. Имелось в виду "Если у вас десктоп, то вы хотите иметь именно Java SE, а не Java EE, например, или Java ME". Думаю тут спорить не с чем.

      >  P.S. и, к слову, поддержка баз данных aka JDBC это часть Java SE, а не JEE.

      В статье напрямую про JDBC не сказано, скорее имелось в виду JPA, но соглашусь, что в формулировке "возможность взаимодействия с СУБД," наверное многие подумают про JDBC.

      3.

      > Поэтому, если для JVM доступно очень мало логических процессорных ядер(<=2),то все GC активно использующие дополнительные потоки(а это все кроме Serial GC) ничего не дают. В таких условиях, они скорее всего будут работать хуже чем Serial GC. А единственное доступное для JVM ядро - это не такая уж экзотическая история.

      Если у вас N ядер, то по умолчанию ParallelGC не будет делать больше N потоков, то есть на одном CPU будет один тред и в ParallelGC, если пользователь не выстрелил себе в ногу и не выставил большое явное значение через 

      -XX:ParallelGCThreads

      и проблем с излишним их переключением (по крайней мере, если не рассматривать приложения вне самой JVM) нет:

      https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/parallel.html

      > On a machine with N hardware threads where N is greater than 8, the parallel collector uses a fixed fraction of N as the number of garbage collector threads. The fraction is approximately 5/8 for large values of N. At values of N below 8, the number used is N

       

      Причем ограничения контейнеров тоже учитываются, и если контейнеру выделено, допустим, 1 ядро, а JVM видит на машине 8, то будет только один поток ParallelGC:

      https://blogs.oracle.com/java/post/java-se-support-for-docker-cpu-and-memory-limits. 

      Опять же, это можно переопределить через 

      -XX:ActiveProcessorCount


      4. Я бы сказал, ParallelGC хорош для gradle/maven и других короткоживущих процессов не потому, что они короткоживущие (сборка большого проекта может идти десятки минут), а потому что они, как правило, неинтерактивные. Latency в них нигде себя не проявит, а thoughput будет влиять на время выполнения.


      1. Gmugra
        00.00.0000 00:00

        Насколько я понимаю, ParallelGC это всегда дополнительный поток, пусть и только 1. т.е. у вас есть поток с приложением и поток(и) в котором работает этот ParallelGC. Если у вас в распоряжение есть ровно одно ядро CPU, то у вас переключение по крайне мере между этими двумя потоками. И это, по-видимому, убивает смысл всей затеи.

        Иначе, JVM, по умолчанию, в этой ситуации использовала бы Parallel, а не Serial GC, думается мне.


        1. okulovas Автор
          00.00.0000 00:00

          Вообще да, так и есть, но есть нюанс.

          Написал самое простое приложение, которое просто бесконечно спит:

          import java.lang.Thread;


          class Sleep {

                  public static void main(String [] argv) throws InterruptedException {

                          while (true) {

                             Thread.sleep(Long.MAX_VALUE);

                          }

                  }

          }

          При его запуске у  меня на Java SE 11 появляется 12 потоков с ParallelGC и 11 c Serial. То есть во время работы приложения работает JIT-компилятор, обработка сигналов, и так далее — все в разных потоках. Так что согласен, разница есть, но разница не между одним-двумя, а между 11-12 потоками — вряд ли она сильно влияет в такой ситуации. Нужна специальная оптимизация, уже за пределами выбора GC, чтобы от них избавиться.