Разработка приложений для Java: Часть 2. Повышение качества сервиса, предоставляемого приложением

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

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

Марк Студли, технический руководитель проекта WebSphere Real Time, IBM

Марк Студли (Mark Stoodley) начал работать в лаборатории IBM в Торонто в 2002 году после получения докторской степени университета Торонто в области проектирования компьютерных систем. Работая в IBM, Марк разработал оптимизации для двух разных JIT-компиляторов и в настоящее время возглавляет команду разработчиков JIT-компилятора Testarossa, а также является техническим руководителем проекта IBM WebSphere Real Time JVM, работа над которым ведется на трех континентах. В свободное время он занимается своим домом, стараясь улучшить его внешний вид.



Чарли Грейси, руководитель группы разработчиков, IBM

Чарли Грейси (Charlie Gracie) начал работать в лаборатории IBM в Торонто в 2004 году после получения степени бакалавра компьютерных наук в университете Нью-Брансуика, присоединившись к команде разработчиков виртуальной машины J9.



13.09.2010

Неравномерность (variability) выполнения кода, вызываемая, как правило, задержками, возникающими в случайные моменты времени, может наблюдаться у приложений всех уровней. Задержки могут быть обусловлены следующими причинами:

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

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

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

Устранение причин неравномерного выполнения

Основные причины неравномерности, кроющиеся внутри JVM, объясняются динамической природой Java.

  • Память никогда не освобождается средствами приложения. Эту задачу решает периодически запускающийся сборщик мусора.
  • Классы загружаются только по мере необходимости.
  • Компиляция (а также повторная компиляция) в машинный код выполняется оперативным (just-in-time – JIT) компилятором в процессе выполнения приложения на основе информации о том, какие классы и методы вызываются наиболее часто.

На уровне Java-приложения основной причиной нарушения равномерности является управление потоками.

Паузы, связанные с работой сборщика мусора

Во время процесса сбора и освобождения памяти, более не требующейся приложению, сборщик мусора может приостановить работу всех прикладных потоков. Сборщики, работающие по этому принципу, часто обозначаются аббревиатурой STW (stop-the-world). Другие алгоритмы сборки могут выполняться параллельно с приложением, однако в любом случае ресурсы, требующиеся сборщику, будут недоступны прикладным потокам. Это является хорошо известной причиной задержек и неравномерности выполнения Java-приложений. Существует множество моделей сборки мусора (GC) со своими преимуществами и недостаткам, но задачу минимизации задержек лучше всего решают две их них: генеративные модели и модели реального времени.

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

В отличие от генеративного подхода, сборщики реального времени управляют своим запуском в целях сокращения длительности циклов (запускаясь в моменты бездействия приложения), либо стараются снизить влияние своей работы на выполнение приложения (освобождая память постепенно, в соответствии с их "контрактом" с приложением). Использование этих сборщиков позволяет оценить максимальную задержку, от которой может пострадать приложение при решении определенной задачи. Например, сборщик мусора в JVM продукта IBM® WebSphere® Real-Time разделяет циклы GC на короткие задачи (кванты GC), которые могут выполняться последовательно и с перерывами. Планирование выполнения каждого кванта практически не отражается на поведении приложения, поскольку это занимает менее 1 миллисекунды. Для снижения длительности до таких значений сборщик мусора должен планировать свое выполнение на основе соглашений о коэффициенте загруженности приложения (application utilization contract). Эти соглашения (или контракт) устанавливают, насколько часто GC позволяет прерывать выполнение приложения. Например, коэффициент загруженности по умолчанию равен 70%, т.е. GC может использовать лишь 3 мс на каждые 10 мс работы приложения, в то время как среднее время цикла GC в операционной системе реального времени составляет около 500 мс. (Более подробно сборщик мусора в IBM WebSphere Real Time описывается в статье Режим реального времени в Java, часть 4: сборка мусора в режиме реального времени).

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

Например, приложению, выполняющемуся в JVM IBM WebSphere Real-Time со стандартной степенью загрузки (70%) требуется больше динамической памяти, чем при выполнении в JVM с классическим сборщиком мусора, не поддерживающим контракты. Сборщики реального времени контролируют длительность циклов GC, поэтому увеличение объема кучи снижает частоту циклов, не повышая их продолжительность. Обычные сборщики мусора также вызываются реже при большом размере кучи (что в целом снижает их влияние на приложение), однако каждый цикл занимает больше времени, поскольку требуется освободить больший объем памяти.

JVM в IBM WebSphere Real Time позволяет варьировать размер кучи при помощи параметра -Xmx<размер>. Например, параметр -Xmx512m устанавливает объем, равный 512 МБ. Кроме того, вы можете изменять степень загрузки, например, параметр -Xgc:targetUtilization=80 задает ее равной 80%.

Паузы, связанные с загрузкой Java-классов

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

Каковы могут быть причины отложенной загрузки класса? Одной из них является тот факт, что некоторые участки кода приложения выполняются сравнительно редко. Например, условие в операторе if, показанном в листинге 1, может редко быть истинным (для краткости в этом листинге практически игнорируется обработка исключений).

Листинг 1. Пример редко выполняющегося условия, при котором загружается новый класс
Iterator<MyClass> cursor = list.iterator();
while (cursor.hasNext()) {
    MyClass o = cursor.next();
    if (o.getID() == 17) {
        NeverBeforeLoadedClass o2 = new NeverBeforeLoadedClass(o);
        // использование o2
    }
    else {
        // использование o
    }
}

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

Классы также могут загружаться, когда некоторые сервисы, например, рефлексия, используются внутри сторонних Java-библиотек. При этом классы рефлексии могут генерировать новые классы "на лету" и загружать их в JVM. Таким образом, активное использование механизма рефлексии в критических участках кода может привести к постоянной загрузке новых классов, что вызовет задержки в выполнении приложения. Для выявления подобных ситуаций можно использовать параметр -verbose:class  при запуске JVM. Как правило, лучший способ избежать загрузки новых классов заключается в исключении использования рефлексии для обращения к классам, методам и полям, заданным в виде строк, при выполнении критических операций. Механизм рефлексии при необходимости следует вызывать как можно раньше, сохраняя результаты для дальнейшего использования, чтобы классы не создавались динамически в неподходящие моменты времени.

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

Листинг 2. Управляемая загрузка списка классов
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
    String className = classIt.next();
    try {
        Class clazz = Class.forName(className);
        String n=clazz.getName();
    } catch (Exception e) {
    System.err.println("Could not load class: " + className);
    System.err.println(e);
}

В этом примере вызов метода clazz.getName() приводит к загрузке и инициализации класса. Для составления списка классов для предварительной загрузки необходимо проанализировать (вручную или автоматически), какие классы требуются вашему приложению в процессе его работы. Например, список классов можно найти в консольном выводе приложения, запущенного с опцией -verbose:class. Пример вывода приложения, выполняющегося внутри IBM WebSphere Real Time , приведен в листинге 3.

Листинг 3. Фрагмент консольного вывода JVM, запущенной с опцией -verbose:class
    ...
    class load: java/util/zip/ZipConstants
    class load: java/util/zip/ZipFile
    class load: java/util/jar/JarFile
    class load: sun/misc/JavaUtilJarAccess
    class load: java/util/jar/JavaUtilJarAccessImpl
    class load: java/util/zip/ZipEntry
    class load: java/util/jar/JarEntry
    class load: java/util/jar/JarFile$JarFileEntry
    class load: java/net/URLConnection
    class load: java/net/JarURLConnection
    class load: sun/net/www/protocol/jar/JarURLConnection
    ...

Сохранив список классов, которые были однажды использованы во время работы приложения и загрузив их в цикле, показанном в листинге 2, вы можете быть уверены, что в следующий раз классы будут инициализированы до начала выполнения полезного кода. Разумеется, в разные моменты времени приложение может выполняться по-разному, поэтому возможно, что не все классы будут предварительно инициализированы. Кроме того, если приложение все еще находится в стадии активной разработки, новые или существенно изменившиеся участки кода могут использовать классы вне этого списка (и наоборот, не все классы в списке могут все еще быть нужными приложению). К сожалению, поддержка таких списков является наиболее слабым местом данного подхода, основанного на предварительной загрузке классов. При его использовании необходимо учитывать, что формат вывода, полученного при помощи -verbose:class, не соответствует формату, требующемуся методу Class.forName(). В первом случае названия пакетов отделяются символом косой черты, а во втором — точками.

При создании приложений, для которых загрузка классов представляет собой проблему, вам могут помочь такие средства, как RATCAT (Real Time Class Analysis Tool) и Java-оптимизатор работы приложений в режиме реального времени от IBM (см. раздел Ресурсы). Эти средства позволяют автоматически формировать списки классов для предварительной загрузки при старте вашего приложения.

Паузы, связанные с JIT-компиляцией

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

Пример оптимизации JIT

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

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

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

Листинг 4. Предварительная JIT-компиляция методов
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
    String className = classIt.next();
    try {
        Class clazz = Class.forName(className);
        String n = clazz.name();
        java.lang.Compiler.compileClass(clazz);
    } catch (Exception e) {
        System.err.println("Could not load class: " + className);
        System.err.println(e);
    }
}
java.lang.Compiler.disable();  // необязательное действие

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

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

Влияние компиляции в машинных кодах на производительность может быть разным для разных приложений. Для того чтобы оценить, является ли JIT-компиляция проблемой, лучше всего включить режим детального вывода, включающий информацию о запусках JIT-компилятора. Например, такая возможность поддерживается JVM IBM WebSphere Real Time при помощи опции командной строки -Xjit:verbose.

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

Тем не менее, некоторые JVM предоставляют ряд возможностей, использование которых зависит от того, насколько паузы, связанные с работой JIT-компилятора, критичны для вашего приложения. JVM, созданные для использования в средах с поддержкой жесткого режима реального времени, как правило, предоставляют ряд дополнительных опций. Например, JVM BM WebSphere Real Time для Real Time Linux® поддерживает пять стратегий компиляции, так или иначе способных сократить длительность пауз:

  • JIT-компиляция по умолчанию, при которой компилятор выполняется в низкоуровневом потоке;
  • JIT-компиляция по умолчанию, при которой компилятор выполняется в низкоуровневом потоке и следует принципу предварительной компиляции (Ahead-of-time — AOT);
  • JIT-компиляция под управлением приложения с возможностью повторной компиляции кода;
  • JIT-компиляция под управлением приложения без возможности повторной компиляции кода;
  • Использование исключительно предкомпилированного (АОТ) кода.

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

Виртуальная машина IBM WebSphere Real Time для Real Time Linux предоставляет утилиту admincache, позволяющую создать разделяемый кэш классов, содержащий классы из набора JAR-файлов. При необходимости в нем также можно хранить предварительно скомпилированный код классов. Далее при помощи параметров командной строки можно указать JVM, что классы должны загружаться из кэша вместе с предварительно скомпилированным кодом. При этом, чтобы получить все преимущества АОТ-компиляции, достаточно простого цикла загрузки классов, подобного показанному в листинге 2. Ссылку на документацию по admincache можно найти в разделе Ресурсы.

Управление потоками

Управление потоками в многопоточном приложении, например, транзакционном сервере, имеет решающее значение для равномерного выполнения транзакций. Язык Java включает в себя модель выполнения потоков, в которой присутствует понятие приоритета, однако поведение потоков в реальной JVM во многом определяется ее реализацией, причем существует лишь несколько правил, на которые могут полагаться прикладные приложения. Например, потоки в Java могут иметь приоритеты от 1 до 10, однако их соответствие приоритетам потоков операционной системы остается на усмотрение JVM (в частности, ничто не запрещает JVM использовать потоки ОС с фиксированным приоритетом для Java-потоков с произвольным приоритетом). Кроме того, принципы планирования потоков также определяются реализацией JVM и, как правило, планирование осуществляется на основе временных интервалов, поэтому высокоприоритетные потоки используют CPU совместно с низкоприоритетными. Разделение ресурсов между потоками разных приоритетов может приводить к задержкам при выполнении высокоприоритетных потоков, когда планировщик решает предоставить CPU потокам с более низким приоритетом. При этом необходимо учитывать, что процессорное время потока зависит не только от его приоритета, но также от общего числа потоков в системе. Таким образом, время выполнения операций потоками с наивысшим приоритетом может колебаться в широких пределах, если только не ограничивать общее число активных потоков.

Другими словами, даже присвоение наивысшего приоритета (java.lang.Thread.MAX_PRIORITY) вашим Java-потокам не позволяет оградить их от влияния потоков с более низким приоритетом, выполняющихся в той же системе. К сожалению, потоковая модель в Java не предоставляет развитых средств управления потоками, поэтому вам остается только ограничивать число рабочих потоков (т.е. не выделять новых потоков и надеяться, что GC освободит неиспользуемые, либо управлять размером пула потоков), а также стараться минимизировать число действий, выполняемых низкоприоритетными потоками в процессе выполнения приложения. Даже JVM с поддержкой мягкого режима реального времени не сможет серьезно улучшить ситуацию, если она полагается на стандартную модель потоков в Java.

Однако использование JVM с жестким режимом реального времени, поддерживающей спецификацию режима реального времени для Java (Real Time Specification for Java – RTSJ), например JVM продукта IBM WebSphere Real Time для Real Time Linux V2.0 или Sun's RTS 2 способно кардинально оптимизировать поведение потоков. Помимо расширений спецификаций Java и JVM, RTSJ включает два новых типа потоков: RealtimeThread и NoHeapRealtimeThread, поведение которых определено гораздо строже по сравнению со стандартными потоками Java. Эти потоки поддерживают вытесняющее планирование на основе приоритета, при котором выполнение низкоприоритетных потоков может быть прервано, если процессор требуется потоку с более высоким приоритетом.

Большинство операционных систем реального времени выполняют вытеснение потоков за десятки микросекунд, что может негативно отразиться только на приложениях с крайне жесткими требованиями к быстродействию. Оба типа потоков, как правило, используют принцип планирования FIFO (first-in, first out) вместо кругового (round robin) принципа, применяемого в JVM, выполняющихся на обычных ОС. Одним из наиболее ярких отличий принципа FIFO от кругового принципа является тот факт, что, получив доступ к процессору, поток будет выполняться до тех пор, пока не будет заблокирован или не освободится процессор по собственной воле. Поскольку в этой модели процессорное время не разделяется между несколькими потоками (даже если они имеют равные приоритеты), продолжительность выполнения операций становится более предсказуемой. Кроме того, ОС не будет прерывать выполнение ваших потоков, если они не будут заблокированы вследствие синхронизации или операций ввода/вывода (IO). Однако на практике бывает крайне сложно полностью избежать синхронизации и IO, поэтому подобное идеальное выполнение потоков практически недостижимо. Тем не менее, планирование по принципу FIFO может быть исключительно полезно для приложений, в которых важно ограничить продолжительность задержек.

Спецификация RTSJ является своего рода набором возможностей, которые могут помочь вам в создании приложений реального времени. При этом вы можете использовать лишь некоторые из них либо полностью переписать приложение, чтобы достичь максимальной предсказуемости его выполнения. Как правило, не составляет труда изменить приложение таким образом, чтобы оно использовало потоки типа RealtimeThread. Более того, вы можете сделать это, даже не имея доступа к JVM реального времени на этапе компиляции, а лишь используя механизм рефлексии в Java.

Однако чтобы воспользоваться всеми преимуществами модели FIFO для снижения неравномерности, вам придется внести дополнительные изменения в ваше приложение. Различия между FIFO и круговым принципом планирования могут привести к зависанию некоторых Java-приложений. Например, если приложение использует метод Thread.yield(), чтобы позволить другим потокам выполняться на процессорном ядре (этот метод часто используется для проверки некоторого условия без использования процессора), это не приведет к желаемому результату, поскольку в модели FIFO вызов Thread.yield() не блокирует выполнение вызывающего потока. При этом текущий поток подлежит планированию и находится в начале очереди, поэтому он просто продолжит выполнение. Таким образом, метод, направленный на предоставление доступа к CPU во время ожидания выполнения условия, приведет к 100%-ной загрузке процессора текущим потоком. Более того, подобный сценарий является наилучшим из возможных. Если поток, который должен сделать условие истинным, имеет более низкий приоритет, он может никогда не получить доступ к процессору. Эта проблема возникает реже на современных многоядерных процессорах, однако она ясно показывает, что необходимо тщательно продумывать приоритеты потоков при использовании класса RealtimeThread. Безопаснее всего задать одинаковые приоритеты для всех потоков, а также исключить использование Thread.yield() и других спин-блокировок, которые могут привести к 100%-ной загрузке CPU. Разумеется, обеспечение нужного уровня качества обслуживания для вашего приложения может потребовать использования всех преимуществ класса RealtimeThread. Дополнительные советы по работе с классом RealtimeThread приведены в статье Режим реального времени в Java, часть 3: управление потоками и синхронизация).


Пример серверного приложения на Java

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

Листинг 5. Классы Server и TaskHandler, использующие Executors
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;

class Server {
    private ExecutorService threadPool;
    Server(int numThreads) {
        ThreadFactory theFactory = new ThreadFactory();
        this.threadPool = Executors.newFixedThreadPool(numThreads, theFactory);
    }

    public void start() {
        while (true) {
            // Основной цикл сервера: 
            // "найти задачу, создать экземпляр TaskHandler, выполнить задачу"
            TaskHandler task = new TaskHandler();
            this.threadPool.execute(task);
        }
        this.threadPool.shutdown();
    }

    public static void main(String[] args) {
        int serverThreads = Integer.parseInt(args[0]);
        Server theServer = new Server(serverThreads);
        theServer.start();
    }
}

class TaskHandler extends Runnable {
    public void run() {
        // Код выполнения задачи (task)
    }
}

Этот сервер создает произвольное число потоков, не превышающее максимальное значение, которые в данном примере задается через параметр командной строки. Каждый рабочий поток выполняет определенную задачу при помощи класса TaskHandler. Мы создадим собственную реализацию метода TaskHandler.run(), выполнение которого должно занимать фиксированное время. Таким образом, любые колебания продолжительности вызовов будут обусловлены неравномерным выполнением приложения в JVM, особенностями управления потоками или низкоуровневыми задержками. Класс TaskHandler  приведен в листинге 6.

Листинг 6. Класс TaskHandler, метод которого должен выполняться за постоянное время
import java.lang.Runnable;
class TaskHandler implements Runnable {
    static public int N=50000;
    static public int M=100;
    static long result=0L;
    
    // Каждая задача включает один и тот же набор действий
    public void run() {
        long dispatchTime = System.nanoTime();
        long x=0L;
        for (int j=0;j < M;j++) {
            for (int i=0;i < N;i++) {
                x = x + i;
            }
        }
        result = x;
        long endTime = System.nanoTime();
        Server.reportTiming(dispatchTime, endTime);
    }
}

Два цикла в теле метода run() вычисляют M (100) раз сумму первых N (50000) целых чисел. Параметры M и N подобраны таким образом, чтобы время выполнения метода занимало около 10 мс. Это означает, что операция может выйти за пределы кванта времени, выделяемого операционной системой (его продолжительность обычно составляет 10 мс). Кроме того, циклы написаны так, чтобы JIT-компилятор смог сгенерировать оптимальный код, выполнение которого будет максимально предсказуемо, в частности, он будет блокироваться между двумя вызовами System.nanoTime(), служащими для измерения длительности. Высокая предсказуемость кода необходима для того, чтобы продемонстрировать, что задержки и неравномерное выполнение могут быть вызваны внешними причинами.

Далее сделаем этот пример чуть более реалистичным, форсировав активность подсистемы сборки мусора во время выполнения методов класса TaskHandler. Класс GCStressThread  показан в листинге 7.

Листинг 7. Класс GCStressThread, постоянно генерирующий объекты, подлежащие уничтожению (мусор)
class GCStressThread extends Thread {
    HashMap<Integer,BinaryTree> map;
    volatile boolean stop = false;

    class BinaryTree {
        public BinaryTree left;
        public BinaryTree right;
        public Long value;
    }
    private void allocateSomeData(boolean useSleep) {
        try {
            for (int i=0;i < 125;i++) {
                if (useSleep)
                    Thread.sleep(100);
                // Создание полного двоичного дерева из 15 уровней
                BinaryTree newTree = createNewTree(15); 
                this.map.put(new Integer(i), newTree);
            }
        } catch (InterruptedException e) {
            stop = true;
        }
    }

    public void initialize() {
        this.map = new HashMap<Integer,BinaryTree>();
        allocateSomeData(false);
        System.out.println("\nFinished initializing\n");
    }

    public void run() {
        while (!stop) {
            allocateSomeData(true);
        }
    }
}

Класс GCStressThread содержит набор экземпляров BinaryTree в контейнере HashMap. Он использует один и тот же набор целочисленных ключей для сохранения новых объектов типа BinaryTree, которые представляют собой бинарные деревья, состоящие из 15 уровней (т.е. каждое дерево, хранящееся в HashMap, содержит 2^15 = 32768 вершин). HashMap постоянно содержит 125 деревьев, меняя одно из них на новое каждые 100 мс. Таким образом, структура данных представляет собой достаточно сложный набор изменяющихся объектов, а код генерирует мусор с постоянной скоростью. Вначале происходит инициализация HashMap методом initialize(), который добавляет в контейнер 125 деревьев, не делая пауз между выделениями памяти под каждое дерево. Класс GCStressThread  запускается непосредственно перед запуском сервера и работает параллельно с потоками, выполняющими операции TaskHandler.

Нам не понадобятся клиенты для данного приложения. Вместо этого мы просто создадим NUM_OPERATIONS == 10000 операций непосредственно внутри главного цикла сервера, находящегося в методе Server.start(). Код этого метода приведен в листинге 8.

Листинг 8. Диспетчеризация выполнения задач внутри сервера
public void start() {
    for (int m=0; m < NUM_OPERATIONS;m++) {
        TaskHandler task = new TaskHandler();
        threadPool.execute(task);
    }
    try {
       // В конце работы переменная serverShutdown устанавливается в true
        while (!serverShutdown) { 
            Thread.sleep(1000);
        }
    }
    catch (InterruptedException e) {
    }
}

Собрав статистику по продолжительности выполнения методов TaskHandler.run(), можно будет оценить, в какой степени неравномерность обусловлена причинами внутри JVM и дизайном приложения. Для этой цели мы использовали сервер IBM xServer e5440 с восемью процессорными ядрами и операционной системой реального времени Red Hat RHEL MRG. При этом был выключен режим гиперпоточности (hyperthreading). Этот режим может несколько улучшить пропускную способность системы, однако при этом производительность выполнения операций на физических ядрах может сильно отличаться от обычной, при которой используются виртуальные ядра. В наших экспериментах сервер запускался с шестью рабочими потоками на восьмиядерной машине (одно ядро было оставлено для основного потока сервера и еще одно – для потока GCStressorThread), в результате чего были получены показанные ниже результаты.

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 16582 ms
Throughput is 603 operations / second
Histogram of operation times:
9ms - 10ms      9942    99 %
10ms - 11ms     2       0 %
11ms - 12ms     32      0 %
30ms - 40ms     4       0 %
70ms - 80ms     1       0 %
200ms - 300ms   6       0 %
400ms - 500ms   6       0 %
500ms - 542ms   6       0 %

Как видите, практически все операции были выполнены в течение 10 мс, однако некоторые заняли более полусекунды (т.е. в 50 раз дольше). Это признак серьезной неравномерности. Далее вы увидите, как можно сгладить подобные эффекты, устранив задержки, связанные с загрузкой Java-классов, JIT-компиляцией, сборкой мусора и управлением потоками.

Вначале мы составим список классов, используемых приложением, запустив его с опцией -verbose:class. Полученный консольный вывод можно сохранить в файле, преобразовав его таким образом, чтобы каждая строка состояла из одного правильно отформатированного имени класса. Далее следует добавить метод preload() в класс Server, который будет загружать все классы, выполнять JIT-компиляцию всех методов, а затем деактивизировать JIT-компилятор (листинг 9).

Листинг 9. Предварительная загрузка и JIT-компиляция методов внутри сервера
private void preload(String classesFileName) {
    try {
        FileReader fReader = new FileReader(classesFileName);
        BufferedReader reader = new BufferedReader(fReader);
        String className = reader.readLine();
        while (className != null) {
            try {
                Class clazz = Class.forName(className);
                String n = clazz.getName();
                Compiler.compileClass(clazz);
            } catch (Exception e) {
            }
            className = reader.readLine();
        }
    } catch (Exception e) {
    }
    Compiler.disable();
}

Загрузка классов не является серьезной проблемой для нашего простого сервера, поскольку метод TaskHandler.run() достаточно прост. После его инициализации загрузка классов практически прекращается, о чем свидетельствуют результаты запуска с опцией -verbose:class. Основной выигрыш в производительности обусловлен компиляцией методов до запуска операций TaskHandler. Мы также могли выполнить так называемый "прогревочный" запуск сервера, однако эффективность такого подхода зависит от JVM, поскольку разные JIT-компиляторы используют разные эвристики для выбора методов для компиляции. Метод Compiler.compile()  предоставляет лучший контроль над компиляцией, но при этом, как было отмечено выше, следует ожидать снижения средней пропускной способности. При выполнении сервера с предварительными этапами загрузки и компиляции классов были получены следующие результаты:

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 20936 ms
Throughput is 477 operations / second
Histogram of operation times:
11ms - 12ms     9509    95 %
12ms - 13ms     478     4 %
13ms - 14ms     1       0 %
400ms - 500ms   6       0 %
500ms - 527ms   6       0 %

Обратите внимание, что хотя максимальная продолжительность выполнения операций практически не изменилась, гистограмма стала значительно короче, чем ранее. Немалая часть коротких задержек была обусловлена работой JIT-компилятора, поэтому их удалось устранить благодаря предварительной компиляции и последующей деактивизации компилятора. При этом интересно отметить, что характерное время выполнения несколько выросло (с 9–10 мс до 11–12 мс). Операции стали выполняться медленнее, поскольку качество кода, сгенерированного до запуска методов, как правило, ниже, чем качество ранее выполнявшегося кода. Это вполне ожидаемый эффект, так как одним из наиболее значимых преимуществ JIT-компиляции является способность выполнять анализ динамических показателей запущенного приложения в целях повышения его эффективности.

В последующих примерах мы продолжим использовать предварительную загрузку классов и JIT-компиляцию методов.

Класс GCStressThread  генерирует постоянно изменяющуюся структуру данных, поэтому, скорее всего, использование генеративного сборщика мусора не приведет к существенному сокращению пауз. Вместо этого мы провели эксперименты с GC реального времени, который входит в состав IBM WebSphere Real Time для операционной системы Real Time Linux V2.0 SR1. Вначале результаты выглядели обескураживающе, несмотря на опцию -Xgcthreads8, благодаря которой сборщик может использовать восемь потоков вместо одного (однопотоковый сборщик мусора, как правило, не успевает за выделением памяти в приложении):

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
10000 operations in 72024 ms
Throughput is 138 operations / second
Histogram of operation times:
11ms - 12ms     82      0 %
12ms - 13ms     250     2 %
13ms - 14ms     19      0 %
14ms - 15ms     50      0 %
15ms - 16ms     339     3 %
16ms - 17ms     889     8 %
17ms - 18ms     730     7 %
18ms - 19ms     411     4 %
19ms - 20ms     287     2 %
20ms - 30ms     1051    10 %
30ms - 40ms     504     5 %
40ms - 50ms     846     8 %
50ms - 60ms     1168    11 %
60ms - 70ms     1434    14 %
70ms - 80ms     980     9 %
80ms - 90ms     349     3 %
90ms - 100ms    28      0 %
100ms - 112ms   7       0 %

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

Затем мы заменили обычные Java-потоки на классы RealtimeThread. Для этого был создан класс-фабрика RealtimeThreadFactory, экземпляр которого можно передавать классу Executors, как показано в листинге 10.

Листинг 10. Класс RealtimeThreadFactory
import java.util.concurrent.ThreadFactory;
import javax.realtime.PriorityScheduler;
import javax.realtime.RealtimeThread;
import javax.realtime.Scheduler;
import javax.realtime.PriorityParameters;

class RealtimeThreadFactory implements ThreadFactory {
    public Thread newThread(Runnable r) {
        RealtimeThread rtThread = new RealtimeThread(null, null, null, null, null, r);

        // При необходимости можно изменить параметры планирования
        PriorityParameters pp = (PriorityParameters) rtThread.getSchedulingParameters();
        PriorityScheduler scheduler = PriorityScheduler.instance();
        pp.setPriority(scheduler.getMaxPriority());

        return rtThread;
    }
}

Передав экземпляр класса RealtimeThreadFactory  в метод Executors.newFixedThreadPool(), мы добились того, что рабочие потоки стали создаваться в виде экземпляров RealtimeThread, планирование которых осуществляется по принципу FIFO в порядке убывания приоритета. Эти потоки по-прежнему могут быть прерваны сборщиком мусора, однако им не могут помешать потоки с более низким приоритетом:

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
Handled 10000 operations in 27975 ms
Throughput is 357 operations / second
Histogram of operation times:
11ms - 12ms     159     1 %
12ms - 13ms     61      0 %
13ms - 14ms     17      0 %
14ms - 15ms     63      0 %
15ms - 16ms     1613    16 %
16ms - 17ms     4249    42 %
17ms - 18ms     2862    28 %
18ms - 19ms     975     9 %
19ms - 20ms     1       0 %

В результате не только существенно сократилось максимальное время выполнения операции (до 19 мс), но и значительно повысилась общая пропускная способность сервера (до 357 операций в секунду). Таким образом, все описанные выше действия позволили в несколько раз сгладить неравномерность работы сервера за счет достаточно резкого снижения пропускной способности. Продолжительность циклов работы сборщика мусора снизилась с 10 мс до 3 мс, что объясняет, почему операции, которые ранее занимали 12 мс, теперь занимают 4–5 мс, а набор операций может быть выполнен всего за 16–17 мс. Ожидалось, что потери пропускной способности будут несколько ниже, поскольку JVM реального времени не только использует GC реального времени от Metronome, но и включает модифицированные блокировки, позволяющие избежать инверсии приоритетов, которая является серьезной проблемой при планировании потоков по системе FIFO (см. статью Режим реального времени в Java, часть 1: создание систем реального времени на Java). К сожалению, синхронизация между главным потоком и рабочими потоками приводит к существенным накладным расходам, которые отражаются на пропускной способности сервера, но не учитываются при измерении длительности операций, а, следовательно, не отражаются на гистограмме результатов.

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


Заключение

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

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

Ресурсы

Научиться

Получить продукты и технологии

Обсудить

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


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

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Мобильные приложения
ArticleID=521251
ArticleTitle=Разработка приложений для Java: Часть 2. Повышение качества сервиса, предоставляемого приложением
publish-date=09132010