ThreadLocal用法及原理

ThreadLocal用法及原理

1 ThreadLocal简介

  • ThreadLocal中文是:线程局部变量。

  • 为什么需要ThreadLocal呢?这是因为在并发编程中,如果一个类变量被多个线程操作,会造成线程安全问题。例如多个线程使用同一个SimpleDateFormat对象。使用ThreadLocal可以让每个线程拥有线程内部的变量,防止多个线程操作一个类变量造成的线程安全问题。

  • 那是不是可以让多线程中的每个任务都创建一个要用的对象呢?这样做可以避免线程安全问题,但是会造成资源的浪费。例如我们要新建1000个格式化打印时间的任务,每个任务中新建一个SimpleDateFormat的对象:

    • 我们可以开辟1000个线程分别执行上述任务,但这种做法太耗费资源了,不可取;

    • 我们可以使用线程池,例如线程池中有10个线程,然后将这1000个任务放到线程池中执行,这样可以实现打印时间的目的,没有线程安全问题,但是新建1000个SimpleDateFormat对象太浪费了。

    • 最好的做法是每个线程中创建一个SimpleDateFormat对象,这样一共只需要创建10个该对象,即保证了线程安全,又节省了资源。

2 ThreadLocal用法

  • 用法一:每个线程需要一个独享的对象。

  • 用法二:每个线程内需要保存全局变量。

2.1 用法一:线程独享对象

请创建1000个格式化打印时间的任务并执行。

  • 做法:使用线程池,线程池中开辟10个线程,用这10个线程执行这1000个任务,为了防止出现线程安全问题,使用ThreadLocal保证每个线程独享一个SimpleDateFormat对象,代码如下:
/**
 * 典型场景1:每个线程需要一个独享的对象
 * 利用ThreadLocal,给每个线程分配自己的dateFormat对象,保证了线程安全,高效利用了内存
 */
public class Main1 {

    public static ExecutorService tp = Executors.newFixedThreadPool(10);

    public String date(int seconds) {
        SimpleDateFormat df = TSF.df.get();  // 获取当前线程拥有的 SimpleDateFormat 对象
        return df.format(new Date(1000 * seconds));
    }

    public static void main(String[] args) {

        for (int i = 0; i < 1000; i++) {
            int finalI = i;

            tp.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new Main1().date(finalI);
                    System.out.println(date);
                }
            });
        }
        tp.shutdown();
    }
}

class TSF {  // ThreadSafeFormatter
    // 本类中定义的类变量都是线程内部的,可以定义多个
    // 每个类变量的用法都是类似的,即:TSF.类变量名.get()    根据类变量名可以知道返回哪个对象
    // 底层map中存在键值对:(UTSF.df, 该函数的返回值)
    public static ThreadLocal<SimpleDateFormat> df = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        }
    };
}

结果会打印出1000个不同的时间。

2.2 用法二:线程全局变量

每个线程都会牵涉到三个服务类:Service1、Service2、Service3,这三个类中都会使用到同一个对象。同一个进程内部这是一个对象,不同进程之间对象不同,请实现该需求。

  • 一种简单的做法是:我们可以在相应的函数中进行参数传递但是这样会导致代码冗余且不易维护,不可取。

  • 做法应该是:使用ThreadLocal保存属于每个线程的对象,然后通过ThreadLocal的get方法获取属于本线程的对象。

/**
 * 每个线程内需要保存全局变量
 * 同一个线程内该全局信息相同,不同线程间该全局信息不同
 * 如下两个线程,线程1保存全局用户"wxx",线程2保存全局用户"she"
 */
public class Main2 {

    public static void main(String[] args) throws Exception {

        new Thread(() -> new Service1().process("wxx")).start();
        Thread.sleep(100);
        new Thread(() -> new Service1().process("she")).start();
    }
}

class Service1 {  // Service1 调用 Service2
    public void process(String name) {
        User user = new User(name);
        UserContextHolder.holder.set(user);  // 底层map中存在键值对:(UserContextHolder.holder, user)
        System.out.println("Service1:" + user.name);
        new Service2().process();
    }
}

class Service2 {  // Service2 调用 Service3
    public void process() {
        User user = UserContextHolder.holder.get();
        System.out.println("Service2:" + user.name);
        new Service3().process();
    }
}

class Service3 {
    public void process() {
        User user = UserContextHolder.holder.get();
        System.out.println("Service3:" + user.name);
    }
}

class UserContextHolder {  // 本类中定义的类变量都是线程内部的,可以定义多个
    public static ThreadLocal<User> holder = new ThreadLocal<>();
}

class User {
    String name;
    public User(String name) {
        this.name = name;
    }
}

结果:

Service1:wxx
Service2:wxx
Service3:wxx
Service1:she
Service2:she
Service3:she

3 ThreadLocal原理

  • 首先我们应该明确如下类之间的关系:ThreadLocal、ThreadLocalMap、Thread。

  • ThreadLocalMap 是 ThreadLocal的内部类。ThreadLocalMap是一个存储键值对Map容器,ThreadLocalMap中还有内部类Entry,用于存储每个键值对,其中键为ThreadLocal变量,值为用户传入的对象。关系如下:

ThreadLocal用法及原理

  • 现在搞清楚了ThreadLocal、ThreadLocalMap之间的关系,那这两个和Thread是什么关系呢?答案是:Thread中有一个ThreadLocal.ThreadLocalMap的变量。如下图:

ThreadLocal用法及原理

public class Thread implements Runnable {
	
    // ...
    
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    // ....
}

  • 接下来我们就可以探究ThreadLocal到底是如何获取属于线程内部的变量的,关键在于探究ThreadLocal的get()方法。该函数如下:
public class ThreadLocal<T> {
    
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
}
  • 该函数中使用到了getMapsetInitialValue两个函数,这两个函数的定义如下:
public class ThreadLocal<T> {
    
    private T setInitialValue() {
        T value = initialValue();  // 用法一 重写了该方法,由多态可知,返回重写的该函数的返回值
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);  // 得到当前线程t的成员变量 threadLocals
        if (map != null)
            map.set(this, value);  // 向 threadLocals 中放入键值对, 关键!!!
        else
            createMap(t, value);
        return value;
    }
    
    public void set(T value) {  // 用法二调用了该方法
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);  // 向 threadLocals 中放入键值对, 关键!!!
        else
            createMap(t, value);
    }
    
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
}
  • 分析get()函数的执行流程:

    • (1)获取当前线程t,然后调用getMap(t),从而得到属于当前线程t的ThreadLocalMap变量map

    • (2)然后判断属于当前线程tmap是否为空,不空的话从map中取出当前键值对,这里的键是this,也就是说调用get()方法的变量。对应于用法一的TSF.df,对应于用法二的UserContextHolder.holder。为空的话则调用setInitialValue(),该函数会将this作为键,重写的initialValue()返回值作为值存入到map中。

    • (3)返回this对象对应的值。

  • 无论是用法一,还是用法二,其实本质上都在操纵 当前线程t的成员变量threadLocals

  • 根据上述get()分析的第(2)点,当我们new ThreadLocal<>();时并没有向 ThreadLocalMap 中存入键值对,只有当调用get()、set()方法时才放入键值对,这是懒加载的一种体现。

4 ThreadLocal注意点

ThreadLocalMap

  • ThreadLocalMapHashMap类似,关于HashMap的详细分析,可以参考:HashMap源码分析

  • 两者也有不少区别:(1)两者解决哈希冲突的方式不同;(2)ThreadLocalMap中的键值对,其中键为软引用,值为强引用,但HashMap中键值都为强引用。

解决哈希冲突

  • ThreadLocalMap采用的是线性探测法,也就是如果发生冲突,就继续找下一个空位置;

  • HashMap采用拉链法(链表+红黑树)。

ThreadLocalMap中节点的键值对

  • 如果弱引用对象只与弱引用关联,则这个弱引用对象可以被回收。

  • ThreadLocalMap中的Entry继承自WeakReference,是弱引用;

    • 每一个Entry都是对key的弱引用;

    • 每个Entry都包含了一个对value的强引用;

  • value为强引用的原因:因为JVM认为这个引用十分重要,是程序员定义的,不能随意回收,回收之后可能发生异响不到的错误;

  • 因为值value是强引用,所以可能导致内存泄露,最终导致OOM,这是因为:如果线程不终止(比如线程需要保持很久),那么key对应的value就不能被回收,存在以下调用链:Thread---->ThreadLocalMap---->Entry(key为null)---->value。导致value无法回收,日积月累可能造成OOM。

  • JDK已经考虑到了这个问题,所以在Entry的set,remove,rehash方法中会扫描key为null的Entry,并把对应的value设置为null,这样value对象就可以被回收。但是这样做还不足够,因为我们必须调用这些方法才能达到上述效果。

  • 为了避免产生内存泄露问题,我们在使用完ThreadLocal之后,就应该调用remove方法(阿里规约)。例如用法二中Service3应该改为:

class Service3 {
    public void process() {
        User user = UserContextHolder.holder.get();
        System.out.println("Service3:" + user.name);
        UserContextHolder.holder.remove();  // 防止内存泄露
    }
}

我们可不可以在新建ThreadLocal并在没有重写initialValue()方法后,直接调用 ThreadLocal 的 get()方法?

  • 可以,只不过会返回null

  • 如下代码演示了上述描述的问题:

public class ThreadLocalNPE {

    ThreadLocal<Long> tl = new ThreadLocal<>();

//    public void set() {
//        tl.set(Thread.currentThread().getId());
//    }

    public long get() {  // 返回值改为 Long 就没有NPE异常了
        return tl.get();  // tl.get() 为 null
    }

    public static void main(String[] args) {

        ThreadLocalNPE main = new ThreadLocalNPE();

        // 不进行set,直接get
        main.get();
    }
}
  • 上述代码会抛出java.lang.NullPointerException异常,这不是因为get()的原因,而是因为:拆箱时null不能转为基本类型。当返回值改为 Long 就没有NPE异常了。
上一篇:一些Linux优化方法


下一篇:ThreadLocal的简单介绍