文章目录
- 1. ThreadLocal解决了什么问题
- 数据隔离
- 避免参数传递
- 资源管理
- 2. ThreadLocal和Synchronized
- 3. ThreadLocal核心
- 核心特性
- 常见方法
- 使用场景
- 注意事项
- 4. ThreadLocal如何实现线程隔离的?(重点)
- ThreadLocal 的自动清理与内存泄漏问题
- 阿里巴巴 ThreadLocal 编程规约
- 5. ThreadLocal源码分析
- ThreadLocal.java
- Thread.java
- ThreadLocalMap 源码分析
- ThreadLocalMap 的获取方法
- ThreadLocalMap 的添加方法
- ThreadLocalMap 的扩容方法
- ThreadLocalMap 的移除方法
- ThreadLocalMap 复杂度分析
- 访问 ThreadLocal 一定会清理无效数据吗?
- 6. 总结
- 参考资料
1. ThreadLocal解决了什么问题
数据隔离
在多线程环境下,不同线程可能需要操作不同的对象实例,避免数据的相互干扰。ThreadLocal为每个线程提供独立的数据副本,使得各线程之间的数据相互隔离。
例如,在一个 Web 应用中,可能需要记录每个用户的请求信息。如果使用全局变量来存储这些信息,不同用户的请求可能会相互覆盖。而使用ThreadLocal,可以为每个用户的请求线程创建一个独立的存储区域,存储该用户的请求信息,避免了数据的混乱。
避免参数传递
在一些复杂的系统中,可能需要在多个方法之间传递一些上下文信息。如果通过方法参数的方式进行传递,会使得代码变得冗长且难以维护。ThreadLocal可以将这些上下文信息存储在当前线程中,在需要的地方直接获取,无需进行繁琐的参数传递。
例如,在一个事务处理系统中,需要在多个方法中获取当前事务的 ID。如果不使用ThreadLocal,可能需要在每个方法中都传递事务 ID 作为参数。而使用ThreadLocal,可以在事务开始时将事务 ID 存储在当前线程中,在需要的地方直接从ThreadLocal中获取事务 ID,简化了代码。
资源管理
在一些情况下,需要为每个线程分配独立的资源,如数据库连接、文件句柄等。使用ThreadLocal可以方便地管理这些资源,确保每个线程都有自己独立的资源副本,避免资源的竞争和冲突。
例如,在一个数据库访问系统中,为了提高性能,可以为每个线程分配一个独立的数据库连接。使用ThreadLocal可以在需要的时候获取当前线程的数据库连接,在使用完毕后及时关闭,避免了数据库连接的频繁创建和销毁,提高了系统的性能。
总之,ThreadLocal在 Java 中提供了一种方便的方式来实现线程局部变量,解决了多线程环境下的数据隔离、参数传递和资源管理等问题,使得多线程编程更加简洁、高效和可靠。
2. ThreadLocal和Synchronized
ThreadLocal专注不同线程之间的数据相互隔离,对变量的操作只在当前线程内可见,不会影响其他线程。适用于需要为每个线程保存独立数据的场景,不存在阻塞问题,但变量过多会占用较多的内存空间,需要注意内存泄漏问题。
synchronized通过对临界区(一段需要同步的代码块或方法)加锁来保证同一时刻只有一个线程能够访问该区域。主要用于解决多个线程对共享资源的并发访问问题,确保数据的一致性和完整性。会导致线程的阻塞和唤醒,有一定的性能开销。
3. ThreadLocal核心
ThreadLocal是Java中的一个类,它提供了线程局部(thread-local)变量。这些变量与普通的变量不同,因为每个访问变量的线程都有其自己独立初始化的变量副本。通过ThreadLocal实例,可以隔离并保存每个线程的数据,确保线程之间不会相互干扰,避免因并发访问导致的数据不一致问题。
核心特性
线程隔离:每个线程对 ThreadLocal 变量的修改对其他线程是不可见的。
无继承性:子线程不能访问父线程的 ThreadLocal 变量,除非子线程中有显式的设置或复制操作。
避免同步:由于每个线程都有自己的变量副本,因此不需要同步就可以保证线程安全。
常见方法
public T get()
:返回当前线程对应的变量的值。如果当前线程没有对应的值,则返回初始值或 null(如果未设置初始值)。
public void set(T value)
:设置当前线程对应的变量的值。
public void remove()
:删除当前线程对应的变量。
protected T initialValue()
:这是一个受保护的方法,用于设置变量的初始值。通常,你可以通过匿名内部类来覆盖这个方法。
使用场景
数据库连接:在多线程应用中,每个线程可能需要自己的数据库连接。使用 ThreadLocal 可以为每个线程保存其自己的连接。
会话管理:在 Web 应用中,每个用户的会话数据可以使用 ThreadLocal 存储,从而确保同一用户的多个请求在同一个线程中处理时能够访问到正确的会话数据。
线程内上下文传递:有时需要在同一个线程的不同方法之间传递一些上下文信息,而不希望使用全局变量或参数传递。这时可以使用 ThreadLocal。
注意事项
- 内存泄漏:如果线程不再需要使用该变量,但忘记调用 remove() 方法来清理,那么由于 ThreadLocalMap 中的 Entry 的 key 是对 Thread 的弱引用,所以 Thread 被回收后,Entry 的 key 会被置为 null,但 value 不会被回收,从而导致内存泄漏。因此,使用完 ThreadLocal 后,最好调用 remove() 方法来清理。
- 线程池中的使用:在线程池中,线程可能会被复用。如果线程之前设置过 ThreadLocal 变量,但在使用后没有清理,那么下一个任务可能会读取到上一个任务设置的值。因此,在线程池中使用 ThreadLocal 时需要特别小心。
- 初始化问题:如果不重写 initialValue() 方法,并且在使用前没有调用 set() 方法设置值,那么 get() 方法将返回 null。为了避免这种情况,可以重写 initialValue() 方法来提供一个默认值。
- 不适用于全局共享状态:虽然 ThreadLocal 可以在多个线程之间隔离数据,但它不适用于需要在多个线程之间共享和修改的全局状态。对于这种情况,应该使用其他同步机制(如锁或原子变量)。
4. ThreadLocal如何实现线程隔离的?(重点)
ThreadLocal 在每个线程的 Thread 对象实例数据中分配独立的内存区域,当我们访问 ThreadLocal 时,本质上是在访问当前线程的 Thread 对象上的实例数据,不同线程访问的是不同的实例数据,因此实现线程隔离。
Thread 对象中这块数据就是一个使用线性探测的 ThreadLocalMap 散列表,ThreadLocal 对象本身就作为散列表的 Key ,而 Value 是资源的副本。当我们访问 ThreadLocal 时,就是先获取当前线程实例数据中的 ThreadLocalMap 散列表,再通过当前 ThreadLocal 作为 Key 去匹配键值对。
ThreadLocal的工作原理主要是通过每个线程内部的ThreadLocalMap来实现的。ThreadLocalMap是ThreadLocal的静态内部类,它实现了类似于Map的键值对存储结构,但是键是弱引用(WeakReference)类型的ThreadLocal对象,而值则是与线程相关的数据。
每个线程都有一个名为threadLocals的成员变量,这个变量就是ThreadLocalMap类型的。当线程调用ThreadLocal的set()方法时,它会将ThreadLocal对象和要存储的值作为键值对添加到自己的threadLocals中。当调用get()方法时,线程会从自己的threadLocals中根据ThreadLocal对象查找对应的值。
由于每个线程都有自己的threadLocals,因此它们之间不会共享这些线程局部变量的值。这就是ThreadLocal能够实现线程隔离的原因。
ThreadLocal 的自动清理与内存泄漏问题
ThreadLocal 提供具有自动清理数据的能力,具体分为 2 个颗粒度:
- 自动清理散列表: ThreadLocal 数据是 Thread 对象的实例数据,当线程执行结束后,就会跟随 Thread 对象 GC 而被清理;
- 自动清理无效键值对: ThreadLocal 是使用弱键的动态散列表,当 Key 对象不再被持有强引用时,垃圾收集器会按照弱引用策略自动回收 Key 对象,并在下次访问 ThreadLocal 时清理无效键值对。
然而,自动清理无效键值对会存在 “滞后性”,在滞后的这段时间内,无效的键值对数据没有及时回收,就发生内存泄漏
场景1: 如果创建 ThreadLocal 的线程一直持续运行,整个散列表的数据就会一致存在。比如线程池中的线程(大体)是复用的,这部分复用线程中的 ThreadLocal 数据就不会被清理;
场景2: 如果在数据无效后没有再访问过 ThreadLocal 对象,那么自然就没有机会触发清理;
场景3: 即使访问 ThreadLocal 对象,也不一定会触发清理(原因见下文源码分析)。
综上所述:虽然 ThreadLocal 提供了自动清理无效数据的能力,但是为了避免内存泄漏,在业务开发中应该及时调用 ThreadLocal#remove 清理无效的局部存储。
阿里巴巴 ThreadLocal 编程规约
在《阿里巴巴 Java 开发手册》中,亦有关于 ThreadLocal API 的编程规约:
【强制】 SimpleDateFormate 是线程不安全的类,一般不要定义为 static ****变量。如果定义为 static,必须加锁,或者使用 DateUtils 工具类(使用 ThreadLocal 做线程隔离)。
private static final ThreadLocal<DataFormat> df = new ThreadLocal<DateFormat>(){// 设置缺省值 / 初始值@Overrideprotected DateFormat initialValue(){return new SimpleDateFormat("yyyy-MM-dd");}
};// 使用:
DateUtils.df.get().format(new Date());
【参考】 ThreadLocal 变量建议使用 static 全局变量,可以保证变量在类初始化时创建,所有类实例可以共享同一个静态变量(例如,在 Android Looper 的案例中,ThreadLocal 就是使用 static 修饰的全局变量)。
【强制】 必须回收自定义的 ThreadLocal 变量,尤其在线程池场景下,线程经常被反复用,如果不清理自定义的 ThreadLocal 变量,则可能会影响后续业务逻辑和造成内存泄漏等问题。尽量在代码中使用 try-finally 块回收,在 finally 中调用 remove() 方法。
5. ThreadLocal源码分析
用一个表格总结 ThreadLocal 的 API:
ThreadLocal.java
public class ThreadLocal < T > {// ThreadLocal 的散列值,类似于重写 Object#hashCode()private final int threadLocalHashCode = nextHashCode();// 全局原子整型,每调用一次 nextHashCode() 累加一次private static AtomicInteger nextHashCode = new AtomicInteger();// 疑问:为什么 ThreadLocal 散列值的增量是 0x61c88647?private static final int HASH_INCREMENT = 0x61c88647;private static int nextHashCode() {// 返回上一次 nextHashCode 的值,并累加 HASH_INCREMENTreturn nextHashCode.getAndAdd(HASH_INCREMENT);}public ThreadLocal() {// do nothing}// 子类可重写此方法设置缺省值(方法命名为 defaultValue 获取更贴切)protected T initialValue() {// 默认不提供缺省值return null;}// 帮助方法:不重写 ThreadLocal 也可以设置缺省值// supplier:缺省值创建工厂public static < S > ThreadLocal < S > withInitial(Supplier < ? extends S > supplier) {return new SuppliedThreadLocal < > (supplier);}// 1. 获取当前线程的副本public T get() {Thread t = Thread.currentThread();// ThreadLocalMap 详细源码分析见下文ThreadLocalMap map = getMap(t);if (map != null) {// 存在匹配的EntryThreadLocalMap.Entry e = map.getEntry(this);if (e != null) {T result = (T) e.value;return result;}}// 未命中,则获取并设置缺省值(即缺省值采用懒初始化策略)return setInitialValue();}// 获取并设置缺省值private T setInitialValue() {T value = initialValue();// 其实源码中是并不是直接调用set(),而是复制了一份 set() 方法的源码// 这是为了防止子类重写 set() 方法后改变缺省值逻辑set(value);return value;}// 2. 设置当前线程的副本public void set(T value) {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null)map.set(this, value);else// 直到设置值的时候才创建(即 ThreadLocalMap 采用懒初始化策略)createMap(t, value);}// 3. 移除当前线程的副本public void remove() {ThreadLocalMap m = getMap(Thread.currentThread());if (m != null)m.remove(this);}ThreadLocalMap getMap(Thread t) {// 重点:获取当前线程的 threadLocals 字段return t.threadLocals;}// ThreadLocal 缺省值帮助类static final class SuppliedThreadLocal < T > extends ThreadLocal < T > {private final Supplier < ? extends T > supplier;SuppliedThreadLocal(Supplier < ? extends T > supplier) {this.supplier = Objects.requireNonNull(supplier);}// 重写 initialValue() 以设置缺省值@Overrideprotected T initialValue() {return supplier.get();}}
}
Thread.java
// Thread 对象的实例数据
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;// 线程退出之前,会置空threadLocals变量,以便随后GC
private void exit() {// ...threadLocals = null;inheritableThreadLocals = null;inheritedAccessControlContext = null;// ...
}
ThreadLocalMap 源码分析
可以看到,散列表必备底层数组 table、键值对数量 size、扩容阈值 threshold 等属性都有,并且也要求数组的长度是 2 的整数倍。主要区别在于 Entry 节点上:
1、ThreadLocal 本身就是散列表的键 Key;
2、扩容阈值为数组容量的 2/3;
3、ThreadLocalMap#Entry 节点没有 next 指针,因为 ThreadLocalMap 采用线性探测解决散列冲突,所以不存在链表指针;
4、ThreadLocalMap#Entry 在键值对的 Key 上使用弱引用,这与 WeakHashMap 相似。
static class ThreadLocalMap {// 默认数组容量(容量必须是 2 的整数倍)private static final int INITIAL_CAPACITY = 16;// 底层数组private Entry[] table;// 有效键值对数量private int size = 0;// 扩容阈值private int threshold; // Default to 0private void setThreshold(int len) {threshold = len * 2 / 3;}// 键值对节点static class Entry extends WeakReference<ThreadLocal<?>> {// next:开放寻址法没有 next 指针// Key:与 WeakHashMap 相同,少了 key 的强引用// Hash:位于 ThreadLocal#threadLocalHashCode// Value:当前线程的副本Object value;Entry(ThreadLocal<?> k, Object v) {super(k/*注意:只有 Key 是弱引用*/);value = v;}}
}
为什么 Key 是弱引用,而不是 Entry 或 Value 是弱引用?
首先,Entry 一定要持有强引用,而不能持有弱引用。这是因为 Entry 是 ThreadLocalMap 内部维护数据结构的实现细节,并不会暴露到 ThreadLocalMap 外部,即除了 ThreadLocalMap 本身之外没有其它地方持有 Entry 的强引用。所以,如果持有 Entry 的弱引用,即使 ThreadLocalMap 外部依然在使用 Key 对象,ThreadLocalMap 内部依然会回收键值对,这与预期不符。
其次,不管是 Key 还是 Value 使用弱引用都可以实现自动清理,至于使用哪一种方法各有优缺点,适用场景也不同。Key 弱引用的优点是外部不需要持有 Value 的强引用,缺点是存在 “重建 Key 不等价” 问题。由于 ThreadLocal 的应用场景是线程局部存储,我们没有重建多个 ThreadLocal 对象指向同一个键值对的需求,也没有重写 Object#equals() 方法,所以不存在重建 Key 的问题,使用 Key 弱引用更方便。
ThreadLocalMap 的获取方法
ThreadLocalMap 的获取方法相对简单,所以我们先分析,区分 2 种情况:
- 数组下标直接命中目标 Key,则直接返回,也不清理无效数据(这就是前文提到访问 ThreadLocal 不一定会触发清理的源码体现);
- 数组下标未命中目标 Key,则开始线性探测。探测过程中如果遇到 Key == null 的无效节点,则会调用 expungeStaleEntry() 清理连续段(说明即使触发清理,也不一定会扫描整个散列表)。
expungeStaleEntry() 是 ThreadLocalMap 核心的连续段清理方法,下文提到的 replaceStaleEntry() 和 cleanSomeSlots() 等清理方法都会直接或间接调用到 expungeStaleEntry()。 它的逻辑很简单:就是线性遍历从 staleSlot 位置开始的连续段:
- k == null 的无效节点: 清理;
- k ≠ null 的有效节点,再散列到新的位置上。
为什么要对有效节点再散列呢?
线性探测只会遍历连续段,而清理无效节点会导致连续段产生断层。如果没有对有效节点做再散列,那么有效节点在下次查询时就有可能探测不到了。
ThreadLocalMap 的添加方法
ThreadLocalMap#set 的流程非常复杂,我将主要步骤概括为 6 步:
- 先将散列值映射到数组下标,并且开始线性探测;
- 如果探测中遇到目标节点,则将旧 Value 更新为新 Value;
- 如果探测中遇到无效节点,则会调用 replaceStaleEntry() 清理连续段并添加键值对;
- 如果未探测到目标节点或无效节点,则创建并添加新节点;
- 添加新节点后调用 cleanSomeSlots() 方法清理部分数据;
- 如果没有发生清理并且达到扩容阈值,则触发 rehash() 扩容。
replaceStaleEntry(): 清理连续段中的无效节点的同时,如果目标节点存在则更新 Value 后替换到 staleSlot 无效节点位置,如果不存在则创建新节点替换到 staleSlot 无效节点位置。
cleanSomeSlots(): 对数式清理,清理复杂度比全数组清理低,在大多数情况只会扫描 log(len) 个元素。如果扫描过程中遇到无效节点,则从该位置执行一次连续段清理,再从连续段的下一个位置重新扫描 log(len) 个元素,直接结束对数扫描。
static class ThreadLocalMap {private void set(ThreadLocal<?> key, Object value) {Entry[] tab = table;int len = tab.length;// 1、散列值转数组下标int i = key.threadLocalHashCode & (len-1);// 线性探测for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {ThreadLocal<?> k = e.get();if (k == key) {// 2、命中,将旧 Value 替换为新 Valuee.value = value;return;}if (k == null) {// 3、清理无效节点,并插入键值对replaceStaleEntry(key, value, i);return;}}// 4、如果未探测到目标节点或无效节点,则创建并添加新节点tab[i] = new Entry(key, value);int sz = ++size;// cleanSomeSlots:清理部分数据// 5、添加新节点后调用 cleanSomeSlots() 方法清理部分数据if (!cleanSomeSlots(i, sz /*有效数据个数*/) && sz >= threshold)// 6、如果没有发生清理并且达到扩容阈值,则触发 rehash() 扩容rehash();}// -> 3、清理无效节点,并插入键值对// key-value:插入的键值对private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {Entry[] tab = table;int len = tab.length;Entry e;// slotToExpunge:记录清理的起点int slotToExpunge = staleSlot;// 3.1 向前探测找到连续段中的第一个无效节点for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))if (e.get() == null)slotToExpunge = i;// 3.2 向后探测目标节点for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {ThreadLocal<?> k = e.get();if (k == key) {// 3.2.1 命中,将目标节点替换到 staleSlot 位置e.value = value;tab[i] = tab[staleSlot];tab[staleSlot] = e;// 3.2.2 如果连续段在 staleSlot 之前没有无效节点,则从 staleSlot 的下一个无效节点开始清理if (slotToExpunge == staleSlot)slotToExpunge = i;// 3.2.3 如果连续段中还有其他无效节点,则清理// expungeStaleEntry:连续段清理// cleanSomeSlots:对数式清理cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);return;}// 如果连续段在 staleSlot 之前没有无效节点,则从 staleSlot 的下一个无效节点开始清理if (k == null && slotToExpunge == staleSlot)slotToExpunge = i;}// 3.3 创建新节点并插入 staleSlot 位置tab[staleSlot].value = null;tab[staleSlot] = new Entry(key, value);// 3.4 如果连续段中还有其他无效节点,则清理if (slotToExpunge != staleSlot)cleanSomeSlots(expungeStaleEntry(slotToExpunge), len /*数组长度*/);}// 5、对数式清理// i:起点// n:数组长度或有效数据个数private boolean cleanSomeSlots(int i, int n) {boolean removed = false;Entry[] tab = table;int len = tab.length;do {i = nextIndex(i, len);Entry e = tab[i];if (e != null && e.get() == null) {// 发现无效节点,重新探测 log2(len)n = len;removed = true;// 连续段清理i = expungeStaleEntry(i);}} while ( (n >>>= 1) != 0); // 探测 log2(len)return removed;}
}
ThreadLocalMap 的扩容方法
ThreadLocalMap 的扩容方法相对于添加方法比较好理解。在添加方法中,如果添加键值对后散列值的长度超过扩容阈值,就会调用 rehash() 方法扩容,主体流程分为 3步:
- 先完整扫描散列表清理无效数据,清理后用较低的阈值判断是否需要扩容;
- 创建新数组;
- 将旧数组上无效的节点忽略,将有效的节点再散列到新数组上。
static class ThreadLocalMap {// 扩容(在容量到达 threshold 扩容阈值时调用)private void rehash() {// 1、全数组清理expungeStaleEntries();// 2、用较低的阈值判断是否需要扩容if (size >= threshold - threshold / 4)// 3、真正执行扩容resize();}// -> 1、完整散列表清理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)// 很奇怪为什么不修改 j 指针expungeStaleEntry(j);}}// -> 3、真正执行扩容private void resize() {Entry[] oldTab = table;// 扩容为 2 倍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) {// 清除无效键值的 Valuee.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;}
}
ThreadLocalMap 的移除方法
ThreadLocalMap 的移除方法是添加方法的逆运算,ThreadLocalMap 也没有做动态缩容。
与常规的移除操作不同的是,ThreadLocalMap 在删除时会执行 expungeStaleEntry() 清除无效节点,并对连续段中的有效节点做再散列,所以 ThreadLocalMap 是 “真删除”。
static class ThreadLocalMap {// 移除private void remove(ThreadLocal<?> key) {Entry[] tab = table;int len = tab.length;// 散列值转数组下标int i = key.threadLocalHashCode & (len-1);// 线性探测for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {if (e.get() == key) {// 清除弱引用关系e.clear();// 清理连续段expungeStaleEntry(i);return;}}}
}
ThreadLocalMap 复杂度分析
总结下 ThreadLocalMap 的时间复杂度,以下 K 为连续段的长度,N 是数组长度。
获取方法: 平均时间复杂度为 O(K);
添加方法: 平均时间复杂度为 O(K),在触发扩容的添加操作中时间复杂度为 O(N),基于摊还分析后时间复杂度依然是 O(K);
移除方法: 移除是 “真删除”,平均时间复杂度为 O(K)。
访问 ThreadLocal 一定会清理无效数据吗?
不一定。只有扩容会触发完整散列表清理,其他情况都不能保证清理,甚至不会触发。
6. 总结
- ThreadLocal 是一种特殊的无锁线程安全方式,通过为每个线程分配独立的资源副本,从根本上避免发生资源冲突;
- ThreadLocal 在所有线程间隔离,InheritableThreadLocal 在创建子线程时会拷贝父线程中 InheritableThreadLocal 的有效键值对;
- 虽然 ThreadLocal 提供了自动清理数据的能力,但是自动清理存在滞后性。为了避免内存泄漏,在业务开发中应该及时调用 remove 清理无效的局部存储;
- ThreadLocal 是采用线性探测解决散列冲突的散列表。
参考资料
数据结构与算法分析 · Java 语言描述(第 5 章 · 散列)—— [美] Mark Allen Weiss 著
算法导论(第 11 章 · 散列表)—— [美] Thomas H. Cormen 等 著
ThreadLocal 超强图解,这次终于懂了~