【问题标题】:Thread-safe caching for expensive resource that needs global clean up需要全局清理的昂贵资源的线程安全缓存
【发布时间】:2015-11-15 11:18:38
【问题描述】:

情况:

  • 需要一个创建成本高且非线程安全的外部资源的缓存
  • 资源需要显式清理
  • 不能hook每个线程的终止,但是可以hook应用程序的终止
  • 代码也在 Servlet 容器中运行,因此无法直接使用导致来自系统类加载器(例如 ThreadLocal)的强引用的缓存(请参阅下面的编辑)。

因此要使用ThreadLocal,它只能将WeakReferences 保存到资源中,并且必须保留一个单独的强引用集合。代码很快变得非常复杂并造成内存泄漏(因为强引用在线程死亡后永远不会被删除)。

ConcurrentHashMap 看起来不错,但它也存在内存泄漏问题。

还有哪些其他选择?一个同步的 WeakHashMap??

(希望解决方案也可以使用给定的Supplier 自动初始化,就像ThreadLocal.withInitial() 一样)


编辑:

只是为了证明类加载器泄漏是一回事。创建一个最小的 WAR 项目:

public class Test {
    public static ThreadLocal<Test> test = ThreadLocal.withInitial(Test::new);
}

index.jsp:

<%= Test.test.get() %>

访问该页面并关闭Tomcat,您会得到:

Aug 21, 2015 5:56:11 PM org.apache.catalina.loader.WebappClassLoaderBase checkThreadLocalMapForLeaks
SEVERE: The web application [test] created a ThreadLocal with key of type [java.lang.ThreadLocal.SuppliedThreadLocal] (value [java.lang.ThreadLocal$SuppliedThreadLocal@54e69987]) and a value of type [test.Test] (value [test.Test@2a98020a]) but failed to remove it when the web application was stopped. Threads are going to be renewed over time to try and avoid a probable memory leak.

【问题讨论】:

  • 资源是绑定到线程还是可以从不同的线程使用,一次只能使用一个?
  • 它们不必绑定到特定线程:)
  • 谁持有对ThreadLocal的强引用?
  • ThreadLocal 只是在我的 WAR 类的一个字段中。单独的List 用于保存(强引用)WeakReferences(在ThreadLocal 中)引用的所有资源实例。这是为了防止每个Thread 引用的ThreadLocalMap 对WAR 的类加载器具有强引用链(从而导致泄漏)。
  • 线程持有对ThreadLocal实例的强引用。内部ThreadLocalMap 使用弱引用。你实际上是导致你试图解决的问题。

标签: java multithreading caching


【解决方案1】:

在研究弱并发map的想法时,发现在Guava的Cache中实现了。

我使用当前线程作为弱键,并提供CacheLoader 来自动为每个新线程创建资源。

还添加了一个删除监听器,这样每个线程的资源在Thread对象被GC'ed或我在关闭期间调用invalidateAll()方法时都会自动清理。

上面的大部分配置也可以在单行中完成(使用 lambdas)。

【讨论】:

    【解决方案2】:

    我建议完全摆脱ThreadLocalWeakReference 的东西,因为正如您所说,资源没有绑定到特定线程,它们只是不能同时从多个线程访问。

    相反,有一个全局缓存 Map &lt;Key, Collection &lt;Resource&gt;&gt;。缓存只包含目前可以免费使用的资源。

    线程将首先从缓存中请求可用资源。如果存在(当然,这应该是同步的,因为缓存是全局的),则从该键的集合中删除任意资源并将其提供给线程。否则,将为该密钥构建一个新密钥并将其提供给线程。

    当一个线程完成使用资源时,它应该将它返回到缓存中,即添加到映射到资源键的集合中。从那里它可以再次被同一个线程使用,甚至可以被不同的线程使用。

    优点:

    • 缓存是全局的,在应用程序退出时关闭所有分配的资源很简单。

    • 几乎没有内存泄漏的可能性,代码应该非常简洁。

    • 线程可以共享资源(前提是它们在不同时间需要相同的资源),这可能会降低需求。

    缺点:

    • 需要同步(但可能便宜且不难编码)。

    • 可能还有其他一些,这取决于您具体做什么。

    【讨论】:

    • 这是我最后走的大致方向。看我的回答。
    【解决方案3】:

    这似乎是典型的“弱键,强值引用键”问题。如果将值设置为弱,则即使密钥可达,也可以收集它,如果将值设置为强,则密钥也是强可达的。如果没有 JVM 的直接支持,这是无法解决的。

    谢天谢地,有一个类提供了这一点(尽管它的文档中没有强调):

    java.lang.ClassValue:

    懒惰地将计算值与(可能)每种类型关联起来。例如,如果一种动态语言需要为在消息发送调用站点遇到的每个类构建一个消息调度表,它可以使用 ClassValue 来为遇到的每个类缓存快速执行消息发送所需的信息。

    虽然本文档没有说这些值可以引用 Class 键,但它的预期用例是为类保存调度表,这意味着具有反向引用的值是典型的。

    让我们用一个小测试类来演示一下:

    public class ClassValueTest extends ClassValue<Method> {
        @Override
        protected Method computeValue(Class<?> type) {
            System.out.println("computeValue");
            return Arrays.stream(type.getDeclaredMethods())
                .filter(m->Modifier.isPublic(m.getModifiers()))
                .findFirst().orElse(null);
        }
        public static void main(String... arg) throws Throwable {
            // create a collectible class:
            MethodHandles.Lookup l=MethodHandles.lookup();
            MethodType noArg = MethodType.methodType(void.class);
            MethodHandle println = l.findVirtual(
                PrintStream.class, "println", MethodType.methodType(void.class, String.class));
            Runnable r=(Runnable)LambdaMetafactory.metafactory(l, "run",
                println.type().changeReturnType(Runnable.class), noArg, println, noArg)
               .getTarget().invokeExact(System.out, "hello world");
            r.run();
            WeakReference<Class<?>> ref=new WeakReference<>(r.getClass());
            ClassValueTest test=new ClassValueTest();
            // compute and get
            System.out.println(test.get(r.getClass()));
            // verify that the value is cached, should not compute
            System.out.println(test.get(r.getClass()));
            // allow freeing
            r=null;
            System.gc();
            if(ref.get()==null) System.out.println("collected");
            // ensure that it is not our cache instance that has been collected
            System.out.println(test.get(String.class));
        }
    }
    

    在我的机器上打印:

    hello world
    computeValue
    public void ClassValueTest$$Lambda$1/789451787.run()
    public void ClassValueTest$$Lambda$1/789451787.run()
    collected
    computeValue
    public boolean java.lang.String.equals(java.lang.Object)
    

    为了解释,这个测试创建了一个匿名类,就像 lambda 表达式产生的一样,它可以被垃圾收集。然后它使用ClassValueTest 实例缓存该ClassMethod 对象。由于Method 实例引用了它们的声明类,所以我们这里有一个值引用它的键的情况。

    不过,在不再使用该类之后,它会被收集,这意味着关联的值也已被收集。所以它对键值的反向引用免疫。

    最后一个使用另一个类的测试只是确保我们不是像described here那样急切垃圾收集的受害者,因为我们仍在使用缓存实例本身。


    此类将单个值与一个类相关联,而不是每个线程的值,但应该可以将 ClassValueThreadLocal 组合以获得所需的结果。

    【讨论】:

      【解决方案4】:

      我不确定您所说的问题。请看:https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem

      一些问题:

      • 资源是如何引用的?
      • 资源的接口是什么?
      • 应该缓存哪些数据?
      • 什么是“非线程安全资源”
      • 多久检索一次资源?
      • 对一个资源的访问时间有多长,并发级别是多少?
      • 一个线程是否多次使用资源,这就是预期缓存的原因?
      • 是否有多个线程使用相同的资源(实例)?
      • 由于实际实例不是线程安全的,是否可以有许多相同资源类型的实例?
      • 你有多少资源?
      • 是同一类型还是不同类型的多个资源实例?

      也许您可以尝试从您的问题中删除单词 ThreadLocal、WeakReference、ConcurrentHashMap?

      一些(疯狂的)猜测:

      从字里行间可以看出,在我看来,这是 Java 缓存的直接用例。例如。您可以使用 Google Guava 缓存并为显式清理添加删除侦听器。

      由于资源不是线程安全的,您需要实现锁定机制。这可以通过将锁定对象放入缓存对象中来完成。

      如果您需要更多并发,请创建更多相同类型的资源,并使用线程的哈希模数您希望拥有的并发级别来增加缓存键。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2012-07-11
        • 1970-01-01
        • 2012-05-13
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多