Надежное принудительное выселение карты Гуавы

РЕДАКТИРОВАТЬ: я реорганизовал этот вопрос, чтобы отразить новую информацию, которая с тех пор стала доступной.

Этот вопрос основан на ответах на вопрос 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). Это оказалось необходимым в моем случае, так как я вызываю этот метод на всех своих картах, и некоторые из них являются авто-эвакуаторами.

Другие вопросы по тегам