当类已经暴露给线程池时,清理ThreadLocal资源真的是我的工作吗?

53

我的ThreadLocal使用经验

在我的Java类中,我有时使用ThreadLocal主要是为了避免不必要的对象创建:

@net.jcip.annotations.ThreadSafe
public class DateSensitiveThing {

    private final Date then;

    public DateSensitiveThing(Date then) {
        this.then = then;
    }

    private static final ThreadLocal<Calendar> threadCal = new ThreadLocal<Calendar>()   {
        @Override
        protected Calendar initialValue() {
            return new GregorianCalendar();
        }
    };

    public Date doCalc(int n) {
        Calendar c = threadCal.get();
        c.setTime(this.then):
        // use n to mutate c
        return c.getTime();
    }
}

我这样做是出于正确的原因 - GregorianCalendar 是那些充满状态、可变且非线程安全对象之一,提供了跨多个调用的服务,而不是表示一个值。此外,它被认为是“昂贵”的实例化(是否真的如此并不是这个问题的重点)。总的来说,我真的很佩服它 :-))

Tomcat 抱怨什么

然而,如果我在任何池线程的环境中使用这样的类,而我的应用程序不能控制这些线程的生命周期,那么可能会导致内存泄漏。Servlet 环境就是一个很好的例子。

事实上,当 Web 应用程序停止时,Tomcat 7 会像下面这样抱怨:

SEVERE: The web application [] created a ThreadLocal with key of type [org.apache.xmlbeans.impl.store.CharUtil$1] (value [org.apache.xmlbeans.impl.store.CharUtil$1@2aace7a7]) and a value of type [java.lang.ref.SoftReference] (value [java.lang.ref.SoftReference@3d9c9ad4]) 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. Dec 13, 2012 12:54:30 PM org.apache.catalina.loader.WebappClassLoader checkThreadLocalMapForLeaks

(在那个特定情况下,甚至不是我的代码在做这件事。)

谁该为此负责?

这似乎不太公平。Tomcat 指责我(或者说是使用我的类的用户)做了正确的事情。

归根结底,这是因为 Tomcat 希望重用它提供给我的线程,用于其他 Web 应用程序。(呃 - 我感到很不舒服。)可能,这不是 Tomcat 的一项好政策 - 因为线程实际上具有/导致状态 - 不要在应用程序之间共享它们。

然而,即使这种策略并不理想,它至少是普遍存在的。作为一个 ThreadLocal 的用户,我觉得我有义务提供一种方法,让我的类“释放”它附加到各个线程上的资源。

但怎么做呢?

在这里应该做什么才是正确的呢?

对我来说,Servlet 引擎的线程重用策略与 ThreadLocal 的意图相抵触。

但也许我应该提供一种方法,允许用户说“离开这个类与线程相关的邪恶状态,即使我没有办法让线程死亡并让 GC 做它的事情?”。我能做到吗?我的意思是,我不能像调用 ThreadLocal#remove() 那样安排在过去某个时刻看到 ThreadLocal#initialValue() 的每个线程被调用。或者还有其他方法吗?

或者我应该告诉我的用户“去找一个好的类加载器和线程池实现”?

EDIT#1: 在一个不知道线程生命周期的普通实用程序类中澄清了如何使用 threadCal

EDIT#2</

https://dev59.com/XUXRa4cB1Zd3GeqPvN_z?rq=1 - schtever
@schtever 嗯,我知道在Servlet中肯定不会使用ThreadLocal来存储每个请求的信息。但是,还有其他原因可以使用它们,它们仍然与servlet引擎交互不良。我的问题是该怎么办,而不是是否应该这样做。 - David Bullock
6个回答

36

唉,这是老新闻了

这个问题已经有点晚了。 2007年10月,Josh Bloch(与Doug Lea一起编写java.lang.ThreadLocal的合著者)写道:

“使用线程池需要极度小心。 糟糕的线程池使用与线程本地变量的糟糕使用相结合会导致意外的对象保留,正如许多地方所指出的那样。”

当时人们就抱怨ThreadLocal与线程池的不良交互。 但是Josh确实授权:

“为了性能而进行的每个线程实例。 Aaron的SimpleDateFormat示例(上面)是此模式的一个示例。”

一些教训

  1. 如果您将任何类型的对象放入任何对象池中,必须提供一种“稍后”删除它们的方法。
  2. 如果您使用ThreadLocal进行“池化”,则对于执行该操作的同一线程在应用程序终止时调用ThreadLocal#remove()。
  3. 因此,您使用ThreadLocal作为对象池将对应用程序和类的设计产生沉重的代价。 这些好处并非免费获得。
  4. 因此,尽管Joshua Bloch敦促您考虑它,“但是使用ThreadLocal作为快速,无争议访问“每个线程实例池”的形式是不容易决定的。”

简而言之,决定使用ThreadLocal作为快速,无争议访问“每个线程实例池”的形式不是轻率的决定。

注意:ThreadLocal除了“对象池”之外还有其他用途,这些教训不适用于仅打算暂时设置ThreadLocal或者需要跟踪真正的每个线程状态的情况。

库实现者的后果

对于库实现者(即使这些库只是项目中的简单实用程序类),存在一些后果。

要么:

  1. 您使用ThreadLocal,完全意识到您可能会在长时间运行的线程中添加额外的负担。 如果您正在实现java.util.concurrent.ThreadLocalRandom,那么这可能是合适的。(如果您没有在java.*中实现,则Tomcat可能仍然会对您的库用户进行抱怨)。有趣的是,java.*使用ThreadLocal技术的纪律性。

要么

您使用ThreadLocal,并为您的类/包的客户端提供: a)选择放弃该优化的机会(“不要使用ThreadLocal…我无法安排清理”);和 b)一种清理ThreadLocal资源的方法(“使用ThreadLocal很好…当我完成它们时,我可以安排所有使用您的线程调用LibClass.releaseThreadLocalsForThread()”)。
尽管如此,这样使您的库“难以正确使用”。
或者
您给客户端提供了机会,让他们自己提供对象池实现(可能使用ThreadLocal或某些同步方式)。 (“好的,如果您认为这真的有必要,我可以给您一个new ExpensiveObjectFactory<;T>() {public T get(){...}} ”。)
没有那么糟糕。如果对象确实非常重要且创建成本高昂,则明确的池化可能是值得的。
或者
您决定对您的应用程序而言这并不值得太多,并找到了解决问题的另一种方法。那些昂贵的、可变的、非线程安全的对象正在给您带来痛苦……使用它们真的是最好的选择吗?
替代方案:
1.所有争用同步的常规对象池。 2.不池化对象-只在本地范围内实例化它们,然后丢弃。 3.不池化线程(除非您可以在喜欢的时间安排清理代码)-不要在JaveEE容器中使用您的东西。 4.聪明到足以在不向您抱怨的情况下清理ThreadLocals的线程池。 5.按“每个应用程序”分配线程的线程池,然后在停止应用程序时让它们死亡。 6.线程池容器和应用程序之间的协议,允许注册“应用程序关闭处理程序”,容器可以在下一个可用该线程时调度要用于服务应用程序的线程运行。例如。 servletContext.addThreadCleanupHandler(new Handler() {@Override cleanup() {...}}) 在未来的JavaEE规范中看到最后3个项目的标准化将是很好的。
附注
实际上,GregorianCalendar的实例化非常轻量级。不可避免的是调用setTime()承担了大部分工作。它也不会在线程的不同点之间保存任何重要状态。将Calendar放入ThreadLocal中可能不会为您带来更多,除非剖析绝对显示new GregorianCalendar()中有一个热点。

new SimpleDateFormat(String) 相比之下是昂贵的,因为它必须解析格式字符串。一旦解析完成,对象的“状态”对于同一线程的后续使用是重要的。这是更好的选择。但是实例化一个新对象可能仍然比让你的类承担额外的责任更“便宜”。


4

由于该线程不是您创建的,而是您租用的,因此在停止使用之前要求清理它是公平的 - 就像您在归还租来的汽车时加满油箱一样。Tomcat可以自动清理所有内容,但它却为您提供了帮助,提醒您已经忘记的事情。

补充: 您使用的预备GregorianCalendar方法是错误的:由于服务请求可以并发进行,并且没有同步,doCalc可能会在另一个请求调用setTime之后调用getTime。引入同步将使事情变慢,因此创建新的GregorianCalendar可能是更好的选择。

换句话说,您的问题应该是:如何保持准备好的GregorianCalendar实例池,以便其数量根据请求速率进行调整。因此,至少需要一个包含该池的单例模式。每个Ioc容器都有管理单例模式的方法,并且大多数都具有现成的对象池实现。如果您尚未使用IoC容器,请开始使用一个(String、Guice),而不是重新发明轮子。


有什么想法可以让我的 DateSensitiveThing 实际上进行清理? - David Bullock
1
好的,我已经修复了代码,现在它真正做到了线程安全(doCalc中的多个线程现在都没问题了)。不过你说得很对 - 我基本上是将“ThreadLocal”用作简单(高效、低争用)的对象池,但我不想“付出代价”来管理我放入该池中的对象的生命周期。 - David Bullock

1
如果有帮助的话,我使用自定义SPI(一个接口)和JDK的ServiceLoader。那么,我所有需要卸载线程本地变量的各种内部库(jars),都遵循ServiceLoader模式。因此,如果一个jar需要线程本地变量清理,它将自动被选中,如果它具有适当的/META-INF/services/interface.name。
然后,在过滤器或监听器中进行卸载(我在监听器方面遇到了一些问题,但我记不清是什么问题了)。
如果JDK/JEE带有用于清除线程本地变量的标准SPI,那就太理想了。

好的,我明白如何使用ServiceLoader来获取ThreadLocalScrubberService,如果库作者致力于提供它,那么框架作者可以利用它。为此,您能否发布您的服务接口?但我不明白的是,作为应用程序作者,您如何诱导servlet容器在需要清理的线程上安排清理工作?还是您会在每个HTTP请求之后刻苦地进行清理,迫使库重新初始化其ThreadLocals,从而阻碍性能以换取安全? - David Bullock
安全起见,如果您正在使用ThreadLocal进行对象池化、缓存或因为您的数据结构不是线程安全的,则我认为您做错了。您应该停止使用任何库来执行此操作...除了ThreadLocalRandom之外的一些例外情况。我们使用ThreadLocal将上下文传递到请求或消息总线中。因此,清理实际上是为了防止意外地重用上下文,但也可以防止出现问题。 - Adam Gent

1

经过一年的思考,我决定在JavaEE容器中不允许共享工作线程池的方式来处理不相关应用程序之间的实例。这根本不是“企业级”的做法。

如果你真的要共享线程,java.lang.Thread(至少在JavaEE环境中)应该支持像setContextState(int key)forgetContextState(int key)这样的方法(与setClasLoaderContext()相似),这些方法允许容器在将线程移交给各种应用程序时隔离应用程序特定的ThreadLocal状态。

java.lang命名空间中进行此类修改之前,对于应用程序部署者来说,采用“一个线程池,一个相关应用程序实例”的规则是明智的选择,而对于应用程序开发人员来说,则需要假设“这个线程是我的,直到ThreadDeath我们分开”。


你想要实现的唯一方法是让Tomcat为每个Web应用程序拥有单独的线程池。我不知道有哪个Servlet容器这样做,也不认为这是一个好主意,特别是考虑到热部署和多WAR部署通常已经过时了(即Spring Boot和Dropwizard超级JAR正在成为常态)。总的来说,这几乎是不可能的,因为必须对请求进行初始处理以确定要分派到哪个Web应用程序(WAR)... - Adam Gent
@Adam,在一个应用容器中托管多个应用程序的整个概念根本不是Java EE能够以任何形式提供资源使用安全性的东西。(这是Java EE最初推广的整个概念的污点)。Tomcat的方法(检测泄漏并随时间流逝释放线程池)是容器真正能够做到的全部。 - David Bullock
最终,我认为这意味着_library developers_ 应该 提供一个API来清理之前提交给库的线程,但用户现在希望对其进行“清理”。而且,Java EE规范的未来版本 应该 提供一个关闭钩子,以便应用程序容器将“之前提交给此应用程序的所有线程”提交给应用程序本身进行清理。 - David Bullock
我对我的内部库做了完全相同的事情。请看我的回答:https://dev59.com/ImYr5IYBdhLWcg3wOXwQ#28945239 - Adam Gent

0

我认为JDK的ThreadPoolExecutor可以在任务执行后进行ThreadLocals的清理,但我们知道它没有。我认为它至少可以提供一个选项。可能的原因是Thread仅向其TreadLocal映射提供包私有访问,因此ThreadPoolExecutor无法访问它们而不更改Thread的API。

有趣的是,ThreadPoolExecutor具有受保护的方法存根beforeExecution和afterExecution,API说:这些可以用来操作执行环境;例如,重新初始化ThreadLocals...。因此,我可以想象一个实现ThreadLocalCleaner接口的Task和我们定制的ThreadPoolExecutor在afterExecution上调用任务的cleanThreadLocals();


只有在我控制启动和停止“线程”时,“afterExecution”钩子才有意义。但是当我作为servlet容器中的租户时,我没有(也不应该)对池中线程的生命周期有任何控制权。使用ThreadLocal的代码假定当前执行的线程由当前执行的应用程序拥有。在servlet容器中,这不幸的是不正确的。 - David Bullock

0
我通过扩展ThreadLocal解决了我的问题。
    public class AccessibleThreadLocal<T> extends ThreadLocal<T> {

    private static List<ThreadLocal<?>> LIST = new ArrayList<>();

    public AccessibleThreadLocal() {
        super();
        LIST.add(this);
    }

    public static void clear() {
        LIST.forEach(ThreadLocal::remove);
    }

    public static <T> ThreadLocal<T> newWith(Supplier<T> supplier) {
        ThreadLocal<T> threadLocal = ThreadLocal.withInitial(supplier);
        LIST.add(threadLocal);
        return threadLocal;
    }
}

为了确保这个类被使用,我禁止通过审计来实例化ThreadLocal。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接