Java并发13:ThreadLocal详解

115 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第13天,点击查看活动详情

学习MOOC视频记录的笔记

1.两大使用场景——ThreadLocal的用途

1.1 场景1

典型场景1:每个线程需要一个独享的对象(通常是工具类,典型需要使用的类有 SimpleDateFormatRandom,这两个工具类都是线程不安全的;因此每个线程需要拥有自己独有的工具类)

  • 每个 Thread 内有自己的实例副本,不共享
  • 比喻:教材只有一本,一起做笔记有线程安全问题。复印后没问题
  • SimpleDateFormat进化之路

模拟场景:2个线程分别用自己的SimpleDateFormat

图片 1

/**
 * 两个线程打印日期
 */
public class ThreadLocalNormalUsage00 {

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                String date = new ThreadLocalNormalUsage00().date(10);
                System.out.println("date = " + date);
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                String date = new ThreadLocalNormalUsage00().date(104707);
                System.out.println("date = " + date);
            }
        }).start();
    }
    public String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        return dateFormat.format(date);
    }
}

2

当有多个线程的时候可以使用for循环:

public class ThreadLocalNormalUsage01 {

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 30; i++) {
            int finalI = i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalNormalUsage01().date(finalI);
                    System.out.println("date = " + date);
                }
            }).start();
        }
        Thread.sleep(100);
    }
    public String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        return dateFormat.format(date);
    }
}

但是当需求变成了1000个,那么必然要用线程池(否则消耗内存太多)

3

/**
 * 1000个打印日期的任务,用线程池来执行
 */
public class ThreadLocalNormalUsage02 {

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

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalNormalUsage02().date(finalI);
                    System.out.println("date = " + date);
                }
            });
        }
        threadPool.shutdown();
    }
    public String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        return dateFormat.format(date);
    }
}

使用线程池有个问题就是每次执行 date 函数的时候都会新建一个 SimpleDateFormat 对象,一直在创建销毁。因此考虑将 SimpleDateFormat 提出来作为类变量,代码如下:

public class ThreadLocalNormalUsage03 {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalNormalUsage03().date(finalI);
                    System.out.println("date = " + date);
                }
            });
        }
        threadPool.shutdown();
    }
    public String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        return dateFormat.format(date);
    }
}

但是运行结果中却出现了相同的时间,出现这个问题的原因就是 SimpleDateFormat 并不是线程安全的

...
date = 1970-01-01 08:16:15
date = 1970-01-01 08:15:58
date = 1970-01-01 08:15:58
date = 1970-01-01 08:15:57
...

所有的线程都共用同一个simpleDateFormat)对象

4

加锁可以解决线程安全问题:

public class ThreadLocalNormalUsage04 {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalNormalUsage04().date(finalI);
                    System.out.println("date = " + date);
                }
            });
        }
        threadPool.shutdown();
    }
    public String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        String s = null;
        synchronized (ThreadLocalNormalUsage04.class) {
            s = dateFormat.format(date);
        }
        return s;
    }
}

SimpleDateFormat的进化之路

  1. 2个线程分别用自己的SimpleDateFormat,这没问题
  2. 后来延伸出10个,那就有10个线程和10个SimpleDateFormat,这虽然写法不优雅(应该复用对象),但勉强可以接受
  3. 但是当需求变成了1000个,那么必然要用线程池(否则消耗内存太多)
  4. 所有的线程都共用同一个simpleDateFormat)对象
  5. 这是线程不安全的,出现了并发安全问题
  6. 我们可以选择加锁,加锁后结果正常,但是效率低
  7. 在这里更好的解决方案是使用 ThreadLocal
  8. lambda表达式写法

更好的解决方案是使用 ThreadLocal

5

创建10个simpleDateFormat就可以

/**
 * 利用ThreadLocal,给每个线程分配自己的dateFormat对象,保证了线程安全,高效利用内存
 */
public class ThreadLocalNormalUsage05 {

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

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalNormalUsage05().date(finalI);
                    System.out.println("date = " + date);
                }
            });
        }
        threadPool.shutdown();
    }

    public String date(int seconds) {
        // 参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        // SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal.get();
        return dateFormat.format(date);
    }
}

/**
 * 每个线程中只有一份
 */
class ThreadSafeFormatter {
    public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        }
    };

    // lambda表达式写法,和上面的等效
    public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal2 = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"));
}

1.2 场景2

典型场景2:每个线程内需要保存全局变量(例如在拦截器中获取用户信息),可以让不同方法直接使用,避免参数传递的麻烦

一个比较繁琐的解决方案是把user作为参数层层传递,从service-1()传到service-2(),再从service-2()传到service-3(),以此类推,但是这样做会导致代码冗余不易维护

6

每个线程内需要保存全局变量,可以让不同方法直接使用,避免参数传递的麻烦

  • 用ThreadLocal保存一些业务内容(用户权限信息、从用户系统获取到的用户名、user ID等)
  • 这些信息在同一个线程内相同,但是不同的线程使用的业务内容是不相同
  • 在线程生命周期内,都通过这个静态ThreadLocal实例的get()方法取得自己set过的那个对象,避免了将这个对象(例如user对象)作为参数传递的麻烦

实例:当前用户信息需要被线程内所有方法共享

  • 在此基础上可以演进,使用UserMap

7

当多线程同时工作时,我们需要保证线程安全,可以用synchronized,也可以用ConcurrentHashMap,但无论用什么,都会对性能有所影响

8

更好的办法是使用ThreadLocal,这样无需synchronized,可以在不影响性能的情况下,也无需层层传递参数,就可达到保存当前线程对应的用户信息的目的

  • 强调的是同一个请求内(同一个线程内)不同方法间的共享
  • 不需重写 initialValue() 方法,但是必须手动调用 set() 方法
public class ThreadLocalNormalUsage06 {
    public static void main(String[] args) {
        new Service1().process();
    }
}

class Service1 {
    public void process() {
        User user = new User("超哥");
        UserContextHolder.holder.set(user);
        new Service2().process();
        new Service3().process();
    }
}

class Service2 {
    public void process() {
        User user = UserContextHolder.holder.get();
        System.out.println("Service2拿到用户名: " + user.name);
    }
}

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

// 这里是通过set方法设置值的
class UserContextHolder {
    public static ThreadLocal<User> holder = new ThreadLocal<>();
}

class User {
    String name;

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

运行结果:

Service2拿到用户名: 超哥
Service3拿到用户名: 超哥

9

ThreadLocal 的两个作用

  1. 让某个需要用到的对象在线程间隔离(每个线程都有自己的独立的对象)
  2. 在任何方法中都可以轻松获取到该对象

回顾前面的两个场景,对应到这两个作用

根据共享对象的生成时机不同,选择 initialValueset 来保存对象

  • initialValue使用场景: 在 ThreadLocal 第一次 get 的时候把对象给初始化出来,对象的初始化时机可以由我们控制
  • set使用场景:如果需要保存到 ThreadLocal 里的对象的生成时机不由我们随意控制,例如拦截器生成的用户信息,用 ThreadLocal.set 直接放到我们的ThreadLocal 中去,以便后续使用。

2.使用ThreadLocal带来的好处

  • 达到线程安全 每个线程持有自己的对象
  • 不需要加锁,提高执行效率 对象不存在共享
  • 更高效地利用内存、节省开销:相比于每个任务都新建一个SimpleDateFormat,显然用ThreadLocali可以节省内存和开销
  • 免去传参的繁琐:无论是场景一的工具类,还是场景二的用户名,都可以在任何地方直接通过ThreadLocal拿到,再也不需要每次都传同样的参数。ThreadLocal使得代码耦合度更低,更优雅

3.主要方法介绍

  • T initialValue():初始化
  1. 该方法会返回当前线程对应的“初始值”,这是一个延迟加载的方法,只有在调用get的时候,才会触发

  2. 当线程第一次使用 get 方法访问变量时,将调用此方法,除非线程先前调用了 set 方法,在这种情况下,不会为线程调用本 initialValue 方法

  3. 这正对应了ThreadLocal的两种典型用法

  4. 通常,每个线程最多调用一次此方法,但如果已经调用了remove()后,再调用get(),则可以再次调用此方法

  5. 如果不重写本方法,这个方法会返回null。一般使用匿名内部类的方法来重写 initialValue() 方法,以便在后续使用中可以初始化副本对象。

  • void set(T t):为这个线程设置一个新值
  • T get():得到这个线程对应的value。如果是首次调用get(),则会调用initialize来得到这个值
  • void remove():删除对应这个线程的值
class Service2 {
    public void process() {
        User user = UserContextHolder.holder.get();
        // ThreadSafeFormatter.dateFormatThreadLocal.get();
        System.out.println("Service2拿到用户名: " + user.name);
        // 清空保存的对象
        UserContextHolder.holder.remove();
        user = new User("王姐");
        // 重新设置
        UserContextHolder.holder.set(user);
    }
}

4.原理、源码分析

搞清楚ThreadThreadLocal 以及 ThreadLocalMap三者之间的关系

每个 Thread 对象中都持有一个 ThreadLocalMap 成员变量

ThreadLocal原理图

public
class Thread implements Runnable {
    ...
	ThreadLocal.ThreadLocalMap threadLocals = null;
    ...
}

5.注意点

5.1 内存泄漏

什么是内存泄漏:某个对象不再有用,但是占用的内存却不能被回收

要么就是key,也就是ThreadLocal回收不了,要么就是value回收不了

  • Key 的泄漏:ThreadLocalMap 中的 Entry 继承自 WeakReference,是弱引用
    • 弱引用的特点是,如果这个对象被弱引用关联(没有任何强引用关联),那么这个对象就可以被回收
    • 所以弱引用不会阻止GC,因此这个弱引用的机制,但是value是强引用,这就导致了内存泄漏的可能性
  • Value 的泄漏
    • ThreadLocalMap 的每个 Entry 都是一个对 key 的弱引用,同时,每个 Entry 都包含了一个对 value 的强引用
    • 正常情况下,当线程终止,保存在 ThreadLocal 里的 value 会被垃圾回收,因为没有任何强引用了
    • 但是,如果线程不终止(比如线程需要保持很久),那么 key 对应的 value 就不能被回收,因为有以下的调用链:Thread -> ThreadLocalMap -> Entry(key为null) -> Value
    • 因为value和Thread之间还存在这个强引用链路,所以导致 value 无法回收,就可能会出现OOM
    • JDK已经考虑到了这个问题,所以在set、remove、rehash方法中会扫描key为null的Entry,并把对应的value设置为null,这样value对象就可以被回收
    • 但是如果一个ThreadLocal不被使用,那么实际上set、remove、rehash方法也不会被调用,如果同时线程又不停止,那么调用链就一直存在,那么就导致了valuel的内存泄漏

如何避免内存泄露(阿里规约)

调用 remove 方法,就会删除对应的 Entry 对象,可以避免内存泄漏,所以使用完 ThreadLocal 之后,应该调甪 remove 方法

5.2 空指针异常

在进行get之前,必须先set,否则可能会报空指针异常?

并不是的,这是由于装箱拆箱导致的,而不是threadLocal导致的。

public class ThreadLocalNPE {
    ThreadLocal<Long> longThreadLocal = new ThreadLocal<>();

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

    // 如果返回是null,无法拆箱为long型,因此这里要写包装类型Long
    public Long get() {
        return longThreadLocal.get();
    }

    public static void main(String[] args) {
        ThreadLocalNPE threadLocalNPE = new ThreadLocalNPE();
        System.out.println(threadLocalNPE.get());

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocalNPE.set();
                // 没有set直接get就会得到一个空值
                System.out.println(threadLocalNPE.get());
            }
        });
        thread1.start();
    }
}

5.3 共享对象

如果在每个线程中 ThreadLocal.set() 进去的东西本来就是多线程共享的同一个对象,比如 static 对象,那么多个线程的 ThreadLocal.get() 取得的还是这个共享对象本身,还是有并发访问问题

这是不对的用法

  • 如果可以不使用 ThreadLocal 就解决问题,那么不要强行使用
    • 例如在任务数很少的时候,在局部变量中可以新建对象就可以解决问题,那么就不需要使用到 ThreadLocal
  • 优先使用框架的支持,而不是自己创造
    • 例如在Spring中,如果可以使用 RequestContextHolder,那么就不需要自己维护 ThreadLocal,因为自己可能会忘记调用 remove() 方法等,造成内存泄漏

6.实际应用场景一在Spring中的实例分析

  • DateTimeContextHolder 类,看到里面用了 ThreadLocal
  • RequestContextHolder
  • 每次 HTTP 请求都对应一个线程,线程之间相互隔离,这就是 ThreadLocal 的典型应用场景

7.常见面试题