Оптимизация доступа к загрузчикам классов, определенных пользователем JVM. Часть 1

Аннотация

В статье рассматривается проблема оптимизации доступа к загрузчикам классов, определяемых пользователем JVM, которая стала «бутылочным горлышком» в архитектуре HotSpot JVM и Java Class Library на современных платформах Intel.

Ниже приведено описание обнаруженной проблемы в инфраструктуре HotSpot JVM и Java Class Library, рассмотрены пути ее решения, а также формализована корректность предлагаемого подхода.

Достигнутые в рамках проекта результаты, описаны в статье Скачкова Данила Оптимизация доступа к загрузчикам классов, определенных пользователем JVM. Часть 2.

Об авторе

Костюков Владимир Владимирович, студент 5-го курса Алтайского Государственного Технического Университета им. И.И. Ползунова, специальности «Программное обеспечение вычислительной техники», факультета Информационных технологий.

Проходил стажировку в городе Новосибирске, под руководством менторов Николая Сидельникова и Алексея Шпиталева и менеджера Вячеслава Шакина.

Введение

Язык Java позволяет пользователю переопределять загрузчики классов, что является аспектом одной из основных особенностей этого языка – динамической загрузки классов. Эта возможность достаточно популярна у программистов и, в частности, занимает существенное место в популярном тесте производительности SPECjvm2008. Однако инфраструктура HotSpot JVM, предоставляющая доступ к пользовательским загрузчикам классов, оказалась не готова к современным архитектурам процессоров, когда количество ядер в одиночном сервере может достигать 64-х и более. Она стала «бутылочным горлышком», существенно ограничивая производительность приложений, интенсивно использующих этот механизм. Задачей является оптимизация доступа к пользовательским загрузчикам классов и как следствие радикальное увеличение произодительности теста SPECjvm2008.serial на платформе Nehalem-EX.

Успешная реализация проекта обеспечит повышение производительности процессоров Intel в популярном тесте SPECjvm2008 и поможет упрочить лидерство Intel в сегменте серверных платформ.

1 Сериализация в Java

Существует два способа воспользоваться стандартным механизмом сериализации в Java - реализовать интерфейс java.io.Serializable или java.io.Externalizable. Разница между данными вариантами использования заключается в том, что при реализации интерфейса java.io.Externalizable, программисту предоставляется возможности определять дополнительное/специфичное поведение для сериализумого или десериализуемого объекта, путем определения методов public void writeExternal(java.io.ObjectOutputStream) и public void readExternal(java.io.ObjectInputStream) соответственно. Наделить сериализуемый или десериализуемый объект дополнительными обязанностями можно и с помощью интерфейса java.io.Serializable, расширив класс объекта методами private void writeObject(java.io.ObjectOutputStream s) и private void readObject(java.io.ObjectInputStream s). В таком случае, вместо штатной сериализации и десереализации через рефлексию у объекта будет вызываться метод writeObject(...) и readObject(...) соответственно.

Аналогичным образом сериализуются и десериализуются объекты java.io.Externalizable, за небольшим исключением. Вместо методов writeObject(...)/readObject(...) вызываются методы writeExternal(...)/readExternal(...).

Примечательно, что разработчики Java SE предпочитают третий вариант (реализация java.io.Serializable и определение методов writeObject()/readObject()) наделения объекта дополнительными обязанностями при сериализации/десериализации. Большинство контейнерных классов (java.util.ArrayList, java.util.Hashtable, java.util.HashMap и т.д.) реализуют данный сценарий.

Кроме того, в Java существуют сущности, cериализация которых поддерживается "из коробки". Это примитивные типы и их обертки в java.lang., строки (java.lang.String), перечисления (Enum), массивы и классы контейнеры в java.util. Во всех остальных случаях необходимо реализовывать один из перечисленных выше сценариев для добавления поддержки сериализации.

Использование интерфейса java.io.Externalizable оправдано существенным приростом производительности по отношению к стандартному механизму использующему рефлексию. Это обусловлено особенностями реализации рефлексии в Java с помощью вызовов в JVM посредством JNI.

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

Рефлексия в стандартном механизме сериализации Java играет определяющую роль. Она используется не только для получения списка полей объекта и их значений но и для делегирования обязанностей по управлению процессом сериализации/десериализации непосредственно сериализующемуся/десериализующемуся объекту. Это достигается за счет вызова скрытых методов writeObject(…) и readObject(…) определенных в классе объекта.

2 Семантика вызова JVM_LatestUserDefinedLoader()

Процесс десериализации объекта в Java можно условно разбить на четыре этапа (рисунок 1):

Рисунок 1

 

Рисунок 1



 

  1. чтение дескриптора класса объекта;
  2. загрузка класса десериализованного объекта в JRE;
  3. создание нового объекта в JRE;
  4. инициализация полей (кроме static и transparent) объекта значениями из потока.

На втором этапе десериализации происходит выбор загрузчика классов, которым следует загружать классы десериализованных объектов. Согласно спецификациям сериализации в Java, это должен быть загрузчик класса, который инициировал процесс десериализации. Проще говоря, используется загрузчик класса, экземпляр которого вызвал метод readObject() (экземпляра java.io.ObjectInputStream).Нативный метод latestUserDefinedLoader() класса java.io.ObjectInputStream и его реализация в JVM JVM_LatestUserDefinedLoader() возвращает последний ненулевой загрузчик классов на стеке исполнения текущего потока. Ненулевым в данном контексте будет являться любой загрузчик исключая базовый (bootstap).Известно понятие стека исполнения для каждого потока в JVM, который содержит так называемые фреймы, отражающие последовательность вызовов методов в процессе иcполнения. Стек исполнения представляется как физическими так и виртуальными фреймами. Физические соответствуют физическим вызовам в JVM (инструкции invokestatic), виртуальные - вызовам методов на уровне исходного кода Java. Один физический фрейм, как правило соответствует нескольким виртуальным. Это обусловлено последствиями инлайнинга кода, проводимого JVM.

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

// Return the first non-null class loader up the execution stack, or null
// if only code from the null class loader is on the stack.

JVM_ENTRY(jobject, JVM_LatestUserDefinedLoader(JNIEnv *env))
  for (vframeStream vfst(thread); !vfst.at_end(); vfst.next()) {
    // UseNewReflection
    vfst.skip_reflection_related_frames(); // Only needed for 1.4 reflection
    klassOop holder = vfst.method()->method_holder();
    oop loader = instanceKlass::cast(holder)->class_loader();
    if (loader != NULL) {
      return JNIHandles::make_local(env, loader);
    }
  }
  return NULL;
JVM_END

3 Разработка путей решения проблемы

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

Первая проблема решается с помощью кеширования загрузчика в JRE. Вторая - переносом точки вызова метода JVM_LatestUserDefinedLoader() в метод readObject().

4 Решение

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

 

Рисунок 2

Рисунок 2



Особенно это способствует повышению производительности при десериализации составных объектов. Таким образом, при десериализации списка с N элементами, поиск нужного загрузчика будет выполнен всего один раз, против N+1 раз в стандартной реализации.

5 Корректность решения

Из описания метода latestUserDefinedLoader() видно, что все его вызовы будут локальными относительно класса java.io.ObjectInputStream. На самом деле, существует еще один класс в Java API, который неявно вызывает нативный метод при помощи рефлексии. Влияние патча на данный вызов рассматриваться не будет, т.к. патч не вносит изменений в реализацию метода в JVM.

Таким образом существуют три открытых точки входа в метод latestUserDefinedLoader(). Это методы readObject(), readUnshared() и defaultReadObject(). Причем, контракт использования метода defaultReadObject() разрешает его вызов исключительно из метода readObject(). Такое поведение объясняется рассмотренным ранее механизмом делегирования обязанностей по сериализации/десериализации внешним объектам. Для проверки корректности вызова defaultReadObject() используется так называемый контекст обратного вызова (CallbackContext).

Рисунок 3

Рисунок 3

Протокол сериализации на платформе Java описан в терминах КС-грамматик. Класс java.io.ObjectInputStream в свою очередь представляет собой последовательно-рекурсивный анализатор для разбора бинарных файлов согласно правилам грамматики. Это приводит к тому, что в момент загрузки класса десериализуемого объекта, а следовательно и в момент вызова latestUserDefinedLoader() на стеке исполнения находится достаточно большое количество виртуальных фреймов, соответствующих локальным вызовам внутри класса java.io.ObjectInputStream (фреймы F2, …, Fn-1 на рисунке 2). Причем, фреймы поражденные в результате локальных вызовов будут игнорироваться кодом метода JVM_LatestUserDefinedLoader(), так как класс, к которому они принадлежат был загружен базовым загрузчиком, а следовательно вызов class_loader() для них всегда будет возвращать NULL. С другой стороны в момент вызова метода latestUserDefinedLoader() на стеке исполнения присутствуют не только локальные виртуальные фреймы. На стеке так же расположены виртуальные фреймы поражденные в результате рефлексивных вызовов методов readObject(java.io.ObjectInputStream is) у десериализумых объектов. Однако, семантика метода JVM_LatestUserDefinedLoader() пропускает подобные фреймы в основном цикле.

Кроме того, класс java.io.ObjectInputStream разработан для single-threaded использования (not synchronized class), поэтому проблема потокобезопасности для кешируемого загрузчика, в данном случае не актуальна. Однако, можно предположить вариант последовательного использования одного объекта ObjectInputStream в двух разных потоках. Очевидно, что в таком случае, кеширование пройдет корректно, так как каждый поток будет заново вызывать метод readObject() а следовательно и инициировать кеширование.

Следует так же рассмотреть возможность разработки thread-safe классов-оберток для класса ObjectInputStream. С одной стороны, это кажется возможным. Однако, модель многопоточного программирования в Java а также иерархия классов пакета java.io.* делают это практически невозможным. Объясняется это тем, в момент инстанциирования нового thread-safe потока и передачи его конструктору в качестве параметра базового потока (java.io.InputStream), возможно изменение последнего, в виду его не ориентированности на многопоточное исполнение.

Таким образом, с небольшим замечанием, можно утверждать, что рассмотренный выше вариант патча корректен для любого варианта использования. Суть отмеченного замечания заключается в следующем: вызов метода readObject() не всегда приводит к вызову метода resolveClass(), а следовательно и к вызову latestUserDefinedLoader(). Это происходит при десериализации большого числа одинаковых объектов. Согласно протоколу сериализации, перед каждым сериализованным объектом должен храниться дескриптор его класса. В платформе Java реализован механизм контроля ссылочной целостности, о котором говорилось выше. На самом деле, этот механизм позволяет не только контролировать ссылочную целостность между группами объектов, но и исключить дублирование одинаковых дескрипторов классов для объектов, путем ссылания на них с помощью 4-х байтовых ссылок, каждая из которых соответствует уже существующему в JRE объекту из хеш-таблицы указателей. Благодаря такой реализации уменьшаются размеры сериализованных данных, и увеличивается скорость сериализации/десериализации.

6 Diff правки Java Class Library

@@ -250,6 +250,12 @@
+     /**
+     * Variable for caching latest user defined class loader in moment 
+     * when readObject() method is calling.
+     */
+    private ClassLoader cachedUserDefinedLoader;
+    
+    /**
      * Creates an ObjectInputStream that reads from the specified InputStream.
      * A serialization stream header is read from the stream and verified.
      * This constructor will block until the corresponding ObjectOutputStream
@@ -341,6 +347,11 @@
     public final Object readObject() 
 	throws IOException, ClassNotFoundException
     {
+    // caching caller class loader if and only if is non-recursive calling
+	if (curContext == null) {
+		cachedUserDefinedLoader = latestUserDefinedLoader();
+	}
+    	
 	if (enableOverride) {
 	    return readObjectOverride();
 	}
@@ -435,6 +446,10 @@
      * @since   1.4
      */
     public Object readUnshared() throws IOException, ClassNotFoundException {
+    // caching caller class loader if and only if is non-recursive calling
+    if (curContext == null) {
+    	cachedUserDefinedLoader = latestUserDefinedLoader();
+	}
 	// if nested read, passHandle contains handle of enclosing object
 	int outerHandle = passHandle;
 	try {
@@ -601,7 +616,10 @@
     {
 	String name = desc.getName();
 	try {
-	  return Class.forName(name, false, latestUserDefinedLoader());
+	  return Class.forName(name, false, cachedUserDefinedLoader == null 
+				? (cachedUserDefinedLoader = latestUserDefinedLoader()) 
+						: cachedUserDefinedLoader);
 	} catch (ClassNotFoundException ex) {
 	    Class cl = (Class) primClasses.get(name);
 	    if (cl != null) {
@@ -666,14 +684,18 @@
     protected Class<?> resolveProxyClass(String[] interfaces)
 	throws IOException, ClassNotFoundException
     {
-	ClassLoader latestLoader = latestUserDefinedLoader();
	ClassLoader nonPublicLoader = null;
 	boolean hasNonPublicInterface = false;
 
 	// define proxy in class loader of non-public interface(s), if any
 	Class[] classObjs = new Class[interfaces.length];
 	for (int i = 0; i < interfaces.length; i++) {
-	    Class cl = Class.forName(interfaces[i], false, latestLoader);
+	    Class cl = Class.forName(interfaces[i], false, 
+	    		cachedUserDefinedLoader == null 
+	    		? (cachedUserDefinedLoader = latestUserDefinedLoader()) 
+	    				: cachedUserDefinedLoader);
 	    if ((cl.getModifiers() & Modifier.PUBLIC) == 0) {
 		if (hasNonPublicInterface) {
 		    if (nonPublicLoader != cl.getClassLoader()) {
@@ -689,7 +711,7 @@
 	}
 	try {
 	    return Proxy.getProxyClass(
-		hasNonPublicInterface ? nonPublicLoader : latestLoader,
+		hasNonPublicInterface ? nonPublicLoader : cachedUserDefinedLoader,
 		classObjs);
 	} catch (IllegalArgumentException e) {
 	    throw new ClassNotFoundException(null, e);

Итоги

В рамках «Летней школы Intel 2010» был реализован патч класса java.io.ObjectInputStream, ускоряющий процесс сериализации на тесте SPECjvm2008.serial в среднем на 40 процентов на современных платформах Intel.

Кроме того, детальное исследование протокола сериализации в Java, позволило сделать вывод о неготовности текущей реализации удовлетворения всех предъявляемых к ней требований. В связи с этим было разработано решение, которое выведет механизм сериализации в Java на новый, более качественный уровень, в котором, проблема выбора загрузчика будет решаться в рамках протокола сериализации. Такой подход обеспечит приложения большей гибкостью, а разработчика – уверенностью в правильности своих действий. Это особенно актуально сейчас, когда динамическая загрузка кода является основой составляющей целого сектора программных систем – серверов приложений. Работа по формализации найденной проблемы была проделана. В дальнейшем планируется представить эти материалы в Oracle.

Неформальная часть или «Два месяца с Intel»

Что такое два месяца в жизни человека? «Ничего» - скажут многие. Но не интерны Летней Школы. Эти два месяца, которые я провел в Интел, просто перевернули мою жизнь: смена образа жизни, обстановки, круга общения, настолько повлияли на меня, что сейчас все по-другому.

Работа, которой я занимался в рамках школы, была действительно мне по нраву. Я даже не ожидал, что это будет настолько интересно. Полностью погрузившись в задачу, я читал, изучал, программировал, радовался удачам и неудачам, пил кофе, чистил обувь, ел шоколадки. Все это я делал с невероятным энтузиазмом. Казалось, что именно о такой жизни я и мечтал. Действительно, я ходил «на праздник – как на работу». Так пролетели два месяца. Два месяца работы и отдыха, Джавы и футбола, Linux’a и гитары, Vim’a и воздушного змея. Сейчас, постепенно приходя в себя, я могу оценить вклад Летней Школы в мое миропонимание.

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

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

Кроме того, сейчас у меня большое количество планов на будущее. Есть тьма дел, которые я обязательно должен сделать. В рамках стажировки я узнал много новых технических подробностей о Java, которые натолкнули меня на новые дополнительные исследования. У меня уже есть несколько отличных идей, которые я планирую реализовать. Возможно, это будут просто публикации, статьи на технических ресурсах, возможно - новый RFE в OpenJDK/Oracle JDK.

Еще, я понял три простые вещи, которые сейчас помогают мне учиться:
1) Нет ничего невозможного;
2) Учиться в Университете очень легко и приятно;
3) Навязать свое мнение преподавателю намного проще, чем бывалому инженеру из Intel.

И в заключение, хотелось вернуться к формальной части. Работа над проектом была закончена на 100%. Мы выполнили и перевыполнили все планы. Получили большое количество положительных отзывов, как от менторов, так и от внешних, по отношению к проекту инженеров из Intel. Я безмерно этому рад, т.к. чувствую, что отплатил Intel за самое лучше лето. Более того, благодаря стажировке и успешной сдаче проекта, через пару недель я снова вернусь в Intel, уже в качестве постоянного интерна. Надеюсь, энтузиазма у меня хватит надолго, не смотря на разницу в 220 км между работой и учебой, но сейчас я – «голодный и безрассудный»1 и все это мне нравится :)

1 – Цитата «Оставайтесь голодными. Оставайтесь безрассудными», Стив Джобс.

Pour de plus amples informations sur les optimisations de compilation, consultez notre Avertissement concernant les optimisations.