Определяем все классы, которые использует приложение на Java

в 14:33, , рубрики: classloader, java, Песочница, метки: ,

Без сомнения каждый, кто в своем резюме указывает опыт разработки на Java, хоть раз в жизни писал строки

public static void main(String[] args)

компилировал их и запускал на выполнение командой наподобие java HelloWorld.
Но многие ли знают, что происходит внутри JVM от момента выполнения этой команды до того как управление передается методу main, как Java находит и загружает необходимые пользователю классы? Возникшая однажды производственная задача заставила автора разобраться в этом вопросе. Результаты изысканий под катом. Сразу стоит оговориться, что статья не претендует на полноту охвата всех существующих JVM, тестирование проводилось только на Sun HotSpot JVM.

Постановка задачи

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

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

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

  1. наследники абстрактного класса java.lang.ClassLoader используются для непосредственной загрузки классов, о чем красноречиво свидетельствует сигнатура метода Class loadClass(String name). Данный метод должен найти массив байт, являющийся байт-кодом искомого класса и передать его методу protected Class defineClass(String name, byte[] b, int off, int len), который превратит его в экземпляр класса java.lang.Class. Таким образом, реализуя свои загрузчики, разработчики могут загружать из любого места, откуда можно получить массив байт;
  2. разработчиками фреймворка декларируется хитрый механизм иерархии и наследования загрузчиков. При этом наследование здесь следует понимать не в терминах наследования классов в ООП, а как отдельную иерархию, организованную с помощью метода getParent класса ClassLoader. При старте JVM создается вершина этой иерархии из трех основных загрузчиков: базового (Bootstrap Classloader, отвечает за загрузку базовых классов фреймворка), загрузчика расширений (Extension Classloader, отвечает за загрузку классов из lib/ext) и системного загрузчика (System Classloader, отвечает за загрузку пользовательских классов). Далее разработчики вольны продолжать эту иерархию от системного загрузчика и ниже. По умолчанию в HotSpot JVM в качестве системного загрузчика используется класс sun.misc.Launcher$AppClassLoader однако его можно легко переопределить с помощью системного свойства java.system.class.loader ключа командной строки java -Djava.system.class.loader=имя.класса.загрузчика;
  3. декларируется правило делегирования загрузки: любой загрузчик, прежде чем пытаться загрузить любой класс, сначала должен обратиться к своему родителю, и только если тот не смог загрузить искомый класс, попытаться загрузить его сам. К сожалению, красота и удобство данного правила компенсируются необязательностью его исполнения. С последствиями неисполнения этого правила автору еще предстоит столкнуться.

Однако на данном этапе уже появилась первая концепция решения поставленной задачи:

  1. Реализовать собственный загрузчик классов, заменяющий системный, который при вызове метода loadClass будет просто записывать имя класса в файл, и передавать запрос на загрузку класса настоящему системному загрузчику. При условии соблюдения описанного выше правила делегирования это должно позволить отловить все загружаемые пользовательские классы, даже если они загружаются другими загрузчиками;
  2. Заставить все JVM, запускаемые на машине использовать данный загрузчик классов как системный.

Для реализации второго пункта необходимо решить следующие задачи:

  • сделать класс видимым для всех запускаемых JVM. Включать класс во все classpath множества компонентов приложения неудобно, трудоемко и нерационально с точки зрения расширения системы. Тем более что существует более красивое решение — поместить класс загрузчика в папку lib/ext JRE. Классы в этой папке становятся доступны автоматически без внесения их в classpath (как отмечалось выше, они загружаются загрузчиком расширений при старте JVM);
  • задать для всех JVM системное свойство java.system.class.loader — из командной строки это можно сделать так: java -Djava.system.class.loader=имя.класса.загрузчика HelloWolrd
  • непосредственно заставить все JVM запускаться с необходимым параметром -Djava.system.class.loader. Как оказалось, для этого тоже существует изящное решение — нужно использовать специальную переменную окружения, значение которой автоматически добавляется к параметрам запуска любой JVM. В процессе поиска было найдено две переменные, которые могли бы отвечать за данную возможность: JAVA_OPTS и JAVA_TOOL_OPTIONS. Однако ни одна из статей не давала четкого ответа на вопрос в чем же отличие этих двух переменных? Ответ на данный вопрос было решено найти опытным путем. В ходе эксперимента было установлено, что по настоящему «волшебной» является переменная JAVA_TOOL_OPTIONS, значение которой автоматически добавляется к параметрам запуска любой запускаемой HotSpot JVM. А JAVA_OPTS — это результат негласного соглашения разработчиков различных Java приложений. Данную переменную в явном виде используют многие скрипты (например, startup.sh/startup.bat для запуска Apache Tomcat), однако никто не гарантирует, что данную переменную будут использовать все разработчики скриптов.

Итак, дело сделано, загрузчик скомпилирован и помещен в lib/ext, значение переменной окружения JAVA_TOOL_OPTIONS задано, запускаем приложение, работаем, открываем лог и видим… скудный список из десятка классов, включая системные и еще несколько сторонних классов. Вот тут-то и пришлось вспомнить о необязательности выполнения правила делегирования загрузки, а так же заглянуть в исходный код Apache Ant и Tomcat. Как оказалось, в этих приложениях используются собственные загрузчики классов. Это, с одной стороны, отчасти и позволило им обрести свой мощный функционал. Однако по тем или иным причинам разработчики этих продуктов решили не придерживаться рекомендованного правила делегирования загрузки и написанные ими загрузчики далеко не всегда обращаются к своим родителям, перед тем как загрузить очередной класс. Именно поэтому наш системный загрузчик почти ничего не знает о классах, загружаемых Tomcat-ом и Ant-ом.
Таким образом, описанный способ не позволяет отловить все требуемые классы, особенно учитывая разнообразие используемых серверов приложений — кто знает, как отнеслись к правилу делегирования загрузки разработчики используемого заказчиком сервера приложений.

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

Порой для решения задачи не достаточно одних знаний или умений. Иногда для достижения цели необходима интуиция и чуточку везения. Сейчас автор уже и не вспомнит, в ответ на какой поисковый запрос о загрузчиках классов, поисковый гигант выдал ссылку на статью о механизме инструментации классов. Как оказалось, данный инструмент предназначен для изменения байт кода Java классов во время их загрузки (к примеру, JProfiler с помощью данного механизма встраивается в классы для замеров производительности). Стоп, что значит во время их загрузки? То есть данный механизм знает о каждом загруженном классе? Да знает, и, как оказалось, даже лучше чем загрузчики классов — метод byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) интерфейса ClassFileTransformer вызывается у реализующего его класса-трансформатора при загрузке любого класса. Этот метод и оказался тем бутылочным горлышком, через которое проходит любой загружаемый класс, за исключением совсем небольшого количества системных.
Теперь задача сводится к следующему:

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

Исходный код класса-трансформатора представлен ниже:

package com.test;

import java.io.File;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.lang.instrument.IllegalClassFormatException;

public class LoggingClassFileTransformer implements ClassFileTransformer {

  public static void premain(String agentArguments, Instrumentation instrumentation) {
    instrumentation.addTransformer(new LoggingClassFileTransformer());
  }

  /**
  * Данный метод вызывается для любого загруженного класса
  * @param className имя загружаемого класса для записи в лог
  * @return неизмененный classfileBuffer содержащий байт-код класса
  */
  public byte[] transform(ClassLoader loader,
         String className,
         Class<?> classBeingRedefined,
         ProtectionDomain protectionDomain,
         byte[] classfileBuffer)
         throws IllegalClassFormatException
  {
    log(className);
    return classfileBuffer;
  }
  
  // сохраняем лог в папку lib/ext
  private static final File logFile = new File(System.getProperty("java.ext.dirs").split(Character.toString(File.pathSeparatorChar))[0]+"/LoggingClassFileTransformer.log");
  
  public static synchronized void log(String text)
  {
    // запись в файл
    // ...
  }
}

Здесь необходимо пояснить механизм использования классов-трансформаторов. Чтобы подключить такой класс к приложению нам понадобиться так называемый premain класс, т.е. класс, содержащий метод public static void premain(String paramString, Instrumentation paramInstrumentation). Из названия метода понятно, что он вызывается до вызова метода main. В этот момент можно подключить к приложению классы-трансформаторы с помощью метода addTransformer интерфейса java.lang.instrument.Instrumentation. Таким образом, приведенный выше класс одновременно является и классом-трансформатором и premain-классом. Чтобы данный класс можно было использовать, его необходимо поместить в JAR файл, манифест которого (файл META-INF/MANIFEST.MF) содержит параметр Premain-Class, указывающий на полное имя premain-класса, в нашем случае Premain-Class: com.test.LoggingClassFileTransformer. Затем необходимо указать полный путь к данному архиву с помощью параметра -javaagent при запуске JVM. Тут нам на помощь снова приходит переменная JAVA_TOOL_OPTIONS.
Итак, класс написан, скомпилирован, упакован вместе с манифестом в JAR, переменная окружения JAVA_TOOL_OPTIONS=-javaagent:"путь к LoggingClassFileTransformer.jar" задана, приложение запущено, лог собран, PROFIT!

Заключение

Итак, какие выводы можно сделать после окончания работы над проектом:

  • механизм загрузки классов в Java — интересная и весьма полезная возможность фреймворка, которая может пригодиться для решения определенного круга задач. Однако стоит помнить, что основная задача, которую можно решить с помощью данного механизма это именно найти и загрузить класс из места, откуда его не могут загрузить другие. Для сбора информации о загруженных классах данный механизм может быть малопригоден;
  • инструментация классов в Java — другой мощный механизм по работе с классами, и как раз таки его задача — произвольная работа с классами как с подопытными кроликами. Нужно ли вам получить информацию о времени работы метода или просто узнать имя только что загруженного класса — данный механизм придет на помощь;
  • Java в целом — открытая и способствующая творчеству платформа. Приложения с открытым исходным кодом на этом (как и на любом другом языке) — полезны не только своей функциональностью, но и ценны идеями, которые можно почерпнуть, изучая на их исходный код. Да и отсутствие исходного кода зачастую не является проблемой для приложений на Java. Существует много средств, позволяющих отобразить исходный код практически любого скомпилированного Java класса. С их помощью вы можете с легкостью проанализировать код практически любого класса и метода, даже из ядра фреймворка (например, из файла rt.jar), разумеется, за исключением нативных методов. В работе над этим и многими другими проектами, автору пригодилась бесплатная утилита Java Decompiler, позволяющая увидеть исходный код практически любого скомпилированного класса Java. Особенно завораживает возможность увидеть ту часть исходного кода ядра Java, которая сама написана на Java, если, например, открыть файл rt.jar лежащий в папке lib JRE.

Список использованных источников

Статьи о загрузчиках классов в Java:

Другие полезные ссылки:

Автор: Askell


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js