Статьи

Дескрипторы файла JAR: очистка после беспорядка!

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

Окна: поддержка запретной земли

В Ultra ESB Legacy загрузчик отлично работал на Windows, но на более новой X-версии он, похоже, имел некоторые сбои. Мы не поддерживаем Windows как целевую платформу, поэтому это не имело большого значения — до недавнего времени, когда мы решили поддерживать непроизводственные дистрибутивы в Windows. (Наша IDE UltraStudio для корпоративной интеграции отлично работает в Windows, поэтому разработчики Windows, вы все охвачены.)

TDD FTW

Исправление загрузчика классов было легким, и все тесты проходили; но я хотел подкрепить свои исправления некоторыми дополнительными тестами, поэтому я написал несколько новых. Большинство из них включали создание нового файла JAR в подкаталоге системного временного каталога и использование загрузчика классов с возможностью горячей замены для загрузки различных артефактов, которые были помещены в JAR. Для дополнительной информации о лучших практиках я также добавил некоторую логику очистки для удаления временного подкаталога с помощью FileUtils.deleteDirectory() .

А потом все сошло с ума .

И сноса больше не было.

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

Находясь на Windows, я не мог lsof себе роскошь lsof ; к счастью, у Sysinternals уже было то, что мне было нужно: handle64 .

Найти виновника было довольно просто: нажмите tearDown() останова в tearDown() непосредственно перед вызовом удаления дерева каталогов и запустите handle64 {my-jar-name}.jar .

Облом.

Мой тестовый Java-процесс содержал дескриптор тестового JAR-файла.

Охота на утечку

Нет, серьезно. Я не

Естественно, моим первым подозреваемым был сам загрузчик классов. Я потратил почти полчаса, снова и снова просматривая кодовую базу загрузчика классов. Не повезло. Все казалось твердым.

«Утечка»; ака мой мрачный жнец для файловых ручек

Лучше всего было посмотреть, какой фрагмент кода открыл обработчик для файла JAR. Поэтому я написал патч quick-n-dirty для Java FileInputStream и FilterInputStream который будет выводить снимки трассировки стека во время получения; всякий раз, когда поток удерживает поток открытым слишком долго.

Этот «дампер утечки» был частично вдохновлен нашим пулом соединений JDBC, который обнаруживает невыпущенные соединения (с льготным периодом), а затем выводит трассировку стека потока, который его заимствовал, обратно во время его заимствования. ( Слава Сачини , моему бывшему коллеге-практиканту в AdroitLogic .)

Утечка разоблачена!

Конечно же, трассировка стека выявила виновника:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
id: 174 created: 1570560438355
--filter--
 
  java.io.FilterInputStream.<init>(FilterInputStream.java:13)
  java.util.zip.InflaterInputStream.<init>(InflaterInputStream.java:81)
  java.util.zip.ZipFile$ZipFileInflaterInputStream.<init>(ZipFile.java:408)
  java.util.zip.ZipFile.getInputStream(ZipFile.java:389)
  java.util.jar.JarFile.getInputStream(JarFile.java:447)
  sun.net.www.protocol.jar.JarURLConnection.getInputStream(JarURLConnection.java:162)
  java.net.URL.openStream(URL.java:1045)
  org.adroitlogic.x.base.util.HotSwapClassLoader.loadSwappableClass(HotSwapClassLoader.java:175)
  org.adroitlogic.x.base.util.HotSwapClassLoader.loadClass(HotSwapClassLoader.java:110)
  org.adroitlogic.x.base.util.HotSwapClassLoaderTest.testServiceLoader(HotSwapClassLoaderTest.java:128)
  sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
  sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
  sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
  java.lang.reflect.Method.invoke(Method.java:498)
  org.testng.internal.MethodInvocationHelper.invokeMethod(MethodInvocationHelper.java:86)
  org.testng.internal.Invoker.invokeMethod(Invoker.java:643)
  org.testng.internal.Invoker.invokeTestMethod(Invoker.java:820)
  org.testng.internal.Invoker.invokeTestMethods(Invoker.java:1128)
  org.testng.internal.TestMethodWorker.invokeTestMethods(TestMethodWorker.java:129)
  org.testng.internal.TestMethodWorker.run(TestMethodWorker.java:112)
  org.testng.TestRunner.privateRun(TestRunner.java:782)
  org.testng.TestRunner.run(TestRunner.java:632)
  org.testng.SuiteRunner.runTest(SuiteRunner.java:366)
  org.testng.SuiteRunner.runSequentially(SuiteRunner.java:361)
  org.testng.SuiteRunner.privateRun(SuiteRunner.java:319)
  org.testng.SuiteRunner.run(SuiteRunner.java:268)
  org.testng.SuiteRunnerWorker.runSuite(SuiteRunnerWorker.java:52)
  org.testng.SuiteRunnerWorker.run(SuiteRunnerWorker.java:86)
  org.testng.TestNG.runSuitesSequentially(TestNG.java:1244)
  org.testng.TestNG.runSuitesLocally(TestNG.java:1169)
  org.testng.TestNG.run(TestNG.java:1064)
  org.testng.IDEARemoteTestNG.run(IDEARemoteTestNG.java:72)
  org.testng.RemoteTestNGStarter.main(RemoteTestNGStarter.java:123)

Попался!

1
2
3
4
5
java.io.FilterInputStream.<init>(FilterInputStream.java:13)
  ...
  sun.net.www.protocol.jar.JarURLConnection.getInputStream(JarURLConnection.java:162)
  java.net.URL.openStream(URL.java:1045)
  org.adroitlogic.x.base.util.HotSwapClassLoader.loadSwappableClass(HotSwapClassLoader.java:175)

Но все же, это не рассказывало всю историю. Если URL.openStream() открывает JAR, почему он не закрывается, когда мы возвращаемся из блока try-with-resources?

01
02
03
04
05
06
07
08
09
10
11
12
try (InputStream is = jarURI.toURL().openStream()) {
            byte[] bytes = IOUtils.toByteArray(is);
            Class<?> clazz = defineClass(className, bytes, 0, bytes.length);
            ...
            logger.trace(15, "Loaded class {} as a swappable class", className);
            return clazz;
 
        } catch (IOException e) {
            logger.warn(16, "Class {} located as a swappable class, but couldn't be loaded due to : {}, " +
                    "trying to load the class as a usual class", className, e.getMessage());
            ...
        }

В дикую природу: JarURLConnection , URLConnection и не только

Благодаря Sun Microsystems, которая сделала это OSS , я смог просмотреть исходный код JDK, вплоть до этого шокирующего комментария — вплоть до java.net.URLConnection :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
private static boolean defaultUseCaches = true;
 
   /**
     * If <code>true</code>, the protocol is allowed to use caching
     * whenever it can. If <code>false</code>, the protocol must always
     * try to get a fresh copy of the object.
     * <p>
     * This field is set by the <code>setUseCaches</code> method. Its
     * value is returned by the <code>getUseCaches</code> method.
     * <p>
     * Its default value is the value given in the last invocation of the
     * <code>setDefaultUseCaches</code> method.
     *
     * @see     java.net.URLConnection#setUseCaches(boolean)
     * @see     java.net.URLConnection#getUseCaches()
     * @see     java.net.URLConnection#setDefaultUseCaches(boolean)
     */
    protected boolean useCaches = defaultUseCaches;

Да, Java кэширует потоки JAR!

От sun.net.www.protocol.jar.JarURLConnection :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
class JarURLInputStream extends FilterInputStream {
        JarURLInputStream(InputStream var2) {
            super(var2);
        }
 
        public void close() throws IOException {
            try {
                super.close();
            } finally {
                if (!JarURLConnection.this.getUseCaches()) {
                    JarURLConnection.this.jarFile.close();
                }
            }
 
        }
    }

Если (ну, потому что ) useCaches по умолчанию имеет значение true , нас useCaches большой сюрприз!

Пусть Java кеширует свои JAR-файлы, но не нарушайте мой тест!

JAR-кэширование, вероятно, улучшит производительность; но значит ли это, что я должен прекратить очистку после — и оставлять после себя чужие файлы после каждого теста?

(Конечно, я мог бы сказать file.deleteOnExit() ; но, поскольку я имел дело с иерархией каталогов, не было никакой гарантии, что все будет удалено по порядку, а неуничтоженные каталоги останутся.)

Поэтому я хотел найти способ очистить кэш JAR — или хотя бы очистить только мою запись JAR; после того, как я закончу, но до того, как JVM закроется.

Отключение JAR-кэширования вообще — вероятно, не очень хорошая идея!

URLConnection предлагает возможность избежать кэширования записей подключения:

01
02
03
04
05
06
07
08
09
10
/**
     * Sets the default value of the <code>useCaches</code> field to the
     * specified value.
     *
     * @param   defaultusecaches   the new value.
     * @see     #getDefaultUseCaches()
     */
    public void setDefaultUseCaches(boolean defaultusecaches) {
        defaultUseCaches = defaultusecaches;
    }

Было бы идеально, если бы кэширование могло быть отключено для каждого файла / URL, как указано выше; наш загрузчик классов кэширует все записи, как только открывает JAR, поэтому ему больше не нужно открывать / читать этот файл снова. Однако, как только JAR открыт, на нем нельзя отключить кэширование; поэтому, как только наш загрузчик классов открыл JAR, избавиться от дескриптора кэшированного файла невозможно — пока сама JVM не закроется!

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

01
02
03
04
05
06
07
08
09
10
/**
     * Sets the default value of the <code>useCaches</code> field to the
     * specified value.
     *
     * @param   defaultusecaches   the new value.
     * @see     #getDefaultUseCaches()
     */
    public void setDefaultUseCaches(boolean defaultusecaches) {
        defaultUseCaches = defaultusecaches;
    }

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

Вниз по кроличьей норе (снова!): JarFileFactory вручную из JarFileFactory

Наименее инвазивный вариант — удалить мой JAR-файл из кэша, когда я знаю, что все готово.

И хорошие новости, кеш — sun.net.www.protocol.jar.JarFileFactory — уже имеет метод close(JarFile) который выполняет эту работу.

Но, к сожалению, класс кэша является закрытым для пакета; это означает, что нет способа манипулировать им из моего тестового кода.

Отражение на помощь!

Благодаря размышлениям все, что мне было нужно, — это один маленький «мост», который будет jarFactory.close(jarFile) и вызывать jarFactory.close(jarFile) от имени меня:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class JarBridge {
 
    static void closeJar(URL url) throws Exception {
 
        // JarFileFactory jarFactory = JarFileFactory.getInstance();
        Class<?> jarFactoryClazz = Class.forName("sun.net.www.protocol.jar.JarFileFactory");
        Method getInstance = jarFactoryClazz.getMethod("getInstance");
        getInstance.setAccessible(true);
        Object jarFactory = getInstance.invoke(jarFactoryClazz);
 
        // JarFile jarFile = jarFactory.get(url);
        Method get = jarFactoryClazz.getMethod("get", URL.class);
        get.setAccessible(true);
        Object jarFile = get.invoke(jarFactory, url);
 
        // jarFactory.close(jarFile);
        Method close = jarFactoryClazz.getMethod("close", JarFile.class);
        close.setAccessible(true);
        //noinspection JavaReflectionInvocation
        close.invoke(jarFactory, jarFile);
 
        // jarFile.close();
        ((JarFile) jarFile).close();
    }
}

И в моем тесте я просто должен сказать:

1
JarBridge.closeJar(jarPath.toUri().toURL());

Прямо перед удалением временного каталога.

Итак, что на вынос?

Ничего особенного для вас, если вы не имеете дело непосредственно с файлами JAR; но если это так, вы можете столкнуться с такими неясными ошибками «файл используется». (Это будет справедливо и для других потоков на основе URLConnection .)

Если вам так (не) повезло, как и мне, просто вспомните, что какой-то небезызвестный блоггер написал какой-то хакерский патч JAR «утечки утечки», который бы точно показал вам, где находится ваша утечка JAR (или не JAR).

Прощайте!

Смотрите оригинальную статью здесь: JAR File Handles: Очистка после беспорядка!

Мнения, высказанные участниками Java Code Geeks, являются их собственными.