Санкт-Петербургская группа тестирования JVM


« Конференция SIMagine... | Main | VisualVM »
20071009 вторник Октябрь 09, 2007

Помните об оптимизации!

В одной из наших предыдущих статей мы рассказывали о том, как совершенно неожиданно для многих Java-программистов компиляция JIT может повлиять на результаты работы программы: рассматривался случай, когда при написании программы для тестирования производительности методов класса String не была учтена возможность JIT компиляции, и в результате 'тестирование' показало очень странные результаты. Однако JIT компиляцию необходимо брать в расчёт не только при тестировании производительности. Существует возможность того, что оптимизации, выполняемые JIT компилятором, могут повлиять на не только на скорость работы вашей программы.

Для начала рассмотрим код, неожиданное поведение которого стало причиной написания этой статьи.

Метод 'testLogger' выполняет простейшее тестирование методов Logger.getLogger(String name) и LogManager.getLogger(String name):

1: void testLogger() {
2: String name = "TestLogger";
3: Logger logger = Logger.getLogger(name);
4: LogManager.getLogManager().getLogger(name).log(Level.SEVERE, "Logger works");
5: }

Метод Logger.getLogger пытается найти Logger с указанным именем среди уже созданных Logger'ов, и если поиск заканчивается неудачей, создаётся новый объект Logger. Каждый новый Logger сохраняется LogManager'ом при помощи метода LogManager.addLogger(Logger logger). Метод LogManager.getLogger пытается найти сохранённый Logger с заданным именем и в случае неудачи возвращает null.

Метод testLogger выглядит очень просто и безобидно: строка 3 создет и регистрирует Logger с именем 'TestLogger', а следующая строка запрашивает у LogManager'а только что созданный Logger и с его помощью выводит сообщение 'Logger works'. И на самом деле, в большинстве случаев метод будет работать как и ожидается, то есть, выводить сообщение 'Logger works'.

Но простота этого метода обманчива—он может сотню раз выполниться так, как вы и планировали, но на сто первый произойдет генерация исключения NullPointerException. Причём, такое поведение не будет противоречить ни спецификации LoggerManager'а, ни спецификации Java. Что может стать причиной NPE? В качестве занимательного упражнения вы можете самостоятельно найти объяснение возможности возникновения NullPointerException в данном коде, почитав документацию на LogManager и раздел 12.6 спецификации Java.

Ну а тот, кто хочет узнать ответ быстрее, найдёт его ниже.

При использовании early access версии JDK7 в результате выполнения метода testLogger будет брошено исключение NullPointerException если:

Это экзотическое сочетание факторов делает крайне сложной диагностику и объяснение необычного поведения метода testLogger, ведь и JIT компиляция, и сборка мусора могут произойти в абсолютно любой момент и практически никак не контролируются программистом.

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

Для того, чтобы спровоцировать JIT компиляцию, вызываем метод testLogger в цикле 20000 раз, а сборка мусора запускается с помощью System.gc():

// TestLogger.java
import java.util.logging.*;

public class TestLogger {

static void testLogger(boolean provokeGC) {
String name = "TestLogger";
Logger logger = Logger.getLogger(name);
if (provokeGC)
System.gc();
LogManager.getLogManager().getLogger(name).log(Level.SEVERE, "Logger works");
}

public static void main(String[] args) {
for (int i = 0; i < 20000; i++) {
System.out.println("Iteration: " + i);
// провоцируем сборку мусора тогда, когда метод testLogger предположительно уже был скомпилирован
boolean provokeGC = i > 10000;
testLogger(provokeGC);
}
}
}

Запустим эту программу:

Iteration: 1
Sep 20, 2007 12:37:44 PM TestLogger testLogger
SEVERE: Logger works
...
...
Iteration: 10000
Sep 20, 2007 12:37:46 PM TestLogger testLogger
SEVERE: Logger works
Iteration: 10001
Exception in thread "main" java.lang.NullPointerException
at TestLogger.testLogger(TestLogger.java:11)
at TestLogger.main(TestLogger.java:19)

Также можно попробовать запустить программу с флагами виртуальной машины -XX:+PrintCompilation и -XX:+PrintGC и убедиться, что метод успешно работает до тех пор, пока не был скомпилирован, и пока во время его работы не произошла сборка мусора.

Откуда же здесь берётся NullPointerException? Для начала заглянем в реализацию классов Logger и LogManager. Как уже было сказано, Logger.getLogger создаёт Logger и сохраняет его с помощью метода LogManager.addLogger(Logger). После этого сохранённый Logger может быть получен с помощью LogManager.getLogger(String name).

LogManager.addLogger и LogManager.getLogger делают примерно следующее:

// LogManager.java

class LogManager {

private Hashtable<String,WeakReference<Logger>> loggers;

public boolean addLogger(Logger logger) {
WeakReference<Logger> ref = loggers.get(logger.getName());
if (ref != null) {
if (ref.get() == null) {
// Logger с заданным именем уже был создан, но был удалён сборщиком мусора
loggers.remove(name);
} else {
// Logger с заданным именем уже был создан
return false;
}
}
loggers.put(name, new WeakReference<Logger>(logger));

// инициализация Logger'а
...
...

return true;
}

public Logger getLogger(String name) {
WeakReference<Logger> ref = loggers.get(name);
if (ref == null) {
return null;
}
return ref.get();
}
Код метода testLogger функционально близок следующему коду:
1: void testLogger() {
2: String name = "TestLogger";
3: Logger logger = new Logger(name, null);
4: WeakReference<Logger> weakReference = new WeakReference<Logger>(logger);
5: weakReference.get().log(Level.SEVERE, "Logger works");
6: }

Спецификация метода LogManager.addLogger позволяет хранить слабые ссылки (WeakReference) на регистрируемые Logger-ы. Поэтому, спецификация также содержит предупреждение о том, что приложение должно самостоятельно хранить ссылку на используемый объект Logger. Иначе, он может быть удалён сборщиком мусора. Именно эта ситуация возникала при работе метода testLogger: если после выполнения строки 4 будет выполнялась сборка мусора, то созданный TestLogger мог быть удалён, так как на него в этот момент могло уже не остаться сильных ссылок. Поэтому, при выполнении строки 5 WeakReference.get() возвращал null, и мы получали NullPointerException.

Но как создаваемый в 3-ей строке объект Logger мог быть удалён сборщиком мусора? Ведь очевидно, что на момент вызова WeakReference.get() локальная переменная logger 'жива', и поэтому объект Logger должен расцениваться как достижимый и не может быть удалён никаким образом? Для того чтобы понять, какое поведение должно считаться корректным, обратимся к спецификации Java и посмотрим, что в ней говорится о достижимости объектов, и должен ли объект Logger считаться достижимым на всём протяжении работы метода testLogger.

К слову, первая и вторая редакции спецификации Java вообще не содержали никакого формального определения достижимости объекта. В последнюю редакцию спецификации был добавлен ряд формальных правил, относящихся к достижимости, но, пожалуй, основным определением всё-таки осталось следующее, неформальное (раздел 12.6.1):

Достижимым называется объект, который потенциально может быть использован в вычислениях живым (alive) пото������ом. Оптимизирующие преобразования программы могут сократить число достижимых объектов. Например, компилятор может записать в неиспользуемую переменную значение null, тем самым давая сборщику мусора возможность освободить память, занимаемую этой переменной. Таким образом, не все объекты, которые могут наивно (naively) считаться достижимыми, будут на самом деле достижимы в результате оптимизации кода.

 

В нашем случае JIT компилятор видит, что после создания слабой ссылки переменная logger больше нигде не используется, и поэтому ссылка на неё может быть удалена. То есть, после компиляции фактически получается следующий код:

void testLogger() {
String name = "TestLogger";
Logger logger = new Logger(name, null);
WeakReference<Logger> weakReference = new WeakReference<Logger>(logger);
logger = null;
weakReference.get().log(Level.SEVERE, "Logger works");
}

Думаю, что возникновение NullPointerException при выполнении этого кода уже не кажется чем-то невероятым.

Может показаться странным, что такая оптимизация разрешена, раз она потенциально может привести к подобным результатам. На самом деле, оправданность обнуления ссылок была бы более очевидна, если бы, например, на месте Logger-а был бы объект, занимающий большой объём памяти, а остаток метода, не использующий этот объект, выполнял бы длительные вычисления, требующие наличия свободной памяти:

someMethod() {
ObjectConsumingALotOfMemory object = new ObjectConsumingALotOfMemory();
object.doSomeActions();
// выполнить длительные вычисления, не использующие 'object'
// ...
}

 

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

Рассмотрим следующий код, который многие Java программисты расценят как абсолютно корректный:

class ClassUsingOutputStream {
OutputStream out = new SomeOutputStream();

void writeByte100Times(byte b) {
for (int i = 0; i < 100; i++)
out.write(b);
}

public void finalize() {
out.close();
}

public static void main(String[] args) {
ClassUsingOutStream localVar = new ClassUsingOutStream();
localVar.writeByte100Times((byte)1);
}
}

Класс ClassUsingOutputStream создаёт в конструкторе поток вывода и гарантирует, что этот поток будет закрыт после того, как объект ClassUsingOutputStream перестанет использоваться, закрывая поток в финализаторе.

А теперь посмотрим, что может произойти в результате оптимизации метода main. Во-первых, метод writeByte100Times может быть включен (inlined) в метод main:

ClassUsingOutStream localVar = new ClassUsingOutputStream();
for (int i = 0; i < 100; i++)
localVar.out.write(1);

После этого, компилятор может решить, что переменная localVar не должна использоваться внутри цикла. Это поможет компилятору выполнить другие оптимизации:

1: ClassUsingOutStream localVar = new ClassUsingOutputStream();
2: OutStream out = localVar.out;
3: for (int i = 0; i < 100; i++)
4: out.write(1);

Теперь получается, что localVar после выполнения 2-й строчки метода больше не нужна, и ссылка на неё может быть удалена:

ClassUsingOutStream localVar = new ClassUsingOutputStream();
OutStream out = localVar.out;
localVar = null;
for (int i = 0; i < 100; i++)
out.write(1);

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

А теперь вспомним приведённые в спецификации слова, относящиеся к достижимости:

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

Приведённый пример—типичный способ использования финализации. Многие классы с финализаторами написаны похожим образом, но с точки зрения специфкации такой код неверен, поскольку он не защищён от преждевременного вызова финализатора. Конечно, на сегодняшний день существует множество приложений, использующих финализацию именно так (без какой либо дополнительной синхронизации между финализатором объекта и остальными его методами) и при этом работающих без проблем и сбоев. Да и вероятность столкнуться с преждевременной финализацией крайне мала: для этого, во-первых, должен использоваться очень оптимизирующий компилятор (я не уверен, что компиляторы, выполняющие подобные оптимизации, существуют на сегодняшний день), и, во-вторых, сборка мусора должна произойти в строго определённый момент. Но кто знает, может быть, всё изменится с выходом следующих версий виртуальных машин. Поэтому, даже если эта статья не заставит вас немедленно переписывать ваш код, об этой проблеме стоит помнить.

Как же должен выглядеть корректный код, использующий финализацию? Поскольку 1-я и 2-я редакции спецификации Java не содержали никакого формального описания достижимости объекта и не давали никаких гарантий, касающихся финализации, то получается, что до появления 3-й редакции невозможно было написать программу, использующую финализацию "кошерно". Более формальное описание финализации было добавлено в спецификацию в рамках новой модели памяти, JSR-133. Согласно новой спецификации, код, приведённый в примере, теперь считается ошибочным. Возможно, тот факт, что при разработке новой спецификации было принято именно такое решение, может удивить, ведь в результате этого были 'сломаны' большинство программ, использующих финализацию.

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

Текущая спецификация содержит следующие гарантии, относящиеся к финализации (здесь приведены наиболее важные положения):

Чтобы финализатор объекта не был вызван раньше времени, необходимо сделать так, чтобы объект был гарантированно достижим на протяжении работы его методов. Синхронизация по объекту гарантирует его достижимость, но только в том случае, если финализатор также синхронизируется по этому же объекту. Следовательно, самый простой способ предотвратить преждевременную финализацию—сделать все методы объекта, включая финализатор, синхронизированными (synchronized).

Конечно, такой способ далёк от идеала, и в процессе обсуждения JSR-133 были предложения добавить в стандартный API Java метод keepAlive, вызов которого бы гарантировал достижимость объекта на момент вызова этого метода. То есть, добавив вызов keepAlive в конец каждого метода класса, использующего финализацию, можно было бы решить нашу проблему. Однако, keepAlive добавлен не был.

Тем не менее, некоторое подобие keepAlive можно реализовать самостоятельно, причём без использования какого-либо native кода. Так, Hans Boehm, один из участников р��зр��ботки JSR-133, предлагает способ, который, возможно, чуть более изящен, чем объявление всех методов объекта синхронизированными: keepAlive должен быть реализован как пустой synchronized метод класса, использующего финализацию. Вызов этого метода должен быть добавлен в конец каждого метода класса. А что бы всё заработало, необходимо в начало финализатора добавить пустой блок 'synchronized (this) {}'.

Корректная версия класса ClassUsingOutputStream, использующая этот подход, выглядит так:

class ClassUsingOutputStream {
OutputStream out = new SomeOutputStream();

void writeByte100Times(byte b) {
for (int i = 0; i < 100; i++)
out.write(b);

keepAlive(); } public void finalize() { synchronized (this) {} out.close(); } synchronized void keepAlive() {} public static void main(String[] args) { ClassUsingOutStream localVar = new ClassUsingOutStream(); localVar.writeByte100Times((byte)1); } }

Конечно, этот код выглядит очень странно и некрасиво (и, думаю, даже несмотря на название метода keepAlive, неосведомлённым людям будет совершенно непонятно, чего пытается добиться этот код), но на сегодняшний день спецификация не предлагает более простого и красивого решения проблемы преждевременного вызова финализатора.

Ссылки:
Странности финализации
The Java Language Specification
Finalization, Threads, and the Java™ Technology-Based Memory Model
Destructors, Finalizers, and Synchronization

Семён Бойков

опубликовал vmrobot ( окт 09 2007, 10:30:38 PM MSD ) Permalink Комментарии [1]

Trackback URL: http://blogs.sun.com/vmrobot/entry/%D0%BF%D0%BE%D0%BC%D0%BD%D0%B8%D1%82%D0%B5_%D0%BE%D0%B1_%D0%BE%D0%BF%D1%82%D0%B8%D0%BC%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D0%B8
Комментарии:

Век живи, век учись. Полезная статья. Но пугающая.

опубликовал Denis Tsyplakov Февраль 08, 2008 at 06:08 PM MSK #

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

Имя
E-Mail:
URL:

Ваш комментарий:

HTML Syntax: Отключен

Хиты страниц за сегодня: 26