乐趣区

关于并发编程:一文让你彻底明白ThreadLocal

前言:

ThreadLocal 在 JDK 中是一个十分重要的工具类,通过浏览源码,能够在各大框架都能发现它的踪影。它最经典的利用就是 事务管理,同时它也是面试中的常客。

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

①、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 中存储的内容是以后线程独有的,在多线程环境下,可能无效避免本人的变量被其余线程批改(存储的内容是同一个援用类型对象的状况除外)。

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 技术文章,福利多多哟!

如果本文对大家有帮忙的话,请多多点赞评论呀,你们的反对就是我一直创作的能源!

退出移动版