该文章主要介绍ThreadLocal以及其变种,所以线程的方法、线程池、以及涉及线程方法的一些变量不在该文章讲解; 后续可能会陆续出线程方法详解、同步块、线程池内部详解等;(如果有时间的话) 因为ThreadLocal的讲解可能会涉及一些线程池等内容,但不会用过多篇幅进行讲解。
在java中线程是无处不在的;那么如何才能开启线程呢?
变量名 | 类型 | 主要作用 | 备注 |
---|---|---|---|
name | String | 标识线程名称 | 名称随时可以修改volatile修饰 |
priority | int | 优先级 | 1-10之间,不能保证绝对优先 |
daemon | boolean | 守护线程标志 | 默认为非守护线程 |
target | Runnable | 运行目标 | 如果target==null,那么运行自身run方法 |
group | ThreadGroup | 所属线程组 | 便于管理相同或类似任务的线程 |
contextClassLoader | ClassLoader | 上下文类加载器 | 可随时设置于修改 |
threadLocals | ThreadLocalMap | 上下文变量 | 时常用于上下文参数设置和获取 |
inheritableThreadLocals | ThreadLocalMap | 上下文变量 | 时常用于父子线程参数设置和获取 |
tid | long | 线程唯一标识 | |
threadStatus | int | 线程状态 |
ThreadLocal是用于存储线程在任务执行过程便于获取参数和设置参数的线程变量,它是跟线程紧密相关的;它的出现是为了解决多线程环境下的变量访问并发问题;所以单线程的任务或进程根本用不着它,直接用全局实例变量或类变量即可。
用过struts2的人或多或少都接触过这么一个类ServletActionContext; 它继承至ActionContext;从ServletActionContext可以很方便的拿到request 等变量;有没有想过它是怎么办到的呢,如果同时有10个用户发起同一个或 者不同的请求,它是如何精确的告知你这次请求的request是什么呢? 其核心功臣就是ThreadLocal啦!
spring中事务管理中的TransactionSynchronizationManager中充斥着ThreadLocal,用于处理其接管的事务;
自定义ThreadLocal,假设项目中有个filter,该filter是用于解码信息的, 那么解码后的信息如何传递下去呢,当然还是使用ThreadLocal啦...
说了这么多,来时应该来个演示代码了
lombok依赖
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.16.18</version> <scope>provided</scope> </dependency> 复制代码
上下文管理类
package com.littlehow.context; import com.littlehow.model.User; /** * 基于线程上下文的用户信息管理 */ public class UserContext { private static final ThreadLocal<User> context = new ThreadLocal<>(); /** * 设置用户信息 * @param user -- 用户信息 */ public static void set(User user) { context.set(user); } /** * 获取用户信息 * @return -- 用户信息 */ public static User get() { return context.get(); } /** * 移除用户信息 */ public static void remove() { context.remove(); } } 复制代码
用户类
package com.littlehow.model; import lombok.Data; import lombok.ToString; import java.time.LocalDate; @Data @ToString public class User { private Integer userId; private String name; private LocalDate birthday; } 复制代码
用户服务类
package com.littlehow.biz; import com.littlehow.context.UserContext; public class UserService { /** * 执行添加用户 */ public void addUser() { System.out.println(Thread.currentThread().getName() + "添加用户信息:" + UserContext.get()); } } 复制代码
测试调用类
package com.littlehow.biz; import com.littlehow.context.UserContext; import com.littlehow.model.User; import java.time.LocalDate; import java.util.concurrent.atomic.AtomicInteger; public class CallService { //用户编号创建器 private static final AtomicInteger creator = new AtomicInteger(1); //备选生日 private static final LocalDate[] birthdays = {LocalDate.of(1988, 9, 11), LocalDate.of(1989, 11, 10), LocalDate.of(1990, 3, 7), LocalDate.of(1995, 7, 26), LocalDate.of(2000, 10, 1) }; private static final int birthdayLength = birthdays.length; public static void main(String[] args) { UserService userService = new UserService(); //同时10个调用 for (int i = 0; i < 10; i++) { new Thread(() -> { UserContext.set(initUser(Thread.currentThread().getName())); //进行调用 userService.addUser(); }, "callService-" + i).start(); } } /** * 初始化用户信息(模拟请求) * @param name -- 用户名 * @return -- 用户信息 */ private static User initUser(String name) { User user = new User(); user.setUserId(creator.getAndIncrement()); user.setName(name); user.setBirthday(birthdays[user.getUserId() % birthdayLength]); return user; } } 复制代码
测试模拟10个用户添加的请求,用户信息放置于上下文中,目标是线程设置的上下文将由相同线程来获取并处理; 以上CallService运行结果如下(得到预期结果): callService-0添加用户信息:User(userId=1, name=callService-0, birthday=1989-11-10)
callService-2添加用户信息:User(userId=3, name=callService-2, birthday=1995-07-26)
callService-3添加用户信息:User(userId=4, name=callService-3, birthday=2000-10-01)
callService-1添加用户信息:User(userId=2, name=callService-1, birthday=1990-03-07)
callService-4添加用户信息:User(userId=5, name=callService-4, birthday=1988-09-11)
callService-5添加用户信息:User(userId=6, name=callService-5, birthday=1989-11-10)
callService-9添加用户信息:User(userId=7, name=callService-9, birthday=1990-03-07)
callService-6添加用户信息:User(userId=8, name=callService-6, birthday=1995-07-26)
callService-7添加用户信息:User(userId=9, name=callService-7, birthday=2000-10-01)
callService-8添加用户信息:User(userId=10, name=callService-8, birthday=1988-09-11)
这时候上面提到的线程变量threadLocals发挥作用的时候,我们来仔细看看,看完之后就能明确知道其为何为如此了,ThreadLocal变量的设置和获取代码:
public void set(T value) { //获取当前线程 Thread t = Thread.currentThread(); //获取存放线程变量的map //getMap(t)在ThreadLocal中的实现为:return t.threadLocals; //是当前线程的变量,如果map存在则在该map下继续设置 ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else //如果不存在则为当前线程的线程变量赋值 //createMap在ThreadLocal下的实现: //t.threadLocals = new ThreadLocalMap(this, firstValue); //由此可以看出,不管在外部设置初始化了多少个ThreadLocal,其实在线程中 //都只有一个Map变量,map的key就是ThreadLocal实例,所以从ThreadLocal读 //取出来的数据都是自己想要的。 createMap(t, value); } public T get() { //同set 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; } } //如果map不存在,设置一个初始值null方法map,如果map不存在,则初始化 //map再放入当前线程变量,最后返回该初始的null值 return setInitialValue(); } 复制代码
其实ThreadLocal还是存在很大限制的,它仅仅能获取自己当前线程设置的变量,那么有些功能比较复杂或者调用比较多的时候,可能会在调用过程中继续开启线程,那么在新开启的线程中就获取不到结果了,但这往往不是我们想要的;下面是演示代码(将callService稍稍做一点改动):
public static void main(String[] args) { //main作为当前调用线程 UserContext.set(initUser(Thread.currentThread().getName())); UserService userService = new UserService(); //开启新线程来进行调用 new Thread(() -> userService.addUser(), "callService").start(); } 复制代码
本来期望是能出现callService添加用户信息:User(userId=1, name=main, birthday=1989-11-10) 结果运行后输出:callService添加用户信息:null 最初始设置的User信息不见了;就好比有个真实项目,里面有个loginFilter,可以用户拦截未登录用户并且将已登录用户的用户信息放置于线程上下文中,然后业务处理的时候开启新的线程,在需要获取用户信息的时候就会出现获取不到的情况。
上一节说到ThreadLocal遇到线程中开启线程后,就不能获取到初始线程设置的变量值了,为了解决这个问题,InheritableThreadLocal应运而生,它继承至ThreadLocal,它在线程中的线程变量是inheritableThreadLocals; 为什么InheritableThreadLocal就能解决上面的问题呢,我们先将之前代码改造运行一次再进入其源码进行分析:
将UserContext里面的上下文变量申明改为下面的代码即可:
private static final ThreadLocal<User> context = new InheritableThreadLocal<>(); 复制代码
继续运行上面的代码后得到结果(符合预期): callService添加用户信息:User(userId=1, name=main, birthday=1989-11-10) 下面就来看看源码是如何实现的 InheritableThreadLocal
ThreadLocalMap getMap(Thread t) { // 仅仅是将获取map变为从线程中获取inheritableThreadLocals变量 return t.inheritableThreadLocals; } void createMap(Thread t, T firstValue) { //仅仅是将赋值改为设置到线程的inheritableThreadLocals 变量 t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue); } 复制代码
从InheritableThreadLocal中的代码来看,其实不可能实现线程中传递的,因为它仅仅重写了上面两个方法,那么为什么运行结果符合预期呢,那就只能从线程自己入手了。
Thread的部分方法:
//我测试代码中使用的线程的构造方法 public Thread(Runnable target, String name) { //核心是init方法,其他构造方法也是调用init方法进行线程的初始化 init(null, target, name, 0); } private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { if (name == null) {//由此可见name是必须设置,默认是thread-内部维护的自增方法 //此处就不发散开了 throw new NullPointerException("name cannot be null"); } this.name = name; //将当前线程设置为新线程的父线程 Thread parent = currentThread(); SecurityManager security = System.getSecurityManager(); if (g == null) { /* Determine if it's an applet or not */ /* If there is a security manager, ask the security manager what to do. */ if (security != null) { g = security.getThreadGroup(); } /* If the security doesn't have a strong opinion of the matter use the parent thread group. */ if (g == null) { g = parent.getThreadGroup(); } } /* checkAccess regardless of whether or not threadgroup is explicitly passed in. */ g.checkAccess(); /* * Do we have the required permissions? */ if (security != null) { if (isCCLOverridden(getClass())) { security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION); } } g.addUnstarted(); this.group = g; //初始时沿用父线程的守护线程属性 this.daemon = parent.isDaemon(); //初始时沿用父线程的优先级 this.priority = parent.getPriority(); //上下文类加载器的设置,这个可以写一个关于类加载器的文章来具体介绍,此处 //就不发散了 if (security == null || isCCLOverridden(parent.getClass())) this.contextClassLoader = parent.getContextClassLoader(); else this.contextClassLoader = parent.contextClassLoader; this.inheritedAccessControlContext = acc != null ? acc : AccessController.getContext(); //构造方法中传入了Runnable接口就有,否则为null,为null则调用在即的run方法 this.target = target; setPriority(priority); //#####此处是关键之处####### //默认inheritThreadLocals =true,那么就关心父线程的inheritableThreadLocals 变量了 //由InheritableThreadLocal重写的两个方法可以看出,父线程如果使用其设置了上下文变量 //那么parent.inheritableThreadLocals是有值得 if (inheritThreadLocals && parent.inheritableThreadLocals != null) // 将父线程的变量遍历到子线程的inheritableThreadLocals 变量中 //从而实现了新开线程也能获取到父线程设置的变量值了, //而且从该方法可以看出,线程的儿子可以得到,线程的孙子也能通过同样的方法获取到 //该过程是自上而下传递的 //#####此处是关键之处######## this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); /* Stash the specified stack size in case the VM cares */ this.stackSize = stackSize; /* Set thread ID */ tid = nextThreadID(); } 复制代码
是否觉得使用InheritableThreadLocal来传递上下文变量就可以一劳永逸了呢? 要是问题都这么简单就好了,但往往事与愿违啊!
到这里就不得不提一嘴线程池了,因为线程的创建和销毁是很耗费资源的一件事情,那么在高性能高并发的场景下如果频繁的创建线程销毁线程明显是不可取的,所以java前辈们自然而然就想到了线程的复用啦!线程池就是线程复用模型的一个实现并广泛运用于各个场景。因为线程池不是本文主要介绍对象,所以就不具体介绍其实现核心和原理了。
线程池为什么会是InheritableThreadLocal的缺陷呢,先来改造一下代码看看效果再说啦! 将CallService类改造如下:
package com.littlehow.biz; import com.littlehow.context.UserContext; import com.littlehow.model.User; import java.time.LocalDate; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; public class CallService { //用户编号创建器 private static final AtomicInteger creator = new AtomicInteger(1); //备选生日 private static final LocalDate[] birthdays = {LocalDate.of(1988, 9, 11), LocalDate.of(1989, 11, 10), LocalDate.of(1990, 3, 7), LocalDate.of(1995, 7, 26), LocalDate.of(2000, 10, 1) }; private static final int birthdayLength = birthdays.length; //申明一个简单的线程池,3个核心线程 private static final AtomicInteger threadIdCreator = new AtomicInteger(1); private static ExecutorService pool = Executors.newFixedThreadPool(3, (runnable) -> new Thread(runnable, "littlehow-" + threadIdCreator.getAndIncrement()) ); public static void main(String[] args) { UserService userService = new UserService(); //同时10个调用 for (int i = 0; i < 10; i++) { new Thread(() -> { UserContext.set(initUser(Thread.currentThread().getName())); //使用线程池进行调用 pool.execute(userService::addUser); }, "callService-" + i).start(); } } /** * 初始化用户信息(模拟请求) * @param name -- 用户名 * @return -- 用户信息 */ private static User initUser(String name) { User user = new User(); user.setUserId(creator.getAndIncrement()); user.setName(name); user.setBirthday(birthdays[user.getUserId() % birthdayLength]); return user; } } 复制代码
运行CallService得到以下结果: littlehow-1添加用户信息:User(userId=2, name=callService-1, birthday=1990-03-07)
littlehow-2添加用户信息:User(userId=6, name=callService-5, birthday=1989-11-10)
littlehow-3添加用户信息:User(userId=3, name=callService-2, birthday=1995-07-26)
littlehow-1添加用户信息:User(userId=2, name=callService-1, birthday=1990-03-07)
littlehow-3添加用户信息:User(userId=3, name=callService-2, birthday=1995-07-26)
littlehow-2添加用户信息:User(userId=6, name=callService-5, birthday=1989-11-10)
littlehow-3添加用户信息:User(userId=3, name=callService-2, birthday=1995-07-26)
littlehow-1添加用户信息:User(userId=2, name=callService-1, birthday=1990-03-07)
littlehow-3添加用户信息:User(userId=3, name=callService-2, birthday=1995-07-26)
littlehow-2添加用户信息:User(userId=6, name=callService-5, birthday=1989-11-10)
从输出可以看出,线程池中的线程执行了10次,但是输出的User信息来来回回就只有3个,与预期的userId[1-10]有很大差距;造成这种现象是因为线程复用导致的;
从之前的分析可以看出InheritableThreadLocal类型的变量,只有在线程初始化的时候才会被赋值,因为使用的线程池,导致当开启的线程数=核心线程数时,将不在新开启线程,而是使用之前的线程来进行当前的任务,那么当前任务获取的上下文变量肯定就是第一次初始化线程时设置进去的。 这个现象明显不是我们期望的,因为造成数据读取错乱,并且越往后越不可能获取到正确的数据信息。
上一节最后说到了InheritableThreadLocal在线程池中造成的数据混乱,那是否就无解了呢?
当前的TransmittableThreadLocal maven最新版本为9月更新的2.8.1
<dependency> <groupId>com.alibaba</groupId> <artifactId>transmittable-thread-local</artifactId> <version>2.8.1</version> </dependency> 复制代码
首先将UserContext里面的上下文变量申明改为下面的代码:
private static final ThreadLocal<User> context = new TransmittableThreadLocal<>(); 复制代码
其次再把刚刚方法线程池执行的runnable申明该为下面的代码:
pool.execute(TtlRunnable.get(userService::addUser)); 复制代码
调用CallService输出如下:
littlehow-1添加用户信息:User(userId=2, name=callService-1, birthday=1990-03-07)
littlehow-1添加用户信息:User(userId=7, name=callService-7, birthday=1990-03-07)
littlehow-1添加用户信息:User(userId=10, name=callService-9, birthday=1988-09-11)
littlehow-3添加用户信息:User(userId=1, name=callService-0, birthday=1989-11-10)
littlehow-1添加用户信息:User(userId=5, name=callService-5, birthday=1988-09-11)
littlehow-3添加用户信息:User(userId=6, name=callService-4, birthday=1989-11-10)
littlehow-1添加用户信息:User(userId=8, name=callService-8, birthday=1995-07-26)
littlehow-3添加用户信息:User(userId=3, name=callService-2, birthday=1995-07-26)
littlehow-1添加用户信息:User(userId=9, name=callService-6, birthday=2000-10-01)
littlehow-2添加用户信息:User(userId=4, name=callService-3, birthday=2000-10-01)
由以上输出可以看出,10个任务都被正确执行,也就是说都获取了正确的参数了,那么TransmittableThreadLocal到底是如何做到的呢?下面还是来进行简单的源码分析: 首先肯定是先看看TransmittableThreadLocal类的主要实现啦:
//它并没有像InheritableThreadLocal那样重写getMap方法,而是重写get,set,remove @Override public final T get() { T value = super.get(); if (null != value) { // 关键之处在于此 addValue(); } return value; } @Override public final void set(T value) { super.set(value); if (null == value) { // may set null to remove value //关键之处,这里如果设置为null,如果上一个任务执行留下了数据,那么必须移除 removeValue(); } else { //关键之处 addValue(); } } @Override public final void remove() { // 关键之处 removeValue(); super.remove(); } 复制代码
核心之处就在于它在原先设置、获取、删除值得地方都加上一自己的方法, 具体如下:
// Note about holder: // 1. The value of holder is type Map<TransmittableThreadLocal<?>, ?> (WeakHashMap implementation), // but it is used as *set*. // 2. WeakHashMap support null value. //此处有一个InheritableThreadLocal用于缓存父线程设置的线程变量,以线程的ThreadLocal作为Key private static InheritableThreadLocal<Map<TransmittableThreadLocal<?>, ?>> holder = new InheritableThreadLocal<Map<TransmittableThreadLocal<?>, ?>>() { @Override protected Map<TransmittableThreadLocal<?>, ?> initialValue() { return new WeakHashMap<TransmittableThreadLocal<?>, Object>(); } @Override protected Map<TransmittableThreadLocal<?>, ?> childValue(Map<TransmittableThreadLocal<?>, ?> parentValue) { return new WeakHashMap<TransmittableThreadLocal<?>, Object>(parentValue); } }; //刚刚提到过的关键之处,在set和get的时候会用到的方法 private void addValue() { //首先判断在此线程是否已经设置过了ThreadLocal,没有设置就缓存起来 if (!holder.get().containsKey(this)) { holder.get().put(this, null); // WeakHashMap supports null value. } } //移除缓存的信息 private void removeValue() { holder.get().remove(this); } //此处才是真正实现了参数传递的第一部分 public static Object capture() { Map<TransmittableThreadLocal<?>, Object> captured = new HashMap<TransmittableThreadLocal<?>, Object>(); for (TransmittableThreadLocal<?> threadLocal : holder.get().keySet()) { captured.put(threadLocal, threadLocal.copyValue()); } return captured; } //重新设置线程变量到线程池中的线程变量中 public static Object replay(@Nonnull Object captured) { @SuppressWarnings("unchecked") Map<TransmittableThreadLocal<?>, Object> capturedMap = (Map<TransmittableThreadLocal<?>, Object>) captured; Map<TransmittableThreadLocal<?>, Object> backup = new HashMap<TransmittableThreadLocal<?>, Object>(); for (Iterator<? extends Map.Entry<TransmittableThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator(); iterator.hasNext(); ) { Map.Entry<TransmittableThreadLocal<?>, ?> next = iterator.next(); TransmittableThreadLocal<?> threadLocal = next.getKey(); // backup backup.put(threadLocal, threadLocal.get()); // clear the TTL values that is not in captured // avoid the extra TTL values after replay when run task if (!capturedMap.containsKey(threadLocal)) { iterator.remove(); threadLocal.superRemove(); } } // set values to captured TTL setTtlValuesTo(capturedMap); // call beforeExecute callback doExecuteCallback(true); return backup; } private static void setTtlValuesTo(@Nonnull Map<TransmittableThreadLocal<?>, Object> ttlValues) { for (Map.Entry<TransmittableThreadLocal<?>, Object> entry : ttlValues.entrySet()) { @SuppressWarnings("unchecked") TransmittableThreadLocal<Object> threadLocal = (TransmittableThreadLocal<Object>) entry.getKey(); //将值设置到线程池给出的执行线程中,运行run的时候自然就能取到。 threadLocal.set(entry.getValue()); } } 复制代码
当然线程的执行才是真正运用前面设置信息的地方TtlRunnable实现了Runnable接口,并且为final类:
private TtlRunnable(@Nonnull Runnable runnable, boolean releaseTtlValueReferenceAfterRun) { //这里的capture就是上面代码中的capture方法,作用是将从父线程那里设置 //的线程变量捕获到此处 this.capturedRef = new AtomicReference<Object>(capture()); this.runnable = runnable; this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun; } @Override public void run() { Object captured = capturedRef.get(); if (captured == null || releaseTtlValueReferenceAfterRun && !capturedRef.compareAndSet(captured, null)) { throw new IllegalStateException("TTL value reference is released after run!"); } //复制并设置线程变量值,并且备份线程执行之前的变量值 Object backup = replay(captured); try { //执行任务,这里就会去到新设置进去的值 runnable.run(); } finally { // 恢复之前设置的变量值,这样可以保证不破坏原有线程变量数据 restore(backup); } } 复制代码
到这里就解释了为什么使用TransmittableThreadLocal能获取到线程变量了,当然它必须配合如TtlRunnable/TtlCallable等一起使用,也可以配合ExecutorServiceTtlWrapper的线程池使用,其实就是它内部帮你封装Runnable转TtlRunnable这样的工序而已。
上面的代码不是需要改变线程的实现就是要改变线程池的实现,如果原有代码已经封装完毕该如何处理呢? TransmittableThreadLocal提供了JVM级别的代理,来实现对jdk中线程池中runnable/callable的代理实现,具体可以参考以下链接: Java SE 6 新特性Instrumentation TTL基于Instrumentation的实现示例代码如下:
public static void premain(String agentArgs, Instrumentation inst) { //这里就是TTL的代理实现,默认加入了 //TtlExecutorTransformlet和TtlForkJoinTransformlet //而以上两个类的对应代理为下面代码所示 TtlAgent.premain(agentArgs, inst); // add TTL Transformer // add your Transformer ... } } //#######下面代码是TtlExecutorTransformlet######## //#######可以看出支持了ThreadPoolExecutor和ScheduledThreadPoolExecutor两个线程池 private static Set<String> EXECUTOR_CLASS_NAMES = new HashSet<String>(); private static final Map<String, String> PARAM_TYPE_NAME_TO_DECORATE_METHOD_CLASS = new HashMap<String, String>(); static { EXECUTOR_CLASS_NAMES.add("java.util.concurrent.ThreadPoolExecutor"); EXECUTOR_CLASS_NAMES.add("java.util.concurrent.ScheduledThreadPoolExecutor"); PARAM_TYPE_NAME_TO_DECORATE_METHOD_CLASS.put("java.lang.Runnable", "com.alibaba.ttl.TtlRunnable"); PARAM_TYPE_NAME_TO_DECORATE_METHOD_CLASS.put("java.util.concurrent.Callable", "com.alibaba.ttl.TtlCallable"); } 复制代码
如果不使用TTL提供的agent,在使用hystrix做容错处理时,就会出现上面所说的线程变量错乱读取的问题,并且hystrix是有自己管理的线程池的。
这时候显然是要使用TTL的,但是该如何将TTL中的Runnable或Callable集成到 hystrix中的线程池中呢? HystrixConcurrencyStrategy这个类hystrix获取线程池的关键类,并且可以自定义实现, HystrixConcurrencyStrategy里面包含的方法有getThreadPool/getThreadFactory/getBlockingQueue,这三个方法的提供为其获取 线程池提供了很大的自由度。 使用方法HystrixPlugins.getInstance().registerConcurrencyStrategy(your concurrency impl);
因为这边文章主要介绍的就是关于线程变量ThreadLocal的原理和应用,所以很多地方都没扩散开来,而且这次也是比较仓促,以后有空闲了也可能进行补充;后面有机会的话,可能还会有:
littlehow 写入2018-10-25 ~ 2018-10-26