前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >浅谈ThreadLocal

浅谈ThreadLocal

作者头像
程序猿杜小头
发布2022-12-01 21:39:43
4440
发布2022-12-01 21:39:43
举报
文章被收录于专栏:程序猿杜小头

ThreadLocal因为内存泄漏问题早已在江湖中声名远扬,引得一众开发人员的吐槽。于是,ThreadLocal 的设计者之一Josh Bloch不得不出来辟谣:ThreadLocal的设计毫无问题,而且历经数次优化后其性能越来越好,内存泄漏是由开发者误用造成的,我们不背这个锅!由此可见,ThreadLocal 是有一定上手门槛的,希望大家在读完本文后可以正确地使用它。

1. 认识ThreadLocal

ThreadLocal用于保存只能由同一线程进行读取、更新和删除操作的线程本地变量,换句话说,该变量对于不同线程是透明的。线程本地变量由ThreadLocalMap承载,ThreadLocalMap 内部结构为key/value键值对,key对应 ThreadLocal 自身,value对应线程本地变量;get()set()remove()方法最终操作的目标就是 ThreadLocalMap。线程本地变量对于不同线程是透明的,是如何保证的呢?

代码语言:javascript
复制
public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

原来每个线程都持有一个 ThreadLocalMap 实例变量,看到这里,大家也许意识到了,ThreadLocal只是操作线程中ThreadLocalMap这一实例变量的入口罢了!


如果隐去实现细节,ThreadLocal 的源码结构一目了然:

代码语言:javascript
复制
public class ThreadLocal<T> {
    protected T initialValue() {}
    public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {}
    public T get() {}
    public void set(T value) {}
    public void remove() {}
    static class ThreadLocalMap {}
}

initialValue()withInitial()方法用于初始化 ThreadLocal,即为线程本地变量设定初始值,否则线程本地变量的初始值默认为null;initialValue() 与 withInitial() 方法将会在当前线程通过调用 get() 方法获取本地变量时进行初始赋值操作。具体细节如下:

代码语言:javascript
复制
public class ThreadLocal<T> {
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = t.threadLocals;
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                T result = (T) e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = t.threadLocals;
        if (map != null) {
            map.set(this, value);
        } else {
            t.threadLocals = new ThreadLocalMap(this, firstValue);
        }
        return value;
    }
}

ThreadLocal 的初始化要格外注意,下面这段代码就是典型的、错误的初始化方式,甚至一位腾讯大佬的博客上也是这么初始化的,大家有没有发现啥问题?

代码语言:javascript
复制
public class BadInitializationWithThreadLocal {
    private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT = new ThreadLocal<>();
    
    static {
        DATE_FORMAT.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
    }

    public String formatDate(Date date) {
        SimpleDateFormat simpleDateFormat = DATE_FORMAT.get();
        return simpleDateFormat.format(date);
    }
}

上述初始化方式的问题在于静态初始化代码块。先来回顾下类加载的相关知识,初始化是类加载过程的最后一个阶段,初始化阶段就是执行<clinit>()方法的过程,<clinit>()方法并不是开发人员在Java代码中直接编写的方法,而是由编译器自动收集类中静态变量的赋值语句和静态初始化代码块合并而产生的;然而一个类一般只会被同一类加载器加载一次,那静态初始化代码块自然只有一次执行机会了。试想一下,如果在线程A中触发了BadInitializationWithThreadLocal的加载,那么其他线程执行其formatDate()方法一定会抛出NullPointerException

set()方法旨在为当前线程建立关于 ThreadLocal 与线程本地变量的映射关系;而remove()方法恰恰相反,它会通过将key/value均置为null来删除这种映射关系;具体源码如下:

代码语言:javascript
复制
public class ThreadLocal<T> {
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = t.threadLocals;
        if (map != null) {
            map.set(this, value);
        } else {
            t.threadLocals = new ThreadLocalMap(this, value);
        }
    }
    
    public void remove() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = t.threadLocals;
        if (map != null) {
            map.remove(this);
        }
    }
}

关于 ThreadLocalMap,这里只提两点:1) 哈希表基于数组实现;2) 哈希表基于线性探测解决哈希冲突问题。更多实现细节,请大家自行阅读源码。

2. 应用场景

ThreadLocal 的应用场景虽然五花八门,但普遍可以归为以下两个场景中的一个。

2.1 保存非线程安全对象,避免多线程并发调用

在多线程环境中,对线程不安全的共享实例变量的访问,一般需要对该共享实例变量加锁。但 ThreadLocal 提供了另一种思路,那就是将共享实例变量变为线程独有的实例变量,各个线程访问的都是各自副本,自然也就不存在竞争关系了。

阿里巴巴<Java开发手册>中提到SimpleDateFormat是线程不安全的,要避免多线程访问,如下图所示:

下面就来模拟一下多线程访问 SimpleDateFormat 究竟会有什么问题。为了进一步提升多线程环境下的并发竞争度,这里使用了j.u.c包中的CyclicBarrier,在20个线程并发访问 SimpleDateFormat 的format()方法前调用 CyclicBarrier 的await()方法,以确保这20个线程准备就绪

代码语言:javascript
复制
public class ThreadLocalMain1 {
    public static void main(String[] args) throws InterruptedException {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        // 构建线程池
        ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("exe-pool-%d").build();
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                20,
                50,
                0L,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(128),
                namedThreadFactory,
                new ThreadPoolExecutor.AbortPolicy());
        // 线程安全的SET
        Set<String> dateSet = Sets.newCopyOnWriteArraySet();
        // J.U.C包中两大利器:递减锁存器与循环屏障
        CountDownLatch countDownLatch = new CountDownLatch(20);
        CyclicBarrier cyclicBarrier = new CyclicBarrier(20);

        for (int i = 0; i < 20; i++) {
            final int offset = i;
            threadPoolExecutor.submit(() -> {
                try {
                    // 等待开闸放水,主要是为了提升多线程环境下的并发竞争度,阻塞性方法
                    cyclicBarrier.await();
                    Calendar calendar = Calendar.getInstance();
                    calendar.add(Calendar.DATE, offset);
                    String date = simpleDateFormat.format(calendar.getTime());
                    dateSet.add(date);
                    // 递减锁存器递减一,非阻塞性方法
                    countDownLatch.countDown();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            });
        }
        // 主线程等待所有线程执行结束,阻塞性方法
        countDownLatch.await();
        // 打印结果
        System.out.println("总数 = " + dateSet.size());
        System.out.println("==========");
        System.out.println(dateSet.stream().sorted().collect(Collectors.joining("\r\n")));
        // 关闭线程池
        threadPoolExecutor.shutdown();
    }
}

运行结果

代码语言:javascript
复制
总数 = 5
==========
2021-09-30 20:53:48
2021-10-01 20:53:48
2021-10-06 20:53:48
2021-10-07 20:53:48
2021-10-30 20:53:48

从上述运行结果来看,控制台仅仅打印了五个日期值,显然是错误的。这主要因为 SimpleDateFormat 的format()方法涉及Calendar.setTime(date)操作,而该Calendar实例是一个实例/全局变量;在多线程环境下,对单个 SimpleDateFormat 实例内的全局变量进行访问肯定是有风险的。

解决方案有很多,比如加锁、使用DateUtils工具类、使用Java 8新增的DateTimeFormatter等。这里我们使用 ThreadLocal 来试试:

代码语言:javascript
复制
public class ThreadLocalMain2 {
    public static void main(String[] args) throws InterruptedException {
        ThreadLocal<SimpleDateFormat> simpleDateFormatThreadLocal = ThreadLocal.withInitial(
                () -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        // 构建线程池
        ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("exe-pool-%d").build();
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                20,
                50,
                0L,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(128),
                namedThreadFactory,
                new ThreadPoolExecutor.AbortPolicy());
        // 线程安全的SET
        Set<String> dateSet = Sets.newCopyOnWriteArraySet();
        // J.U.C包中两大利器:递减锁存器与循环屏障
        CountDownLatch countDownLatch = new CountDownLatch(20);
        CyclicBarrier cyclicBarrier = new CyclicBarrier(20);

        for (int i = 0; i < 20; i++) {
            final int offset = i;
            threadPoolExecutor.submit(() -> {
                try {
                    // 等待开闸放水,主要是为了提升多线程环境下的并发竞争度,阻塞性方法
                    cyclicBarrier.await();
                    Calendar calendar = Calendar.getInstance();
                    calendar.add(Calendar.DATE, offset);
                    String date = simpleDateFormatThreadLocal.get().format(calendar.getTime());
                    dateSet.add(date);
                    // 递减锁存器递减一,非阻塞性方法
                    countDownLatch.countDown();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                } finally {
                    simpleDateFormatThreadLocal.remove();
                }
            });
        }
        // 主线程等待所有线程执行结束,阻塞性方法
        countDownLatch.await();
        // 打印结果
        System.out.println("总数 = " + dateSet.size());
        System.out.println("==========");
        System.out.println(dateSet.stream().sorted().collect(Collectors.joining("\r\n")));
        // 关闭线程池
        threadPoolExecutor.shutdown();
    }
}

运行结果

代码语言:javascript
复制
总数 = 20
==========
2021-09-28 20:54:30
2021-09-29 20:54:30
2021-09-30 20:54:30
2021-10-01 20:54:30
2021-10-02 20:54:30
2021-10-03 20:54:30
2021-10-04 20:54:30
2021-10-05 20:54:30
2021-10-06 20:54:30
2021-10-07 20:54:30
2021-10-08 20:54:30
2021-10-09 20:54:30
2021-10-10 20:54:30
2021-10-11 20:54:30
2021-10-12 20:54:30
2021-10-13 20:54:30
2021-10-14 20:54:30
2021-10-15 20:54:30
2021-10-16 20:54:30
2021-10-17 20:54:30

恭喜,运行结果符合预期!

2.2 保存线程上下文对象,避免多层级参数透传

Spring Security用于为Spring应用提供 认证 (authentication) 与 授权 (authorization) 服务,在其SecurityFilterChain过滤器链中默认有15个过滤器;其中SecurityContextPersistenceFilter处于整个过滤器链的顶端,如下所示:

代码语言:javascript
复制
[0 ] WebAsyncManagerIntegrationFilter
[1 ] SecurityContextPersistenceFilter
[2 ] HeaderWriterFilter
[3 ] CsrfFilter
[4 ] LogoutFilter
[5 ] UsernamePasswordAuthenticationFilter
[6 ] DefaultLoginPageGeneratingFilter
[7 ] DefaultLogoutPageGeneratingFilter
[8 ] BasicAuthenticationFilter
[9 ] RequestCacheAwareFilter
[10] SecurityContextHolderAwareRequestFilter
[11] AnonymousAuthenticationFilter
[12] SessionManagementFilter
[13] ExceptionTranslationFilter
[14] FilterSecurityInterceptor

既然聊到SecurityContextPersistenceFilter,就不得不提SecurityContext,其可以用于存储当前用户、当前用户是否已认证、当前用户具有哪些权限等信息;而 SecurityContext 则通过SecurityContextHolder中的 ThreadLocal 来维护,如下所示:

代码语言:javascript
复制
public class SecurityContextHolder {
    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

    public static void clearContext() {
        contextHolder.remove();
    }

    public static SecurityContext getContext() {
        SecurityContext ctx = contextHolder.get();
        if (ctx == null) {
            ctx = new SecurityContextImpl();
            contextHolder.set(ctx);
        }
        return ctx;
    }

    public static void setContext(SecurityContext context) {
        contextHolder.set(context);
    }
}

在 SecurityFilterChain 的过滤器链中,存在若干过滤器需要借助 SecurityContext 来存取当前用户的相关信息的场景,那该如何在不修改doFilter()方法签名的前提下实现多过滤器层级下SecurityContext的透传呢?既然一个Http请求的完整执行流程是在一个线程上下文中完成的,那显然可以通过ThreadLocal来实现,SecurityContextPersistenceFilter 就是这么干的哈。

代码语言:javascript
复制
public class SecurityContextPersistenceFilter extends GenericFilterBean {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
        SecurityContext contextBeforeChainExecution = this.securityContextRepository.loadContext(holder);
        try {
            // 设置当前线程上下文中的SecurityContext实例
            // 一般是一个空的SecurityContext实例
            SecurityContextHolder.setContext(contextBeforeChainExecution);
            // 执行过滤器链中的后续过滤器
            chain.doFilter(holder.getRequest(), holder.getResponse());
        } finally {
            // SecurityContextPersistenceFilter后续的过滤器执行完毕后
            // 清空SecurityContext实例
            SecurityContextHolder.clearContext();
        }
    }
}

3. 内存泄漏 (Memory Leak)

3.1 何为内存泄露

如果一个对象已经没有什么用途,但因为有一个有效的引用(reference) 指向它而导致垃圾收集器无法销毁该对象,那就可以说该对象造成了内存泄漏 。内存泄露并不是一个好兆头,持续不断地发生内存泄漏最终会耗尽内存资源,Java应用程序也最终会因java.lang.OutOfMemoryError异常终止。来看一个内存泄露的例子:

代码语言:javascript
复制
public class MemoryLeakMain {
    public static void main(String[] args) {
        // 模拟占用50MB堆内存
        byte[] byteArr = new byte[50 * 1024 * 1024];
        // 通过死循环模拟长生命周期线程
        for (;;) {}
    }
}

上述代码表明:在一长生命周期的主线程中,定义了一个长度为52428800且毫无用处的字节数组,约占用50MB堆内存空间。运行上述程序,然后通过Eclipse Memory Analyzer工具进行内存泄露探测,其结果如下:

上图表明探测到一个内存泄露可疑点,详细描述信息如下:

代码语言:javascript
复制
The thread java.lang.Thread @ 0x607e06430 main keeps local variables with total size 52,430,064 bytes.
The memory is accumulated in one instance of “byte[]”, loaded by “<system class loader>”, which occupies 52,428,816  bytes.

Keywords
  byte[]
  com.example.crimson_typhoon.threadlocal.MemoryLeakMain.main([Ljava/lang/String;)V
  MemoryLeakMain.java:9

下面就来验证下这个字节数组对象究竟有没有造成内存泄漏。通过VisualVM手动进行垃圾回收,但堆内存占用空间仅少量收缩,依然保持60MB左右的体量,这说明垃圾收集器压根就没销毁这个没卵用的大对象,如下图所示:

那垃圾收集器为什么没有销毁该字节数组对象呢?因为垃圾收集器从GC Root根节点向下搜索发现:该字节数组对象与GC Root存在一条结结实实的引用链 (reference chain),或者用图论的行话说就是从GC Root到这个字节数组对象是可达的,所以没有销毁这个字节数组对象,这里扮演GC Root角色的正是主线程。

3.2 ThreadLocal与内存泄漏

概念搞明白了,下面就要分析 ThreadLocal 为什么会造成内存泄漏问题了。阅读源码发现:ThreadLocalMap 内部封装了一key/value结构的内部静态类Entry,其继承自WeakReference<ThreadLocal<?>>,表明 Entry 的 key 由弱引用关联,而 value 则是强引用,所以关于 ThreadLocal 引发内存泄露的话题主要就是围绕 Entry 的 value 展开的。

代码语言:javascript
复制
public class ThreadLocal {
    static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    }
}

弱引用 弱引用可以描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象若不存在强引用则只能生存到下一次垃圾收集发生为止;当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2后提供了WeakReference类来实现弱引用。

下面贴了一段代码,大家觉得这个大字节数组对象会造成内存泄漏吗?

代码语言:javascript
复制
public class ThreadLocalMemoryLeakMain {
    public static void main(String[] args) {
        // 模拟占用100MB堆内存
        byte[] byteArr = new byte[100 * 1024 * 1024];

        ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();
        threadLocal.set(byteArr);
        threadLocal = null;
        byteArr = null;

        // 通过死循环模拟长生命周期线程
        for (;;) {}
    }
}

继续借助Eclipse Memory Analyzer工具进行内存泄露探测,其结果如下:

卧槽,从上图来看又检测到一个内存泄露可疑点,详细描述信息如下:

代码语言:javascript
复制
One instance of “java.lang.ThreadLocal$ThreadLocalMap$Entry” loaded by “<system class loader>” occupies 104,857,648  bytes. The instance is referenced by java.lang.Thread @ 0x60b606430 main , loaded by “<system class loader>”. 

The thread java.lang.Thread @ 0x60b606430 main keeps local variables with total size 1,264 bytes.
The memory is accumulated in one instance of “byte[]”, loaded by “<system class loader>”, which occupies 104,857,616  bytes.

Keywords
  java.lang.ThreadLocal$ThreadLocalMap$Entry
  byte[]

继续通过VisualVM手动触发垃圾回收后来观察堆内存是否有一个明显的收缩趋势,从下图来看收缩效果微乎其微啊:

至于垃圾收集器没有销毁该字节数组对象的原因就不再赘述了,直接上图:


下面聊一聊内存泄露的场景。上述代码中通过死循环来模拟一长生命周期的线程,如果没有这个死循环,ThreadLocal 还会引起内存泄露吗?答案是不会,因为主线程紧接着就执行完毕了,线程都死亡了,那它引用的 ThreadlocalMap 对象肯定也被回收掉了啊··· 事实上,ThreadLocal 内存泄露现象一般多发于长生命周期的线程中,有哪些线程的生命周期比较长呢?大家肯定猜到了:线程池中的核心 (core) 线程。

那如何才能使得垃圾收集器回收该对象呢?很简单,追加threadLocal.remove();这一行代码即可,效果立竿见影:

3.3 Entry中的key为什么被设计成弱引用

这个问题反过来问可能更容易得出答案:如果 Entry 中的 key 被设计成强引用,会出现什么问题呢?还是通过代码来说明:

代码语言:javascript
复制
public class WeakReferenceEntryKeyMain {
    public static void main(String[] args) {
        byte[] byteArr = new byte[100 * 1024 * 1024];

        ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();
        threadLocal.set(byteArr);
        threadLocal = null;

        for (;;) { }
    }
}

运行上述代码,然后通过Eclipse Memory Analyzer可以很直观地观测到:ThreadLocal 对象被垃圾收集器销毁了,有图为证:

假如 Entry 中的 key 被设计为强引用,即使threadLocal变量被置为null也无法使得垃圾收集器销毁这个 ThreadLocal 对象。所以,最终的结论就是:将Entry中的key设计成弱引用就是为了不干扰用户主动销毁 ThreadLocal 对象的意图。

4. 总结

最后再强调一下ThreadLocal的使用场景:

  1. 保存非线程安全对象,避免多线程并发调用;
  2. 保存线程上下文对象,避免多层级参数透传。

另外,尽量将set()remove()这俩方法搭配起来使用,尤其是在线程池中,一定要使用使用remove()方法,切莫当归还线程对象时,还将线程本地变量驻留在线程对象中!!!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-09-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 程序猿杜小头 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 认识ThreadLocal
  • 2. 应用场景
    • 2.1 保存非线程安全对象,避免多线程并发调用
      • 2.2 保存线程上下文对象,避免多层级参数透传
      • 3. 内存泄漏 (Memory Leak)
        • 3.1 何为内存泄露
          • 3.2 ThreadLocal与内存泄漏
            • 3.3 Entry中的key为什么被设计成弱引用
            • 4. 总结
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档