Finalize(), вызванный для объекта с высокой степенью достижимости в Java 8

Недавно мы обновили наше приложение обработки сообщений от Java 7 до Java 8. С момента обновления мы получаем случайное исключение, что поток был закрыт во время чтения. Ведение журнала показывает, что поток финализатора вызывает finalize() объекта, который содержит поток (который, в свою очередь, закрывает поток).

Основной контур кода выглядит следующим образом:

MIMEWriter writer = new MIMEWriter( out );
in = new InflaterInputStream( databaseBlobInputStream );
MIMEBodyPart attachmentPart = new MIMEBodyPart( in );
writer.writePart( attachmentPart );

MIMEWriter и MIMEBodyPart являются частью домашней библиотеки MIME/HTTP. MIMEBodyPart extends HTTPMessage, который имеет следующее:

public void close() throws IOException
{
    if ( m_stream != null )
    {
        m_stream.close();
    }
}

protected void finalize()
{
    try
    {
        close();
    }
    catch ( final Exception ignored ) { }
}

Исключение происходит в цепочке вызовов MIMEWriter.writePart, которая выглядит следующим образом:

  • MIMEWriter.writePart() записывает заголовки для части, затем вызывает part.writeBodyPartContent( this )
  • MIMEBodyPart.writeBodyPartContent() вызывает наш служебный метод IOUtil.copy( getContentStream(), out ) для потоковой передачи содержимого на вывод
  • MIMEBodyPart.getContentStream() просто возвращает входной поток, переданный в контрструктор (см. блок кода выше)
  • IOUtil.copy имеет цикл, который считывает блок 8K из входного потока и записывает его в выходной поток до тех пор, пока входной поток не станет пустым.

Вызывается MIMEBodyPart.finalize() во время выполнения IOUtil.copy, и он получает следующее исключение:

java.io.IOException: Stream closed
    at java.util.zip.InflaterInputStream.ensureOpen(InflaterInputStream.java:67)
    at java.util.zip.InflaterInputStream.read(InflaterInputStream.java:142)
    at java.io.FilterInputStream.read(FilterInputStream.java:107)
    at com.blah.util.IOUtil.copy(IOUtil.java:153)
    at com.blah.core.net.MIMEBodyPart.writeBodyPartContent(MIMEBodyPart.java:75)
    at com.blah.core.net.MIMEWriter.writePart(MIMEWriter.java:65)

Мы помещаем некоторый журнал в метод HTTPMessage.close(), который регистрировал трассировку стека вызывающего и доказал, что это определенно поток финализатора, вызывающий HTTPMessage.finalize(), пока выполняется IOUtil.copy().

Объект MIMEBodyPart определенно доступен из текущего стека потоков как this в фрейме стека для MIMEBodyPart.writeBodyPartContent. Я не понимаю, почему JVM будет называть finalize().

Я попытался извлечь соответствующий код и запустить его в узком цикле на моей собственной машине, но я не могу воспроизвести проблему. Мы можем надежно воспроизвести проблему с высокой нагрузкой на одном из наших серверов-разработчиков, но все попытки создать меньший воспроизводимый тестовый пример потерпели неудачу. Код компилируется под Java 7, но выполняется под Java 8. Если мы перейдем на Java 7 без перекомпиляции, проблема не возникает.

В качестве обходного пути я переписал затронутый код с помощью библиотеки электронной почты Java Mail MIME, и проблема исчезла (предположительно, Java Mail не использует finalize()). Тем не менее, я обеспокоен тем, что другие методы finalize() в приложении могут быть вызваны некорректно или что Java пытается уничтожить все объекты, которые все еще используются.

Я знаю, что действующая передовая практика рекомендует использовать finalize(), и я, вероятно, вернусь к этой домашней библиотеке, чтобы удалить методы finalize(). Говоря это, кто-нибудь сталкивался с этим вопросом раньше? Кто-нибудь имеет какие-либо идеи относительно причины?

Ответ 1

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

См. этот другой ответ для примера того, как объект может быть GC'ed, в то время как локальная переменная, ссылающаяся на него, все еще находится в области видимости.

Вот пример того, как объект может быть финализирован при активном вызове метода экземпляра:

class FinalizeThis {
    protected void finalize() {
        System.out.println("finalized!");
    }

    void loop() {
        System.out.println("loop() called");
        for (int i = 0; i < 1_000_000_000; i++) {
            if (i % 1_000_000 == 0)
                System.gc();
        }
        System.out.println("loop() returns");
    }

    public static void main(String[] args) {
        new FinalizeThis().loop();
    }
}

Пока метод loop() активен, нет никакой возможности какого-либо кода делать что-либо со ссылкой на объект FinalizeThis, поэтому он недоступен. И поэтому он может быть доработан и GC'ed. В JDK 8 GA это печатает следующее:

loop() called
finalized!
loop() returns

каждый раз.

Что-то подобное может происходить с MimeBodyPart. Сохраняется ли она в локальной переменной? (Кажется, так, поскольку код, похоже, придерживается соглашения о том, что поля называются префиксом m_.)

UPDATE

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

    public static void main(String[] args) {
        FinalizeThis finalizeThis = new FinalizeThis();
        finalizeThis.loop();
    }

С этим изменением он не соблюдал финализацию, и я тоже. Однако, если это дальнейшее изменение сделано:

    public static void main(String[] args) {
        FinalizeThis finalizeThis = new FinalizeThis();
        for (int i = 0; i < 1_000_000; i++)
            Thread.yield();
        finalizeThis.loop();
    }

завершается еще раз. Я подозреваю, что причина в том, что без цикла метод main() интерпретируется, а не компилируется. Интерпретатор, вероятно, менее агрессивен в анализе достижимости. С использованием цикла доходности метод main() компилируется, и компилятор JIT обнаруживает, что FinalizeThis стал недоступным, когда выполняется метод loop().

Другим способом запуска этого поведения является использование опции -Xcomp для JVM, которая заставляет методы скомпилировать JIT перед выполнением. Я бы не запускал все приложение таким образом - JIT-компиляция все может быть довольно медленной и занимать много места - но это полезно для очистки таких случаев в небольших тестовых программах, вместо того, чтобы перебирать петли.

Ответ 2

Ваш финализатор неправильный.

Во-первых, ему не нужен блок catch, и он должен вызывать super.finalize() в своем собственном блоке finally{}. Каноническая форма финализатора такова:

protected void finalize() throws Throwable
{
    try
    {
        // do stuff
    }
    finally
    {
        super.finalize();
    }
}

Во-вторых, вы предполагаете, что у вас есть единственная ссылка на m_stream, которая может быть или не быть правильной. Член m_stream должен финализировать себя. Но для этого вам не нужно ничего делать. В конечном итоге m_stream будет FileInputStream или FileOutputStream или потоком сокета, и они уже правильно финализируются.

Я просто удалю его.