当前位置: 首页>后端>正文

并发与多线程-ThreadLocal

??ThreadLocal 初衷是在线程并发时,解决变量共享问题,但由于过度设计,比如弱引用和哈希碰撞,导致理解难度大、使用成本高,反而成为故障高发点,容易出现内存泄漏、脏数据、共享对象更新等问题。

1. 引用类型

??对象在堆上创建之后所持有的引用其实是一种变量类型,引用之间可以通过赋值构成一条引用链。从GCRoots 开始遍历,判断引用是否可达。引用的可达性是判断能否被垃圾回收的基本条件。JVM 会据此自动管理内存的分配与回收,不需要开发工程师干预。但在某些场景下,即使引用可达,也希望能够根据语义的强弱进行有选择的回收,以保证系统的正常运行。根据引用类型语义的强弱来决定垃圾回收的阶段,我们可以把引用分为强引用、软引用、弱引用和虚引用四类。后三类引用,本质上是可以让开发工程师通过代码方式来决定对象的垃圾回收时机。我们先简要了解一下这四类引用。

  • 强引用,即 Strong Reference,最为常见。
    如 Object object = new Object(); 这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且 GC Roots可达,那么 Java 内存回收时,即使濒临内存耗尽,也不会回收该对象。
  • 软引用,即 Sof Reference
    引用力度弱于“强引用”,是用在非必需对象的场景在即将 OOM 之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间,让程序能够继续健康运行。主要用来缓存服务器中间计算结果及不需要实时保存的用户行为等。
  • 弱引用,即 Weak Reference
    引用强度较前两者更弱,也是用来描述非必需对象的。如果弱引用指向的对象只存在弱引用这一条线路,则在下一次YGC 时会被回收。由于 YGC 时间的不确定性,弱引用何时被回收也具有不确定性。弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象。调用 WeakReference.get()可能返回null,要注意空指针异常。
  • 虚引用,即 Phantom Reference
    是极弱的一种引用关系,定义完成后,就无法通过该引用获取指向的对象。为一个对象设置虚引用的唯一目的就是希望能在这个对象被回收时收到一个系统通知。虚引用必须与引用队列联合使用,当垃圾回收时,如果发现存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。
    对象的引用类型如图所示。
    并发与多线程-ThreadLocal,第1张
    对象引用类型

    ??举个具体例子,在房产交易市场中,某个卖家有一套房子,成功出售卖给某个买家后引用置为 null。这里有4个买家使用4种不同的引用关系指向这套房子。买家buyer1是强引用,如果把 seller 引用赋值给它,则永久有效,系统不会因为 seller=null就触发对这套房子的回收,这是房屋交易市场最常见的交付方式。买家 buyer2 是软引用,只要不产生OOM,buyer2.get() 就可以获取房子对象,就像房子是租来的一样。买家 buyer3 是弱引用,一旦过户后,seller 置为 null,buyer3 的房子持有时间估计只有几秒钟,卖家只是给买家做了一张假的房产证,买家高兴了几秒钟后,发现房子已经不是自己的了。buyer4 是虚引用,定义完成后无法访问到房子对象,卖家只是虚构了房源,是空手套白狼的诈骗术。
    ??强引用是最常用的,而虚引用在业务中几乎很难用到。本文重点介绍一下软引用和弱引用。先来说明一下软引用的回收机制。首先设置JVM参数:-Xms20mXmx20m,即只有50MB 的堆内存空间。在下方的示例代码中不断地往集合里添加House 对象,而每个House 有2000个Door 成员变量,狭小的堆空间加上大对象的产生,就是为了尽快触达内存耗尽的临界状态:
    idea 修改vm参数: Run > Edit Configurations>Appliaction 找到应用类 在右侧 VM options: 填写 -Xms50m -Xmx50m
public class SoftReferenceHouse {
    public static void main(String[] args) {
        //List<House> houses = new ArrayList<House>(); 第1处
        List<SoftReference> houses = new ArrayList<SoftReference>();

        int i = 0;
        while (true) {
            // houses.add(new House()); 第2处
            //剧情反转注释处
            SoftReference<House> buyer2 = new SoftReference<House>(new House());
            //剧情发展注释处
            houses.add(buyer2);
            System.out.println("i=" + (++i));
        }
    }
}

class House {
    private static final Integer DOOR_NUMBER = 2000;
    public Door[] doors = new Door[DOOR_NUMBER];

    class Door {
    }
}

new House0是匿名对象,产生之后即赋值给软引用。正常运行一段时间后,内存到达耗尽的临界状态,House$Door超过24MB左右,内存占比达到50%,(可通过visualvm 查看,jconsole也可)


并发与多线程-ThreadLocal,第2张
软应用下的对象堆积

软引用的特性在数秒之后产生价值,House对象数从千数量级迅速降到百数量级内存容量迅速被释放出来,保证了程序的正常运行,


并发与多线程-ThreadLocal,第3张
OOM 时软引用触发对象回收

??软引用 SoftReference 的父类 Reference 的属性: private T referent,它指向 newHouse()对象,而SoftReference 的 get(),也是调用了 super.get() 来访问父类这个私有属性。大量的 House 在内存即将耗尽前,成功地一次又一次被清理掉。对象 buyer2虽然是引用类型,但其本身还是占用一定内存空间的,它是被集合 ArrayList强引用劫持的。在不断循环执行 houses.add(),后在=566581时,终于产生了 OOM。软引用、弱引用、虚引用均存在带有队列的构造方法:
??public SoftReference(T referent, ReferenceQueue<super T> 9){...}

??可以在队列中检查哪个软引用的对象被回收了,从而把失去 House 的软引用对象清理掉。
??反转一下剧情。在同一个类中,使用完全相同的运行环境和内存参数,把SoftReference<House>中被注释掉的两句代码激活( 即示例代码中的第1处和第2处)同时把在后边标记了“剧情反转注释处”的3 句代码注释掉,再次运行。观察一下,在没有软引用的情况下,这个循环能够撑多久?运行得到的结果在i=2404 时,就产生 OOM 异常。这个示例简单地证明了软引用在内存紧张情况下的回收能力。软引用一般用于在同一服务器内缓存中间结果。如果命中缓存,则提取缓存结果,否则重新计算或获取。但是,软引用肯定不是用来缓存高频数据的,万一服务器重启或者软引用触发大规模回收,所有的访问将直接指向数据库,导致数据库的压力时大时小,甚至崩溃。
如果内存没有达到OOM,软引用持有的对象会被回收吗?下面用代码来验证一下

public class SoftReferenceWhenIdle {
    public static void main(String[] args) {
        House seller = new House();
        //(第1处)
        SoftReference<House> buyer2 = new SoftReference<House>(seller);
        seller = null;
        while (true) {
            // 下方两句代码建议 JVM 进行垃圾回收
            System.gc();
            System.runFinalization();
            if (buyer2.get() == null) {
                System.out.println("house is null");
                break;
            } else {
                System.out.println("still there.");
            }
        }
    }
}

??System.gc()方法建议垃圾收集器尽快进行垃圾收集,具体何时执行仍由JVM来
判断。System.runFinalization0方法的作用是强制调用已经失去引用对象的finalize()。
??在代码中同时调用这两者,有利于更快地执行垃圾回收。在相同的运行环境下,一直输出stillthere,说明 buyer2一直持有new House()的有效引用。如果在对方置头null 时仍能自动感知,并且主动断开引用指向的对象,这是哪种引用方式可以担负的使命?答案是弱引用。事实上,把示例代码中第1处的两个红色SoftReference 修改为 WeakReference 即可实现回收。出于对 WeakReference 的尊重,摒弃刚才催促垃圾回收的代码,让WeakReference 自然地被YGC 回收,使对象能够存活更长的时间我们可以在JVM启动参数加-XX:+PrintGCDetails(或高版本JDK使用-Xlog:gc)来观察GC的触发情况:

public class WeakReferenceWhenIdle {
    public static void main(String[] args) {
        House seller = new House();
        WeakReference<House> buyer3 = new WeakReference<House>(seller);

        long start = System.nanoTime();
        int count = 0;
        while (true) {
            if (buyer3.get() == null) {
                long duration = (System.nanoTime() - start) / (1000 * 1000);
                System.out.println("house is null and exited time=" + duration + "ms");
            } else {
                System.out.println("still there. count =" + (count++));
            }
        }
    }
}

执行结果如下:
still there.count = 232639
[GC [PSYoungGen: 65536K->688K(76288K)] 65536K->696K(251392K), 0.0074719secs] [Times: user=0.01 sys-0.00,real-0.01 secs]
still there. count = 232640
house is null and exited time = 1013ms

2. ThreadLocal价值

??我们从真人 CS 游戏说起。游戏开始时,每个人能够领到一把电子枪,枪把上有三个数字:子弹数、杀敌数、自己的命数,为其设置的初始值分别为 1500、0、10。假设战场上的每个人都是一个线程,那么这三个初始值写在哪里呢?如果每个线程写死这三个值,万一将初始子弹数统一改成 1000 发呢?如果共享,那么线程之间的并发修改会导致数据不准确。能不能构造这样一个对象,将这个对象设置为共享变量,统一设置初始值,但是每个线程对这个值的修改都是互相独立的。这个对象就是ThreadLocal。注意不能将其翻译为线程本地化或本地线程,英语恰当的名称应该叫作CopyValueIntoEveryThread。具体示例代码如下:

public class CsGameByThreadLocal {
    private static final Integer BULLET_NUMBER = 1500;
    private static final Integer KILLED_ENEMIES = 0;

    private static final Integer LIFE_VALUE = 10;
    private static final Integer TOTAL_PLAYERS = 10;
    // 随机数用来展示每个对鼻的不同的数据(第1处)
    private static final ThreadLocalRandom RANDOM = ThreadLocalRandom.current();

    //初始化子弹数
    private static final ThreadLocal<Integer> BULLET_NUMBER_THREADLOCAL = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return BULLET_NUMBER;
        }
    };
    //初始化杀敌数
    private static final ThreadLocal<Integer> KILLED_ENEMIES_THREADLOCAL = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return KILLED_ENEMIES;
        }
    };
    //初始化自己的明叔
    private static final ThreadLocal<Integer> LIFE_VALUE_THREADLOCAL = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return LIFE_VALUE;
        }
    };

    //定义每位队员
    private static class Player extends Thread {
        @Override
        public void run() {
            Integer bullets = BULLET_NUMBER_THREADLOCAL.get() - RANDOM.nextInt(BULLET_NUMBER);

            Integer killEnemies = KILLED_ENEMIES_THREADLOCAL.get() + RANDOM.nextInt(TOTAL_PLAYERS / 2);

            Integer lifeValue = LIFE_VALUE_THREADLOCAL.get() - RANDOM.nextInt(LIFE_VALUE);

            System.out.println(getName() + ",BULLET NUMBER is " + bullets);
            System.out.println(getName() + ",KILLED_ENEMIES is " + killEnemies);
            System.out.println(getName() + ",LIEF_VALUES is " + lifeValue + "\n");

            BULLET_NUMBER_THREADLOCAL.remove();
            KILLED_ENEMIES_THREADLOCAL.remove();
            LIFE_VALUE_THREADLOCAL.remove();

        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < TOTAL_PLAYERS; i++) {
            new Player().start();
        }
    }
}

??此示例中,没有进行 set 操作,那么初始值又是如何进入每个线程成为独立挠贝的呢?首先,虽然 ThreadLocal 在定义时覆写了initialValue()方法,但并非是在BULLET_NUMBER_THREADLOCAL对象加载静态变量的时候执行的,而是每个线程在ThreadLocal.get()的时候都会执行到,其源码如下

    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();
    }

??每个线程都有自己的ThreadLocalMap,如果map==null,则直接执行setInitialValue()。如果map 已经创建,就表示Thread类的threadLocals属性已经初始化:如果e==null,依然会执行到 setInitialValue0。setInitialValue()的源码如下

    protected T initialValue() {
        return null;
    }
    private T setInitialValue() {
      // 这是一个保护方法,CsGameByThreadLocal 中初始化ThreadLocal 对象时已覆写
        T value = initialValue();
        Thread t = Thread.currentThread();
      // getMap 的源码就是提取线程对象t的ThreadLocalMap 属性:t.threadLocals
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
        if (this instanceof TerminatingThreadLocal) {
            TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
        }
        return value;
    }

??在 CsGameByThreadLocal 类的第 1处,使用了ThreadLocalRandom 生成单独的Random 实例。此类在JDK7 中引入,它使得每个线程都可以有自己的随机数生成器。我们要避免 Random实例被多线程使用,虽然共享该实例是线程安全的,但会因竞争同一seed 而导致性能下降。
??我们已经知道了 ThreadLocal是每一个线程单独持有的。因为每一个线程都有独立的变量副本,其他线程不能访问,所以不存在线程安全问题,也不会影响程序的执行性能。ThreadLocal 对象通常是由 private static 修饰的,因为都需要复制进入本地线程,所以非 static作用不大。需要注意的是,ThreadLocal无法解决共享对象的更新问题,下面的代码实例将证明这点。因为 CsGameByThreadLocal中使用的是Integer 的不可变对象,所以可以使用相同的编码方式来操作一下可变对象看看,示例源码如下:

public class InitValueInThreadLocal {
    private static final StringBuilder INIT_VALUE = new StringBuilder("init");

    //覆写ThreadLocal的initalValue、返回StringBuilder静态引用
    private static final ThreadLocal<StringBuilder> builder = new ThreadLocal<StringBuilder>() {
        @Override
        protected StringBuilder initialValue() {
            return INIT_VALUE;
        }
    };

    private static class AppendStringThread extends Thread {
        @Override
        public void run() {
            StringBuilder inThread = builder.get();
            for (int i = 0; i < 10; i++) {
                inThread.append("_" + i);
            }
            System.out.println(inThread.toString());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new AppendStringThread().start();
        }
        TimeUnit.SECONDS.sleep(10);
    }
}

??输出的结果是乱序不可控的,所以使用某个引用来操作共享对象时,依然需要进行线程同步。
??ThreadLocal 有个静态内部类叫 ThreadLocalMap,它还有一个静态内部类叫 Entry, 在 Thread 中的 ThreadLocalMap 属性的赋值是在 ThreadLocal 类中的createMap() 中进行的。ThreadLocal 与 ThreadLocalMap 有三组对应的方法: get()、set()和 remove0,在ThreadLocal中对它们只做校验和判断,最终的实现会落在ThreadLocalMap上。Entry继承自 WeakReference,没有方法,只有一个value 成员变量它的key是ThreadLocal对象。

  • 1个 Thread 有且仅有1个 ThreadLocalMap 对象;
  • 1个 Entry 对象的 Key 弱引用指向1个 ThreadLocal 对象;
  • 1个 ThreadLocalMap 对象存储多个Entry 对象
  • 1个ThreadLocal对象可以被多个线程所共享
  • ThreadLocal对象不持有 Value,Value 由线程的 Entry 对象持有。
    ??最后,SimpleDateFormat 是线程不安全的类,定义static 对象,会有数据同步风险。通过源码可以看出,SimpleDateFormat 内部有一个 Calendar 对象,在日期转字符串或字符串转日期的过程中,多线程共享时有非常高的概率产生错误,推荐的方式之一就是使用 ThreadLocal,让每个线程单独拥有这个对象。示例代码如下:
    private static final ThreadLocal<DateFormat> DATE_FORMAT_THREADLOCAL = new ThreadLocal<DateFormat>(){
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd");
        }
    };

3.ThreadLocal副作用

??为了使线程安全地共享某个变量,JDK 开出了 ThreadLocal 这剂药方。但“是药三分毒”,ThreadLocal有一定的副作用,所以需要仔细阅读药方说明书,了解药性和注意事项。ThreadLocal 的主要问题是会产尘数据和内存泄漏。这两个问题通常是在线程池的线程中使用 ThreadLocal 引发的,因为线程范有线程复用和内存常驻两个特点。
??1.脏数据
??线程复用会产生脏数据。由于线程池会重用 Thread 对象,那么与 Thread 绑定的类的静态属性 ThreadLocal 变量也会被重用。如果在实现的线程 run()方法体中不显式地调用remove()清理与线程相关的 ThreadLocal信息,那么倘若下一个线程不调用set()设置初始值,就可能 get()到重用的线程信息,包括 ThreadLocal 所关联的线程对象的 value 值。
??脏数据问题在实际故障中十分常见。比如,用户 A 下单后没有看到订单记录,而用户 B 却看到了用户A 的订单记录。通过排查发现是由于 session 优化引发的。在原来的请求过程中,用户每次请求 Server,都需要通过 sessionId 去缓存里查询用户的session 信息,这样做无疑增加了一次调用。因此,开发工程师决定采用某框架来缓存每个用户对应的 SecurityContext,它封装了 session 相关信息。优化后虽然会为每个用户新建一个session 相关的上下文,但是由于Threadlocal没有在线程处理结束时及时进行 remove()清理操作,在高并发场景下,线程池中的线程可能会读取到上一个线程缓存的用户信息。为了便于理解,用一段简要代码来模拟,如下所示:

public class DirtyDataInThreadLocal {
    public static ThreadLocal<String> threadLocal = new ThreadLocal<String>();

    public static void main(String[] args) {
        // 使用固定大小为1的线程池,说明上一个的线程属性会被下一个线程属性复用
        ExecutorService pool = Executors.newFixedThreadPool(1);
        for (int i = 0; i < 2; i++) {
            Mythead mythead = new Mythead();
            pool.execute(mythead);
        }
    }

    private static class Mythead extends Thread {
        private static boolean flag = true;

        @Override
        public void run() {
            if (flag) {
                //第1个线程set后,并没有进行remove
                //而第二个线程由于某种原因没有进行set 操作
                threadLocal.set(getName() + ". session info.");
                flag = false;
            }
            System.out.println(getName() + " 线程是 " + threadLocal.get());
        }
    }
}

执行结果如下:
Thread-0 线程是 Thread-0. session info.
Thread-1 线程是 Thread-0. session info.
??2.内存泄漏
??在源码注释中提示使用 static 关键字来修饰 ThreadLocal。在此场景下,寄希望于ThreadLocal对象失去引用后,触发弱引用机制来回收 Entry 的 Value就不现实了。在上例中,如果不进行 remove()操作,那么这个线程执行完成后,通过ThreadLocal对象持有的String对象是不会被释放的。
??以上两个问题的解决办法很简单,就是在每次用完 ThreadLocal时,必须要及时调用remove()方法清理。


https://www.xamrdz.com/backend/3m41934966.html

相关文章: