почему этот метод Java протекает - и почему встраивание в него устраняет утечку? - PullRequest
0 голосов
/ 04 сентября 2018

Я написал в качестве эксперимента минимальный несколько ленивый (int) класс последовательности, GarbageTest.java , чтобы посмотреть, смогу ли я обработать очень длинные, ленивые последовательности в Java, как я могу в Clojure.

Учитывая naturals() метод, который возвращает ленивую, бесконечную последовательность натуральных чисел; drop(n,sequence) метод, который отбрасывает первые n элементы sequence и возвращает остальные sequence; и nth(n,sequence) метод, который возвращает просто: drop(n, lazySeq).head(), я написал два теста:

static int N = (int)1e6;

// succeeds @ N = (int)1e8 with java -Xmx10m
@Test
public void dropTest() {
    assertThat( drop(N, naturals()).head(), is(N+1));
}

// fails with OutOfMemoryError @ N = (int)1e6 with java -Xmx10m
@Test
public void nthTest() {
    assertThat( nth(N, naturals()), is(N+1));
}

Обратите внимание, что тело dropTest() было сгенерировано путем копирования тела nthTest() и последующего вызова «встроенного» рефакторинга IntelliJ при вызове nth(N, naturals()). Поэтому мне кажется, что поведение dropTest() должно быть идентично поведению nthTest().

Но это не идентично! dropTest() завершается с N до 1e8, тогда как nthTest() завершается с OutOfMemoryError для N, равным 1e6.

Я избегал внутренних классов. И я экспериментировал с вариантом моего кода, ClearingArgsGarbageTest.java , который обнуляет параметры метода перед вызовом других методов. Я применил профилировщик YourKit. Я посмотрел на байт-код. Я просто не могу найти утечку, которая приводит к сбою nthTest().

Где "утечка"? И почему у nthTest() есть утечка, а у dropTest() нет?

Вот остальная часть кода из GarbageTest.java на тот случай, если вы не хотите переходить к проекту Github:

/**
 * a not-perfectly-lazy lazy sequence of ints. see LazierGarbageTest for a lazier one
 */
static class LazyishSeq {
    final int head;

    volatile Supplier<LazyishSeq> tailThunk;
    LazyishSeq tailValue;

    LazyishSeq(final int head, final Supplier<LazyishSeq> tailThunk) {
        this.head = head;
        this.tailThunk = tailThunk;
        tailValue = null;
    }

    int head() {
        return head;
    }

    LazyishSeq tail() {
        if (null != tailThunk)
            synchronized(this) {
                if (null != tailThunk) {
                    tailValue = tailThunk.get();
                    tailThunk = null;
                }
            }
        return tailValue;
    }
}

static class Incrementing implements Supplier<LazyishSeq> {
    final int seed;
    private Incrementing(final int seed) { this.seed = seed;}

    public static LazyishSeq createSequence(final int n) {
        return new LazyishSeq( n, new Incrementing(n+1));
    }

    @Override
    public LazyishSeq get() {
        return createSequence(seed);
    }
}

static LazyishSeq naturals() {
    return Incrementing.createSequence(1);
}

static LazyishSeq drop(
        final int n,
        final LazyishSeq lazySeqArg) {
    LazyishSeq lazySeq = lazySeqArg;
    for( int i = n; i > 0 && null != lazySeq; i -= 1) {
        lazySeq = lazySeq.tail();
    }
    return lazySeq;
}

static int nth(final int n, final LazyishSeq lazySeq) {
    return drop(n, lazySeq).head();
}

Ответы [ 2 ]

0 голосов
/ 04 сентября 2018

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

Прежде чем я углублюсь в то, что делает Clojure, вот краткое резюме того, что происходит в этом примере:

  1. nth(int, LazyishSeq) реализовано в терминах drop(int, LazyishSeq) и LazyishSeq.head().

  2. nth передает оба своих аргумента в drop и больше их не использует.

  3. drop может быть легко реализовано, чтобы избежать удержания на вершине передаваемой последовательности.

Здесь nth все еще держится за заголовок аргумента последовательности. Среда выполнения может потенциально отбросить эту ссылку, но это не гарантируется.

Способ, которым Clojure справляется с этим, заключается в явной очистке ссылки на последовательность перед передачей управления drop. Это делается с помощью довольно элегантного трюка ( ссылка на приведенный ниже фрагмент на GitHub начиная с Clojure 1.9.0 ):

//  clojure/src/jvm/clojure/lang/Util.java

/**
 *   Copyright (c) Rich Hickey. All rights reserved.
 *   The use and distribution terms for this software are covered by the
 *   Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php)
 *   which can be found in the file epl-v10.html at the root of this distribution.
 *   By using this software in any fashion, you are agreeing to be bound by
 *   the terms of this license.
 *   You must not remove this notice, or any other, from this software.
 **/

// … beginning of the file omitted …

// the next line is the 190th in the file as of Clojure 1.9.0
static public Object ret1(Object ret, Object nil){
        return ret;
}

static public ISeq ret1(ISeq ret, Object nil){
        return ret;
}

// …

Учитывая вышеизложенное, вызов drop внутри nth можно изменить на

drop(n, ret1(lazySeq, lazySeq = null))

Здесь lazySeq = null оценивается как выражение перед передачей управления в ret1; значение равно null, а также имеется побочный эффект установки ссылки lazySeq на null. Однако первый аргумент ret1 будет оценен этой точкой, поэтому ret1 получает ссылку на последовательность в своем первом аргументе и возвращает ее, как и ожидалось, и затем это значение передается в drop.

Таким образом, drop получает исходное значение, хранящееся в lazySeq local, но сам local очищается перед передачей управления в drop.

Следовательно, nth больше не держится за начало последовательности.

0 голосов
/ 04 сентября 2018

В вашем методе

static int nth(final int n, final LazyishSeq lazySeq) {
    return drop(n, lazySeq).head();
}

переменная параметра lazySeq содержит ссылку на первый элемент вашей последовательности в течение всей операции drop. Это предотвращает сбор мусора во всей последовательности.

В отличие от

public void dropTest() {
    assertThat( drop(N, naturals()).head(), is(N+1));
}

первый элемент вашей последовательности возвращается naturals() и напрямую передается на вызов drop, таким образом удаляется из стека операндов и не существует во время выполнения drop.

Ваша попытка установить переменную параметра на null, т.е.

static int nth(final int n, /*final*/ LazyishSeq lazySeqArg) {
    final LazyishSeq lazySeqLocal = lazySeqArg;
    lazySeqArg = null;
    return drop(n,lazySeqLocal).head();
}

не помогает, так как теперь переменная lazySeqArg равна null, но lazySeqLocal содержит ссылку на первый элемент.

Локальная переменная не предотвращает сборку мусора в целом, сбор неиспользуемых объектов разрешен , но это не означает, что конкретная реализация способна это сделать.

В случае HotSpot JVM только оптимизированный код избавит от таких неиспользуемых ссылок. Но здесь nth не является горячей точкой, так как тяжелые вещи происходят в методе drop.

Это причина, по которой такая же проблема не возникает в методе drop, хотя она также содержит ссылку на первый элемент в своей переменной параметра. Метод drop содержит цикл, выполняющий реальную работу, следовательно, JVM может с большой вероятностью оптимизировать его, что может привести к исключению неиспользуемых переменных, что позволит собирать уже обработанную часть последовательности.

Существует множество факторов, которые могут повлиять на оптимизацию JVM. Помимо другой формы кода, кажется, что быстрое выделение памяти во время неоптимизированной фазы может также уменьшить улучшения оптимизатора. Действительно, когда я запускаю с -Xcompile, чтобы вообще запретить интерпретируемое выполнение, оба варианта запускаются успешно, даже int N = (int)1e9 больше не проблема. Конечно, принудительная компиляция увеличивает время запуска.

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

...