Мягкие ссылки на страже доступной памяти или как экономить память правильно

в 8:06, , рубрики: garbage collector, java, Программирование, метки:

image

Все 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 с объектами, на которые они ссылаются. Мы более детально обсудим особенности каждого типа ссылок позже, а пока достаточно будет следующих знаний:

  1. SoftReference — если GC видит что объект доступен только через цепочку soft-ссылок, то он удалит его из памяти. Потом. Наверно.
  2. WeakReference — если GC видит что объект доступен только через цепочку weak-ссылок, то он удалит его из памяти.
  3. 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-ссылка)

И если во время выполнения программы, переменная builder станет недоступной, но при этом ссылка на объект, на который ссылается softBuilder, будет еще доступна И запуститься GC -> то объект StringBuilder будет помечен как доступный только через цепочку 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<Task>>() {
                   @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 проверяет идентичность объектов (использует оператор ==). Как только доступ к ключу потерян, его уже нельзя создать заново.

Хорошо, тогда в каких случаях удобно использовать WeakHashMap? Допустим нам нужно создать XML документ для пользователя. Конструированием документа будут заниматься несколько сервисов, которые на вход будут получать org.w3c.Node в который будут добавлять необходимые элементы. Так же для сервисов нужно много информации о пользователе с Базы Данных. Эти данные мы будем складировать в классе UserInfo. Класс UserInfo занимает много места в памяти и актуален только для построения конкретного XML документа. Кешировать UserInfo не имеет смысла. Нам нужно только ассоциировать его с документом и желательно удалить из памяти, когда документ более не используется программой. Все что нам нужно сделать:

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, объект еще есть в памяти.

Практика

На первый взгляд не ясно как можно использовать такой тип ссылок. Для того чтоб объяснить как их использовать, ознакомимся сначала с проблемами возникающими при использовании метода fanalize(): переопределение этого метода позволяет нам очистить ресурсы связанные с объектом. Когда GC определяет что объект более недоступный, то перед тем как удалит его из памяти, он выполняет этот метод. Вот какие проблемы с этим связаны:

  1. GC запускается непредсказуемо, мы не можем знать когда будет выполнен метод finalize()
  2. Методы finalize() запускаются в одном потоке, по очереди. И до тех пор, пока не выполниться этот метод, объект не может быть удален с памяти
  3. Нет гарантии, что этот метод будет вызван. JVM может закончить свою работу и при этом объект так и не станет недоступным.
  4. Во время выполнения метода finalize() может быть создана strong-ссылка на объект и он не будет удален, но в следующий раз, когда GC увидит что объект более недоступен, метод finalize() больше не выполниться.

Вернемся к PhantomReference. Этот тип ссылок в комбинации с ReferenceQueue позволяет нам узнать, когда объект более недоступен и на него нет других ссылок. Это позволяет нам сделать очистку ресурсов, используемых объектом, на уровне приложения. В отличии от finalize() мы сами контролируем процесс очистки ресурсов. Помимо этого, мы можем контролировать процесс создания новых объектов. Допустим у нас есть фабрика, которая будет возвращать нам объект HdImage. Мы можем контролировать, сколько таких объектов будет загружено в память:

public HdImageFabric {
	public static int count = 0;
	public static ReferenceQueue<HdImage> queue = new ReferenceQueue<HdImage>();

	public HdImage loadHdImage(String imageName) {
		while (true) {
			if (count > 10) {
				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++;
	}
}

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

  1. Контракт гарантирует что ссылка появиться в очереди после того как GC заметит что объект доступен только по phantom-ссылкам и перед тем как объект будет удален из памяти. Контракт не гарантирует, что эти события произойдут одно за другим. В реальности между этими событиями может пройти сколько угодно времени. Поэтому не стоит опираться на PhantomReference для очистки критически важных ресурсов.
  2. Выполнение метода finalize() и добавление phantom-ссылки в ReferenceQueue выполняется в разных запусках GC. По этому если у объекта переопределен метод finalize() то для его удаления необходимы 3 запуска GC, а если метод не переопределен, то нужно, минимум, 2 запуска GC

.

В качестве вывода хочу сказать что java.lang.ref.* дает нам неплохие возможности для работы с памятью JVM и не стоит игнорировать эти классы, они могут здорово нам помочь. Их использование связанно с большим количеством ошибок, и нужно быть крайне осторожным для достижения желаемого результата. Но разве эти трудности нас когда-то останавливали? На этом все. Спасибо всем кто дочитал до конца. Постараюсь в комментариях ответить на те вопросы, которые не сумел раскрыть в этой статье.

Автор: obie

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js