Содержание


Модификация байт-кода Java VM

Часть 1. Основные принципы модификации байт-кода и обзор библиотеки ASM

Comments

Серия контента:

Этот контент является частью # из серии # статей: Модификация байт-кода Java VM

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Модификация байт-кода Java VM

Следите за выходом новых статей этой серии.

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

1. Основные принципы модификации байт-кода

Важнейшим принципом Java-платформы является трансляция исходного кода Java в байт-код, выполняемый на виртуальной машине JVM. Эта схема позволяет программистам писать собственные загрузчики классов (classloader), которые могут во время выполнения подменить системный загрузчик классов (system classloader) и взять на себя динамическую загрузку class-файлов и передачу содержащегося в них байт-кода в JVM. Благодаря этому можно получить исключительную возможность "на лету" изменять байт-код загружаемых классов еще до того, как начнётся их действительное выполнение. Поведение и конфигурацию таких загрузчиков классов можно настраивать для каждого сеанса выполнения, за счет чего исчезает необходимость вносить изменения в исходные class-файлы. Таким образом, основная задача инструментов для модификации байт-кода – это анализ и динамическое создание или изменение class-файлов Java.

1.1. Структура class-файла

Для лучшего понимания процесса модификации байт-кода необходимо рассмотреть формат class-файла. В упрощённом виде структуру class-файла можно описать следующим образом:

  • заголовок (header), располагающийся в начале файла и включающий "магическое число" 0xCAFEBABE и номер версии;
  • блок констант (constant pool), который можно считать аналогом text-сегмента в выполняемом файле;
  • права доступа (access rights);
  • список интерфейсов, реализуемых данным классом;
  • список полей класса;
  • список методов класса;
  • атрибуты класса.

Во время исполнения в JVM имена классов, полей и методов представляются в виде строковых констант, поэтому блок констант занимает большую часть class-файла – от 1/2 до 2/3 его размера.

1.2. Трансляция исходного кода Java в байт-код JVM

Предположим, что в исходном коде имеется следующая строка:

System.out.println( "Начало передачи данных..." );

Тогда в байт-коде будут присутствовать следующие команды ассемблера JVM:

getstatic	 	java.lang.System.out Ljava/io/PrintStream;
ldc 			"Начало передачи данных..."
invokevirtual	java.io.PrintStream.println (Ljava/lang/String;)V

Команда getstatic на первой строке помещает объект класса java.io.PrintStream (значение поля out класса java.lang.System) в стек операндов. На второй строке команда ldc (LoaD Constant) передаёт ссылку на указанную строку в тот же стек. Последняя инструкция вызывает метод println, передавая ему в качестве параметров оба значения из стека операндов.

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

  • ссылки на классы, поля и методы;
  • ссылки на строки;
  • ссылки на числовые значения (типов int, long, float и double).

1.3. Команды ассемблера JVM

При вызове любого метода интерпретатором JVM создаётся локальный стек фиксированного размера (так называемый «стек метода»). Размер этого стека устанавливается компилятором при компиляции исходного кода Java в байт-код.

Набор команд для использования в байт-коде (так называемый ассемблер JVM) включает более 200 операций с уникальными кодами, которые можно сгруппировать следующим образом:

Команды для операций со стеком. С помощью команды ldc в стек можно загружать содержимое блока констант. Также существуют специализированные команды со встроенными операндами, например, iconst_0 (поместить в стек константу «номер нуль») или bipush (поместить в стек значение байта).

Команды для арифметических действий. Эти команды различаются по типу операндов. Команда iadd предназначена для сложения двух целочисленных операндов и записи результата в стек. К целочисленным относятся типы int, short, char, byte, boolean, и поэтому коды команд для них начинаются с символа i.

Команды для управления ходом программы. К подобным командам относятся: переход goto, ветвление if_icmpeq и связка из команд jsr (переход в подпрограмму) и ret (возврат из подпрограммы). Для генерации исключительных ситуаций (exceptions) используется команда athrow.

Команды для сохранения и загрузки локальных переменных. Для простых типов данных используются команды istore и iload с уже упоминавшемся префиксом i. Для записи значений в массив имеется специальная группа команд – iastore для целочисленных и т.д.

Команда для доступа к полям класса. Согласно стандартам Java для этого используется классическая пара из getter и setter-методов - команд getfield и putfield. Для доступа к статическим полям используются команды getstatic и putstatic.

Команды для вызова методов. Статические методы вызываются напрямую командой invokestatic или посредством виртуального связывания командой invokevirtual. Методы родительского класса и закрытые (private) методы вызываются с помощью команды invokespecial, а для интерфейсов существует команда invokeinterface.

Команды для создания объектов. Команда new позволяет создать объект и выделяет для него необходимый фрагмент памяти. Для массивов простых типов применяется команда newarray, для массивов ссылок – anewarray или multinewarray.

Команды для преобразования и проверки типов. Для преобразования float-значения в int-значение используется команда f2i, аналогичные команды существуют для преобразований между другими простыми типами данных (l2d для перехода от long к double и т.д.). Корректность преобразования проверяется с помощью команд checkcast и instanceof.

Большинство команд имеет постоянную длину. К исключениям относятся команды lookupswitch и tableswitch, необходимые для реализации Java-конструкции switch() {case ...}. Очевидно, что количество вариантов для сравнения может быть различным, поэтому длина соответствующих команд в байт-коде меняется для каждого конкретного случая.

1.4. Типы данных

Строгая типизация данных, используемая в языке Java, требует хранения информации о типах локальных переменных, полей, входных и выходных параметров методов в так называемых сигнатурах (signatures). Сигнатуры – это строки, записанные в специальном формате и хранящиеся в блоке констант. Если метод main определён, как показано ниже:

public static void main( String[] argv )

то ему будет соответствовать следующая сигнатура:

([java/lang/String;)V

В круглых скобках указывается тип входного параметра. Квадратная открывающая скобка в самом начале сигнатуры говорит о том, что параметр является массивом. Классы (в данном случае класс String) обозначаются строками, в которых разделителем служит символ /. Если аргумент принадлежит к простым типам, то его обозначение состоит из одного символа: I – целое число, F – число с плавающей точкой и т.д. После закрывающей круглой скобки следует тип выходного параметра (в данном случае V), что соответствует void.

1.5. Методы

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

2. Библиотека ASM

ASM – это один из инструментов, предназначенных для работы с байт-кодом, как непосредственно во время работы JVM, так и в offline-режиме. Загрузить всё необходимое для работы с библиотекой ASM можно с сайта http://asm.objectweb.org/ или из репозиториев любого дистрибутива LINUX.

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

2.1. Краткий обзор библиотеки ASM

В состав библиотеки ASM входят два прикладных программных интерфейса (API) для работы с байт-кодом, хранящимся в скомпилированных классах:

  • Core API предлагает модель представления класса на основе событий;
  • Tree API – объектное представление класса в виде дерева.

Событийная модель реализована в виде последовательности событий, каждое из которых соответствует элементу класса: заголовку, полю, объявлению метода, команде ассемблера JVM и т. д. Таким образом, определяется набор возможных событий и порядок их наступления. Core API предоставляет синтаксический анализатор (parser) для class-файлов, который генерирует по одному событию для каждого элемента анализируемого класса, и обработчик (writer), который генерирует классы на основе последовательности таких событий.

В объектном представлении класс представлен в форме дерева объектов, каждый из которых соответствует определённому элементу данного класса: сам класс, поле, метод, команда ассемблера JVM и т. д. В свою очередь каждый объект содержит ссылки на входящие в его состав объекты. Возможности Tree API основываются на функциональности Core API. Поэтому в Tree API есть возможность трансформации последовательности событий, описывающих класс, в дерево объектов, изображающее этот же класс, и обратно – преобразование дерева объектов в последовательность соответствующих событий.

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

2.2. Схема использования библиотеки ASM

Приложения, которые хотят использовать возможности библиотеки ASM, должны строго следовать определенному набору правил. Так, при использовании Core API в программе должны быть следующие компоненты:

  • синтаксический анализатор для Java-классов (объект типа parser);
  • компонент, генерирующий события (объект типа event producer), может быть совмещен с анализатором;
  • компонент, обрабатывающий события (чаще всего это объект типа writer);
  • различные фильтры для событий (adapter).

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

У Tree API есть своя особенность: связи между компонентами, обрабатывающими дерево объектов, жёстко определяют порядок внесения изменений и дополнений. Но и в этом случае из простых компонентов можно сформировать весьма сложную архитектурную схему.

2.3. Структура библиотеки ASM

В библиотеку ASM входят следующие пакеты:

  • пакеты org.objectweb.asm и org.objectweb.asm.sinature, находящиеся в файле asm.jar, содержат основные классы и интерфейсы Core API, включая компоненты для анализа (reader) и генерации (writer) байт-кода;
  • в пакете org.objectweb.asm.util из файла asm-util.jar представлено множество классов на основе Core API, которые можно использовать для разработки и отладки Java-программ;
  • в файле asm-commons.jar хранится пакет org.objectweb.asm.commons, предлагающий уже готовые решения для внесения различных изменений в Java-классы с помощью Core API;
  • пакет org.objectweb.asm.tree в файле asm-tree.jar содержит все, что относится к Tree API, а также инструменты, для перехода от модели, управляемой событиями, к древовидному представлению;
  • подпакет org.objectweb.asm.tree.analysis, помещенный в отдельный файл asm-analysis.jar, предоставляет полный комплекс инструментов на основе Tree API для статического анализа Java-классов.

2.4. Иерархия классов Core API

Краеугольным камнем Core API является интерфейс ClassVisitor, каждый метод которого соответствует определенному элементу структуры Java-класса (см. раздел «Структура class-файла») с соответствующим типом. К простым элементам можно обращаться с помощью методов, которые принимают на вход только содержимое элемента и ничего не возвращают обратно. Если же элемент сам является составным, то для работы с ним используется метод, который возвращает специальный объект, и уже методы этого объекта используются для манипулирования элементом. Например, метод visitAnnotation возвращает объект типа AnnotationVisitor, метод visitField возвращает объект типа FieldVisitor, а метод visitMethod – объект типа MethodVisitor. Подробное описание интерфейса ClassVisitor и связанных с ним компонентов можно найти в документации JavaDoc библиотеки ASM.

Три следующих класса также являются ключевыми компонентами библиотеки ASM:

  • ClassReader – генератор событий; принимает на вход скомпилированный Java-класс в виде байтового массива, анализирует его структуру и вызывает методы для обработки ее отдельных элементов;
  • ClassWriter – класс для обработки событий, реализующий интерфейс ClassVisitor, способный генерировать Java-классы в формате байт-кода и записывать их в байтовый массив. При необходимости этот массив можно извлечь с помощью метода toByteArray;
  • ClassAdapter – класс, реализующий интерфейс ClassVisitor и использующийся для фильтрации событий. Фильтр может как передать события для обработки другому объекту типа ClassVisitor, так и игнорировать их.

2.5. Внесение изменений в структуру класса

Подробное описание всех классов библиотеки ASM можно найти в документации JavaDoс на сайте http://asm.ow2.org/. С практической точки зрения наибольший интерес представляет «конвейер» для манипуляции байт-кодом, который можно организовать с помощью перечисленных выше классов, как показано в листинге 1:

Листинг 1. Конвейерная обработка байт-кода.
// байт-код Java-класса каким-то способом загружается в байтовый массив b1
byte b1[] = …
// создается объект-обработчик cw для обработки событий
ClassWriter cw = new ClassWriter();
// создается объект-фильтр ca для трансляции событий обработчику
ClassAdapter ca = new ClassAdapter( cw );
// создается объект-анализатор cr для анализа байт-кода и генерации событий
ClassReader cr = new ClassReader( b1 );
// методом accept запускается синтаксический анализ байт-кода,
// с последующей генерацией событий и вызовом соответствующих методов фильтра
cr.accept( ca, 0 );	
// теперь в массиве b2 содержится точно такой же байт-код, как и массиве b1
byte[] b2 = cw.toByteArray();

В листинге 1 байт-код без изменений передаётся по цепочке Reader-Adapter-Writer. Никаких преимуществ подобная обработка не приносит, однако есть множество проблем, возникающих при программировании на Java, когда подобный «конвейер» может оказаться действительно полезным. Чаще всего возникает ситуация, когда в существующий класс необходимо добавить новое поле. Для решения этой задачи можно создать класс, расширяющий функциональность ClassAdapter, как показано в листинге 2:

Листинг 2. Фильтр, добавляющий в класс новое поле.
public class NewFieldAdder extends ClassAdapter
{
// класс FieldNode входит в объектный Tree API
	private final FieldNode fld_nd; 
 	
public NewFieldAdder( ClassVisitor cv, FieldNode fld_nd )
 	{
	super( cv );
		this.fld_nd = fld_nd;
	}

	public void visitEnd()
 	{
		fld_nd.accept( cv );
		super.visitEnd();
	}
}

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

Заключение

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


Ресурсы для скачивания


Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=601987
ArticleTitle=Модификация байт-кода Java VM: Часть 1. Основные принципы модификации байт-кода и обзор библиотеки ASM
publish-date=12162010