2023-01-04

简介

了解java.lang.ThreadLocal<T>类的两种常见使用和原理,并在项目中使用ThreadLocal

理论

ThreadLocal是Thread的局部变量,用于编多线程程序,对解决多线程程序的并发问题有一定的启示作用。

ThreadLocal_百度百科 (baidu.com)

ThreadLocal 是線程的局部變量, 是每一個線程所單獨持有的,其他線程不能對其進行訪問

Java - ThreadLocal 類的使用 (kucw.github.io)

使用场景

介绍ThreadLocal使用中的两种经典场景

  • 每个线程需要一个独享的对象:通常为工具类,如SimpleDateFormat
  • 每个线程内需要保存全局变量,可以在不同地方直接使用:如在拦截器中获取用户信息。

场景一

发现问题

使用SimpleDateFormat时,每个线程都new一个新的SimpleDateFormat,没有出现问题,如下。

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

    public static void main(String[] args) {

        for (int i = 0; i < 100; i++) {
            int finalI = i;
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(date(finalI));
                }
            });
        }
        threadPool.shutdown();

        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(date(10000));
            }
        }).start();
    }

    static String date(int sec) {
        Date data = new Date(1000L * sec);
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");//每个线程使用时都会new一个新的
        return format.format(data);
    }
}
复制代码

此时我们想,每个线程都创建一个新的SimpleDateFormat对象会浪费资源,所以我们把date()方法中的SimpleDateFormat format变量拿出来写成一个静态变量,每个线程都使用这个静态的static SimpleDateFormat format,如下。

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

    static SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");//静态变量

    public static void main(String[] args) {

        for (int i = 0; i < 100; i++) {
            int finalI = i;
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(date(finalI));
                }
            });
        }
        threadPool.shutdown();

        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(date(10000));
            }
        }).start();
    }

    static String date(int sec) {
        Date data = new Date(1000L * sec);
        return format.format(data);
    }
}
复制代码

输出的结果中出现了相同的时间,这是有问题的,因为传入date()函数的变量一定是不一样的,如下。

1970-01-01 08:01:37
1970-01-01 08:01:34
1970-01-01 08:01:34
1970-01-01 08:01:32
复制代码

结论:SimpleDateFormat线程不安全的。

SimpleDateFormat线程安全问题-极光社区 (jiguang.cn)

解决问题

  1. 发现问题中的第一段代码,将SimpleDateFormat format定义为局部变量(即每次都new新的),而不是静态变量。
  2. 使用synchronized加锁,但因为等待排队会出现性能问题。
static String date(int sec) {
    Date data = new Date(1000L * sec);
    String str = null;
    synchronized (Solution.class) {
        str = format.format(data);
    }
    return str;
}
复制代码
  1. 利用ThreadLocal给每个线程分配自己的SimpleDateFormat对象,保证了线程安全&&高效利用内存。
static String date(int sec) {
    Date data = new Date(1000L * sec);
    //SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
    SimpleDateFormat format = ThreadSafeFormatter.dateFormatThreadLocal.get();
    return format.format(data);
}
复制代码

dateFormatThreadLocaldateFormatThreadLocal2等效。

class ThreadSafeFormatter {
    public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal
            = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        }
    };

    public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal2
            = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"));
}
复制代码
  1. 使用DateTimeFormatter

场景二

发现问题

一个web server中存在一条service链:request -> service1 -> service2 -> service3 -> service4 -> ...

service1负责把用户信息userInfo从request中提取出来,之后userInfo就会从service1开始在链上传递,这样会导致代码冗余,不易维护。

补充信息

浏览器通过此连接发送HTTP请求,当请求进入Tomcat时,Tomcat会从其线程池中分配一个线程来处理请求.生成并发送响应后,线程将返回池,准备从任何客户端提供另一个请求.

Tomcat如何处理多个请求 | (1r1g.com)

我们的需求:每个线程内需要保存一个全局变量userInfo,可以在不同的地方直接使用。

解决问题

  1. 一个static变量存储信息可以么?

答:不可以。信息在同一个线程内相同,但是在不同的线程中不一定相同,即:用户信息在一次request中应该相同,但在不同request中不一定相同。

  1. 使用Map可以么?在发现问题的条件下,引入一个map,service1中将用户信息put进map,service2等后续方法可以直接从map中get到userInfo。

答:同时会有多个请求访问web server,即在多线程环境下,我们需要保证线程安全,无论是使用synchronized,还是concurrentHashMap,都会影响性能。

  1. ThreadLocal
public class TestThreadLocal {

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                new service1().process("绫波丽");
                new service2().sayHello();
                new service3().sayGoodbye();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                new service1().process("玛奇玛");
                new service2().sayHello();
                new service3().sayGoodbye();
            }
        }).start();
    }
}

class UserContextHolder {
    public static ThreadLocal<User> threadLocal = new ThreadLocal<>();
}

class service1 {
    public void process(String name) {
        User user = new User(name);
        UserContextHolder.threadLocal.set(user);
    }
}

class service2 {
    public void sayHello() {
        System.out.println("Hello," + UserContextHolder.threadLocal.get().getName() + "!");
    }
}

class service3 {
    public void sayGoodbye() {
        System.out.println("Goodbye," + UserContextHolder.threadLocal.get().getName() + "!");
        UserContextHolder.threadLocal.remove();
    }
}

//User类省略
复制代码

结果如下。

Hello,绫波丽!
Hello,玛奇玛!
Goodbye,绫波丽!
Goodbye,玛奇玛!
复制代码

每次http请求都对应一个线程,线程之间相互隔离,这就是ThreadLocal的经典应用场景。

总结

  • 对象生成时机由我们控制,重写initialValue()方法来保存对象。
  • 对象生成时机不由我们控制,调用set()方法来保存对象。

好处:

  • 线程安全
  • 不加锁,效率高
  • 高效利用内存:相比于每一个线程任务都新建一个SimpleDateFormat,使用ThreadLocal可以节约内存(注意线程池在此处与ThreadLocal的关联)。
  • 解决场景二的问题

成员方法

ThreadLocal - Java 11中文版 - API参考文档 (apiref.com)

initialValue()

  1. 返回此线程局部变量的当前线程的“初始值”。
  2. 这是一个延时加载方法,只有在调用第一次调用get()方法时才会触发。如果不set()直接调用get()方法,get()方法会返回setInitialValue()方法,setInitialValue()方法调用initialValue()方法。
  3. 如果用了set(),就不会再调用initialValue()方法了。
  4. 通常每个线程最多调用一次此方法,但如果调用了remove()后,再get()还可以再调用此方法。
  5. initialValue()方法默认返回null,所以如果需要使用initialValue()初始化,需要重写此方法。

成员方法setInitialValue()源码如下。

private T setInitialValue() {
    T value = initialValue();//调用initialValue()
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);//调用map.set()
    else
        createMap(t, value);
    return value;
}
复制代码

set()、get()、remove()

  • 设置新值。
  • 得到线程对应value。如果没有set()过,且首次调用get(),会调用initialValue()获得初始值。
  • 删除线程对应的值。

get()方法是先取出当前线程的ThreadLocalMap map,然后调用map.getEntry()方法,把本ThreadLocal的引用作为参数传入,取出map中属于本ThreadLocal的value,源码如下。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);//map不为空,就从map中通过键(ThreadLocal)获取键值对对象Entry
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();//map为空或者map中键对应的键值对对象为空,调用setInitialValue()
}
复制代码

set()源码如下:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);//调用map.set()
    else
        createMap(t, value);
}
复制代码

场景一场景二综合:观察到setInitialValue()initialValue())和set()都是利用map.set()方法来设置值,最后都对应到ThreadLocalMap map中的一个Entry e

ThreadLocal部分源码如下。

public class ThreadLocal<T> {
    ...
    static class ThreadLocalMap {
        ...
        private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            ...
        }
    }
}
复制代码

原理

每个Thread(线程)持有一个ThreadLocalMap成员变量,一个ThreadLocalMap中维护多个ThreadLocal

为什么map中有多个ThreadLocal呢?

答:场景一格式化日期时使用ThreadLocal<SimpleDateFormat> dateFormatThreadLocal,场景二保存与使用用户信息使用ThreadLocal<User> threadLocal。如果一个线程既要格式化日期,又要保存与使用用户信息,那么这个线程的ThreadLocalMap中就有两个ThreadLocal了。

怎么理解ThreadLocal.ThreadLocalMap threadLocals

答:ThreadLocalMap类是每个线程Thread类里的变量,里面最重要的是一个键值对数组Entry[] table,可以认为是一个哈希表。

  • 键:ThreadLocal引用。
  • 值:实际需要的对象,如SimpleDateFormat对象,User对象。

ThreadLocalMap 是一个定制的哈希映射,仅适用于 维护线程本地值。不导出任何操作 在 ThreadLocal 类之外。

ThreadLocal.ThreadLocalMap (mpg.de)

ThreadLocalMap的冲突处理?

答:HashMap使用拉链法+红黑树解决冲突问题,而ThreadLocalMap中使用线性探测法,即如果发生冲突,就继续找下一个空着的位置。

注意

内存泄露

内存泄露:某个对象不再有用,但是它占用着的内存却不能被收回。

线程活着,线程里的ThreadLocalMap就活着,里面维护的键值对们就活着,导致无法GC。

  • 為了解決這個問題,java 做了一個小優化,也就是存放在 ThreadLocalMap 中的 ThreadLocal,會使用 弱引用 來儲存,也就是說,如果一個 ThreadLocal 內存地址沒有外部強引用來引用他,只有這條 ThreadLocalMap 的弱引用來引用他時,那麼當系統 GC 時,這些 ThreadLocal 就會被回收(因為是弱引用),如此一來,ThreadLocalMap 中就會出現 key 為 null 的 Entry 們
  • 這個弱引用優化只能使得 ThreadLocal 被正確回收,但是這些 key 為 null 的 Entry 們仍然會存在在 ThreadLocalMap 裡,因此 value 仍然無法被回收
  • 所以 java 又做了一個優化,就是在 ThreadLocal 執行 get()set()remove() 方法時,都會將該線程 ThreadLocalMap 裡所有 key = null 的 value 也設置為 null,手動幫助 GC
ThreadLocal k = e.get();
if (k == null) {
    e.value = null; // Help the GC
} 
复制代码
  • 但是根本上的解決辦法,還是在當前線程使用完這個 ThreadLocal 時,就即時的 remove() 掉該 value,也就是使得 ThreadLocalMap 中不要存在這個鍵值對,這樣才能確保 GC 能正確回收

Java - ThreadLocal 類的使用 (kucw.github.io)

空指针异常

出现问题

public class TestNPE {

    public static void main(String[] args) {
        IntegerHolder integerHolder = new IntegerHolder();
        System.out.println(integerHolder.get());//注意此处之前没有set或初始化
        new Thread(new Runnable() {
            @Override
            public void run() {
                integerHolder.set(Thread.currentThread().getId());
                System.out.println(integerHolder.get());//此处前有set
            }
        }).start();
    }
}

class IntegerHolder {
    public ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    public long get() {//注意此处返回值为long而不是Long
        return threadLocal.get();
    }

    public void set(long num) {
        threadLocal.set(num);
    }
}
复制代码

以上代码运行结果如下。

Exception in thread "main" java.lang.NullPointerException
    at daijizai.IntegerHolder.get(TestNPE.java:29)
    at daijizai.TestNPE.main(TestNPE.java:14)
复制代码

问题分析:出现问题的地方为之前没有初始化或set()get()语句,因为没有初始化或set(),所以threadLocal.get()会返回一个值为nullLong,而class IntegerHolderget()方法的返回值类型为long(注意是long而不是Long),包装类Longnull自动拆箱为基本数据类型long时,出现空指针异常。

解决问题

class IntegerHolderget()方法的返回值类型改为Long

修改后运行结果如下。

null
12
复制代码

共享对象

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

实战

ThreadLocal

HostHolder

@Component
public class HostHolder {
    private ThreadLocal<User> users=new ThreadLocal<>();

    public void setUser(User user){
        users.set(user);
    }

    public User getUser(){
        return users.get();
    }

    public void clear(){
        users.remove();
    }
}
复制代码

LoginTicketInterceptor

@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
    @Autowired
    private HostHolder hostHolder;

    ...

    @Override
    public boolean preHandle(...){
        ...
        hostHolder.setUser(user);
        ...
    }

    ...

    @Override
    public void afterCompletion(...){
        hostHolder.clear();
        ...
    }
}
复制代码

在任何需要user对象的地方User user = hostHolder.getUser();

RequestContextHolder

在Web开发中,service层或者某个工具类中需要获取到HttpServletRequest对象还是比较常见的。一种方式是将HttpServletRequest作为方法的参数从controller层一直放下传递,不过这种有点费劲,且做起来不是优雅;还有另一种则是RequestContextHolder,直接在需要用的地方使用如下方式取HttpServletRequest即可

RequestContextHolder - 简书 (jianshu.com)

使用AOP统一记录日志时需要获取登录用户的IP地址,这个地址保存在request请求对象中。

省略与主题无关的代码,核心代码如下。

...
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String ip = request.getRemoteHost();
...
复制代码

RequestContextHolder部分源码如下。

public abstract class RequestContextHolder {
    ...
    private static final ThreadLocal<RequestAttributes> requestAttributesHolder = new NamedThreadLocal("Request attributes");
    ...
    public static RequestAttributes getRequestAttributes() {
        ...
    }
    ...
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容