ThreadLocal分析

打印 上一主题 下一主题

主题 1692|帖子 1692|积分 5076

ThreadLocal

本文以JDK21为例子,其实大致方法和JDK8都一样。
1.根本介绍

ThreadLocal 是一个在多线程编程中常用的概念,差别编程语言中实现方式差别,但核心头脑同等:为每个使用该变量的线程都提供一个独立的变量副本,每个线程都可以独立地改变自己的副本,而不会影响其他线程所对应的副本。
重要作用

  • 线程安全:避免多线程共享变量时需要进行同步操作(如加锁),从而简化并发编程。
  • 通报上下文:在同一个线程的差别方法中通报数据,避免显式通报参数。
它的几个API:
方法声明描述ThreadLocal()创建ThreadLocal对象public void set(T value)设置当火线程绑定的局部变量public T get()获取当火线程绑定的局部变量public void remove()移除当火线程绑定的局部变量下面来简单使用一下:
  1. public class SimpleLocalTest {
  2.     private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
  3.     public static void main(String[] args) {
  4.         threadLocal.set("main" + "变量");
  5.         new Thread(() -> {
  6.             // 在线程1中设置变量
  7.             threadLocal.set("thread1" + "变量");
  8.             try {
  9.                 Thread.sleep(1000);
  10.             } catch (InterruptedException e) {
  11.                 e.printStackTrace();
  12.             }
  13.             // 在线程1中得到的仍然是该变量的值,并没有得到其他线程的值,达到了线程间数据隔离
  14.             System.out.println(Thread.currentThread().getName() + " : " + threadLocal.get());
  15.             threadLocal.remove(); // 删除掉
  16.         }, "线程1").start();
  17.         new Thread(() -> {
  18.             threadLocal.set("thread2" + "变量"); // 同理
  19.             try {
  20.                 Thread.sleep(1000);
  21.             } catch (InterruptedException e) {
  22.                 e.printStackTrace();
  23.             }
  24.             System.out.println(Thread.currentThread().getName() + " : " + threadLocal.get());
  25.             threadLocal.remove();
  26.         }, "线程2").start();
  27.                 // 主线程的本地变量值
  28.         System.out.println(Thread.currentThread().getName() + " : " + threadLocal.get());
  29.         threadLocal.remove();
  30.     }
  31. }
复制代码
2.对比Synchronized

ThreadLocal 和 Synchronized(或其他同步机制)都用于处置惩罚多线程情况下的并发题目,但它们的核心思路和应用场景完全差别。
ThreadLocalSynchronized避免共享:为每个线程创建独立的变量副本,线程之间互不干扰。控制共享:通过锁机制保证多线程对共享资源的有序访问空间换时间:每个线程单独存储数据,断送内存换取无锁的高效。时间换空间:通过阻塞其他线程的访问,保证共享资源的安全性。每个线程内部通过 ThreadLocalMap 存储自己的变量副本,键是 ThreadLocal 对象,值是变量值。基于 JVM 内置锁(Monitor)实现,通过锁的获取和释放控制代码块或方法的访问权限。通过 get()/set() 直接操作当火线程的局部变量,无需锁。锁竞争时,未获取锁的线程会进入阻塞状态(或自旋),直到锁释放。线程隔离:每个线程需要独立操作变量(如用户会话、数据库连接)。共享资源掩护:多个线程需要操作同一资源(如计数器、缓存)。避免线程安全题目:通过隔离变量副本,无需同步(如 SimpleDateFormat)。原子性保证:确保一段代码的原子执行(如余额扣减)。性能敏感场景:避免锁竞争的开销(如线程池中的上下文通报)。临界区掩护:掩护共享数据的读写同等性。内存泄漏风险:若未调用 remove(),线程池中的线程大概因 ThreadLocalMap 的强引用导致内存泄漏。性能开销:锁竞争猛烈时,线程阻塞和唤醒会带来性能消耗(尤其是重量级锁)。无锁操作:get()/set() 直接操作线程私有数据,性能极高。锁优化:JVM 对 synchronized 有锁升级机制(偏向锁→轻量级锁→重量级锁)。

  • 两者可以结合使用:例如用 ThreadLocal 保存线程私有数据【数据隔离】,用 Synchronized 掩护共享状态【数据共享】。
  • 今世框架中的典范应用:Spring 的事件管理通过 ThreadLocal 保存数据库连接
3.原理分析

首先从上面的ThreadLocal简单使用案例的方法来看看。
①set
  1. // 首先是构造方法
  2. public ThreadLocal() {} // 没啥好看的
  3. // 然后是set方法
  4. public void set(T value) {
  5.     //public static native Thread currentThread();这是个native方法
  6.     //currentThread方法返回正在被执行的线程的信息。
  7.     set(Thread.currentThread(), value);
  8.     if (TRACE_VTHREAD_LOCALS) { // 这个就不用看了
  9.         dumpStackIfVirtualThread();
  10.     }
  11. }
  12. private void set(Thread t, T value) {
  13.     ThreadLocalMap map = getMap(t); // 调用getMap方法
  14.     if (map != null) {
  15.         // key 是Thread
  16.         map.set(this, value); // 如果map不是null,就把kv设置进去
  17.     } else {
  18.         // 创建map
  19.         createMap(t, value);
  20.     }
  21. }
  22. // 返回Thread的threadLocals变量
  23. ThreadLocalMap getMap(Thread t) {
  24.     return t.threadLocals;
  25. }
  26. void createMap(Thread t, T firstValue) {
  27.     // 把Thread的这个变量初始化
  28.     t.threadLocals = new ThreadLocalMap(this, firstValue);
  29. }
  30. // ThreadLocalMap的构造方法
  31. ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
  32.     // 初始化哈希表,然后把第一个kv放进去
  33.     table = new Entry[INITIAL_CAPACITY];
  34.     int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
  35.     table[i] = new Entry(firstKey, firstValue);
  36.     size = 1;
  37.     setThreshold(INITIAL_CAPACITY);
  38. }
复制代码
经过上面的源码分析我们可以得出以下信息:

  • 每个Thread对象内里都有一个threadLocals变量,它的类型是ThreadLocalMap;
  • ThreadLocalMap是ThreadLocal的静态内部类
下面来简单看一下ThreadLocalMap(ThreadLocal的静态内部类)
  1. static class ThreadLocalMap {
  2.     static class Entry extends WeakReference<ThreadLocal<?>> {
  3.         /** The value associated with this ThreadLocal. */
  4.         Object value;
  5.         Entry(ThreadLocal<?> k, Object v) {
  6.             super(k);
  7.             value = v;
  8.         }
  9.     }
  10.     private static final int INITIAL_CAPACITY = 16;
  11.     private Entry[] table;
  12.     private int size = 0;
  13.     private int threshold;
  14.     // set
  15.     // getEntry【返回hash索引位置上的对象Entry】
  16.     private Entry getEntry(ThreadLocal<?> key) {
  17.         int i = key.threadLocalHashCode & (table.length - 1);
  18.         Entry e = table[i];
  19.         if (e != null && e.refersTo(key))
  20.             return e;
  21.         else
  22.             return getEntryAfterMiss(key, i, e);
  23.     }
  24.     .......
  25. }
复制代码
Entry将ThreadLocal作为Key【为弱引用】,值作为value保存,它继承自WeakReference. 这个弱引用是啥?前面还说了,可以把它看作为一个Map(ThreadLocalMap并没有实现Map接口),但是我们熟知的HashMap之类的,key大概是会辩论的,这里的ThreadLocalMap内里的key辩论了咋办呢?本节就来分析一下。
1) 辩论

发生辩论的时候,那么肯定是在set/get的时候把,我们看一下set方法
  1. public T get() {
  2.     return get(Thread.currentThread());
  3. }
  4. private T get(Thread t) {
  5.     //getMap在面已经知道了
  6.     ThreadLocalMap map = getMap(t);
  7.     if (map != null) {
  8.         ThreadLocalMap.Entry e = map.getEntry(this);
  9.         if (e != null) {
  10.             @SuppressWarnings("unchecked")
  11.             T result = (T) e.value;
  12.             return result;
  13.         }
  14.     }
  15.     // 如果t.threadLocals == null
  16.     return setInitialValue(t);
  17. }
  18. // 初始化
  19. private T setInitialValue(Thread t) {
  20.     T value = initialValue(); // 调用这个
  21.     ThreadLocalMap map = getMap(t);
  22.     if (map != null) {
  23.         map.set(this, value);
  24.     } else {
  25.         createMap(t, value);
  26.     }
  27.     if (this instanceof TerminatingThreadLocal<?> ttl) {
  28.         TerminatingThreadLocal.register(ttl);
  29.     }
  30.     if (TRACE_VTHREAD_LOCALS) {
  31.         dumpStackIfVirtualThread();
  32.     }
  33.     return value;
  34. }
  35. /*
  36. 此方法的作用是返回该线程局部变量的初始值。
  37. - 这个方法是一个延迟调用方法,从上面的代码我们得知,在set方法还未调用而先调用了get方法时才执行,并且仅执行1次。
  38. - 这个方法直接返回一个null。
  39. - 如果想要一个除null之外的初始值,可以重写此方法。(该方法是一个protected的方法,显然是为了让子类覆盖而设计的)
  40. */
  41. protected T initialValue() {
  42.     return null;
  43. }
复制代码
根据上面的源码分析,我们得知,set的时候,根据ThreadLocal对象的hash值,定位到table中的位置i,然后判定该位置是否为空;如果key相同,直接覆盖旧值;如果是空的,初始化一个Entry对象放在位置i上;否则,就在i的位置上,往后一个一个找。【线性探测法
再来看一下get方法:
  1. public void remove() {
  2.     remove(Thread.currentThread());
  3. }
  4. private void remove(Thread t) {
  5.     ThreadLocalMap m = getMap(t);
  6.     if (m != null) {
  7.         // 实际是ThreadLocalMap的remove方法
  8.         m.remove(this);
  9.     }
  10. }
  11. // 静态内部类ThreadLocalMap.java
  12. private void remove(ThreadLocal<?> key) {
  13.     Entry[] tab = table;
  14.     int len = tab.length;
  15.     int i = key.threadLocalHashCode & (len-1);
  16.     for (Entry e = tab[i];
  17.          e != null;
  18.          e = tab[i = nextIndex(i, len)]) {
  19.         if (e.refersTo(key)) {
  20.             e.clear();
  21.             expungeStaleEntry(i);
  22.             return;
  23.         }
  24.     }
  25. }
  26. // 会调用这个
  27. /*
  28. 主要用于清理因弱引用导致key为null 的过期Entry,从而避免内存泄漏-见下文
  29. */
  30. private int expungeStaleEntry(int staleSlot) {
  31.     。。。。
  32. }
复制代码
上面的源码很简单吧。可以知道,ThreadLocal在hash辩论严重的时候,他的效率其实是不高的。
2) 弱引用

那么这个弱引用呢?提及这个,肯定会提到ThreadLocal老生常谈的内存泄漏题目了。
内存泄漏:【不会再被使用的对象大概变量占用的内存不能被回收,就是内存泄露。】
Memory overflow:内存溢出,没有足够的内存提供申请者使用。
Memory leak:内存泄漏是指步伐中己动态分配的堆内存由于某种原因步伐未释放或无法释放,造成系统内存的浪费,导致步伐运行速度减慢乃至系统溃等严重后果。内存泄漏的堆积终将导致内存溢出。
在 Java 中,弱引用(Weak Reference) 是一种特别的引用类型,它的核心特点是:当垃圾回收(GC)发生时,无论内存是否充足,弱引用指向的对象【对象必须仅被弱引用指向(没有任何强引用)】都会被回收。这与其他引用类型(如强引用、软引用、虚引用)有明显区别。下面通过对比差别引用类型,具体表明弱引用的特性及用途。
引用类型GC 行为典范应用场景实现类强引用对象有强引用时,永久不会被回收(如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,如许可以使JVM在合适的时间就会回收该对象。)日常对象的使用(如 new Object())默认引用类型弱引用只要发生 GC,就会被回收缓存、ThreadLocal 防内存泄漏WeakReference软引用内存不敷时才会被回收缓存(如图片缓存)SoftReference虚引用无法通过虚引用访问对象,GC 后会收到通知资源整理跟踪(如堆外内存释放)PhantomReference弱引用的回收机遇由 GC 决定,无法精确控制。
在弄清楚上述的内存泄漏和弱引用题目之前,我们需要先知道ThreadLocal体系的对象存在哪里的,如下图:
Thread ThreadRef = new Thread(xx);就是强引用,下图中用实线连接起来的,ThreadLocal同理。

在 ThreadLocal 的使用中,内存泄漏的核心原因是 ThreadLocalMap 的 Entry 对 value 是强引用,而 Entry 的 key 是对 ThreadLocal 实例的弱引用。当 ThreadLocal 实例失去外部强引用时,GC 会回收 key(弱引用),key就为null了,但 value 仍被强引用保留在 Entry 中,若线程长期存活(如线程池线程),value 将无法被回收,导致内存泄漏。那么,什么时候ThreadLocal会失去外部的强引用呢?下面给出两个例子
  1. static class ThreadLocalMap {
  2.     static class Entry extends WeakReference<ThreadLocal<?>> {
  3.         Object value;
  4.         Entry(ThreadLocal<?> k, Object v) {
  5.             super(k);
  6.             value = v;
  7.         }
  8.     }
  9. }
复制代码
总结一下,何时出现 “无外部强引用”?

  • 当ThreadLocal 实例不再被任何强引用直接或间接指向的时候

    • 局部变量超出作用域。
    • 所属对象实例被回收。

  • 但线程仍存活(如线程池线程长期复用)。
我们平时开发都是使用的线程池,线程有的大概会不停存活。
为了避免出现上述情况,我们平时使用完成之后,只管在业务结束并且不需要该线程本地变量的时候,给它remove掉
通过上述分析,我们可以看到一个非常致命的条件,那就是线程存活的时间 大于了 ThreadLocal的强引用存活时间。如果说,ThreadLocal 和 Thread的生命周期一样长,即时我们不remove,一样不会内存泄漏的。( 如下图 )

ThreadLocal其实它有兜底措施的:【就是上面的remove方法内里调用的expungeStaleEntry】
  1. // 静态内部类ThreadLocalMap.java
  2. private void set(ThreadLocal<?> key, Object value) {
  3.     Entry[] tab = table;
  4.     int len = tab.length;
  5.     int i = key.threadLocalHashCode & (len-1);
  6.     /*
  7.         这里的nextIndex如下【说实话就是 i+1】
  8.         private static int nextIndex(int i, int len) {
  9.             return ((i + 1 < len) ? i + 1 : 0);
  10.         }
  11.         */
  12.     for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
  13.         if (e.refersTo(key)) { // 如果key相同
  14.             e.value = value; // value直接覆盖
  15.             return;
  16.         }
  17.         //如果当前位置是空的,就初始化一个Entry对象放在位置i上
  18.         if (e.refersTo(null)) {
  19.             replaceStaleEntry(key, value, i);
  20.             return;
  21.         }
  22.     }
  23.     // 找到了下标为null的位置
  24.     tab[i] = new Entry(key, value); // 这个位置设置上key,value
  25.     int sz = ++size;
  26.     if (!cleanSomeSlots(i, sz) && sz >= threshold)
  27.         rehash();
  28. }
复制代码
当 ThreadLocal 实例被垃圾回收(GC)后,其对应的 Entry 的 key 会变为 null(弱引用特性),但 value 仍被强引用保留。expungeStaleEntry 会遍历哈希数组,将这些 key 为 null 的 Entry 的 value 置为 null,并释放 Entry 对象自己,从而堵截 value 的强引用链,帮助 GC 回收内存
删除的时候:

  • 从指定位置开始向后遍历哈希数组,若发现 Entry 的 key 为 null,则清除其 value 并释放 Entry 对象。
  • 若 Entry 的 key 有效,则重新计算其哈希值(k.threadLocalHashCode & (len - 1)),检查是否需要调整位置以优化哈希分布
从上述分析可以看出:expungeStaleEntry 仅整理从指定位置开始的一连过期 Entry,而非整个哈希表,因此无法完全避免内存泄漏;整理效果取决于 set、get 等方法的调用频率,若线程长期不操作 ThreadLocal,残留的 value 仍大概堆积。
如果key是强引用呢?会出现上述内存泄漏题目吗?

为什么key设计成弱引用呢?
当 ThreadLocalMap 的键是 弱引用 时:

  • 外部强引用 threadLocal 被置为 null 后,键(弱引用)指向的 ThreadLocal 对象仅被弱引用持有。
  • GC 会回收 ThreadLocal 对象,并将对应的弱引用放入引用队列,key就为null了。
  • ThreadLocalMap 在下次操作(如 get()、set())时,会整理引用队列中的失效条目,释放值对象的内存。【就是我们说的兜底措施嘛】
如果 ThreadLocalMap 的键是 强引用,会发生:

  • threadLocal 变量被置为 null,但 ThreadLocalMap 中的键仍强引用着 ThreadLocal 对象。
  • ThreadLocal 对象无法被 GC 回收,导致其对应的值(BigObject)也无法被回收,即使线程大概长期存活(如线程池中的线程)。
  • 内存泄漏:无用的键值对会不停存在于 ThreadLocalMap 中
5.框架中的应用

这一节只看一下ThreadLocal在Spring事件中的应用。
在事件中,需要做到如下保证:

  • 每个事件的执行需要通过数据源连接池获取到数据库的connetion。
  • 为了保证所有的数据库操作都属于同一个事件,事件使用的连接必须是同一个,也就是在一个线程内里需要操作同一个连接
  • 线程隔离:在多线程并发的情况下,每个线程都只能操作各自的connetion,不能使用其他线程的连接
在Spring事件的源码中:
DataSourceTransactionManager.java
  1. // 静态内部类ThreadLocalMap.java
  2. private Entry getEntry(ThreadLocal<?> key) {
  3.     int i = key.threadLocalHashCode & (table.length - 1);
  4.     Entry e = table[i];
  5.     if (e != null && e.refersTo(key))
  6.         return e;
  7.     else
  8.         return getEntryAfterMiss(key, i, e);
  9. }
  10. private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
  11.     Entry[] tab = table;
  12.     int len = tab.length;
  13.     // 相等就直接返回,不相等就继续+1查找,找到相等为止。
  14.     while (e != null) {
  15.         if (e.refersTo(key))
  16.             return e;
  17.         if (e.refersTo(null))
  18.             expungeStaleEntry(i);
  19.         else
  20.             i = nextIndex(i, len);
  21.         e = tab[i];
  22.     }
  23.     return null;
  24. }
复制代码
TransactionSynchronizationManager.java :下面就把线程和ThreadLocal绑定起来了。
  1. // 下面两个例子中,线程一直存活
  2. // 1.局部变量场景
  3. public void processRequest() {
  4.     ThreadLocal<User> userContext = new ThreadLocal<>(); // 局部变量
  5.     userContext.set(currentUser);
  6.     // ...业务逻辑...
  7. }
  8. // 另一个线程一直存活
  9. Thread(() -> {
  10.     processRequest();
  11.     ......
  12. }).start();
  13. /*
  14. 在上述场景中:
  15. 方法结束时,局部变量 userContext 的强引用被释放。
  16. 此时 ThreadLocal 实例仅被 ThreadLocalMap 的弱引用(Entry 的 key)指向。
  17. 下次 GC 发生时,ThreadLocal 实例的 key 被回收,Entry 变为 key=null, value=强引用。
  18. 若线程持续运行(如线程池线程),value 无法自动回收,导致泄漏。
  19. */
  20. // 2.实例变量所属对象被回收
  21. public class Service {
  22.     // 在这里,ThreadLocal作为了对象的实例变量
  23.     private ThreadLocal<Connection> connHolder = new ThreadLocal<>();
  24.     public void execute() {
  25.         connHolder.set(getConnection());
  26.         // ...使用连接...
  27.     }
  28. }
  29. // 使用示例
  30. void process() {
  31.     Service service = new Service();
  32.     service.execute();
  33.     service = null; // Service 实例失去强引用,可能被回收
  34. }
  35. // 另一个线程一直存活
  36. Thread(() -> {
  37.     process();
  38.     ......
  39. }).start();
复制代码
在上面源码中,每个线程内里的本地变量是一个map,map是以DateSource为key,ConnectionHolder为value。在一个系统可以有多个DataSource,connection又是由相应的DataSource得到的。所以ThreadLocal维护的是以DataSource作为key, 以ConnectionHolder为value的一个Map

总结一下,在开启事件的时候,绑定资源Spring 事件管理器(如 DataSourceTransactionManager)会调用 doBegin(),获取数据库连接并绑定到 ThreadLocal。
在执行sql的时候,MyBatis 通过 SpringManagedTransaction 获取当前事件的连接
  1. /*
  2. 在这些方法也会调用的
  3. set()方法: 当插入新值时发现哈希冲突,且当前槽位的Entry已过期,触发清理流程
  4. get()方法: 当查询Entry未命中(key 不匹配)时,触发清理以优化后续查询效率
  5. */
  6. private int expungeStaleEntry(int staleSlot) {
  7.     Entry[] tab = table;
  8.     int len = tab.length;
  9.     // 清理当前槽位
  10.     tab[staleSlot].value = null;
  11.     tab[staleSlot] = null;
  12.     size--;
  13.     // Rehash until we encounter null
  14.     Entry e;
  15.     int i;
  16.     for (i = nextIndex(staleSlot, len);
  17.          (e = tab[i]) != null;
  18.          i = nextIndex(i, len)) {
  19.         ThreadLocal<?> k = e.get();
  20.         if (k == null) {// 清理过期 Entry
  21.             e.value = null;
  22.             tab[i] = null;
  23.             size--;
  24.         } else {// 重新哈希有效 Entry
  25.             int h = k.threadLocalHashCode & (len - 1);
  26.             if (h != i) {
  27.                 tab[i] = null;
  28.                 while (tab[h] != null)
  29.                     h = nextIndex(h, len);
  30.                 tab[h] = e;
  31.             }
  32.         }
  33.     }
  34.     return i;
  35. }
复制代码
末了提交事件,相关整理工作.... 就是remove那些了。
总流程如下所示:
  1. @Override
  2. protected void doBegin(Object transaction, TransactionDefinition definition) {
  3.     DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
  4.     Connection con = null;
  5.     try {
  6.         if (!txObject.hasConnectionHolder() ||
  7.                 txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
  8.             Connection newCon = obtainDataSource().getConnection();
  9.             ...
  10.             txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
  11.         }
  12.         txObject.getConnectionHolder().setSynchronizedWithTransaction(true);
  13.         con = txObject.getConnectionHolder().getConnection();
  14.         ....
  15.         // 手动开启一个事务
  16.         if (con.getAutoCommit()) {
  17.             txObject.setMustRestoreAutoCommit(true);
  18.             ...
  19.             con.setAutoCommit(false);
  20.         }
  21.         prepareTransactionalConnection(con, definition);
  22.         txObject.getConnectionHolder().setTransactionActive(true);
  23.         int timeout = determineTimeout(definition);
  24.         if (timeout != TransactionDefinition.TIMEOUT_DEFAULT) {
  25.             txObject.getConnectionHolder().setTimeoutInSeconds(timeout);
  26.         }
  27.         // Bind the connection holder to the thread.
  28.         if (txObject.isNewConnectionHolder()) {
  29.             TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder());
  30.         }
  31.     }
  32.     catch (Throwable ex) {
  33.         if (txObject.isNewConnectionHolder()) {
  34.             DataSourceUtils.releaseConnection(con, obtainDataSource());
  35.             txObject.setConnectionHolder(null, false);
  36.         }
  37.         throw new CannotCreateTransactionException("Could not open JDBC Connection for transaction", ex);
  38.     }
  39. }
复制代码
end.参考

留一个题目:ThreadLocal另有一个很经典的题目,那就是在父子线程中通信的题目了。
1.https://blog.csdn.net/qq_35190492/article/details/107599875
2.https://blog.csdn.net/u010445301/article/details/111322569
3.https://zhuanlan.zhihu.com/p/102571059
4.https://cloud.tencent.com/developer/article/2355282

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

农民

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表