Як завантажити dll з Java-машини

Java взаємодіє з операційною системою через методи, позначені ключовим словом native, за допомогою системних бібліотек, що завантажуються процедурою System.дзвінки на loadlibrary().

Завантажити системну бібліотеку дуже просто, а от щоб вивантажити її, як виявилося, потрібно докласти чимало зусиль. Як саме вивантажуються системні бібліотеки, і навіщо це потрібно, я постараюся розповісти.

Припустимо, ми хочемо зробити невелику утиліту, яку будуть запускати користувачі на своїх комп'ютерах в локальній мережі. Нам би хотілося позбавити користувачів від проблем з установкою і налаштуванням програми, але немає ресурсів на розгортання й підтримку централізованої інфраструктури. У таких випадках зазвичай збирають програму разом з усіма залежностями в єдиний jar-файл. Це легко зробити за допомогою maven-assembly-plugin або просто експортувати з IDE Runnable jar. Запуск програми буде здійснюватися командою:

java -jar my-program.jar

На жаль, це не працює, якщо одна з бібліотек вимагає для своєї роботи системну динамічну бібліотеку, простіше кажучи dll. Зазвичай в одному з класів такої бібліотеки в статичному инициализаторе робиться виклик System.дзвінки на loadlibrary(). Щоб dll завантажилася, потрібно покласти її в каталог, доступний через системне властивість JVM java.library.path. Як це обмеження можна обійти?

Запакуємо dll всередину jar-файлу. Перед початком використання класів, які потребують завантаження dll, створимо тимчасовий каталог, зробимо бібліотеку туди і додамо каталог в java.library.path. Це буде виглядати приблизно так:

prepareLibrary
private void addLibraryPath(String pathToAdd) throws ReflectiveOperationException {
Field usrPathsField = ClassLoader.class.getDeclaredField("usr_paths");
usrPathsField.setAccessible(true);
String[] paths = (String[]) usrPathsField.get(null);
String[] newPaths = Arrays.copyOf(paths, paths.length + 1);
newPaths[newPaths.length - 1] = pathToAdd;
usrPathsField.set null, newPaths);
}
private Path prepareLibrary() throws IOException, ReflectiveOperationException {
Path dir = Files.createTempDirectory("lib");
try (InputStream input = ExampleClass.class.getResourceAsStream("custom.dll")) {
if (input == null) {
throw new FileNotFoundException("can't load resource custom.dll");
}
Files.copy(input, dir.resolve("custom.dll"));
}
addLibraryPath(dir.toAbsolutePath().toString());
return dir;
}



На жаль, доводиться хімічити з reflection, тому що стандартних методів розширити java.library.path Java не надає.

Тепер завантаження бібліотеки проходить прозоро для користувача, і він не повинен турбуватися про копіювання файлів або налаштування змінних оточення. Для роботи досить просто запустити звичайний скрипт. Проте після кожного запуску програми залишається тимчасовий каталог з файлами. Це не дуже добре, тому на виході треба виконати очищення.

try {
...
} finally {
delete(dir);
}

Але на Windows це не працює. Завантажена в JVM бібліотека блокує dll-файл і каталог, в якому він лежить. Таким чином, щоб вирішити завдання акуратного завершення програми, треба вивантажити з JVM системну динамічну бібліотеку.

Спроба вирішення
Перш за все розумно додати в код діагностику. Якщо файли вдалося видалити, наприклад. коли бібліотека не використовувалася, то й робити нічого не треба, а якщо файли заблоковані, тоді вжити додаткових заходів.

if (!delete(dir)) {
forceDelete(dir);
}

Як швидке, але не дуже гарне рішення, я використовував планувальник. На виході створюю xml-файл з завданням на виконання через 1 хвилину команди cmd /c rd /s /q temp-dir» і завантажую завдання планувальник командою «schtasks -create taskName -xml taskFile.xml». До моменту виконання завдання програма вже завершена, і файли ніхто не тримає.

Саме вірне рішення — це забезпечити вивантаження засобами бібліотеки Java-машини. Документація говорить про те, що системна бібліотека буде вивантажено при видаленні класу, а клас видаляється складальником сміття разом з класслоадером, коли не залишилося ні одного примірника з його класів. На мій погляд, краще завжди писати такий код, який повністю очищає після себе всю пам'ять та інші ресурси. Тому що якщо код робить щось корисне, рано чи пізно захочеться його переиспользовать і задеплоить на якийсь сервер, де встановлені й інші компоненти. Тому я вирішив витратити час на те, щоб розібратися, як коректно програмно завантажити dll.

Використання класслоадера
У моїй програмі проблеми виходили з JDBC-драйвера, тому далі я буду розглядати приклад з JDBC. Але і з іншими бібліотеками можна працювати аналогічним чином.

Якщо dll завантажена з системного завантажувача класів, то вивантажити її вже не вийде, тому необхідно створити свій класслоадер таким чином, щоб клас, підтягуючий бібліотеку, був завантажений з нього. Новий класслоадер повинен бути пов'язаний з системним класслоадером через властивість parent, інакше в ньому не будуть доступні класи String, Object та інші необхідні в господарстві речі.

Спробуємо:

Завантаження класу з нового завантажувача (1)
ClassLoader parentCl = ExampleClass.class.getClassLoader();
classLoader = new URLClassLoader(new URL[0], parentCl);
Class.forName("org.jdbc.CustomDriver", classLoader, true);
try (Connection connection = DriverManager.getConnection(dbUrl, dbProperties)) {
if (connection.getClass().getClassLoader() != classLoader) {
System.out.printf("щось пішло не так%n");
}
...
}


Не працює. При завантаженні класу спочатку проводиться спроба підняти його з батьківського завантажувача, тому наш драйвер завантажився не так, як нам потрібно. Для використання нового класслоадера, потрібно JDBC-драйвер з jar-файлу програми видалити, щоб він не був доступний системного завантажувачу. Значить, запаковуємо бібліотеку у вигляді вкладеного jar-файлу, а перед використанням розгортаємо його в тимчасовому каталозі (у тому же, де і dll у нас лежить).

Загрузука класу з нового завантажувача (2)
ClassLoader cl = ExampleClass.class.getClassLoader();
URL url = UnloadableDriver.class.getResource("CustomJDBCDriver.jar");
if (url == null) {
throw new FileNotFoundException("can't load resource CustomJDBCDriver.jar");
}
Path dir = prepareLibrary();
try (InputStream stream = url.openStream()) {
Path target = dir.resolve("CustromJDBCDriver.jar");
Files.copy(stream, target);
url = target.toUri().toURL();
}
ClassLoader classLoader = new URLClassLoader(new URL[] {url}, cl);
Class.forName("org.jdbc.CustomDriver", true, classLoader);
try (Connection connection = DriverManager.getConnection(dbUrl, dbProperties)) {
if (connection.getClass().getClassLoader() != classLoader) {
System.out.printf("щось пішло не так%n");
} else {
System.out.printf("Вийшло, можна йти далі%n");
}
...
}


Ми отримали об'єкт, завантажений з нашого нового завантажувача, по закінченні роботи нам треба позакривати все, що ми відкривали, почистити всі наші змінні, і, мабуть, викликати System.gc(), після чого вже намагатися чистити файли. В цьому місці має сенс инкапсулировать всю логіку роботи з завантажувачами класів в окремому класі з явними методами ініціалізації.

Скелет основного класу
public class ExampleClass implements AutoCloseable {
private final Path dir;
private URLClassLoader classLoader;

public ExampleClass() {
...
}

public void doWork() {
...
}

@Override
public void close() {
...
this.classLoader.close();
this.classloader = null;
System.gc(); // десь тут повинна вивантажитися dll
if (!delete(this.dir)) {
scheduleRemovalToTaskschd(this.dir);
}
}
}

public class Main {
public static void main(String args[]) {
try (ExampleClass example = new ExampleClass()) {
example.doWork();
} catch (Throwable e) {
e.printStackTrace();
}
}
}


Експерименти зі складальником сміття
Незважаючи на те, що начебто формально все необхідне для вивантаження бібліотеки зроблено, фактично вивантаження не відбувається. Читання вихідних з пакетка java.lang дозволило визначити, що видалення нативних бібліотек проводиться в метод finalize() в одному з внутрішніх класів. Це засмучує і насторожує, тому що документація не дає ніякого точного визначення, коли виконається даний метод і виконається взагалі. Тобто успіх залежить від якихось чинників, які можуть відрізнятися в різному оточенні, в різних версіях JVM або в різних збирачів сміття. Тим не менш є метод System.runFinalization(), який дає деяку надію.

Пробуємо:

Run finalization...
@Override
public void close() {
...
this.classLoader.close();
this.classloader = null;
System.gc();
System.runFinalization(); // десь тут повинна вивантажитися dll
if (!delete(this.dir)) {
scheduleRemovalToTaskschd(this.dir);
}
}


Не працює. Каталог заблокований процесом java. З цього моменту я використовував таку техніку:

  1. Ставлю на виході System.in.read()
  2. Коли програма зупиняється в цьому місці, роблю дамп пам'яті з jvisualvm
  3. Дивлюся дамп за допомогою Eclipse Memory Analysis Tool або jhat
  4. Шукаю екземпляри об'єктів, класи яких були завантажені мої завантажувачем
Виявилося 5 джерел витоку:

  1. Локальні змінні
  2. DriverManager
  3. ResourceBundle
  4. ThreadLocals
  5. Виключення
Локальні змінні

Локальні змінні



Виявилося, що збирач сміття не вважає локальну змінну недосяжною, поки не буде завершена функція, що містить цю змінну, навіть якщо змінна вийшла з області видимості.

if (needConnection) {
try (Connection connection = DriverManager.connect()) {
...
}
}
// Ось тут мінлива connection ще вважається живою.

Тому для розв'язання задачі вивантаження класслоадера необхідно перед викликом gc вийти із всіх функцій, які використовують вивантажувані класи.

DriverManager

DriverManager

JDBC-драйвери при завантаженні їх класу реєструються в класі DriverManager методом registerDriver(). Судячи з усього, перед вивантаженням треба викликати метод deregisterDriver(). Пробуємо.

Enumeration<Driver> drivers = driverManager.getDrivers();
while (drivers.hasMoreElements()) {
Driver driver = drivers.nextElement();
if (driver.getClass().getClassLoader() == classLoader) {
DriverManager.deregisterDriver(driver);
break;
}
}

Не працює. Heapdump не змінився. Дивимося в исходники класу DriverManager і виявляємо, що в методі deregisterDriver() варто перевірка на те, що виклик повинен бути з класу, який належить тому ж класслоадеру, що і клас, який викликав раніше registerDriver(). А registerDriver() викликаний самим драйвером з статичного ініціалізатор. Несподіваний поворот.

Виходить, ми не можемо безпосередньо разрегистрировать драйвер. Замість цього ми повинні попросити який-небудь клас з нового класслоадера, щоб він зробив це від свого імені. Вихід полягає у створенні спеціального класу DriverManagerProxy, точніше навіть двох, класу і інтерфейсу.

public interface DriverManagerProxy {
void deregisterDriver(Driver driver) throws SQLException;
}

public class DriverManagerProxyImpl implements DriverManagerProxy {
@Override
public void deregisterDriver(Driver driver) throws SQLException {
DriverManager.deregisterDriver(driver);
}
}

Інтерфейс буде знаходитися в основному classpath-е, а реалізація буде завантажена новим завантажувачем з допоміжного jar-файлу разом з JDBC-драйвер. Теоретично без інтерфейсу можна було б обійтися, але тоді для виклику функції довелося б застосовувати reflection. Використовується проксі наступним чином:

Використання DriverManagerProxy
public class ExampleClass implements AutoCloseable {
private final Path dir;
private URLClassLoader classLoader;
private DriverManagerProxy driverManager;

public ExampleClass() {
...
this.classLoader = ...;
Class.forName("org.jdbc.CustomDriver", true, classLoader);
Class<?> dmClass = Class.forName("ru.example.DriverManagerProxyImpl",
true,
classLoader);
this.driverManager = (DriverManagerProxy) dmClass.newInstance();
}

public void doWork() {
...
}

@Override
public void close() {
...
Enumeration<Driver> drivers = driverManager.getDrivers();
while (drivers.hasMoreElements()) {
Driver driver = drivers.nextElement();
if (driver.getClass().getClassLoader() == classLoader) {
driverManager.deregisterDriver(driver);
break;
}
}
this.driverManager = null;
this.classLoader.close();
this.classloader = null;
System.gc();
System.runFinalization(); // десь тут повинна вивантажитися dll
if (!delete(this.dir)) {
scheduleRemovalToTaskschd(this.dir);
}
}
}



ResourceBundle

ResourceBundle

Наступна зачіпка на класслоадер, який я намагався передати, виявилася в надрах класу ResourceBundle. На щастя, на відміну від DriverManager, ResourceBundle надає спеціальну функцію clearCache(), якій класслоадер передається як параметр.

ResourceBundle.clearCache(classLoader);

Треба зауважити, що, судячи з исходниками, в ResourceBundle використовуються слабкі посилання, які не повинні перешкоджати збиранню сміття. Можливо, якщо очистити всі інші посилання на наші об'єкти, то чистити цей кеш немає необхідності.

ThreadLocals

ThreadLocals

Останнє місце, де виявилися хвости відсутнього драйвера, виявилося ThreadLocals. Після історії з DriverManager-му, очищення локальних поточних змінних здається парою дрібниць. Хоча тут не вдалося обійтися без reflection.

private static void cleanupThreadLocals(ClassLoader cl)
throws ReflectiveOperationException {
int length = 1;
Thread threads[] = new Thread[length];
int cnt = Thread.enumerate(threads);
while (cnt >= length) {
length *= 2;
threads = new Thread[length];
cnt = Thread.enumerate(threads);
}
for (int i = 0; i < cnt; i++) {
Thread thread = threads[i];
if (thread == null) {
continue;
}
cleanupThreadLocals(thread, cl);
}
}

private static void cleanupThreadLocals(Thread thread, ClassLoader cl)
throws ReflectiveOperationException {
Field threadLocalsField = Thread.class.getDeclaredField("threadLocals");
threadLocalsField.setAccessible(true);
Object threadLocals = threadLocalsField.get(thread);
if (threadLocals == null) {
return;
}
Class<?> threadLocalsClass = threadLocals.getClass();
Field tableField = threadLocalsClass.getDeclaredField("table");
tableField.setAccessible(true);
Object table = tableField.get(threadLocals);
Object entries[] = (Object[]) table;
Class<?> entryClass = table.getClass().getComponentType();
Field valueField = entryClass.getDeclaredField("value");
valueField.setAccessible(true);
Method expungeStaleEntry = threadLocalsClass.getDeclaredMethod("expungeStaleEntry", Integer.TYPE);
expungeStaleEntry.setAccessible(true);
for (int i = 0; i < entries.length; i++) {
Object entry = entries[i];
if (entry == null) {
continue;
}
Object value = valueField.get(entry);
if (value != null) {
ClassLoader valueClassLoader = value.getClass().getClassLoader();
if (valueClassLoader == cl) {
((java.lang.ref.Reference<?>) entry).clear();
expungeStaleEntry.invoke(threadLocals, i);
}
}
}
}


Винятки

Виключення

Ми розраховуємо на те, що код очищення можна помістити в блоці finally. На вході в цей блок у нас вже має бути все закрито автоматично за допомогою механізму try-with-resources. Однак наш класслоадер як і раніше не буде в цьому місці видалений з пам'яті, якщо з блоку try викинуто виняток, клас якого завантажений цим класслоадером.

Щоб видалити з пам'яті небажаний exception, його треба зловити і обробити, а якщо потрібно помилку все-таки викинути нагору, то скопіювати exception в інший клас. Ось як це зробив я в своїй програмі:

try {
...
} catch (RuntimeException e) {
if (e.getClass().getClassLoader() == this.getClass().getClassLoader()) {
throw e;
}
RuntimeException exception = new RuntimeException(String.format("%s: %s", e.getClass(), e.getMessage()));
exception.setStackTrace(e.getStackTrace());
throw exception;
} catch (SQLException e) {
if (e.getClass().getClassLoader() == this.getClass().getClassLoader()) {
throw e;
}
SQLException exception = new SQLException(String.format("%s: %s", e.getClass(), e.getMessage()));
exception.setStackTrace(e.getStackTrace());
throw exception;
}


Java завдає удар
Після очищення всіх виявлених посилань на вивантажувані класи вийшла трохи парадоксальна ситуація. Ніяких об'єктів у пам'яті немає, судячи з дампу пам'яті, кількість примірників у всіх класах одно 0. Але самі класи та їх завантажувач нікуди не поділися, і відповідно не пішла нативна бібліотека.

Усунути проблему вийшло ось таким прийомом:

System.gc();
System.runFinalization();
System.gc();
System.runFinalization();

Напевно, в Java 1.7, яку я використовував, була якась особливість очищення об'єктів, які лежать в PermGen. З налаштуваннями сміття я не експериментував, тому що намагався написати код, який буде однаково працювати в різному оточенні, у тому числі в серверах додатків.

Після зазначеного прийому код запрацював як слід, бібліотека вивантажується, каталоги віддалялися. Однак після переходу на Java 8 проблема повернулася. Розібратися, в чому справа, не було часу, але судячи по всьому, змінилося щось у поведінці збирача сміття.

Тому довелося застосувати важку артилерію, а саме JMX:

Як змусити Java зібрати сміття
private static void dumpHeap() {
try {
Class<?> clazz = Class.forName("com.sun.management.HotSpotDiagnosticMXBean");
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
Object hotspotMBean =
ManagementFactory.newPlatformMXBeanProxy(
server, "com.sun.management:type=HotSpotDiagnostic", clazz);
Method m = clazz.getMethod("dumpHeap", String.class, boolean.class);
m.invoke(hotspotMBean, "nul", true);
} catch (@SuppressWarnings("unused") RuntimeException e) {
return;
} catch (@SuppressWarnings("unused") ReflectiveOperationException e) {
return;
} catch (@SuppressWarnings("unused") IOException e) {
return;
}
}


Через HotSpotDiagnosticMXBean викликаємо збереження дампу пам'яті. Як ім'я файлу вказуємо nul, що в Windows означає те ж саме, що і /dev/null в Unix. Другий параметр вказує на те, що в дамп повинні бути вивантажені тільки живі об'єкти. Саме цей параметр змушує JVM виконати повну збірку сміття.

Ось після цього лайфхака проблема видалення бібліотеки з тимчасового каталогу більше не виникала. Підсумковий код очищення файлів виглядає так:

this.classLoader = null;
System.gc();
System.runFinalization();
System.gc();
System.runFinalization();
if (!delete(this.dir)) {
dumpHeap();
if (!delete(this.dir)) {
scheduleRemovalToTaskschd(this.dir);
}
}

Перевірка за допомогою OSGI
Для перевірки якості коду я написав свій JDBC-драйвер, який повністю прибирає за собою. Він працює як обгортка навколо будь-якого іншого драйвера, подгружаемого з окремого classpath.

UnloadableDriver
public class UnloadableDriver implements Driver, AutoCloseable {
private final Path dir; // тимчасовий каталог, який підлягає видаленню
private URLClassLoader classLoader;
private DriverManagerProxy driverManager;
private Driver driver;

public UnloadableDriver() throws SQLException {
...
}

@Override
public void close() {
...
}

...
}


Цей драйвер я вставив в сервіс OSGI на Apache Felix.

JDBCService
public interface JDBCService {
Connection getConnection(String url, Properties properties) throws SQLException;
}

@Service(JDBCService.class)
public class JDBCServiceImpl implements JDBCService {
private UnloadableDriver driver;

@Activate
public void activate(ComponentContext ctx) throws SQLException {
this.driver = new UnloadableDriver();
}

@Deactivate
public void deactivate() {
this.driver.close();
this.driver = null;
}

@Override
public Connection getConnection(String url, Properties info) throws SQLException {
return this.driver.connect(url, властивості);
}
}


При старті модуля через системну консоль Apache Felix, запущену на Java 1.8.0_102, з'являється тимчасовий каталог dll-файл. Файл заблокований процесом java. Як тільки модуль зупиняється, каталог видаляється автоматично. Якщо ж замість UnloadableDriver використовувати DriverManager і звичайну бібліотеку з Embedded-Artifacts, то після оновлення модуля виникає помилка java.lang.UnsatisfiedLinkError: Native Library already loaded in another classloader.

Висновки
Універсального способу вивантажити системну динамічну бібліотеку з Java-машини не існує, але ця задача вирішена.

В Java існує чимало місць, в яких можна випадково залишити посилання на свої класи, і це є передумовою до витоків пам'яті.

Навіть якщо ваш код робить все коректно, витік може бути привнесена який-небудь бібліотекою, яку ви використовуєте.

Особливу увагу слід звернути на випадки, коли програма щось завантажує за допомогою створюваного під час виконання нового завантажувача класів. Якщо залишиться хоча б одна посилання на один із завантажених класів, то класслоадер і всі його класи залишаться в пам'яті.

Щоб виявити витік пам'яті, треба зробити дамп і проаналізувати за допомогою спеціальних інструментів, таких як Eclipse MAT.

При виявленні витоку пам'яті в сторонньої бібліотеці можна спробувати усунути її за допомогою одного з описаних у статті рецептів.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.