Теория и практика Java: Устранение утечек памяти посредством слабых ссылок

Слабые ссылки упрощают выражение связей жизненного цикла объекта

Несмотря на то, что теоретически программы, написанные на языке программирования Java™, устойчивы к "утечкам памяти (memory leaks)", случаются ситуации, когда при очистке памяти не удаляются объекты, даже если они не являются больше частью логического состояния программы. В этом месяце специалист по отладке ПО Брайан Гетц рассмотрит наиболее распространенную причину случайного удержания в памяти объекта и покажет, как устранить утечку при помощи слабых ссылок.

Брайан Гетц, главный консультант, Quiotix

Брайан Гетц (Brian Goetz) - консультант по ПО и последние 15 лет работал профессиональными разработчиком ПО. Сейчас он является главным консультантом в фирме Quiotix, занимающейся разработкой ПО и консалтингом и находящейся в Лос-Альтос, Калифорния. Следите за публикациями Брайана в популярных промышленных изданиях. Вы можете связаться с Брайаном по адресу brian@quiotix.com



02.03.2007

В ходе сборки мусора (garbage collection, GC) возвращение памяти, занятой объектами, более не использующимися программой, требует, чтобы логическое время существования объекта (тот период времени, когда он используется приложением) и фактическое время существования незавершенных связей с этим объектом было одинаковым. В большинстве случаев, грамотные приемы проектирования ПО гарантируют автоматическое выполнение этого условия, без необходимости уделять особое внимание проблеме времени существования объекта. Но время от времени мы создаем ссылку, которая удерживает объект в памяти дольше, чем ожидалось; подобную ситуацию называют случайным удержанием объекта в памяти.

Утечки памяти и глобальные карты распределения

Наиболее распространенной причиной случайного удержания объекта в памяти является использование карты распределения Map для связи метаданных с временными объектами. Скажем, у Вас имеется объект со средним временем существования - более продолжительным, чем время существования метода вызова, но более коротким, по сравнению с временем существования приложения - например канал подключения клиента. Вы хотите связать некоторые метаданные с этим каналом, например идентификатор пользователя, установившего соединение. Вам неизвестна эта информация при создании объекта Socket (Соединение), и вы не можете добавить данные объекту Socket поскольку не контролируете класс Socket или его экземпляр. В данном случае обычным приемом является хранение такой информации в глобальной карте распределения Map, как это сделано в классе SocketManager в Листинге 1:

Листинг 1. Использование глобальной карты распределения Map для соотнесения метаданных с объектом
public class SocketManager {
    private Map<Socket,User> m = new HashMap<Socket,User>();
    
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
    public void removeUser(Socket s) {
        m.remove(s);
    }
}
SocketManager socketManager;
...
socketManager.setUser(socket, user);

Недостатком данного подхода является то, что время существования метаданных необходимо привязать к времени существования соединения. Когда вы будете точно знать, что программе больше не требуется данное соединение, Вам нужно будет не забыть удалить соответствующую запись из карты распределения (Map), иначе объекты Соединение (Socket) и Пользователь (User) будут существовать в Map всегда - долгое время после того, как запрос был обслужен, а соединение закрыто. Это не позволит очистить память, занимаемую объектами Socket и User, даже если они никогда больше не будут использованы в работе приложения. Будучи бесконтрольным, данное явление легко может привести к переполнению памяти, если программа работает достаточно долго. В большинстве случаев приемы для определения того, что Socket больше не требуется программе, напоминают раздражающие и способствующие ошибкам приемы, необходимые для неавтоматизированного управления памятью.


Обнаружение утечек памяти

Первым знаком того, что Ваша программа имеет утечку памяти, обычно становится выводимое ею сообщение об ошибке OutOfMemoryError (Нехватка Памяти), или неудовлетворительная производительность программы из-за частой очистки памяти. К счастью, сборщик мусора позволяет получить доступ к большому количеству информации, которая может быть использована для обнаружения утечки памяти. Если Вы активизируете JVM с опцией -verbose:gc или -Xloggc, сообщение об обнаружении ошибки выводится на экран или в лог-файл при каждом запуске сборщика мусора, и содержит также информацию о затраченном времени, текущем использовании динамической памяти и объеме восстановленной памяти. Сбор данных сборщиком мусора и занесение их в лог-файл не докучает Вам, поэтому разумно разрешить эту опцию для сборщика мусора по умолчанию на случай, если Вам когда-либо придется анализировать проблемы памяти или настраивать сборщик мусора.

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

В Листинге 2 приведен пример программы с утечкой памяти. MapLeaker обрабатывает задания в потоковом пуле и записывает состояние каждого задания в Map. К сожалению, она не удаляет записи по завершении выполнения задания, поэтому записи состояния и объекты заданий (а также их внутреннее состояние) постоянно накапливаются.

Листинг 2. Программа с утечкой памяти, связанной с картой распределения
public class MapLeaker {
    public ExecutorService exec = Executors.newFixedThreadPool(5);
    public Map<Task, TaskStatus> taskStatus 
        = Collections.synchronizedMap(new HashMap<Task, TaskStatus>());
    private Random random = new Random();

    private enum TaskStatus { NOT_STARTED, STARTED, FINISHED };

    private class Task implements Runnable {
        private int[] numbers = new int[random.nextInt(200)];

        public void run() {
            int[] temp = new int[random.nextInt(10000)];
            taskStatus.put(this, TaskStatus.STARTED);
            doSomeWork();
            taskStatus.put(this, TaskStatus.FINISHED);
        }
    }
    public Task newTask() {
        Task t = new Task();
        taskStatus.put(t, TaskStatus.NOT_STARTED);
        exec.execute(t);
        return t;
    }
}

На рисунке 1 показан график изменения загрузки динамической памяти приложением после очистки памяти для MapLeaker с течением времени. Изменение кривой по восходящей - показатель наличия утечки памяти. (В настоящих приложениях угол наклона кривой не бывает настолько резким, но тенденция становится очевидной, если данные сборщика мусора собираются достаточно долго.)

Рисунок 1. Устойчивая восходящая динамика использования памяти
Устойчивая восходящая динамика использования памяти

После того, как Вы убедились, что имеет место утечка памяти, следующим Вашим шагом будет установление того, какой тип объектов является ее причиной. Любой профилировщик памяти позволяет сделать моментальный снимок состояния памяти, дефрагментированной объектным классом. Существуют прекрасные коммерческие программы, предоставляющие такую возможность, но Вам не придется тратить деньги для обнаружения утечек памяти - встроенный инструмент hprof также может выполнить эту задачу. Чтобы использовать hprof и отследить использование памяти вызовите JVM с опцией -Xrunhprof:heap=sites.

В Листинге 3 показана соответствующая часть выходных данных hprof, иллюстрирующая нарушение использования памяти приложением. (Инструмент hprof прерывает использование памяти по завершении работы приложения или после подачи сигнала kill -3 приложению или при нажатии Ctrl+Break в Windows.) Обратите внимание на заметное увеличение у объектов Map.Entry, Task и int[] между двумя снимками.

См. Листинг 3.

В Листинге 4 приведена другая часть выходных данных hprof, содержащая информацию о стеке вызовов к местам размещения для объектов Map.Entry. Эти данные сообщают нам, какие цепочки вызовов генерируют объекты Map.Entry; проведя анализ программы, обычно достаточно просто обнаружить источник утечки памяти.

Листинг 4. Выходные данные HPROF, показывающие место размещения для объектов Map.Entry
TRACE 300446:
	java.util.HashMap$Entry.<init>(<Unknown Source>:Unknown line)
	java.util.HashMap.addEntry(<Unknown Source>:Unknown line)
	java.util.HashMap.put(<Unknown Source>:Unknown line)
	java.util.Collections$SynchronizedMap.put(<Unknown Source>:Unknown line)
	com.quiotix.dummy.MapLeaker.newTask(MapLeaker.java:48)
	com.quiotix.dummy.MapLeaker.main(MapLeaker.java:64)

Слабые ссылки спешат на помощь

Проблема с SocketManager заключается в том, что время существования ссылки Socket-User должно совпадать со временем существования Socket, но язык программирования не дает нам возможности для простой реализации этого условия. Это вынуждает программу прибегать к приемам, напоминающим управление памятью вручную. К счастью, начиная с JDK 1.2, программа очистки памяти дает возможность объявлять такие зависимости жизненного цикла объектов, с тем, чтобы сборщик мусора мог помочь нам избежать утечек памяти такого рода - с помощью слабых ссылок.

Слабая ссылка - это хранитель ссылки на объект, называемый объектом ссылки (референтом, referent). Посредством слабых ссылок Вы можете сохранять ссылку на объект ссылки, не препятствуя его обработке сборщиком мусора. Когда сборщик мусора обследует динамическую память, объект ссылки будет обработан сборщиком мусора, если существующие внешние связи объекта это только слабые ссылки, так, словно внешних связей нет совсем, и любые эти ссылки будут стерты. (Объект, имеющий только слабые ссылки, называется слабо доступным.)

Объект ссылки слабой связи WeakReference создается при работе конструктора и его значение, если он не был еще стерт, может быть получено посредством метода get(). Если слабые ссылки были стерты (потому что объект ссылки был обработан сборщиком мусора, или потому что был вызван метод WeakReference.clear()), get() возвращает null. Соответственно, необходимо всегда проверять, является ли возвращенное методом get() значение ненулевым, прежде чем использовать его результат, поскольку предполагается, что со временем объект ссылки будет обработан сборщиком мусора.

Копируя ссылку на объект используя обычные (сильные) ссылки, Вы налагаете ограничение на время существования объекта ссылки, которое должно быть по меньшей мере таким же, как и у копируемой ссылки. Если вы не внимательны, оно может быть таким же, как у Вашей программы - как когда Вы помещаете объект в глобальную коллекцию. С другой стороны, создавая слабую ссылку на объект, Вы не увеличиваете время существования объекта ссылки совсем; Вы просто обеспечиваете альтернативный способ обращения к нему пока он еще существует.

Слабые ссылки наиболее полезны при создании слабых коллекций, таких, как хранящие метаданные об объектах ровно столько, сколько приложение их (объекты) использует - именно то, что должен делать класс SocketManager. Поскольку это обычное использование слабых ссылок, WeakHashMap, использующая слабые ссылки для ключей (а не значений), была также добавлена в класс библиотек в JDK 1.2. Если Вы используете объект как ключ в обычной HashMap, он не может быть обработан сборщиком мусора пока запись не удалена из карты распределения Map; WeakHashMap позволяет Вам использовать объект как ключ Map, не препятствуя его обработке сборщиком мусора. В Листинге 5 приведена возможная реализация метода get() из WeakHashMap, демонстрирующая использование слабых ссылок:

Листинг 5. Возможная реализация метода WeakReference.get()
public class WeakHashMap<K,V> implements Map<K,V> {

    private static class Entry<K,V> extends WeakReference<K> 
      implements Map.Entry<K,V> {
        private V value;
        private final int hash;
        private Entry<K,V> next;
        ...
    }

    public V get(Object key) {
        int hash = getHash(key);
        Entry<K,V> e = getChain(hash);
        while (e != null) {
            K eKey= e.get();
            if (e.hash == hash && (key == eKey || key.equals(eKey)))
                return e.value;
            e = e.next;
        }
        return null;
    }

При вызове метода WeakReference.get() будет возвращена сильная ссылка на объект ссылки (если он еще существует), поэтому можно не волноваться по поводу исчезновения карты размещения информации в теле цикла while, поскольку сильная ссылка препятствует обработке объекта сборщиком мусора. Реализация метода WeakHashMap демонстрирует простое выражение со слабыми ссылками - какой-то внутренний объект расширяет WeakReference. Причина этого будет объяснена в следующем разделе, когда мы обсудим очереди ссылок.

При добавлении записи размещения информации в WeakHashMap, помните, что эта запись размещения может позднее стать "выпавшей" (нерабочей), поскольку ключ будет обработан сборщиком мусора. В этом случае get() возвращает null, что делает проверку возвращаемого get() значения (проверку, является ли это значение null) еще более важной, чем обычно.

Устранение утечки с помощью WeakHashMap

Устранение утечки в SocketManager несложно; просто замените HashMap на WeakHashMap, как показано в Листинге 6. (Если SocketManager должен быть поточно-ориентированным, Вы можете перенести WeakHashMap посредством Collections.synchronizedMap()). Данный способ может использоваться всегда, когда время существования карты размещения должно быть привязано к времени существования ключа. Тем не менее, не следует злоупотреблять этим приемом; в большинстве случаев обычная HashMap является подходящей для использования реализацией Map.

Листинг 6. Исправление SocketManager при помощи WeakHashMap
public class SocketManager {
    private Map<Socket,User> m = new WeakHashMap<Socket,User>();
    
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
}

Очереди ссылок

WeakHashMap использует слабые ссылки для хранения ключей карт, что позволяет объектам ключей быть обработанными программой очистки памяти, когда они более не используются приложением, а такая реализация метода get() как WeakReference.get() может распознать, является ли объект используемым или нет, возвращая значение null в последнем случае. Но это лишь половина того, что нужно для удержания использования памяти картой распределения Map от возрастания на протяжении жизненного цикла приложения; что-то еще должно быть сделано для удаления неиспользуемых более записей из Map после того, как ключ был обработан сборщиком мусора. Иначе Map просто будет переполнена записями, соответствующими "мертвым" ключам. И хотя приложение не будет этого видеть, может возникнуть перерасход памяти приложением, поскольку Map.Entry и объекты, хранящие значения, не будут обработаны сборщиком мусора, несмотря на то, что обработаны ключи.

Неиспользующиеся записи могут быть обнаружены и удалены периодическим сканированием Map посредством вызова get() к каждой слабой ссылке и удалением записи, если возвращаемое get() значение - null. Но этот способ малопродуктивен, если Map хранит много рабочих записей. Было бы весьма удобно получать уведомление в случае, если объект ссылки был обработан программой очистки памяти. Именно эту задачу выполняют очереди ссылок.

Очереди ссылок - основное средство сборщика мусора для предоставления обратной информации приложению о жизненном цикле объектов. Слабые ссылки имеют два конструктора: один из которых в качестве аргумента принимает только объект ссылки, а второй - также и очередь ссылок. Если слабая ссылка была создана с использованием связанной очереди ссылок и объект ссылки подлежит обработке сборщиком мусора, ссылочный объект (не объект ссылки, референт) помещается в очередь в очереди ссылок после удаления ссылки. Приложение тогда может получить ссылку из очереди ссылок и, зная, что референт был обработан сборщиком мусора, выполнить соответствующие действия, например удаление записей для объектов, не попавших в слабую коллекцию. (Очереди ссылок имеют те же способы исключения из очереди, что и BlockingQueue - обработка и удаление по порядку, блокировка по времени и без времени.)

WeakHashMap имеет также собственный метод expungeStaleEntries(), который вызывается при большинстве операций Map, по порядку обрабатывающий в очереди ссылок любые нерабочие ссылки и удаляющий ассоциированные отображения. Возможная реализация метода expungeStaleEntries() приведена в Листинге 7. Тип Entry, используемый для хранения отображений ключ-значение, является расширением WeakReference, поэтому когда expungeStaleEntries() запрашивает следующую нерабочую ссылку, ему возвращается Entry. Использование очередей ссылок для очистки Map вместо периодического поиска по всему ее содержимому более эффективно, поскольку рабочие записи никогда не затрагиваются в процессе очистки; очистка происходит только при наличии ссылок, действительно помещенных в очередь.

Листинг 7. Возможная реализация метода WeakHashMap.expungeStaleEntries()
    private void expungeStaleEntries() {
	Entry<K,V> e;
        while ( (e = (Entry<K,V>) queue.poll()) != null) {
            int hash = e.hash;

            Entry<K,V> prev = getChain(hash);
            Entry<K,V> cur = prev;
            while (cur != null) {
                Entry<K,V> next = cur.next;
                if (cur == e) {
                    if (prev == e)
                        setChain(hash, next);
                    else
                        prev.next = next;
                    break;
                }
                prev = cur;
                cur = next;
            }
        }
    }

Заключение

Слабые ссылки и коллекции - мощный инструмент для управления динамически распределяемой памятью, позволяющие приложению использовать более сложные виды достижимости, чем "все или ничего", обеспечиваемой обычными (сильными) ссылками. В следующем месяце мы рассмотрим гибкие ссылки, нередко используемые со слабыми ссылками, и изучим поведение сборщика мусора при наличии слабых и гибких ссылок.

Ресурсы

Научиться

  • Оригинал статьи: Plugging memory leaks with weak references.
  • "Настройка очистки памяти в HotSpot JVM": Кирк Пеппердайн (Kirk Pepperdine) и Джек Ширази (Jack Shirazi) покажут, как даже незначительная утечка памяти со временем начинает оказывать сильное давление на сборщик мусора.
  • "HPROF": В данном документе компании Sun описано использование встроенного инструмента HPROF для анализа профиля.
  • Ссылочные объекты и сборка мусора: В данном документе компании Sun, написанном вскоре после добавления ссылочных объектов в библиотеку классов, описано, как сборщик мусора обрабатывает ссылочные объекты.
  • Теория и практика Java: Полное собрание статей Брайана Гетца.
  • Раздел Java-технологии: Сотни статей по каждому аспекту программирования на Java.

Получить продукты и технологии

  • JTune: Бесплатный инструмент JTune, позволяющий обрабатывать регистрационные записи сборщика мусора, представлять в графическом виде объем динамической памяти, продолжительность сборки мусора (процесса очистки памяти) и другие полезные данные по управлению памятью.

Обсудить

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=199683
ArticleTitle=Теория и практика Java: Устранение утечек памяти посредством слабых ссылок
publish-date=03022007