一文让你完全明白ThreadLocal

前言:

ThreadLocal在JDK中是一个很是重要的工具类,经过阅读源码,能够在各大框架都能发现它的踪迹。它最经典的应用就是 事务管理 ,同时它也是面试中的常客。

今天就来聊聊这个ThreadLocal;本文主线:java

①、ThreadLocal 介绍面试

②、ThreadLocal 实现原理数组

③、ThreadLocal 内存泄漏分析安全

④、ThreadLocal 应用场景及示例数据结构

注:本文源码基于 JDK1.8

ThreadLocal 介绍:

正如 JDK 注释中所说的那样: ThreadLocal 类提供线程局部变量,它一般是私有类中但愿将状态与线程关联的静态字段。

简而言之,就是 ThreadLocal 提供了线程间数据隔离的功能,从它的命名上也能知道这是属于一个线程的本地变量。也就是说,每一个线程都会在 ThreadLocal 中保存一份该线程独有的数据,因此它是线程安全的。多线程

熟悉 Spring 的同窗可能知道 Bean 的做用域(Scope),而 ThreadLocal 的做用域就是线程。框架

下面经过一个简单示例来展现一下 ThreadLocal 的特性:函数

public static void main(String[] args) {
  ThreadLocal<String> threadLocal = new ThreadLocal<>();
  // 建立一个有2个核心线程数的线程池
  ExecutorService threadPool = new ThreadPoolExecutor(2, 2, 1, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(10));
  // 线程池提交一个任务,将任务序号及执行该任务的子线程的线程名放到 ThreadLocal 中
  threadPool.execute(() -> threadLocal.set("任务1: " + Thread.currentThread().getName()));
  threadPool.execute(() -> threadLocal.set("任务2: " + Thread.currentThread().getName()));
  threadPool.execute(() -> threadLocal.set("任务3: " + Thread.currentThread().getName()));
  // 输出 ThreadLocal 中的内容
  for (int i = 0; i < 10; i++) {
    threadPool.execute(() -> System.out.println("ThreadLocal value of " + Thread.currentThread().getName() + " = " + threadLocal.get()));
  }
  // 线程池记得关闭
  threadPool.shutdown();
}
上面代码首先建立了一个有2个核心线程数的普通线程池,随后提交一个任务,将任务序号及执行该任务的子线程的线程名放到 ThreadLocal 中,最后在一个 for 循环中输出线程池中各个线程存储在 ThreadLocal 中的值。

这个程序的输出结果是:工具

ThreadLocal value of pool-1-thread-1 = 任务3: pool-1-thread-1
ThreadLocal value of pool-1-thread-2 = 任务2: pool-1-thread-2
ThreadLocal value of pool-1-thread-1 = 任务3: pool-1-thread-1
ThreadLocal value of pool-1-thread-2 = 任务2: pool-1-thread-2
ThreadLocal value of pool-1-thread-1 = 任务3: pool-1-thread-1
ThreadLocal value of pool-1-thread-2 = 任务2: pool-1-thread-2
ThreadLocal value of pool-1-thread-1 = 任务3: pool-1-thread-1
ThreadLocal value of pool-1-thread-2 = 任务2: pool-1-thread-2
ThreadLocal value of pool-1-thread-1 = 任务3: pool-1-thread-1
ThreadLocal value of pool-1-thread-2 = 任务2: pool-1-thread-2

因而可知,线程池中执行提交的任务的是名为 pool-1-thread-1 的线程,随后屡次输出线程池核心线程在 ThreadLocal 变量中存储的的内容也代表:每一个线程在 ThreadLocal 中存储的内容是当前线程独有的,在多线程环境下,可以有效防止本身的变量被其余线程修改(存储的内容是同一个引用类型对象的状况除外)。this

ThreadLocal 实现原理:

在 JDK1.8 版本中 ThreadLocal 类的源码总共723行,去掉注释大概有350行,应该算是 JDK 核心类库中代码量比较少的一个类了,相对来讲它的源码仍是挺容易理解的。

下面,就从 ThreadLocal 的数据结构开始聊聊它的实现原理吧。

底层数据结构:

ThreadLocal 底层是经过 ThreadLocalMap 这个静态内部类来存储数据的,ThreadLocalMap 就是一个键值对的 Map,它的底层是 Entry 对象数组,Entry 对象中存放的键是 ThreadLocal 对象,值是 Object 类型的具体存储内容。

除此以外,ThreadLocalMap 也是 Thread 类一个属性。

如何证实上面给出的 ThreadLocal 类底层数据结构的正确性?

咱们能够从 ThreadLocal#get() 方法开始追踪代码,看看线程局部变量究竟是从哪里被取出来的。

public T get() {
  // 获取当前线程
  Thread t = Thread.currentThread();
  // 获取 Thread 类中 ThreadLocal.ThreadLocalMap 类型的 threadLocals 变量
  ThreadLocalMap map = getMap(t);
  // 若 threadLocals 变量不为空,根据 ThreadLocal 对象来获取 key 对应的 value
  if (map != null) {
    ThreadLocalMap.Entry e = map.getEntry(this);
    if (e != null) {
      @SuppressWarnings("unchecked")
      T result = (T)e.value;
      return result;
    }
  }
  // 若 threadLocals 变量是 NULL,初始化一个新的 ThreadLocalMap 对象
  return setInitialValue();
}

// ThreadLocal#setInitialValue
// 初始化一个新的 ThreadLocalMap 对象
private T setInitialValue() {
  // 初始化一个 NULL 值
  T value = initialValue();
  // 获取当前线程
  Thread t = Thread.currentThread();
  // 获取 Thread 类中 ThreadLocal.ThreadLocalMap 类型的 threadLocals 变量
  ThreadLocalMap map = getMap(t);
  if (map != null)
    map.set(this, value);
  else
    createMap(t, value);
  return value;
}

// ThreadLocalMap#createMap
void createMap(Thread t, T firstValue) {
  t.threadLocals = new ThreadLocalMap(this, firstValue);
}

经过 ThreadLocal#get() 方法能够很清晰的看到,咱们根据 ThreadLocal 对象从 ThreadLocal 中读取数据时,首先会获取当前线程对象,而后获得当前线程对象中 ThreadLocal.ThreadLocalMap 类型的 threadLocals 属性;

若是 threadLocals 属性不为空,会根据 ThreadLocal 对象做为 key 来获取 key 对应的 value;若是 threadLocals 变量是 NULL,就初始化一个新的ThreadLocalMap 对象。

再看 ThreadLocalMap 的构造方法,也就是 Thread 类中 ThreadLocal.ThreadLocalMap 类型的 threadLocals 属性不为空时的执行逻辑。

// ThreadLocalMap 构造方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
  table = new Entry[INITIAL_CAPACITY];
  int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
  table[i] = new Entry(firstKey, firstValue);
  size = 1;
  setThreshold(INITIAL_CAPACITY);
}

这个构造方法实际上是将 ThreadLocal 对象做为 key,存储的具体内容 Object 对象做为 value,包装成一个 Entry 对象,放到 ThreadLocalMap 类中类型为 Entry 数组的 table 属性中,这样就完成了线程局部变量的存储。

因此说, ThreadLocal 中的数据最终是存放在 ThreadLocalMap 这个类中的

散列方式:

ThreadLocalMap#set(ThreadLocal<?> key, Object value) 方法中我写了一行注释:

// 获取当前 ThreadLocal 对象的散列值
int i = key.threadLocalHashCode & (len-1);

这行代码获得的值实际上是一个 ThreadLocal 对象的散列值,这就是 ThreadLocal 的散列方式,咱们称之为 斐波那契散列

// ThreadLocal#threadLocalHashCode
private final int threadLocalHashCode = nextHashCode();

// ThreadLocal#nextHashCode
private static int nextHashCode() {
      return nextHashCode.getAndAdd(HASH_INCREMENT);
}

// ThreadLocal#nextHashCode
private static AtomicInteger nextHashCode = new AtomicInteger();

// AtomicInteger#getAndAdd
public final int getAndAdd(int delta) {
      return unsafe.getAndAddInt(this, valueOffset, delta);
}

// 魔数 ThreadLocal#HASH_INCREMENT
private static final int HASH_INCREMENT = 0x61c88647;

key.threadLocalHashCode 所涉及的函数及属性如上所示,每个 ThreadLocal 的 threadLocalHashCode 属性都是基于魔数 0x61c88647 来生成的。

这里就不讨论选择这个魔数的缘由了(实际上是我看不太懂),总之大量的实践证实: 使用 0x61c88647 做为魔数生成的 threadLocalHashCode 再与2的幂取余,获得的结果分布很均匀。

注: 对 A 进行2的幂取余操做 A % 2^N 能够经过 A & (2^n-1) 来代替,位运算的效率比取模效率高不少。

如何解决哈希冲突:

咱们已经知道 ThreadLocalMap 类的底层数据结构是一个 Entry 类型的数组,但与 HashMap 中的 Node 类数组+链表形式不一样的是,Entry 类没有 next 属性来构成链表,因此它是一个单纯的数组。

就算上面所说的 斐波那契散列法 真的可以充分散列,但不免仍是可能会发生哈希碰撞,那么问题来了,Entry 数组是如何解决哈希冲突的?

这就须要拿出 ThreadLocal#set(T value) 方法了,而具体处理哈希冲突的逻辑是在 ThreadLocalMap#set(ThreadLocal<?> key, Object value) 方法中的:

public void set(T value) {
  // 获取当前线程
  Thread t = Thread.currentThread();
  // 获取 Thread 类中 ThreadLocal.ThreadLocalMap 类型的 threadLocals 变量
  ThreadLocalMap map = getMap(t);
  // 若 threadLocals 变量不为空,进行赋值;不然新建一个 ThreadLocalMap 对象来存储
  if (map != null)
    map.set(this, value);
  else
    createMap(t, value);
}

// ThreadLocalMap#set
private void set(ThreadLocal<?> key, Object value) {
  // 获取 ThreadLocalMap 的 Entry 数组对象
  Entry[] tab = table;
  int len = tab.length;
  // 基于斐波那契散列法获取当前 ThreadLocal 对象的散列值
  int i = key.threadLocalHashCode & (len-1);
  // 解决哈希冲突,线性探测法
  for (Entry e = tab[i];
       e != null;
       e = tab[i = nextIndex(i, len)]) {
    ThreadLocal<?> k = e.get();
        // 代码(1)
    if (k == key) {
      e.value = value;
      return;
    }
        // 代码(2)
    if (k == null) {
      replaceStaleEntry(key, value, i);
      return;
    }
  }
  // 代码(3)将 key-value 包装成 Entry 对象放在数组退出循环时的位置中
  tab[i] = new Entry(key, value);
  int sz = ++size;
  if (!cleanSomeSlots(i, sz) && sz >= threshold)
    rehash();
}

// ThreadLocalMap#nextIndex
// Entry 数组的下一个索引,若超过数组大小则从0开始,至关于环形数组
private static int nextIndex(int i, int len) {
  return ((i + 1 < len) ? i + 1 : 0);
}

具体分析处理哈希冲突的 ThreadLocalMap#set(ThreadLocal<?> key, Object value) 方法,能够看到,在拿到 ThreadLocal 对象的散列值以后进入了一个 for 循环,循环的条件也很清楚:从 Entry 数组的 ThreadLocal 对象散列值处开始,每次向后挪一位,若是超过数组大小则从0开始继续遍历,直到 Entry 对象为 NULL 为止。

在循环过程当中:

  • 如代码(1),若是当前 ThreadLocal 对象正好等于 Entry 对象中的 key 属性,直接更新 ThreadLocal 中 value 的值;
  • 如代码(2),若是当前 ThreadLocal 对象不等于 Entry 对象中的 key 属性,而且 Entry 对象的 key 是空的,这里进行的逻辑实际上是 设置键值对,同时清理无效的 Entry (必定程序防止内存泄漏,下文会有详细介绍);
  • 如代码(3),若是在遍历中没有发现当前 TheadLocal 对象的散列值,也没有发现 Entry 对象的 key 为空的状况,而是知足了退出循环的条件,即 Entry 对象为空时,那么就会建立一个 新的 Entry 对象进行存储 ,同时作一次 启发式清理 ,将 Entry 数组中 key 为空,value 不为空的对象的 value 值释放;
至此,咱们分析完了在向 ThreadLocal 中存储数据时,拿到 ThreadLocal 对象散列值以后的逻辑,回到本小节的主题—— ThreadLocal 是如何解决哈希冲突的?

由上面的代码能够知道,在基于斐波那契散列法获取当前 ThreadLocal 对象的散列值以后进入了一个循环,在循环中是处理具体处理哈希冲突的方法:

  • 若是散列值已存在且 key 为同一个对象,直接更新 value
  • 若是散列值已存在但 key 不是同一个对象,尝试在下一个空的位置进行存储

因此,来总结一下 ThreadLocal 处理哈希冲突的方式就是:若是在 set 时遇到哈希冲突,ThreadLocal 会经过线性探测法尝试在数组下一个索引位置进行存储,同时在 set 过程当中 ThreadLocal 会释放 key 为 NULL,value 不为 NULL 的脏 Entry对象的 value 属性来防止内存泄漏

初始容量及扩容机制:

在上文中有提到过 ThreadLocalMap 的构造方法,这里详细说明一下。

// ThreadLocalMap 构造方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
  // 初始化 Entry 数组
  table = new Entry[INITIAL_CAPACITY];
  int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
  table[i] = new Entry(firstKey, firstValue);
  size = 1;
  // 设置扩容条件
  setThreshold(INITIAL_CAPACITY);
}

ThreadLocalMap 的初始容量是 16:

// 初始化容量
private static final int INITIAL_CAPACITY = 16;

下面聊一下 ThreadLocalMap 的扩容机制 ,它在扩容前有两个判断的步骤,都知足后才会进行最终扩容:

  • ThreadLocalMap#set(ThreadLocal<?> key, Object value) 方法中可能会触发启发式清理,在清理无效 Entry 对象后,若是数组长度大于等于数组定义长度的 2/3,则首先进行 rehash;
// rehash 条件
private void setThreshold(int len) {
  threshold = len * 2 / 3;
}
  • rehash 会触发一次全量清理,若是数组长度大于等于数组定义长度的 1/2,则进行 resize(扩容);
// 扩容条件
private void rehash() {
  expungeStaleEntries();

  // Use lower threshold for doubling to avoid hysteresis
  if (size >= threshold - threshold / 4)
    resize();
}
  • 进行扩容时,Entry 数组为扩容为 原来的2倍 ,从新计算 key 的散列值,若是遇到 key 为 NULL 的状况,会将其 value 也置为 NULL,帮助虚拟机进行GC。
// 具体的扩容函数
private void resize() {
  Entry[] oldTab = table;
  int oldLen = oldTab.length;
  int newLen = oldLen * 2;
  Entry[] newTab = new Entry[newLen];
  int count = 0;

  for (int j = 0; j < oldLen; ++j) {
    Entry e = oldTab[j];
    if (e != null) {
      ThreadLocal<?> k = e.get();
      if (k == null) {
        e.value = null; // Help the GC
      } else {
        int h = k.threadLocalHashCode & (newLen - 1);
        while (newTab[h] != null)
          h = nextIndex(h, newLen);
        newTab[h] = e;
        count++;
      }
    }
  }

  setThreshold(newLen);
  size = count;
  table = newTab;
}

父子线程间局部变量如何传递:

咱们已经知道 ThreadLocal 中存储的是线程的局部变量,那若是如今有个需求,想要实现线程间局部变量传递,这该如何实现呢?

大佬们早已料到会有这样的需求,因而设计出了 InheritableThreadLocal 类。

InheritableThreadLocal 类的源码除去注释以外一共不超过10行,由于它是继承于 ThreadLocal 类,不少东西在 ThreadLocal 类中已经实现了,InheritableThreadLocal 类只重写了其中三个方法:

public class InheritableThreadLocal<T> extends ThreadLocal<T> {

    protected T childValue(T parentValue) {
        return parentValue;
    }

    ThreadLocalMap getMap(Thread t) {
       return t.inheritableThreadLocals;
    }
  
    void createMap(Thread t, T firstValue) {
        t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
    }
}

咱们先用一个简单的示例来实践一下父子线程间局部变量的传递功能。

public static void main(String[] args) {
  ThreadLocal<String> threadLocal = new InheritableThreadLocal<>();
  threadLocal.set("这是父线程设置的值");

  new Thread(() -> System.out.println("子线程输出:" + threadLocal.get())).start();
}

// 输出内容
子线程输出:这是父线程设置的值

能够看到,在子线程中经过调用 InheritableThreadLocal#get() 方法,拿到了在父线程中设置的值。

那么,这是如何实现的呢?

实现父子线程间的局部变量共享须要追溯到 Thread 对象的构造方法:

public Thread(Runnable target) {
  init(null, target, "Thread-" + nextThreadNum(), 0);
}

private void init(ThreadGroup g, Runnable target, String name, long stackSize) {
  init(g, target, name, stackSize, null, true);
}

private void init(ThreadGroup g, Runnable target, String name,
                  long stackSize, AccessControlContext acc,
                  // 该参数通常默认是 true
                  boolean inheritThreadLocals) {
  // 省略大部分代码
  Thread parent = currentThread();
  
  // 复制父线程的 inheritableThreadLocals 属性,实现父子线程局部变量共享
  if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
       this.inheritableThreadLocals =
    ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); 
  }
  
    // 省略部分代码
}

在最终执行的构造方法中,有这样一个判断:若是当前父线程(建立子线程的线程)的 inheritableThreadLocals 属性不为 NULL,就会将当下父线程的 inheritableThreadLocals 属性复制给子线程的 inheritableThreadLocals 属性。具体的复制方法以下:

// ThreadLocal#createInheritedMap
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
  return new ThreadLocalMap(parentMap);
}

private ThreadLocalMap(ThreadLocalMap parentMap) {
  Entry[] parentTable = parentMap.table;
  int len = parentTable.length;
  setThreshold(len);
  table = new Entry[len];
    // 一个个复制父线程 ThreadLocalMap 中的数据
  for (int j = 0; j < len; j++) {
    Entry e = parentTable[j];
    if (e != null) {
      @SuppressWarnings("unchecked")
      ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
      if (key != null) {
        // childValue 方法调用的是 InheritableThreadLocal#childValue(T parentValue)
        Object value = key.childValue(e.value);
        Entry c = new Entry(key, value);
        int h = key.threadLocalHashCode & (len - 1);
        while (table[h] != null)
          h = nextIndex(h, len);
        table[h] = c;
        size++;
      }
    }
  }
}

须要注意的是,复制父线程共享变量的时机是在建立子线程时,若是在建立子线程后父线程再往 InheritableThreadLocal 类型的对象中设置内容,将再也不对子线程可见。

ThreadLocal 内存泄漏分析:

最后再来讲说 ThreadLocal 的内存泄漏问题,众所周知,若是使用不当,ThreadLocal 会致使内存泄漏。

内存泄漏 是指程序中已动态分配的堆内存因为某种缘由程序未释放或没法释放,形成系统内存的浪费,致使程序运行速度减慢甚至系统崩溃等严重后果。

发生内存泄漏的缘由:

而 ThreadLocal 发生内存泄漏的缘由须要从 Entry 对象提及。

// ThreadLocal->ThreadLocalMap->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 对象的 key 即 ThreadLocal 类是继承于 WeakReference 弱引用类。具备弱引用的对象有更短暂的生命周期,在发生 GC 活动时,不管内存空间是否足够,垃圾回收器都会回收具备弱引用的对象。

因为 Entry 对象的 key 是继承于 WeakReference 弱引用类的,若 ThreadLocal 类没有外部强引用,当发生 GC 活动时就会将 ThreadLocal 对象回收。

而此时若是建立 ThreadLocal 类的线程依然活动,那么 Entry 对象中 ThreadLocal 对象对应的 value 就依旧具备强引用而不会被回收,从而致使内存泄漏。

如何解决内存泄漏问题:

要想解决内存泄漏问题其实很简单,只须要记得在使用完 ThreadLocal 中存储的内容后将它 remove 掉就能够了。

这是主动防止发生内存泄漏问题的手段,但其实设计 ThreadLocal 的大神固然也发现了 ThreadLocal 可能引起内存泄漏的问题,因此他们也设计了相应的手段来防止内存泄漏。

ThreadLocal 内部如何防止内存泄漏:

在上文中描述 ThreadLocalMap#set(ThreadLocal<?> key, Object value) 其实已经有涉及 ThreadLocal 内部清理无效 Entry 的逻辑了,在经过线性检测法处理哈希冲突时,若 Entry 数组的 key 与当前 ThreadLocal 不是同一个对象,同时 key 为空的时候,会进行 清理无效 Entry 的处理,即 ThreadLOcalMap#replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) 方法:

  • 这个方法中也是一个循环,循环的逻辑与 ThreadLocalMap#set(ThreadLocal<?> key, Object value) 方法一致;
  • 在循环过程当中若是找到了将要存储的 ThreadLocal 对象,则会将它与进入 replaceStaleEntry 方法时知足条件的 k 值作交换,同时将 value 更新;
  • 若是没有找到将要存储的 ThreadLocal 对象,则会在此 k 值处新建一个 Entry 对象存储;
  • 同时,在循环过程当中若是发现其余无效的 Entry( key 为 NULL,value还在的状况,可能致使内存泄漏,下文会有详细描述),会顺势找到 Entry 数组中全部的无效 Entry,释放这些无效 Entry(经过将 key 和 value 都设置为NULL),在必定程度上避免了内存泄漏;
若是知足线性检测循环结束条件了,即遇到了 Entry==NULL 的状况,就新建一个 Entry 对象来存储数据。而后会进行一次启发式清理,若是启发式清理没有成功释放知足条件的对象,同时知足扩容条件时,会执行 ThreadLocalMap#rehash() 方法。
private void rehash() {
  // 全量清理
  expungeStaleEntries();
  // 知足条件则扩容
  if (size >= threshold - threshold / 4)
    resize();
}

ThreadLocalMap#rehash() 方法中会对 ThreadLocalMap 进行一次全量清理,全量清理会遍历整个 Entry 数组,删除全部 key 为 NULL,value 不为 NULL 的脏 Entry对象。

// 全量清理
private void expungeStaleEntries() {
  Entry[] tab = table;
  int len = tab.length;
  for (int j = 0; j < len; j++) {
    Entry e = tab[j];
    if (e != null && e.get() == null)
      expungeStaleEntry(j);
  }
}

进行全量清理以后,若是 Entry 数组的大小大于等于 threshold - threshold / 4 ,则会进行2倍扩容。

总结一下:在ThreadLocal 内部是经过在 get、set、remove 方法中主动进行清理 key 为 NULL 且 value 不为 NULL 的无效 Entry 来避免内存泄漏问题。

可是基于 get、set 方法让 ThreadLocal 自行清理无效 Entry 对象并不能彻底避免内存泄漏问题,要完全解决内存泄漏问题还得养成使用完就主动调用 remove 方法释放资源的好习惯。

ThreadLocal 应用场景及示例:

ThreadLocal 在不少开源框架中都有应用,好比:Spring 中的事务隔离级别的实现、MyBatis 分页插件 PageHelper 的实现。

同时,我在项目中也有基于 ThreadLocal 与过滤器实现接口白名单的鉴权功能。

小结:

以面试题的形式来总结一下关于 ThreadLocal 本文所描述的内容:

  • ThreadLocal 解决了哪些问题
  • ThreadLocal 底层数据结构
  • ThreadLocalMap 的散列方式
  • ThreadLocalMap 如何处理哈希冲突
  • ThreadLocalMap 扩容机制
  • ThreadLocal 如何实现父子线程间局部变量共享
  • ThreadLocal 为何会发生内存泄漏
  • ThreadLocal 内存泄漏如何解决
  • ThreadLocal 内部如何防止内存泄漏,在哪些方法中存在
  • ThreadLocal 应用场景

❤ 点赞 + 评论 + 转发 哟

您能够VX搜索【木子雷】公众号,坚持高质量原创java技术文章,福利多多哟!

若是本文对你们有帮助的话,请多多点赞评论呀,大家的支持就是我不断创做的动力!

相关文章
相关标签/搜索