Все java-разработчики, рано или поздно, встречаются с пресловутой ошибкой OutOfMemoryError.После этой встречи мы начинаем более бережно относится к используемой памяти, экономить ее. Начиная с версии 1.2 в Java появился пакет java.lang.ref.* с классами SoftReference, WeakReference, PhantomReference. Далее я расскажу вам о том, как помогут эти классы в борьбе с OutOfMemoryError. И что более интересно, приведу реальные примеры их использования. Начнем.
Общее Описание
Для начала немного общей теории. Вспомним, в общих чертах, как работает Garbage Collector (далее GC). Если не вдаваться в детали, то алгоритм прост: при запуске сборщика виртуальная машина рекурсивно находит, для всех потоков, все доступные объекты в памяти и помечает их неким образом. А на следующем шаге GC удаляет из памяти все непомеченные объекты. Таким образом, после чистки, в памяти будут находиться только те объекты, которые могут быть полезны программе. Идем дальше.
В Java есть несколько видов ссылок. Есть StrongReference — это самые обычные ссылки которые мы создаем каждый день.
StringBuilder builder = new StringBuilder();
builder это и есть strong-ссылка на объект StringBuilder. И есть 3 «особых» типа ссылок — SoftReference, WeakReference, PhantomReference. По сути, различие между всеми типами ссылок только одно — поведение GC с объектами, на которые они ссылаются. Мы более детально обсудим особенности каждого типа ссылок позже, а пока достаточно будет следующих знаний:
- SoftReference — если GC видит что объект доступен только через цепочку soft-ссылок, то он удалит его из памяти. Потом. Наверно.
- WeakReference — если GC видит что объект доступен только через цепочку weak-ссылок, то он удалит его из памяти.
- PhantomReference — если GC видит что объект доступен только через цепочку phantom-ссылок, то он его удалит из памяти. После нескольких запусков GC.
Эти 3 типа ссылок наследуются от одного родителя — Reference, у которого они собственно и берут все свои public методы и конструкторы.
StringBuilder builder = new StringBuilder();
SoftReference<StringBuilder> softBuilder = new SoftReference(builder);
После выполнения этих двух строчек у нас будет 2 типа ссылок на 1 объект StringBuilder:
- builder — strong-ссылка
- softBuilder — soft-ссылка (формально это strong-ссылка на soft-ссылку, но для простоты я буду писать soft-ссылка)
Рассмотрим доступные методы:
softBuilder.get() — вернет strong-ссылку на объект StringBuilder в случае если GC не удалил этот объект из памяти. В другом случае вернется null.
softBuilder.clear() — удалит ссылку на объект StringBuilder (то есть soft-ссылки на этот объект больше нет)
Все то же самое работает и для WeakReference и для PhantomReference. Правда, PhantomReference.get() всегда будет возвращать null, но об этом позже.
Есть еще такой класс – ReferenceQueue. Он позволяет отслеживать момент, когда GC определит что объект более не нужен и его можно удалить. Именно сюда попадает Reference объект после того как объект на который он ссылается удален из памяти. При создании Reference мы можем передать в конструктор ReferenceQueue, в который будут помещаться ссылки после удаления.
Детали SoftReference
Особенности GC
Так всё же, как ведет себя GC когда видит что объект доступен только по цепочке soft-ссылок? Давайте рассмотрим работу GC более детально:И так, GC начал свою работу и проходит по всем объектам в куче. В случае, если объект в куче это Reference, то GC помещает этот объект в специальную очередь в которой лежат все Reference объекты. После прохождения по всем объектам GC берет очередь Reference объектов и по каждому из них решает удалять его из памяти или нет. Как именно принимается решение об удалении объекта — зависит от JVM. Но общий контракт звучит следующим образом: GC гарантировано удалит с кучи все объекты, доступные только по soft-ссылке, перед тем как бросит OutOfMemoryError.
SoftReference это наш механизм кэширования объектов в памяти, но в критической ситуации, когда закончится доступная память, GC удалит не использующиеся объекты из памяти и тем самым попробует спасти JVM от завершения работы. Это ли не чудно?
Вот как Hotspot принимает решение об удалении SoftReference: если посмотреть на реализацию SoftReference, то видно, что в классе есть 2 переменные — private static long clock и private long timestamp. Каждый раз при запуске GC, он сетит текущее время в переменную clock. Каждый раз при создании SoftReference, в timestamp записывается текущее значение clock. timestamp обновляется каждый раз при вызове метода get() (каждый раз, когда мы создаем strong-ссылку на объект). Это позволяет вычислить, сколько времени существует soft-ссылка после последнего обращения к ней. Обозначим этот интервал буквой I. Буквой F обозначим количество свободного места в куче в MB(мегабайтах). Константой MSPerMB обозначим количество миллисекунд, сколько будет существовать soft-ссылка для каждого свободного мегабайта в куче.
Дальше все просто, если I <= F * MSPerMB, то не удаляем объект. Если больше то удаляем.
Для изменения MSPerMB используем ключ -XX:SoftRefLRUPolicyMSPerMB. Дефалтовое значение — 1000 ms, а это означает что soft-ссылка будет существовать (после того как strong-ссылка была удалена) 1 секунду за каждый мегабайт свободной памяти в куче. Главное не забыть что это все примерные расчеты, так как фактически soft-ссылка удалится только после запуска GC.
Обратите внимание на то, что для удаления объекта, I должно быть строго больше чем F * MSPerMB. Из этого следует что созданная SoftReference прожи��ет минимум 1 запуск GC. (*если не понятно почему, то это останется вам домашним заданием).
В случае VM от IBM, привязка срока жизни soft-ссылки идет не к времени, а к количеству переживших запусков GC.
Применение
Главная плюшка SoftReference в том что JVM сама следит за тем нужно удалять из памяти объект или нет. И если осталось мало памяти, то объект будет удален. Это именно то, что нам нужно при кэшировании. Кэширование с использованием SoftReference может пригодится в системах чувствительных к объему доступной памяти. Например, обработка изображений. Первый пример применения будет немного выдуманным, зато показательным:Наша система занимается обработкой изображений. Допустим, у нас есть громадное изображение, которое находиться где-то в файловой системе и это изображение всегда статично. Иногда пользователь хочет соединить это изображение с другим изображением. Вот наша первая реализация такой конкатенации:
public class ImageProcessor {
private static final String IMAGE_NAME = "bigImage.jpg";
public InputStream concatenateImegeWithDefaultVersion(InputStream userImageAsStream) {
InputStream defaultImage = this.getClass().getResourceAsStream(IMAGE_NAME);
// calculate and return concatenated image
}
}
Недостатков в таком подходе много, но один из них это то что мы должны каждый раз загружать с файловой системы изображение. А это не самая быстрая процедура. Давайте тогда будем кешировать загруженное изображение. Вот вторая версия:
public class CachedImageProcessor {
private static final String IMAGE_NAME = "bigImage.jpg";
private InputStream defaultImage;
public InputStream concatenateImegeWithDefaultVersion(InputStream userImageAsStream) {
if (defaultImage == null) {
defaultImage = this.getClass().getResourceAsStream(IMAGE_NAME);
}
// calculate and return concatenated image
}
}
Этот вариант уже лучше, но проблема все ровно есть. Изображение большое и забирает много памяти. Наше приложение работает со многими изображениями и при очередной попытке пользователя обработать изображение, легко может свалиться OutOfMemoryError. И что с этим можно сделать? Получается, что нам нужно выбирать, либо быстродействие либо стабильность. Но мы то знаем о существовании SoftReference. Это поможет нам продолжать использовать кеширование, но при этом в критических ситуациях выгружать их из кэша для освобождения памяти. Да еще и при этом нам не нужно беспокоиться о детектировании критической ситуации. Вот так будет выглядеть наша третья реализация:
public class SoftCachedImageProcessor {
private static final String IMAGE_NAME = "bigImage.jpg";
private SoftReference<InputStream> defaultImageRef = new SoftReference(loadImage());
public InputStream concatenateImegeWithDefaultVersion(InputStream userImageAsStream) {
if (defaultImageRef.get() == null) { // 1
defaultImage = this.getClass().getResourceAsStream(IMAGE_NAME);
defaultImageRef = new SoftReference(defaultImage);
}
defaultImage = defaultImageRef.get(); // 2
// calculate and return concatenated image
}
}
Эта версия не идеальна, но она показывает как просто мы можем контролировать размер занимаемый кэшем, а точнее возложить контроль на виртуальную машину. Опасность данной реализации заключается в следующем. В строчке №1 мы делаем проверку на null, фактически мы хотим проверить, удалил GC данные с памяти или нет. Допустим, что не удалил. Но перед выполнением строки №2 может начать работу GC и удалить данные. В таком случае результатом выполнения строчки №2 будет defaultImage = null. Для безопасной проверки существования объекта в памяти, нам нужно создать strong-ссылку, defaultImage = defaultImageRef.get(); Вот как будет выглядеть финальная реализация:
public class SoftCachedImageProcessor {
private static final String IMAGE_NAME = "bigImage.jpg";
private SoftReference<InputStream> defaultImageRef = new SoftReference(loadImage());;
public InputStream concatenateImegeWithDefaultVersion(InputStream userImageAsStream) {
defaultImage = defaultImageRef.get();
if (defaultImage == null) {
defaultImage = this.getClass().getResourceAsStream(IMAGE_NAME);
defaultImageRef = new SoftReference(defaultImage);
}
// calculate and return concatenated image
}
}
Пойдем дальше. java.lang.Class тоже использует SoftReference для кэширования. Он кэширует данные о конструкторах, методах и полях класса. Интересно посмотреть, что именно они кешируют. После того как решено использовать SoftReference для кеширования, нужно решить что именно кеширова��ь. Допустим нам нужно кешировать List. Мы можем использовать как List<SoftReference> так и SoftReference<List>. Второй вариант более приемлемый. Нужно помнить, что GC применяет специфическую логику при обработке Reference объектов, да и освобождение памяти будет происходить быстрее если у нас будет 1 SoftReference а не их список. Это мы и видим в реализации Class — разработчики создали soft-ссылку на массив конструкторов, полей и методов. Если говорить про производительность, то стоить отметить что часто, ошибочно, люди используют WeakReference для построения кэша там где стоит использовать SoftReference. Это приводит к низкой производительности кэша. На практике weak-ссылки быстро будут удалены из памяти, как только исчезнут strong-ссылки на объект. И когда нам реально понадобиться вытянуть объект с кэша, мы увидим что его там уже нет.
Ну и еще один пример использования кэша на основе SoftReference. В Google Guava есть класс MapMaker. Он поможет нам построить ConcurrentMap в которой будут следующая особенность — ключи и значения в Map могут заворачиваться в WeakReference или SoftReference. Допустим в нашем приложении есть данные, которые может запросить пользователь и эти данные достаются с базы данных очень сложным запросом. Например, это будет список покупок пользователя за прошлый год. Мы можем создать кэш в котором значения (список покупок) будут храниться с помощью soft-ссылок. А если в кэше не будет значения то нужно вытянуть его с БД. Ключом будет ID пользователя. Вот как может выглядеть реализация:
ConcurrentMap<Long, List<Product>> oldProductsCache = new MapMaker().softValues().
.makeComputingMap(new Function<User, List<Product>>() {
@Override
public List<Product> apply(User user) {
return loadProductsFromDb(user);
}
});
WeakReference
Особенности GC
Теперь рассмотрим более детально, что же собой представляет WeakReference. Когда GC определяет, что объект доступен только через weak-ссылки, то этот объект «сразу» удаляется с памяти. Тут стоить вспомнить про ReferenceQueue и проследить за порядком удаления объекта с памяти. Напомню что для WeakReference и SoftReference алгоритм попадания в ReferenceQueue одинаковый. Итак, запустился GC и определил что объект доступен только через weak-ссылки. Этот объект был создан так:StrIngBuilder AAA = new StringBuilder();
ReferenceQueue queue = new ReferenceQueue();
WeakReference weakRef = new WeakReference(AAA, queue);
Сначала GC очистит weak-ссылку, то есть weakRef.get() – будет возвращать null. Потом weakRef будет добавлен в queue и соответственно queue.poll() вернет ссылку на weakRef. Вот и все что хотелось написать про особенности работы GC с WeakReference. Теперь посмотрим, как это можно использовать.
Применение
Ну конечно WeakHashMap. Это реализация Map<K,V> которая хранит ключ, используя weak-ссылку. И когда GC удаляет ключ с памяти, то удаляется вся запись с Map. Думаю не сложно понять, как это происходит. При добавлении новой пары <ключ, значение>, создается WeakReference для ключа и в конструктор передается ReferenceQueue. Когда GC удаляет ключ с памяти, то ReferenceQueue возвращает соответствующий WeakReference для этого ключа. После этого соответствующий Entry удаляется с Map. Все довольно просто. Но хочется обратить внимание на некоторые детали.- WeakHashMap не предназначена для использования в качестве кэша. WeakReference создается для ключа а не для значения. И данные будут удалены только после того как в программе не останется strong-ссылок на ключ а не на значение. В большинстве случаев это не то чего вы хотите достичь кэшированием.
- Данные с WeakHashMap будут удалены не сразу после того как GC обнаружит что ключ доступен только через weak-ссылки. Фактически очистка произойдет при следующем обращении к WeakHashMap.
- В первую очередь WeakHashMap предназначен для использования с ключами, у которых метод equals проверяет идентичность объектов (использует оператор ==). Как только доступ к ключу потерян, его уже нельзя создать заново.
private static final NODE_TO_USER_MAP = new WeakHashMap<Node, UserInfo>();
Создание XML документа будет выглядеть примерно так:
Node mainDocument = createBaseNode();
NODE_TO_USER_MAP.put(mainDocument, loadUserInfo());
Ну а вот чтение:
UserInfo userInfo = NODE_TO_USER_MAP.get(mainDocument);
If(userInfo != null) {
// …
}
UserInfo будет находиться в WeakHashMap до тех пор пока GC не заметит, что на mainDocument остались только weak-ссылки.
Другой пример использования WeakHashMap. Многие знают про метод String.intern(). Так вот с помощью WeakReference можно создать нечто подобное. (Давайте не будет обсуждать, в рамках этой статьи, целесообразность этого решения, и примем факт, что у этого решения есть некоторые преимущества по сравнению с intern()). Итак, у нас есть ооочень много строк. Мы знаем что строки повторяются. Для сохранения памяти мы хотим использовать повторно уже существующие объекты, а не создавать новые объекты для одинаковых строк. Вот как в этом нам поможет WeakHashMap:
private static Map<String, WeakReference<String>> stringPool = new WeakHashMap<String, WeakReference<String>>;
public String getFromPool(String value) {
WeakReference<String> stringRef = stringPool.get(value);
if (stringRef == null || stringRef.get() == null ) {
stringRef = new WeakReference<String>(value);
stringPool.put(value, stringRef);
}
return stringRef.get();
}
И на последок добавлю, что WeakReference используется во многих классах – Thread, ThreadLocal, ObjectOutpuStream, Proxy, LogManager. Вы можете посмотреть на их реализацию для того чтоб понять в каких случаях вам может помочь WeakReference.
PhantomReference
Особенности GC
Особенностей у этого типа ссылок две. Первая это то, что метод get() всегда возвращает null. Именно из-за этого PhantomReference имеет смысл использовать только вместе с ReferenceQueue. Вторая особенность – в отличие от SoftReference и WeakReference, GC добавит phantom-ссылку в ReferenceQueue послетого как выполниться метод finalize(). Тоесть фактически, в отличии от SoftReference и WeakReference, объект еще есть в памяти.Практика
На первый взгляд не ясно как можно использовать такой тип ссылок. Для того чтоб объяснить как их использовать, ознакомимся сначала с проблемами возникающими при использовании метода finalize(): переопределение этого метода позволяет нам очистить ресурсы связанные с объектом. Когда GC определяет что объект более недоступный, то перед тем как удалит его из памяти, он выполняет этот метод. Вот какие проблемы с этим связаны:- GC запускается непредсказуемо, мы не можем знать когда будет выполнен метод finalize()
- Методы finalize() запускаются в одном потоке, по очереди. И до тех пор, пока не выполниться этот метод, объект не может быть удален с памяти
- Нет гарантии, что этот метод будет вызван. JVM может закончить свою работу и при этом объект так и не станет недоступным.
- Во время выполнения метода finalize() может быть создана strong-ссылка на объект и он не будет удален, но в следующий раз, когда GC увидит что объект более недоступен, метод finalize() больше не выполнится.
public HdImageFabric {
public static final int IMAGE_LIMIT = 10;
public static int count = 0;
public static ReferenceQueue<HdImage> queue = new ReferenceQueue<HdImage>();
public HdImage loadHdImage(String imageName) {
while (true) {
if (count < IMAGE_LIMIT) {
return wrapImage(loadImage(imageName));
} else {
Reference<HdImage> ref = queue.remove(500);
if (ref != null) {
count--;
System.out.println(“remove old image”);
}
}
}
}
private HdImage wrapImage(HdImage image) {
PhantomReference<HdImage> refImage = new PhantomReference(image, queue);
count++;
return refImage ;
}
}
Этот пример не потокобезопасный и имеет друге недостатки, но зато он показывает, как можно использовать на практике PhantomReference.
Из-за того что метод get() всегда возвращает null, становится непонятным а как все же понять какой именно объект был удален. Для этого нужно создать собственный класс, который будет наследовать PhantomReference, и который содержит некий дескриптор, который в будущем поможет определить какие ресурсы нужно чистить.
Когда вы используете PhantomReference нужно помнить о следующих вещах:
- Контракт гарантирует что ссылка появится в очереди после того как GC заметит что объект доступен только по phantom-ссылкам и перед тем как объект будет удален из памяти. Контракт не гарантирует, что эти события произойдут одно за другим. В реальности между этими событиями может пройти сколько угодно времени. Поэтому не стоит опираться на PhantomReference для очистки критически важных ресурсов.
- Выполнение метода finalize() и добавление phantom-ссылки в ReferenceQueue выполняется в разных запусках GC. По этому если у объекта переопределен метод finalize() то для его удаления необходимы 3 запуска GC, а если метод не переопределен, то нужно, минимум, 2 запуска GC
В качестве вывода хочу сказать что java.lang.ref.* дает нам неплохие возможности для работы с памятью JVM и не стоит игнорировать эти классы, они могут здорово нам помочь. Их ис��ользование связанно с большим количеством ошибок, и нужно быть крайне осторожным для достижения желаемого результата. Но разве эти трудности нас когда-то останавливали? На этом все. Спасибо всем кто дочитал до конца. Постараюсь в комментариях ответить на те вопросы, которые не сумел раскрыть в этой статье.
