线程池-Threadlocal
ThreadLoclc初衷是线程并发时,解决变量共享问题,但是由于过度设计,比如弱引用的和哈希碰撞,导致理解难度大、使用成本高,反而成为故障高发点,容易出现内存泄露,脏数据、贡献对象更新等问题。单从ThreadLoacl命名来看人们认为只要用它就对了,包治变量共享问题,然而并不是。一下以内存模型、弱引用,哈希算法为铺垫,然后从cs真人游戏的示例代码入手,详细分析Threadlocal源码。我们从中可以学习到全新的编程思维方式,并认识到问题的来源,也能够帮助我们谙熟此类设计之道,扬长避短。
引用类型
对象在堆上创建之后所持有的引用其实是一种变量类型,引用之间可以通过赋值构成一条引用链。从GC Roots 开始遍历,判断引用是否可达。引用的可达性是判断能否被垃圾回收的基本条件。JVM会据此自动管理内存分配与回收,不需要开发工程师干预。但是在某些场景下,即使引用可达,也希望根据语义的强弱进行有选择的回收,以保证系统的正常运行。根据引用类型语义的强弱来决定垃圾回收的阶段,我们可以把引用分为强引用,软引用,弱引用和虚引用四类。后三类引用,本质上可以让开发工程师通过代码的方式来决定对象的垃圾回收时机。我们先简要了解一下这个四类引用。
强引用,即Strong Reference , 最为常见,如Object object = new Object();这样的变量声明和定义就会产生该对象的强引用。只要对象有强引用指向,并且GC roots 可达,那么java内存回收时,即使濒临内存耗尽,也不会回收该对象。
软引用,即soft Reference ,引用力度弱于"强引用",是用在非必须对象的场景。在即将OOM之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间,让程序能够继续健康运行。主要用来缓存服务器中间计算结果集不需要试试保存的用户行为等。
弱引用,即Weak Reference,引用强度较前两者更弱,也是用来描述非必须对象的。如果弱引用指向的对象只存在弱引用这一条线路,则在下一次YGC的时候被回收。由于YGC时间的不确定性,弱引用何时被回收也有不确定性。弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象。调用WeakReference.get() 可能返回null,要注意空指针异常。
虚引用,即Phantom Reference ,是极弱的一种引用关系,定义完成后,就无法通过该引用获取指定的对象。为对象设置虚引用的唯一目的就是希望能在这个对象被回收时收到一个系统通知,虚引用必须与引用队列联合使用,当垃圾回收时,如果发现存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。
强引用是最常用的,而虚引用在业务中几乎很难用到。下面重点介绍一下软引用和弱引用。先来说明一下软引用的回收机制。首先设置JVM 参数:-Xms 20m,-Xmx 20m,即只有20m的堆内存空间。
1 public class SoftReferenceHouse { 2 public static void main(String[] args) { 3 //List<House> houses = new ArrayList<>(); //(第1处) 4 List<SoftReference> houses = new ArrayList<>(); 5 6 //剧情反转注释处 7 int i = 0; 8 while (true){ 9 //houses.add(new House()); //(第2处) 10 11 //剧情反转注释处 12 SoftReference<House> buyer2 = new SoftReference<>(new House()); 13 14 //剧情反转注释处 15 houses.add(buyer2); 16 System.out.println("i=" + (++i)); 17 } 18 } 19 } 20 21 class House{ 22 private static final Integer DOOR_NUMBER = 2000; 23 public Door [] doors = new Door[DOOR_NUMBER]; 24 class Door{} 25 }
new House() 是匿名对象,产生之后即赋值给软引用。正常运行一段时间后,内存达到耗尽的临界状态。
ThreadLoacl 价值
我们从真人 CS 游戏说起。游戏开始时,每个人能够领到一把电子枪,枪把上有三个数字,子弹数,杀敌数,自己的命数,为其设置的初始值分别为:1500,0,10.假设战场上每个人都是一个线程,那么这三个出事值写在哪里呢?如果每个线程写死这三个值,万一将初始字段数统一改成1000发呢?如果共享,那么线程直接的并发修改会导致数据不准确。能不能构造这样一个对象,将这个对象设置为共享变量,统一设置初始值,但是每个线程都这个值的修改都是相互独立的。这个对象就是ThreadLoacl。注意不能将其翻译成线程本地化或者本地线程。英语恰当的名称应该叫做:CopyValueIntoEveryThread。具体代码示例如下:
1 /** 2 * @Author: MikeWang 3 * @Date: 2019/1/13 3:38 PM 4 * @Description: 5 */ 6 public class CsGameByThreadLoacl { 7 private static final Integer BULLET_NUMBER = 1500; 8 private static final Integer KILLED_ENEMIES = 0; 9 private static final Integer LIFE_VALUE = 10; 10 private static final Integer TOTAL_PLAYERS = 10; 11 //随机数用来展示每个对象的不同的数据(第1处) 12 private static final ThreadLocalRandom RANDOM = ThreadLocalRandom.current(); 13 14 //初始化子弹数 15 private static final ThreadLocal<Integer> BULLET_NUMBER_THREADLOCAL = new ThreadLocal<Integer>(){ 16 @Override 17 protected Integer initialValue() { 18 return BULLET_NUMBER; 19 } 20 }; 21 //初始化杀敌数 22 private static final ThreadLocal<Integer> KILLED_ENEMIES_THREADLOCAL = new ThreadLocal<Integer>(){ 23 @Override 24 protected Integer initialValue() { 25 return KILLED_ENEMIES; 26 } 27 }; 28 //初始化自己的命数 29 private static final ThreadLocal<Integer> LIFE_VALUE_THREADLOCAL = new ThreadLocal<Integer>(){ 30 @Override 31 protected Integer initialValue() { 32 return LIFE_VALUE; 33 } 34 }; 35 36 37 //定义每位队员 38 private static class Player extends Thread{ 39 @Override 40 public void run(){ 41 Integer bullets = BULLET_NUMBER_THREADLOCAL.get() - RANDOM.nextInt(BULLET_NUMBER); 42 Integer killEnemies = KILLED_ENEMIES_THREADLOCAL.get() + RANDOM.nextInt(TOTAL_PLAYERS/2); 43 Integer lifeValue = LIFE_VALUE_THREADLOCAL.get() - RANDOM.nextInt(LIFE_VALUE); 44 45 System.out.println(getName()+", BULLET_NUMBER is "+ bullets); 46 System.out.println(getName()+", KILLED_ENEMIES is "+ killEnemies); 47 System.out.println(getName()+", LIFE_VALUE is "+ lifeValue +"\n"); 48 49 BULLET_NUMBER_THREADLOCAL.remove(); 50 BULLET_NUMBER_THREADLOCAL.remove(); 51 BULLET_NUMBER_THREADLOCAL.remove(); 52 } 53 } 54 55 public static void main(String[] args) { 56 57 for (int i = 0 ; i < TOTAL_PLAYERS;i++){ 58 new Player().start(); 59 } 60 } 61 }
此例中,没有进行set 操作,那么初始值又是如何进入每个线程成为独立拷贝的呢?首先,虽然ThreadLocal 在定义时覆写了initiaValue() 方法,但并非是在 BULLET_NUMBER_THREADLOCAL
对象加载静态变量的时候执行的,而是每个线程在ThreadLoacl.get() 的时候都会执行到,其源码如下:
1 public T get() { 2 Thread t = Thread.currentThread(); 3 ThreadLocalMap map = getMap(t); 4 if (map != null) { 5 ThreadLocalMap.Entry e = map.getEntry(this); 6 if (e != null) { 7 @SuppressWarnings("unchecked") 8 T result = (T)e.value; 9 return result; 10 } 11 } 12 return setInitialValue(); 13 }
每个线程都有自己的ThreadLoaclMap , 如果 map == null ,则直接执行setInitiaValue()。如果map 已经创建,就表示Thread 类的ThreadLocals 属性已经初始化; 如果 e == null ,依然会执行到setInitiaValue()。setInitiaValue()的源码如下:
1 private T setInitialValue() { 2 T value = initialValue(); 3 Thread t = Thread.currentThread(); 4 ThreadLocalMap map = getMap(t); 5 if (map != null) 6 map.set(this, value); 7 else 8 createMap(t, value); 9 return value; 10 }
在 CsGameByThreadLoacl 类的第1处 ,使用了ThreadLocalRandom 生成单独的Random 实例。此类在JDK7 中引入,它使得每个线程都可以有自己的随机数生成器。我们要避免Random 实例被多线程使用,虽然共享实例是线程安全的,但是会因竞争同一seed 而导致性能下降。 我们已经知道ThreadLoacl是每个线程单独持有的。因为每个线程都有独立的变量副本。其他线程不能访问,所以不存在线程安全问题,也不会影响程序执行性能。ThreadLocal 对象通常是由private static 修饰的,因为都需要复制进入本地线程,所以非static 作用不大。需要注意的是,ThreadLocal 无法解决共享对象的更新问题。所以使用某个引用来操作共享对象是,依然需要进行线程同步。
ThreadLocal 有个静态内部类叫ThreadLoaclMap,它还有个静态内部类叫Entry ,在Thread 中的ThreadLocalMap 属性的赋值是在ThreadLocal 类中的createMap() 中进行的,ThreadLoacl 与 ThreadLoclMap 有三组对应的方法:get()、set()、和remove(),在Threadlocal 中对他们只做校验和判断,最终的实现会落在ThreadLocalMap 上。Entry 继承自WeakReference,没有方法,只有一个value 成员变量,它的Key 是ThreadLocal对象。两者简要关系如下:
- 1个Thread 有且仅有一个ThreadLoaclMap 对象;
- 1个Entry 对象的key 弱应用指向一个ThreadLocal对象;
- 1个ThreadLocalMap 对象存储多个Entry 对象;
- 1个ThreadLocal 对象可以被多个线程共享;
- ThreadLocal 对象不持有Value,Value 由线程的Entry 对象持有。
Entry 源码如下:
static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
所有Entry 对象都被ThreadLocalMap 类实例化对象threadLocals 持有。当线程对象执行完毕时,线程对象内的示例属性均会被垃圾回收。源码中weakReference 标识 ThreadLocal 的弱引用,及时线程正在执行中,只要ThreadLoacl对象引用被置成null,Entry 的key 就会在下一次YGC时被垃圾回收。而在ThreadLoacl 使用set() 和get()时,又会自动地将那些 key == null 的Value 置为null,使value 能够被垃圾回收,避免内存泄露,但是理想很丰满,现实很骨感,ThreadLocal 如源码注释所述:
ThreadLocal instances are typically private static fields in classes.
ThreadLocal 对象 通常作为私有静态变量使用,那么其生命周期至少不会随着线程池结束而结束。
线程池使用ThreadLocal 有三个重要方法。
set():如果没有set 操作的ThreadLoacl,容易引起脏读数据问题。
get():始终没有get 操作的ThreadLocal 对象是没有意义的。
remove() : 如果没有remove 操作,容易引起内存泄露。
如果说一个Thread 是非静态的,属于某一个线程实例类,那就失去了线程间共享的本质属性。那么ThreadLocal 到底有什么作用呢?我们知道,局部变量在方法内各个代码块间进行传递,而类变量在类方法间进行传递。复杂的线程方法可能需要调用多个方法来实现某个功能,这个时候用什么来传递线程内变量呢?答案就是ThreadLocal , 它通常用于同一线程内,跨类,夸方法传递数据。如果没有ThreadLocal ,那么相互之间的信息传递,势必要靠返回值和参数,这样无形之中,有些类甚至有些架构会相互耦合。通过将Thread构造方法的最后一个参数设置为true,可以把当前线程的变量继续往下传递给它创建子线程。
ThreadLocal 副作用
为了使线程安全地共享某个变量,JDK 开出了ThreadLocal 这剂药方,但是药有三分毒。ThreadLocl 主要会产生脏数据和内存泄露。这两个问题通常是在线程池的线程中使用ThreadLocal 引发的,因为线程池有线程复用和内存常驻两个特点。
1.脏数据
线程复用会产生脏数据。由于线程池会重用Thread对象,那么与Thread绑定的类静态属性也会被重用。如果在实现线程run() 方法中不显示的调用remove() 清理与线程相关的ThreadLocal 信息。如果先一个线程不调用set() 设置初始值,那么就get() 到重用信息,包括ThreadLocl 所关联线对象的值。
脏数据问题在实际故障中十分常见。比如 用户A下单后没有看到订单记录,而B却看到了A的订单记录。通过排查发现是通过session 优化引起的。在原来的请求中,用户每次请求Server,都需要去缓存里查询用户的session信息,这样做无疑增加了一次调用。因此开发工程师决定采用某框架来缓存每个用户对应的SecurityContext,它封装了session 相关信息。优化后虽然为每一个用户新建了一个session 相关的上下文,但是因为ThreadLoacl 没有再线程结束是及时进行remove() 清理操作,在高并发场景下,线程池中的线程可能会读取到上一个线程缓存的用户信息。为了便于理解,用一段简要代码来模拟,如下所示:
public class DirtyDataInThreadLocal { private static ThreadLocal<String> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { ExecutorService pool = Executors.newFixedThreadPool(1); for (int i = 0; i < 2; i++) { Mythread mythread = new Mythread(); pool.execute(mythread); } } private static class Mythread extends Thread{ private static boolean flag = true; @Override public void run() { if (flag){ threadLocal.set(this.getName()+". session info ."); flag = false; } System.out.println(this.getName()+" 线程是 "+threadLocal.get()); } } }
执行结果如下:
Thread-0 线程是 Thread-0. session info .
Thread-1 线程是 Thread-0. session info .
内存泄露
在源码注释中提示使用static 关键字来修改ThreadLocal。在此场景下,寄希望于ThreadLocal对象失去引用后,触发弱引用机制来回收Entry 的Value 就不现实了。在上例中,如果不进行remove() 操作,那么这个线程执行完成后,通过ThreadLocal 对象持有的string对象是不会被释放的。
以上两个问题解决的办法很简单,就是每次用完ThreadLocal 时,必须调用remove() 方法清理。
ThreadLocal 并不解决多线程 共享 变量的问题。