1

ThreadLocal是线程私有的局部变量存储容器,可以理解成每个线程都有自己专属的存储容器,用来存储线程私有变量。ThreadLocal 在日常开发框架中应用广泛,但用不好也会出现各种问题,本文就此讲解一下。

1. 应用场景

ThreadLocal 的常见应用场景有两种:

  1. 多线程并发场景中,用来保障线程安全。
  2. 处理较为复杂的业务时,使用ThreadLocal代替参数的显示传递。

1.1. 保障线程安全

多线程访问同一个共享变量的时候容易出现并发问题,特别是多个线程对一个变量进行写入的时候,为了保证线程安全,一般使用者在访问共享变量的时候需要进行额外的同步措施才能保证线程安全性,如:synchronized、Lock之类的锁。

ThreadLocal是除了加锁这种同步方式之外的一种,规避多线程访问出现线程不安全的方法。当我们在创建一个变量后,如果每个线程对其进行访问的时候访问的都是线程自己的变量,这样就不会存在线程不安全问题。

ThreadLocal是JDK包提供的,它提供线程本地变量,如果创建一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个副本,在实际多线程操作的时候,操作的是自己本地内存中的变量,从而规避了线程安全问题。

1.2. 显示传递参数

这里举几个例子:

示例1:获取接口的当前请求用户

在后台接口业务逻辑的全过程中,如果需要在多个地方获取当前请求用户的信息。通常的一种做法就是:在接口请求时,通过过滤器、拦截器、AOP等方式,从session或token中获取当前用户信息,存入ThreadLocal中。

在整个接口处理过程中,如果没有另外创建线程,都可以直接从ThreadLocal变量中获取当前用户,而无需再从Session、token中验证和获取用户。这种方案设计不仅提高性能,最重要的是将原本复杂的逻辑和代码实现,变得简洁明了。例如下面的这个例子:

(1)定义ThreadLocal变量:UserProfileThread.java

public class UserProfileThread {
    private static ThreadLocal<UserProfile> USER_PROFILE_TL =new ThreadLocal<>();

    public static void  setUserProfile(UserProfile userProfile){
        USER_PROFILE_TL.set(userProfile);
    }

    public static UserProfile getUserProfile() {
        return USER_PROFILE_TL.get();
    }

    public static String getCurrentUser() {
        return Optional.ofNullable(USER_PROFILE_TL.get())
                .map(UserProfile::getUid)
                .orElse(UserProfile.ANONYMOUS_USER);
    }
}

(2)在过滤器中设置变量值:

   @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        UserProfile userProfile = null;
        // ... 验证和获取用户信息 userProfile
        UserProfileThread.setUserProfile(userProfile);
        filterChain.doFilter(servletRequest, servletResponse);
    }

(3)获取当前用户信息

//获取当前用户
String uid=UserProfileThread.getCurrentUser();
//获取当前用户对象
UserProfile user=UserProfileThread.getUserProfile();
示例2:spring框架中保证数据库事务在同一个连接下执行

要想实现jdbc事务, 就必须是在同一个连接对象中操作,多个连接下事务就会不可控,需要借助分布式事务完成。那spring框架如何保证数据库事务在同一个连接下执行的呢?

DataSourceTransactionManager 是spring的数据源事务管理器,它会在你调用getConnection()的时候从数据库连接池中获取一个connection, 然后将其与ThreadLocal绑定,事务完成后解除绑定。这样就保证了事务在同一连接下完成。

2. 实现原理

ThreadLocal类提供set/get方法存储和获取value值,但实际上ThreadLocal类并不存储value值,真正存储是靠ThreadLocalMap这个类。

每个线程实例都对应一个TheadLocalMap实例,我们可以在同一个线程里实例化很多个ThreadLocal来存储很多种类型的值,这些ThreadLocal实例分别作为key,对应各自的value,最终存储在Entry table数组中。

我们看看ThreadLocal的set方法:

public class ThreadLocal<T> {
 public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    // 省略其他方法
}

set的逻辑比较简单,就是获取当前线程的ThreadLocalMap,然后往map里添加KV,K是当前ThreadLocal实例,V是我们传入的value。这里需要注意一下,map的获取是需要从Thread类对象里面取,看一下Thread类的定义。

public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
    //省略其他
}

Thread类维护了一个ThreadLocalMap的变量引用。

因此,我们可以得出如下结论:

  • 每个线程是一个Thread实例,其内部维护一个threadLocals的实例成员,其类型是ThreadLocal.ThreadLocalMap。
  • ThreadLocal本身并不是一个容器,我们存取的value实际上存储在ThreadLocalMap中,ThreadLocal只是作为TheadLocalMap的key。

3. 推荐 static final 修饰

编程规范推荐使用static final修饰 ThreadLocal对象。

首先static修饰的变量是在类在加载时就分配地址了,在类卸载才会被回收

ThreadLocal 的原理是在 Thread 内部有一个 ThreadLocalMap 的集合对象,他的key是 ThreadLocal,value 就是你要存储的变量副本, 不同的线程他的 ThreadLocalMap 是隔离开的,如果变量 ThreadLocal 是非 static 的就会造成每次生成实例都要生成不同的 ThreadLocal 对象,虽然这样程序不会有什么异常,但是会浪费内存资源.造成内存泄漏。

一个 ThreadLocal 实例对应当前线程中的一个 TSO(thread specific object,即与线程相关的变量)实例。因此,如果把 ThreadLocal 声明为某个类的实例变量(而不是静态变量),那么每创建一个该类的实例就会导致一个新的TSO实例被创建。显然,这些被创建的TSO实例是同一个类的实例。于是,同一个线程可能会访问到同一个 TSO(指类)的不同实例,这即便不会导致错误,也会导致浪费(重复创建等同的对象)!

ThreadLocal 实例作为 ThreadLocalMap 的 Key,针对一个线程内的所有操作是共享的,所以建议设置 static 修饰符,以便被所有的对象共享。由于静态变量会在类第一次被使用时装载,只会分配一次存储空间,此类的所有实例都会共享这个存储空间,所以使用static 修饰 ThreadLocal 就会节约内存空间。另外,为了确保 ThreadLocal 实例的唯一性,除了使用 static 修饰之外,还会使用 final 进行加强修饰,以防止其在使用过程中发生动态变更。

但是也要考虑 static final 修饰的副作用,看下面的内存泄露场景。

4. 内存泄露

1. ThreadLocalMap 和 Entry

ThreadLocal的实现是这样的:每个Thread 维护一个 ThreadLocalMap 映射表,其实现了一套简单的Map结构。Entry用于保存ThreadLocalMap的Key-Value对条目,但是Entry使用了对ThreadLocal实例进行包装之后的弱引用(WeakReference)作为Key,value 是真正需要存储的 Object。代码如下:

static class ThreadLocalMap {
    // map的条目数,作为哈希表使用
    private Entry[] table;
    
    // Entry继承了WeakReference,并使用WeakReference对key进行了包装
    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
}

这里要注意的是 Entry包含的Key-Value对中:

  • Key:是对ThreadLocal实例进行包装之后的弱引用。
  • Value:是真正存储的Object值(强引用)。
2. 强引用、软引用、弱引用
  1. 强引用:就是最常见的普通对象引用,我们平常编码Object obj = new Object()中的obj就是强引用。通过关键字new创建的对象所关联的引用就是强引用,只要这个强引用还指向着某一对象,那就说明这个对象还“活着”,垃圾收集器就不会碰,当JVM内存空间不足,JVM宁愿抛出OutOfMemoryError运行时错误(OOM),使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。

    如何解决强引用问题呢?对于一个普通的对象,如果没有其他的引用关系,只要超出了引用的作用域或显示的将(强)引用赋值为null(obj=null),就是可以被垃圾收集的了。当然具体的回收时机还得看垃圾收集策略。

    可能有人不理解作用域在一个方法的内部有一个强引用,这个引用保存在栈中,而真正的引用内容(Object)保存在堆中。当这个方法运行完成后就会退出方法栈,则引用内容的引用不存在,这个Object会被回收。

  2. 软引用:如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
  3. 弱引用:弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
  4. 虚引用:顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收的活动。
3. Entry Key 弱引用的目的

因为 ThreadLocalMap 中 Entry 的 Key 存放的是 ThreadLocal 实例,也就是用户创建的 ThreadLocal 实例对象的一个引用。那么此时用户创建的 ThreadLocal 至少会有两个引用:

  • 一个是 ThreadLocal 内部的 Entry 对应的 Key;
  • 另一个是用户手动 new 出来的对象引用。

如果 Entry 对应的 Key 也是强引用,那么即使用户手动创建的引用被释放了,但是该对象还被另一个强引用(Entry的key)所引用,如果 Thread 线程不销毁,ThreadLocal 对应不会被释放。

实际上如果你手动创建的 ThreadLocal 强引用都被释放了,那么对应的实例对象肯定是要释放的。所以 ThreadLocal 里的 Entry 的 Key 设计成弱引用就是为了 ThreadLocal 实例对象,随着你手动创建的强引用销毁后该实例对象能正常的被销毁。

4. Entry Value 强引用的目的

既然 Entry 的 Key 是弱引用,作为键值对,Value 为什么是强引用呢?

因为当 ThreadLocal 的强引用不用了,即用户手动 new 的 ThreadLocal 实例不用了,也就说明该 Key-Value 已经没用用处,Key 可以被 GC 回收。

但 Value 对应的 Object 只是作为值,和 ThreadLocal 机制本身无关。如果将 Object Value 也设置为弱引用,当 Object 外部的强引用都释放了,下一次 GC 时 Value 必然也就释放了。而此时如果 ThreadLocal 实例依然存在,肯定会导致拿到的数据为空。

5. 清除 Key 为null 的 Entry

前面讲到当 ThreadLocal 实例没有引用关系后,作为 Entry 弱引用的 Key 就会在 GC 中被清除掉。这时就会留下 Key 为null,Value 依然存在的情况,无意义地占用了内存。

但其实线程的 ThreadLocalMap 是有机制去处理这种情况的:

  • 如果线程终止了,即 ThreadLocalMap 都销毁了,也就释放了对应 Entry的内存。
  • 在该线程 ThreadLocalMap 中,只要存在一个针对任何 ThreadLocal 实例的get()、set()或 remove() 操作,就会触发 Thread 实例拥有的ThreadLocalMap 的 Key 为null的所有 Entry 清理工作,释放掉 ThreadLocal 弱引用为 null 的 Entry。
6. 内存溢出

前面说到了 ThreadLocal 释放内存的机制,但内存溢出依然容易出现。有下面几种情况:

  1. 实际项目中基本都是基于线程池做多线程,同一个线程可能会被一直重复使用,即同一个 ThreadLocalMap,如果历史占用内存的 Entry 不释放内存,会越积越多。
  2. 前面讲到 Entry Key 的弱引用,以及 get()、set() 回收空key Entry 的机制。但是前提是 ThreadLocal new 出来的实例(强引用)释放了。可按照开发规范,ThreadLocal 的修饰符通常都是 static final,static 带来的副作用就是所创建 ThreadLocal 实例在线程生命周期内永远不会释放。所以后面的机制也就没有作用。

所以,这就要求我们要牢牢记住 remove() 方法,在实例不用时,及时 remove 掉整个 Entry。

5. 注意事项

ThreadLocal实例有提供remove()方法,用于回收对象,清除对应的内存占用。这个方法通常容易被忽略,而导致出现了各种问题。如下面几种:

  1. 线程复用:在“获取接口的当前请求用户”的例子中,Tomcat中是通过线程池来处理用户请求的,而线程池中线程是复用的。肯定会出现一个线程前后被不同用户的接口请求复用的情况,因此需要对用过的ThreaLocal变量进行覆盖或清除。
  2. 内存溢出:由于ThreadLocalMap的生命周期跟Thread一样长,如果创建的ThreadLocal变量很多,即对应的key占用的内存很大,但却没有手动删除,到了一定程度就会导致内存泄漏。

KerryWu
641 声望159 粉丝

保持饥饿


引用和评论

0 条评论