【问题标题】:reliably forcing Guava map eviction to take place可靠地迫使 Guava 地图驱逐发生
【发布时间】:2011-10-27 12:16:27
【问题描述】:

编辑:我已经重新组织了这个问题,以反映此后可用的新信息。

此问题基于Viliam 对 Guava Maps 使用惰性驱逐的问题的回答:Laziness of eviction in Guava's maps

请先阅读这个问题及其回复,但基本上得出的结论是 Guava 地图不会异步计算和强制驱逐。给定以下地图:

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() 是否也会导致驱逐发生。

答案: 所以 Mark 指出,在第 9 版中,驱逐仅由 get()put()replace() 方法调用,这可以解释为什么我没有看到containsKey() 的效果。这显然会随着即将发布的下一个版本的番石榴而改变,但不幸的是我的项目的发布设置得更早。

这让我陷入了一个有趣的困境。通常我仍然可以通过调用get("") 来触摸地图,但我实际上使用的是计算地图:

ConcurrentMap<String, MyObject> cache = new MapMaker()
        .expireAfterAccess(10, TimeUnit.MINUTES)
        .makeComputingMap(loadFunction);

其中loadFunction 加载与数据库中的键对应的MyObject。看起来我在 r10 之前没有简单的方法可以强制驱逐。但即使是能够可靠地强制驱逐,我的问题的第二部分也令人怀疑:

我的问题的第二部分 [已解决]: 作为反应 to one of the responses to the linked question,触摸地图是否可靠地驱逐所有过期条目?在链接的答案中,Niraj Tolia 另有说明,表示驱逐可能仅分批处理,这意味着可能需要多次调用触摸地图以确保所有过期对象都被驱逐。他没有详细说明,但这似乎与基于并发级别将地图分割成段有关。假设我使用了 r10,其中 containsKey("") 确实调用了驱逐,那么这将适用于整个地图,还是仅适用于其中一个片段?

答案: maaartinus 已经解决了这部分问题:

注意containsKey 和其他读取方法只运行postReadCleanup,它只会在每次第 64 次调用时执行任何操作(请参阅 DRAIN_THRESHOLD)。此外,看起来所有清理方法都只适用于单个 Segment。

所以看起来调用containsKey("") 不是一个可行的修复方法,即使在 r10 中也是如此。这将我的问题简化为标题:我怎样才能可靠地强制驱逐发生?

注意:我的 Web 应用程序明显受此问题影响的部分原因是,当我实现缓存时,我决定使用多个地图 - 一个用于我的数据对象的每一类。所以这个问题有可能是某个区域的代码被执行,导致一堆Foo对象被缓存,然后Foo缓存很长一段时间都没有被再次触及,所以它不会被驱逐任何事物。与此同时,BarBaz 对象正在从其他代码区域缓存,并且内存正在被占用。我在这些地图上设置了最大尺寸,但这充其量只是一个脆弱的保护措施(我假设它的效果是立竿见影的 - 仍然需要确认这一点)。

更新 1:感谢 Darren 链接相关问题 - 他们现在有我的投票。所以看起来解决方案正在酝酿中,但似乎不太可能出现在 r10 中。与此同时,我的问题仍然存在。

更新 2: 在这一点上,我只是在等待 Guava 团队成员对我整理的 hack maaartinus 提供反馈(请参阅下面的答案)。

最新更新:已收到反馈!

【问题讨论】:

标签: java caching guava


【解决方案1】:

我刚刚将 Cache.cleanUp() 方法添加到 Guava 中。从MapMaker 迁移到CacheBuilder 后,您可以使用它来强制驱逐。

【讨论】:

  • cleanUp()invalidateAll() 有什么区别?两者似乎都在删除/驱逐当时存在的缓存中的条目。
  • cleanUp() 应该只删除 invalidated 条目,根据您的缓存配置。如果仍然存在有效条目(即尚未过期),则不会触及它们。相反,invalidateAll() 将使所有条目无效,即使是那些不会被视为过期的条目。
【解决方案2】:

我不知道它是否适合您的用例,但您对缺乏后台缓存驱逐的主要担忧似乎是内存消耗,所以我认为在 MapMaker 上使用 softValues() 来允许当发生内存不足的情况时,垃圾收集器从缓存中回收条目。很容易成为您的解决方案。我在订阅服务器 (ATOM) 上使用了它,其中条目通过使用 SoftReferences 值的 Guava 缓存提供服务。

【讨论】:

【解决方案3】:

除非绝对必要,否则我不喜欢侵入或分叉外部代码。出现这个问题的部分原因是 MapMaker 早期决定分叉 ConcurrentHashMap,从而拖累了许多本来可以推迟到算法制定后的复杂性。通过在 MapMaker 上进行修补,代码对库更改具有健壮性,因此您可以根据自己的计划删除解决方法。

一种简单的方法是使用弱引用任务的优先级队列和专用线程。这具有创建许多陈旧的无操作任务的缺点,由于 O(lg n) 插入惩罚,这些任务可能变得过多。它适用于较小的、不太常用的缓存。这是 MapMaker 采用的原始方法,您可以轻松编写自己的 decorator

更稳健的选择是使用单个过期队列来镜像锁摊销模型。队列的头部可以是易失的,因此读取可以随时查看以确定它是否已过期。这允许所有读取触发过期和可选的清理线程定期检查。

到目前为止,最简单的方法是使用#concurrencyLevel(1) 来强制 MapMaker 使用单个段。这降低了写入并发性,但大多数缓存读取量很大,因此损失很小。使用虚拟键轻推地图的原始黑客可以正常工作。这将是我的首选方法,但如果您的写入负载较高,其他两个选项也可以。

【讨论】:

  • +1 感谢您的富有洞察力的建议。您的中间两段值得长期考虑(目前对我来说时间很短)。我需要查看第三段的代码示例才能完全理解它,否则需要花更多时间来理解锁摊销模型。感谢您的关注。
  • 查看此 1000 英尺design document 或相关代码,这是 MapMaker 算法的基础。查尔斯和我将在 9 月中旬进行演示,提供更多详细信息。
  • 谢谢,我一定会查看此文档。在探索这个问题之前,我对 Guava 的内部运作一无所知,这教会了我很多关于幕后发生的事情。期待了解更多信息,并期待您提到的演示文稿。
【解决方案4】:

是的,我们已经反复讨论了这些清理任务是应该在后台线程(或池)上完成,还是应该在用户线程上完成。如果它们是在后台线程上完成的,这最终会自动发生;事实上,它只会在每个段被使用时发生。我们仍在尝试在这里提出正确的方法 - 在未来的某个版本中看到这种变化我不会感到惊讶,但我也不能承诺任何事情,甚至不能对 如何做出可靠的猜测 它会改变。尽管如此,您还是为某种背景或用户触发的清理提供了一个合理的用例。

您的破解是合理的,只要您记住它是一种破解,并且在未来的版本中可能会被破坏(可能以微妙的方式)。正如您在源代码中看到的那样,Segment.runCleanup() 调用 runLockedCleanup 和 runUnlockedCleanup:runLockedCleanup() 如果无法锁定该段将无效,但如果它无法锁定该段,那是因为其他一些线程具有段被锁定,并且可以期望其他线程调用 runLockedCleanup 作为其操作的一部分。

此外,在 r10 中,还有 CacheBuilder/Cache,类似于 MapMaker/Map。缓存是许多 makeComputingMap 当前用户的首选方法。它在 common.cache 包中使用单独的 CustomConcurrentHashMap;根据您的需要,您可能希望您的 GuavaEvictionHacker 与两者一起使用。 (机制相同,但它们是不同的类,因此是不同的方法。)

【讨论】:

  • +1 谢谢,这是我希望得到的确认(尽管是匿名的)。如果我们移到 r10,关于 CacheBuilder 的优点。我喜欢与 Guava 合作,并期待未来的发展。
【解决方案5】:

根据 maaartinus 的回答,我提出了以下代码,它使用反射而不是直接修改源代码(如果您觉得这很有用,请点赞他的回答!)。虽然使用反射会带来性能损失,但差异应该可以忽略不计,因为我将为每个缓存 Map 每 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 团队成员在他们有时间时的反馈。

编辑: 更新了解决方案以允许自动驱逐地图(NullConcurrentMapNullComputingConcurrentMap 驻留在 ComputingMapAdapter 中)。在我的情况下,这被证明是必要的,因为我在我的所有地图上都调用了这个方法,其中一些是自动驱逐者。

【讨论】:

  • 恕我直言,性能损失不值得一提,因为清理是一项非常重要的操作。也就是说,我更喜欢破解源代码而不是反射,因为它更短,因此更不容易出错。当番石榴发生变化时,无论如何,两者都可能破裂。当然,您可能有一些充分的理由偏爱反射。
  • @maaartinus - 实际上,到达CustomConcurrentHashMap 是最重要的战斗 - 无论如何,这样的事情可能是必要的,或者对多个番石榴类进行了许多相互关联的小技巧。不过,仍然欠您提供基本代码。
【解决方案6】:

我想知道您在问题的第一部分中描述的相同问题。通过查看 Guava 的 CustomConcurrentHashMap (release 9) 的源代码,我可以看出,get()put()replace() 方法上的条目似乎被逐出。 containsKey() 方法似乎没有调用驱逐。我不能 100% 确定,因为我快速通过了代码。

更新:

我还在 Guava 的 git 存储库中找到了更新版本的 CustomConcurrentHashmap,看起来 containsKey() 已更新为调用驱逐。

当调用size() 时,第 9 版和我刚刚找到的最新版本都不会调用驱逐。

更新 2:

我最近注意到Guava r10(尚未发布)有一个名为CacheBuilder 的新类。基本上这个类是MapMaker 的一个分支版本,但考虑到了缓存。文档表明它将支持您正在寻找的一些驱逐要求。

我查看了 r10 版本的 CustomConcurrentHashMap 中的更新代码,发现看起来像是预定的地图清洁器。不幸的是,该代码此时似乎尚未完成,但 r10 看起来越来越有前途。

【讨论】:

  • +1 找到源头做得很好——我也应该这样做。有趣的是containsKey() 在版本 9 中没有调用驱逐。我也在使用该版本,这可以解释为什么我没有看到效果,因为没有效果!
  • @Kublai Khan:我更新了答案中的链接。 Google 最近为 Guava 从 svn 切换到了 git。
  • 感谢您的更新。我认为CacheBuilder 向我们展示了 Guava 团队关心这些用例,我很高兴看到它们分支以覆盖它们。
【解决方案7】:

注意containsKey 和其他读取方法只运行postReadCleanup,它只会在每 64 次调用时执行任何操作(请参阅 DRAIN_THRESHOLD)。此外,看起来所有清理方法都只适用于单个 Segment。

强制驱逐的最简单方法似乎是将一些虚拟对象放入每个段中。为此,您需要分析CustomConcurrentHashMap.hash(Object),这肯定不是一个好主意,因为此方法可能随时更改。此外,根据密钥类别,可能很难找到带有 hashCode 的密钥,以确保它位于给定的段中。

您可以改为使用读取,但每个片段必须重复 64 次。在这里,很容易找到具有适当 hashCode 的键,因为这里允许任何对象作为参数。

也许你可以黑进CustomConcurrentHashMap 源代码,它可能像这样微不足道

public void runCleanup() {
    final Segment<K, V>[] segments = this.segments;
    for (int i = 0; i < segments.length; ++i) {
        segments[i].runCleanup();
    }
}

但如果没有 guava 团队成员的大量测试和/或 OK,我不会这样做。

【讨论】:

  • +1 感谢您的洞察力和指出这个黑客。我已将其编码为基于反射的 hack,在下面作为答案发布。这样做的原因是修改 Guava 源对我的特定项目不方便。无论如何,我希望一些番石榴人可以很快参与进来。一旦我确认这是可行的,我将奖励赏金。
  • 感谢您的赏金,很高兴它能帮助您找到可行的解决方案。
  • @maaartinus 我在 Guava 加载缓存上有一个类似的问题here,我也需要做同样的事情。我的困惑是,如果我从后台线程(使用 ScheduledExecutorService)每 1 分钟调用一次cleanUp(),那么我的 RemovalListener 会被调用吗?我没有看到它被调用。或者我可能在这里混合了很多东西。我只想加载缓存中的所有指标,并通过发送到其他系统每隔 1 分钟定期删除这些指标。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-05-04
  • 2013-04-14
  • 1970-01-01
相关资源
最近更新 更多