码出高效:Java开发手册笔记(java对象四种引用关系及ThreadLocal)


码出高效:Java开发手册笔记(java对象四种引用关系及ThreadLocal)


前言

“水能载舟,亦能覆舟。”用这句话来形容 ThreadLocal 最贴切不过。ThreadLocal 初衷是在线程并发时,解决变量共享问题,但由于过度设计,比如弱引用和哈希碰撞,导致理解难度大、使用成本高,反而成为故障高发点,容易出现内存泄漏、脏数据、共享对象更新等问题。单从 ThreadLocal 的命名看人们会认为只要用它就对了,包治变量共享问题,然而并不是。本节以内存模型、弱引用、哈希算法为铺垫 , 然后从 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 , 是极弱的一种引用关系,定义完成后,就无法通过该引用获取指向的对象。为一个对象设置虚引用的唯一目的就是希望能在这个对象被回收时收到一个系统通知。虚引用必须与引用队列联合使用,当垃圾回收时,如果发现存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。
在这里插入图片描述
     举个具体例子 , 在房产交易市场中 , 某个卖家有一套房子,成功出售给某个买家后引用置为 null。这里有 4 个买家使用 4 种不同的引用关系指向这套房子。买家buyer1 是强引用,如果把 seller 引用赋值给它,则永久有效,系统不会因为 seller=null就触发对这套房子的回收 , 这是房屋交易市场最常见的交付方式。买家 buyer2 是软引用,只要不产生 OOM , buyer2.get() ,就可以获取房子对象 ,就像房子是租来的一样。买家 buyer3 是弱引用 ,一旦过户后 ,seller 置为 null, buyer3 的房子持有时间估计只有几秒钟 , 卖家只是给买家做了一张假的房产证 , 买家高兴了几秒钟后 ,发现房子已经不是自己的了。buyer4 是虚引用 , 定义完成后无法访问到房子对象,卖家只是虚构了房源 ,是空手套白狼的诈骗术。
     强引用是最常用的,而虚引用在业务中几乎很难用到。本节重点介绍一下软引用和弱引用。先来说明一下软引用的回收机制。首先设置JVM参数 -Xms20m -Xmx20m , 即只有 20MB 的堆内存空间。在下方的示例代码中不断地往集合里添加House 对象 , 而每个 House 有 2000 个 Door 成员变量 , 狭小的堆空间加上大对象的产生 , 就是为了尽快触达内存耗尽的临界状态 :

二、ThreadLocal价值

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

package com.example.demo.test;

import java.util.concurrent.ThreadLocalRandom;

/**
 * @Author: Ron
 * @Create: 2023-04-23 11:01
 */
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() + ", LIFE_VALUE 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() 的时候都会执行到 , 其源码如下:

    /**
     * Returns the value in the current thread's copy of this
     * thread-local variable.  If the variable has no value for the
     * current thread, it is first initialized to the value returned
     * by an invocation of the {@link #initialValue} method.
     *
     * @return the current thread's value of this thread-local
     */
    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 ,则直接执行setlnitialValue()。如果 map 已经创建,就表示 Thread 类的 threadLocals 属性已经初始化;如果e==null , 依然会执行到 setlnitialValue() 。setlnitialValue() 的源码如下 。

    /**
     * Returns the current thread's "initial value" for this
     * thread-local variable.  This method will be invoked the first
     * time a thread accesses the variable with the {@link #get}
     * method, unless the thread previously invoked the {@link #set}
     * method, in which case the {@code initialValue} method will not
     * be invoked for the thread.  Normally, this method is invoked at
     * most once per thread, but it may be invoked again in case of
     * subsequent invocations of {@link #remove} followed by {@link #get}.
     *
     * <p>This implementation simply returns {@code null}; if the
     * programmer desires thread-local variables to have an initial
     * value other than {@code null}, {@code ThreadLocal} must be
     * subclassed, and this method overridden.  Typically, an
     * anonymous inner class will be used.
     *
     * @return the initial value for this thread-local
     */
    protected T initialValue() {
        return null;
    }

    /**
     * Variant of set() to establish initialValue. Used instead
     * of set() in case user has overridden the set() method.
     *
     * @return the initial value
     */
    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);
        return value;
    }

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

package com.example.demo.test;

import java.util.concurrent.TimeUnit;

/**
 * @Author: Ron
 * @Create: 2023-04-23 16:23
 */
public class InitValueInThreadLocal {
    private static final StringBuilder INIT_VALUE = new StringBuilder("init");
    // 覆写ThreadLocal的initialValue,返回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 和 Thread 的类图,了解其主要方法,如图 7-9 所示。
在这里插入图片描述
     ThreadLocal 有个静态内部类叫 ThreadLocalMap ,它还有个静态内部类叫Entry,在 Thread 中的ThreadLocalMap 属性的赋值是在 ThreadLocal 类中的createMap()中进行的。 ThreadLocal 与ThreadLocalMap 有三组对应的方法 get()、set() 和 remove(), 在 ThreadLocal 中对它们只做校验和判断 ,最终的实现会落在ThreadLocalMap 上。 Entry 继承自 WeakReference ,没有方法,只有一个 value 成员变量,它的 key 是 ThreadLocal 对象。再从栈与堆的内存角度看看两者的关系 ,如 图 7-10 所示。
在这里插入图片描述
图 7-10 中的简要关系:

  • 1 个 Thread 有且仅有 1 个 ThreadLocalMap 对象;
  • 1 个 ThreadLocalMap 对象存储多个 Entry 对象;
  • 1 个 Entry 对象的 Key 弱引用指向 1 个 ThreadLocal 对象;
  • 1 个 ThreadLocal 对象可以被多个线程所共享;
  • ThreadLocal 对象不持有 Value, Value 由线程的 Entry 对象持有。

    图中的红色虚线箭头是重点,也是整个 ThreadLocal 难以理解的地方, Entry 的源码如下:

	  /**
       * The entries in this hash map extend WeakReference, using
       * its main ref field as the key (which is always a
       * ThreadLocal object).  Note that null keys (i.e. entry.get()
       * == null) mean that the key is no longer referenced, so the
       * entry can be expunged from table.  Such entries are referred to
       * as "stale entries" in the code that follows.
       */
      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 持有。当线程对象执行完毕时 ,线程对象内的实例属性均会被垃圾回收。源码中的红色字标识的 ThreadLocal 的弱引用 ,即使线程正在执行 中, 只要 ThreadLocal 对象 引用被置成null, Entry 的 Key 就会自动在下一次 YGC 时被垃圾回收。而在 ThreadLocal 使用set() 和 get() 时 , 又会自动地将那些 key==null 的 value 置为 null , 使 value 能够被垃圾回收,避免内存泄漏,但是理想很丰满 , 现实很骨感 , ThreadLocal 如源码注释所述:
    ThreadLocal instances are typically private static fields in classes.
    ThreadLocal 对象通常作为私有静态变量使用 , 那么其生命周期至少不会随着线程结束而结束。
    线程使用 ThreadLocal 有三个重要方法 ·
    ( 1 ) set() :如果没有 set 操作的 ThreadLocal ,容易引起脏数据问题。
    ( 2 ) get(): 始终没有 get 操作的 ThreadLocal 对象是没有意义的。
    ( 3 ) remove() :如果没有 remove 操作,容易引起内存泄漏。

    如果说一个 ThreadLocal 是非静态的 , 属于某个线程实例类 , 那就失去了线程间共享的本质属性。那么 ThreadLocal 到底有什么作用呢?我们知道 , 局部变量在方法内各个代码块间进行传递 , 而类内变量在类内方法间进行传递。复杂的线程方法可能需要调用很多方法来实现某个功能 ,这时候用什么来传递线程内变量呢?答案就是 ThreadLocal , 它通常用于同一个线程内,跨类、跨方法传递数据。如果没有ThreadLocal ,那么相互之间的信息传递,势必要靠返回值和参数 , 这样无形之中 ,有些类甚至有些框架会互相耦合。通过将 Thread 构造方法的最后一个参数设置为 true ,可以把当前线程的变量继续往下传递给它创建的子线程

    最后, SimpleDateFormat 是线程不安全的类,定义为 static 对象,会有数据同步风险。通过源码可以看出, SimpleDateFormat 内部有一个 Calendar 对象,在日期转字符串或字符串转日期的过程中,多线程共享时有非常高的概率产生错误 , 推荐的方式之一就是使用 ThreadLocal,让每个线程单独拥有这个对象。示例代码如下:

package com.example.demo.test;


import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @Author: Ron
 * @Create: 2023-04-24 14:05
 */
public class SimpleDateFormatDemo {

    public static final SimpleDateFormat SIMPLE_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
    public static final ThreadLocal<DateFormat> DATE_FORMAT_THREADLOCAL = new ThreadLocal<DateFormat>(){
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd");
        }
    };

    public static void main(String[] args) {

        ExecutorService executorService = Executors.newFixedThreadPool(5);

        for (int i = 0; i < 9; i++) {
//            executorService.execute( () -> {
//                try {
//                    System.out.println("parse: "+DATE_FORMAT_THREADLOCAL.get().parse("2023-04-24"));
//                    System.out.println("format: "+DATE_FORMAT_THREADLOCAL.get().format(new Date()));
//                } catch (Exception e) {
//                    e.printStackTrace();
//                }
//            });

            // 会报错,因为SimpleDateFormat不是线程安全的
            executorService.execute(() -> {
                try {
                    System.out.println(SIMPLE_DATE_FORMAT.parse("2023-04-24"));
                } catch (ParseException e) {
                    throw new RuntimeException(e);
                }
            });
        }
    }
}

在这里插入图片描述

三、ThreadLocal副作用

    为了使线程安全地共享某个变量, JDK 开出 了 ThreadLocal 这剂药方。但 “是药三分毒”, ThreadLocal 有一定的副作用 ,所以需要仔细阅读药方说明书,了解药性和注意事项。 ThreadLocal 的主要问题是会产生脏数据和内存泄漏。这两个问题通常是在线程池的线程中使用 ThreadLocal 引 发的,因为线程池有线程复用和内存常驻两个特点。

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

package com.example.demo.test;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @Author: Ron
 * @Create: 2023-04-24 10:16
 */
public class DirtyDataInThreadLocal {

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

    public static void main(String[] args) {
        // 使用固定大小为1的线程池,说明上一个线程属性会被下一个线程属性复用
        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) {
                // 第1个线程set后,并没有进行remove操作
                // 而第二个线程由于某种原因没有进行set操作,导致第二个线程get到了第一个线程的数据
                threadLocal.set(this.getName() + ", session info.");
                flag = false;
            }
            System.out.println(this.getName() + " 线程是 " + threadLocal.get());
        }
    }
}

执行结果如下:
在这里插入图片描述

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


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值