Неправильное освобождение ресурсов — одна из наиболее часто допускаемых ошибок среди Java-программистов. Под ресурсом в данной статье я буду подразумевать всё, что реализует интерфейс java.io.Closeable
. Итак, сразу к делу.
Будем рассматривать на примере OutputStream
. Задача: получить на вход OutputStream
, сделать некоторую полезную работу с ним, закрыть OutputStream
.
Неправильное решение №1
OutputStream stream = openOutputStream();
// что-то делаем со stream
stream.close();
Данное решение опасно, потому что если в коде сгенерируется исключение, то stream.close() не будет вызван. Произойдет утечка ресурса (не закроется соединение, не будет освобожден файловый дескриптор и т.д.)
Неправильное решение №2
Попробуем исправить предыдущий код. Используем try-finally
:
OutputStream stream = openOutputStream();
try {
// что-то делаем со stream
} finally {
stream.close();
}
Теперь close()
всегда будет вызываться (ибо finally
): ресурс в любом случае будет освобождён. Вроде всё правильно. Ведь так?
Нет.
Проблема следующая. Метод close()
может сгенерировать исключение. И если при этом основной код работы с ресурсом тоже выбросит исключение, то оно перезатрется исключением из close()
. Информация об исходной ошибке пропадёт: мы никогда не узнаем, что было причиной исходного исключения.
Неправильное решение №3
Попробуем исправить ситуацию. Если stream.close()
может затереть «главное» исключение, то давайте просто «проглотим» исключение из close()
:
OutputStream stream = openOutputStream();
try {
// что-то делаем со stream
} finally {
try {
stream.close();
} catch (Throwable unused) {
// игнорируем
}
}
Теперь вроде всё хорошо. Можем идти пить чай.
Как бы не так. Это решение ещё хуже предыдущего. Почему?
Потому что мы просто взяли и проглотили исключение из close()
. Допустим, что outputStream
— это FileOutputStream
, обёрнутый в BufferedOutputStream
. Так как BufferedOutputStream
делает flush()
на низлежащий поток порциями, то есть вероятность, что он его вызовет во время вызова close()
. Теперь представим, что файл, в который мы пишем, заблокирован. Тогда метод close()
выбросит IOException
, которое будет успешно «съедено». Ни одного байта пользовательских данных не записались в файл, и мы ничего об этом не узнали. Информация утеряна.
Если сравнить это решение с предыдущим, то там мы хотя бы узнаем, что произошло что-то плохое. Здесь же вся информация об ошибке пропадает.
Замечание: если вместо OutputStream
используется InputStream
, то такой код имеет право на жизнь. Дело в том, что если в InputStream.close()
выбрасывается исключение, то (скорее всего) никаких плохих последствий не будет, так как мы уже считали с этого потока всё что хотели. Это означает, что InputStream
и OutputStream
имеют совершенно разную семантику.
Неидеальное решение
Итак, как же всё-таки правильно выглядит код обработки ресурса?
Нам нужно учесть, что если основной код выбросит исключение, то это исключение должно иметь приоритет выше, чем то, которое может быть выброшено методом close()
. Это выглядит так:
OutputStream stream = openOutputStream();
Throwable mainThrowable = null;
try {
// что-то делаем со stream
} catch (Throwable t) {
// сохраняем исключение
mainThrowable = t;
// и тут же выбрасываем его
throw t;
} finally {
if (mainThrowable == null) {
// основного исключения не было. Просто вызываем close()
stream.close();
}
else {
try {
stream.close();
} catch (Throwable unused) {
// игнорируем, так как есть основное исключение
// можно добавить лог исключения (по желанию)
}
}
}
Минусы такого решения очевидны: громоздко и сложно. Кроме того, пропадает информация об исключении из close()
, если основной код выбрасывает исключение. Также openOutputStream()
может вернуть null
, и тогда вылетит NullPointerException
(решается добавлением еще одного if'а, что приводит к ещё более громоздкому коду). Наконец, если у нас будет два ресурса (например, InputStream
и OutputStream
) и более, то код просто будет невыносимо сложным.
Правильное решение (Java 7)
В Java 7 появилась конструкция try-with-resources
. Используем её:
try (OutputStream stream = openOutputStream()) {
// что-то делаем со stream
}
И всё.
Если исключение будет выброшено в основном коде и в методе close()
, то приоритетнее будет первое исключение, а второе исключение будет подавлено, но информация о нем сохранится (с помощью метода Throwable.addSuppressed(Throwable exception)
, который вызывается неявно Java компилятором):
Exception in thread "main" java.lang.RuntimeException: Main exception
at A$1.write(A.java:16)
at A.doSomething(A.java:27)
at A.main(A.java:8)
Suppressed: java.lang.RuntimeException: Exception on close()
at A$1.close(A.java:21)
at A.main(A.java:9)
Правильное решение (Java 6 с использованием Google Guava)
В Java 6 средствами одной лишь стандартной библиотеки не обойтись. Однако нам на помощь приходит замечательная библиотека Google Guava. В Guava 14.0 появился класс com.google.common.io.Closer
(try-with-resources
для бедных), с помощью которого неидеальное решение выше можно заметно упростить:
Closer closer = Closer.create();
try {
OutputStream stream = closer.register(openOutputStream());
// что-то делаем со stream
} catch (Throwable e) { // ловим абсолютно все исключения (и даже Error'ы)
throw closer.rethrow(e);
} finally {
closer.close();
}
Решение заметно длиннее, чем в случае Java 7, но всё же намного короче неидеального решения. Вывод будет примерно таким же, как Java 7.
Closer
также поддерживает произвольное количество ресурсов в нём (метод register(...)
). К сожалению, Closer
— это класс, помеченный аннотацией @Beta
, а значит может подвергнуться значительным изменениям в будущих версиях библиотеки (вплоть до удаления).
Выводы
Правильно освобождать ресурсы не так просто, как кажется (просто только в Java 7). Всегда уделяйте этому должное внимание. InputStream
и OutputStream
(Reader
и Writer
) обрабатываются по-разному (по крайней мере в Java 6)!
Дополнения/исправления приветствуются!
В следующий раз я планирую рассказать, как бороться с NullPointerException
.
Автор: orionll