Надежное принудительное выселение карты Гуавы
РЕДАКТИРОВАТЬ: я реорганизовал этот вопрос, чтобы отразить новую информацию, которая с тех пор стала доступной.
Этот вопрос основан на ответах на вопрос Viliam относительно использования "ленивым выселением " карт Гуавы: лень выселения на картах Гуавы
Пожалуйста, сначала прочтите этот вопрос и ответ на него, но по сути вывод: карты гуавы не рассчитывают и не приводят в исполнение выселение асинхронно. Учитывая следующую карту:
ConcurrentMap<String, MyObject> cache = new MapMaker()
.expireAfterAccess(10, TimeUnit.MINUTES)
.makeMap();
После того, как пройдет десять минут после доступа к записи, она все равно не будет выселена до тех пор, пока карта не будет снова "затронута". Известные способы сделать это включают обычные средства доступа - get()
а также put()
а также containsKey()
,
Первая часть моего вопроса [решена]: какие еще вызовы вызывают "прикосновение" к карте? В частности, кто-нибудь знает, если size()
попадает в эту категорию?
Причина удивления заключается в том, что я реализовал запланированную задачу, чтобы иногда подталкивать карту Guava, которую я использую для кэширования, с помощью этого простого метода:
public static void nudgeEviction() {
cache.containsKey("");
}
Однако я также использую cache.size()
программно сообщать о количестве объектов, содержащихся на карте, как способ подтверждения того, что эта стратегия работает. Но я не смог увидеть разницу от этих отчетов, и теперь мне интересно, если size()
также вызывает выселение.
Ответ: Марк отметил, что в выпуске 9 выселение вызывается только get()
, put()
, а также replace()
методы, которые объясняют, почему я не вижу эффекта для containsKey()
, Очевидно, это изменится со следующей версией guava, которая скоро будет выпущена, но, к сожалению, выпуск моего проекта установлен раньше.
Это ставит меня в интересное положение. Обычно я все еще могу коснуться карты, позвонив get("")
но я на самом деле использую вычислительную карту:
ConcurrentMap<String, MyObject> cache = new MapMaker()
.expireAfterAccess(10, TimeUnit.MINUTES)
.makeComputingMap(loadFunction);
где loadFunction
загружает MyObject
соответствующий ключу из базы данных. Похоже, у меня нет простого способа заставить выселиться до r10. Но даже способность надежно форсировать выселение ставится под сомнение второй частью моего вопроса:
Вторая часть моего вопроса [решена]: В ответ на один из ответов на связанный вопрос, действительно ли касание карты высвобождает все просроченные записи? В связанном ответе Niraj Tolia указывает иное, говоря, что выселение потенциально может быть обработано только партиями, что может означать, что может потребоваться несколько вызовов для прикосновения к карте, чтобы гарантировать, что все просроченные объекты были выселены. Он не уточнил, однако это связано с тем, что карта разбита на сегменты в зависимости от уровня параллелизма. Предполагая, что я использовал R10, в котором containsKey("")
вызывает ли выселение, будет ли это для всей карты или только для одного из сегментов?
Ответ: Маартин обратился к этой части вопроса:
Остерегайтесь этого
containsKey
и другие методы чтения только запуститьpostReadCleanup
, который ничего не делает, кроме каждого 64-го вызова (см. DRAIN_THRESHOLD). Более того, похоже, что все методы очистки работают только с одним сегментом.
Так выглядит вызов containsKey("")
не было бы жизнеспособного решения, даже в r10. Это сводит мой вопрос к названию: Как я могу надежно заставить выселение произойти?
Примечание. Одна из причин, по которой эта проблема заметно затронула мое веб-приложение, заключается в том, что когда я реализовал кэширование, я решил использовать несколько карт - по одной для каждого класса моих объектов данных. Таким образом, с этой проблемой существует вероятность того, что одна область кода выполняется, вызывая кучу Foo
объекты для кэширования, а затем Foo
кэш уже давно не трогается, поэтому ничего не высвобождает. между тем Bar
а также Baz
объекты кэшируются из других областей кода, а память используется. Я устанавливаю максимальный размер на этих картах, но в лучшем случае это хрупкая гарантия (я предполагаю, что ее эффект немедленный - все еще нужно это подтвердить).
ОБНОВЛЕНИЕ 1: Спасибо Даррену за связывание соответствующих проблем - у них теперь есть мои голоса. Таким образом, похоже, что разрешение находится в стадии разработки, но вряд ли будет в r10. Пока что мой вопрос остается.
ОБНОВЛЕНИЕ 2: На данный момент я просто жду от члена команды Гуавы, чтобы дать отзыв о взломе maaartinus, и я собрал (см. Ответы ниже).
ПОСЛЕДНИЕ ОБНОВЛЕНИЯ: обратная связь получена!
7 ответов
Я только добавил метод Cache.cleanUp()
в гуаву. Как только вы мигрируете из MapMaker
в CacheBuilder
Вы можете использовать это для принудительного выселения.
Мне было интересно узнать о той же проблеме, которую вы описали в первой части вашего вопроса. Из того, что я могу сказать, посмотрев на исходный код для CustomConcurrentHashMap Guava (выпуск 9), видно, что записи выселяются на get()
, put()
, а также replace()
методы. containsKey()
Метод, по-видимому, не вызывает выселение. Я не уверен на 100%, потому что я быстро перешел к коду.
Обновить:
Я также нашел более свежую версию CustomConcurrentHashmap в git-репозитории Guava, и она выглядит так containsKey()
был обновлен, чтобы вызвать выселение.
И версия 9, и последняя версия, которую я только что нашел, не вызывают выселение, когда size()
называется.
Обновление 2:
Я недавно заметил, что Guava r10
(еще не выпущен) имеет новый класс под названием CacheBuilder. В основном этот класс является раздвоенной версией MapMaker
но с учетом кеширования. Документация предполагает, что она будет поддерживать некоторые из требований по выселению, которые вы ищете.
Я просмотрел обновленный код в версии CustomConcurrentHashMap для r10 и нашел то, что похоже на запланированный очиститель карты. К сожалению, этот код кажется незавершенным на данный момент, но r10 с каждым днем выглядит все более и более многообещающим.
Остерегайтесь этого containsKey
и другие методы чтения только запустить postReadCleanup
, который ничего не делает, кроме каждого 64-го вызова (см. DRAIN_THRESHOLD). Более того, похоже, что все методы очистки работают только с одним сегментом.
Кажется, что самый простой способ принудительного выселения - поместить в каждый сегмент какой-нибудь фиктивный объект. Чтобы это работало, вам нужно проанализировать CustomConcurrentHashMap.hash(Object)
, что, безусловно, не очень хорошая идея, так как этот метод может измениться в любое время. Кроме того, в зависимости от класса ключа может быть трудно найти ключ с хэш-кодом, обеспечивающим его попадание в данный сегмент.
Вместо этого вы можете использовать операции чтения, но вам придется повторять их 64 раза на сегмент. Здесь было бы легко найти ключ с соответствующим hashCode, так как здесь любой объект разрешен в качестве аргумента.
Может быть, вы могли бы взломать CustomConcurrentHashMap
вместо исходного кода, это может быть так же тривиально, как
public void runCleanup() {
final Segment<K, V>[] segments = this.segments;
for (int i = 0; i < segments.length; ++i) {
segments[i].runCleanup();
}
}
но я бы не сделал этого без большого тестирования и / или одобрения со стороны члена команды гуавы.
Да, мы несколько раз обсуждали, должны ли эти задачи очистки выполняться в фоновом потоке (или пуле) или в пользовательских потоках. Если бы они были сделаны в фоновом потоке, это в конечном итоге произошло бы автоматически; как это, это произойдет только тогда, когда каждый сегмент привыкнет. Мы все еще пытаемся найти правильный подход здесь - я не удивлюсь, увидев это изменение в каком-то будущем выпуске, но я также не могу ничего обещать или даже сделать надежное предположение о том, как это изменится. Тем не менее, вы представили разумный вариант использования для некоторой фоновой или пользовательской очистки.
Ваш взлом разумен, если вы помните, что он взломан и может сломаться (возможно, тонкими способами) в будущих выпусках. Как видно из источника, Segment.runCleanup() вызывает runLockedCleanup и runUnlockedCleanup: runLockedCleanup() не будет иметь эффекта, если не сможет заблокировать сегмент, но если он не может заблокировать сегмент, то это потому, что какой-то другой поток имеет сегмент заблокирован, и этот другой поток может вызвать runLockedCleanup как часть своей операции.
Кроме того, в r10 есть CacheBuilder/Cache, аналогичный MapMaker/Map. Кэширование является предпочтительным подходом для многих текущих пользователей makeComputingMap. Он использует отдельный CustomConcurrentHashMap в пакете common.cache; в зависимости от ваших потребностей, вы можете захотеть, чтобы ваш GuavaEvictionHacker работал с обоими. (Механизм один и тот же, но это разные классы и, следовательно, разные методы.)
Я не большой поклонник взлома или разветвления внешнего кода, пока это не станет абсолютно необходимым. Эта проблема возникает отчасти из-за раннего решения MapMaker о форке ConcurrentHashMap, что влечет за собой большую сложность, которую можно было отложить до тех пор, пока не будут разработаны алгоритмы. Установив исправления над MapMaker, код становится устойчивым к изменениям библиотеки, так что вы можете удалить свое решение по собственному расписанию.
Простым подходом является использование приоритетной очереди задач со слабыми ссылками и выделенного потока. Это имеет недостаток в создании многих устаревших неактивных задач, которые могут стать чрезмерными из-за штрафа за вставку O(lg n). Это работает достаточно хорошо для небольших, менее часто используемых кэшей. Это был оригинальный подход MapMaker, и было просто написать собственный декоратор.
Более надежным вариантом является зеркальное отражение модели амортизации блокировки с одной очередью истечения срока действия. Голова очереди может быть изменчивой, так что чтение всегда может посмотреть, истек ли срок его действия. Это позволяет всем чтениям инициировать истечение срока и дополнительный поток очистки, чтобы регулярно проверять.
Безусловно, самым простым является использование #concurrencyLevel(1), чтобы заставить MapMaker использовать один сегмент. Это уменьшает параллелизм записи, но большинство кэшей считываются тяжелыми, поэтому потери минимальны. Оригинальный хак, чтобы подтолкнуть карту с помощью фиктивного ключа, будет работать нормально. Это был бы мой предпочтительный подход, но другие два варианта в порядке, если у вас высокая нагрузка при записи.
Я не знаю, подходит ли он для вашего случая использования, но ваша основная озабоченность по поводу отсутствия вытеснения фонового кэша, похоже, заключается в потреблении памяти, поэтому я бы подумал, что использование softValues () в MapMaker позволяет сборщику мусора восстановить записи из кэша, когда возникает ситуация с нехваткой памяти. Может быть легко решением для вас. Я использовал это на сервере подписки (ATOM), где записи обслуживаются через кеш Guava, используя SoftReferences для значений.
Основываясь на ответе maaartinus, я придумал следующий код, который использует отражение, а не напрямую изменяет источник (если вы находите это полезным, пожалуйста, подпишите его ответ!). Несмотря на то, что это приведет к снижению производительности за использование отражения, разница должна быть незначительной, так как я буду запускать ее примерно раз в 20 минут для каждой кеширующей карты (я также кеширую динамические поиски в статическом блоке, который поможет). Я провел некоторое первоначальное тестирование, и оно работает так, как задумано:
public class GuavaEvictionHacker {
//Class objects necessary for reflection on Guava classes - see Guava docs for info
private static final Class<?> computingMapAdapterClass;
private static final Class<?> nullConcurrentMapClass;
private static final Class<?> nullComputingConcurrentMapClass;
private static final Class<?> customConcurrentHashMapClass;
private static final Class<?> computingConcurrentHashMapClass;
private static final Class<?> segmentClass;
//MapMaker$ComputingMapAdapter#cache points to the wrapped CustomConcurrentHashMap
private static final Field cacheField;
//CustomConcurrentHashMap#segments points to the array of Segments (map partitions)
private static final Field segmentsField;
//CustomConcurrentHashMap$Segment#runCleanup() enforces eviction on the calling Segment
private static final Method runCleanupMethod;
static {
try {
//look up Classes
computingMapAdapterClass = Class.forName("com.google.common.collect.MapMaker$ComputingMapAdapter");
nullConcurrentMapClass = Class.forName("com.google.common.collect.MapMaker$NullConcurrentMap");
nullComputingConcurrentMapClass = Class.forName("com.google.common.collect.MapMaker$NullComputingConcurrentMap");
customConcurrentHashMapClass = Class.forName("com.google.common.collect.CustomConcurrentHashMap");
computingConcurrentHashMapClass = Class.forName("com.google.common.collect.ComputingConcurrentHashMap");
segmentClass = Class.forName("com.google.common.collect.CustomConcurrentHashMap$Segment");
//look up Fields and set accessible
cacheField = computingMapAdapterClass.getDeclaredField("cache");
segmentsField = customConcurrentHashMapClass.getDeclaredField("segments");
cacheField.setAccessible(true);
segmentsField.setAccessible(true);
//look up the cleanup Method and set accessible
runCleanupMethod = segmentClass.getDeclaredMethod("runCleanup");
runCleanupMethod.setAccessible(true);
}
catch (ClassNotFoundException cnfe) {
throw new RuntimeException("ClassNotFoundException thrown in GuavaEvictionHacker static initialization block.", cnfe);
}
catch (NoSuchFieldException nsfe) {
throw new RuntimeException("NoSuchFieldException thrown in GuavaEvictionHacker static initialization block.", nsfe);
}
catch (NoSuchMethodException nsme) {
throw new RuntimeException("NoSuchMethodException thrown in GuavaEvictionHacker static initialization block.", nsme);
}
}
/**
* Forces eviction to take place on the provided Guava Map. The Map must be an instance
* of either {@code CustomConcurrentHashMap} or {@code MapMaker$ComputingMapAdapter}.
*
* @param guavaMap the Guava Map to force eviction on.
*/
public static void forceEvictionOnGuavaMap(ConcurrentMap<?, ?> guavaMap) {
try {
//we need to get the CustomConcurrentHashMap instance
Object customConcurrentHashMap;
//get the type of what was passed in
Class<?> guavaMapClass = guavaMap.getClass();
//if it's a CustomConcurrentHashMap we have what we need
if (guavaMapClass == customConcurrentHashMapClass) {
customConcurrentHashMap = guavaMap;
}
//if it's a NullConcurrentMap (auto-evictor), return early
else if (guavaMapClass == nullConcurrentMapClass) {
return;
}
//if it's a computing map we need to pull the instance from the adapter's "cache" field
else if (guavaMapClass == computingMapAdapterClass) {
customConcurrentHashMap = cacheField.get(guavaMap);
//get the type of what we pulled out
Class<?> innerCacheClass = customConcurrentHashMap.getClass();
//if it's a NullComputingConcurrentMap (auto-evictor), return early
if (innerCacheClass == nullComputingConcurrentMapClass) {
return;
}
//otherwise make sure it's a ComputingConcurrentHashMap - error if it isn't
else if (innerCacheClass != computingConcurrentHashMapClass) {
throw new IllegalArgumentException("Provided ComputingMapAdapter's inner cache was an unexpected type: " + innerCacheClass);
}
}
//error for anything else passed in
else {
throw new IllegalArgumentException("Provided ConcurrentMap was not an expected Guava Map: " + guavaMapClass);
}
//pull the array of Segments out of the CustomConcurrentHashMap instance
Object[] segments = (Object[])segmentsField.get(customConcurrentHashMap);
//loop over them and invoke the cleanup method on each one
for (Object segment : segments) {
runCleanupMethod.invoke(segment);
}
}
catch (IllegalAccessException iae) {
throw new RuntimeException(iae);
}
catch (InvocationTargetException ite) {
throw new RuntimeException(ite.getCause());
}
}
}
Я жду отзывов о том, является ли этот подход целесообразным в качестве временного промежутка, пока проблема не будет решена в выпуске Guava, особенно от членов команды Guava, когда они получат минуту.
РЕДАКТИРОВАТЬ: обновлено решение для автоматического выселения карт (NullConcurrentMap
или же NullComputingConcurrentMap
проживающий в ComputingMapAdapter
). Это оказалось необходимым в моем случае, так как я вызываю этот метод на всех своих картах, и некоторые из них являются авто-эвакуаторами.