Java笔记:ThreadLocal和压力测试
472
2022-05-30
1、简介
ThreadLocal也称线程变量,它是一个以ThreadLocal对象为键、任意对象为值的存储结构(ThreadLocal中ThreadLocalMap的Entry结构),这个结构会被附带在线程上,以此来做线程数据的隔离。ThreadLocal是维持线程的封闭性的一种规范,它提供set()/get()等方法维护和访问线程中存储的私有副本,ThreadLocal通常用于防止对可变的单实例变量或者全局变量进行共享。
ThreadLocal和synchronized两者经常会被拿出来一起讨论,虽然二者都是用来解决多线程中资源的访问冲突等问题,但是二者存在本质上的区别具有完全不一样的使用场景。这里简单说明一下:
synchronized是通过线程阻塞(加锁),只允许同步区域内同一时刻只有一个线程在执行来实现共享资源的互斥访问,牺牲了程序的执行时间
ThreadLocal是每个线程具有不同的数据副本,通过线程数据隔离互不影响的方式来解决并发资源的访问,牺牲的是存储空间
相比之下ThreadLocal的使用场景比较特殊,在某些需要以线程为作用域做资源隔离的场景下使用,比如应用程序中以线程为单位发起的数据库连接,可以通过将JDBC的连接保存到ThreadLocal对象中来保证线程安全。
ThreadLocal的简单使用示例:
package com.liziba.tl; /** *
* ThreadLocal demo -> 线程隔离 *
* * @Author: Liziba */ public class ThreadLocalDemo { ThreadLocal输出结果:
上述运行结果可以看到线程并行运行,但线程各自拥有资源副本,彼此之间互不影响,是线程安全的。
2、Thread、ThreadLocal、ThreadLocalMap三者的关系
在进行源码分析和原理讲解之前,有必要先了解这三者之间的关系。Thread、ThreadLocal、ThreadLocalMap这三者从命名都包含一个Thread那么它们具体是什么关系呢?接下来通过一些重要的代码片段和图示来阐述三者之间的关系,并且也会介绍到ThreadLocal、ThreadLocalMap中的一些重要属性和数据结构。
java.lang.Thread 中的代码片段:
public class Thread implements Runnable { /** Thread中持有一个ThreadLocal中的ThreadLocalMap */ ThreadLocal.ThreadLocalMap threadLocals = null; }
java.lang.ThreadLocal中的代码片段:
public class ThreadLocal
java.lang.ThreadLocal.ThreadLocalMap中的代码片段:
static class ThreadLocalMap { /** 默认的初始Entry的大小 */ private static final int INITIAL_CAPACITY = 16; /** 定义一个Entry数组,用来存放多个ThreadLocal */ private Entry[] table; /** 数组扩容因子 */ private int threshold; /** 记录table中Entry的个数 */ private int size = 0; /** * ThreadLocalMap中有静态内部类Entry,Entry继承了WeakReference弱引用,引用类型是ThreadLocal> */ static class Entry extends WeakReference
看了上述的代码和代码的注释,可以很明确的看到Thread、ThreadLocal、ThreadLocalMap这三者关系
Thread线程类内部维护了一个ThreadLocalMap成员变量(ThreadLocalMap的实例)
ThreadLocalMap是ThreadLocal的静态内部类,此外ThreadLocalMap内部维护了一个Entry数组table,用来存放多个ThreadLocal
ThreadLocal类用于存储以线程为作用域的数据,用于数据隔离
从这张图能非常清晰的看出,ThreadLocal只是ThreadLocalMap操作的一个入口,它提供的set()/get()方法供程序员开发使用,具体的数据存取都是在ThreadLocalMap中去实现,而每一个Thread对象中持有一个ThreadLocalMap对象,不难看出ThreadLocalMap才是实现的关键和重难点。
3、ThreadLocal源码分析
ThreadLocal是JDK提供给程序员直接使用的类,其重点在于ThreadLocalMap,因此下面主要介绍ThreadLocal的关键成员属性、如何通过魔数计算散列均匀的索引、get()/set()方法。重点将在ThreadLocalMap中去介绍。
ThreadLocal中有几个重要的成员属性如下所示:
/** 定义数组的初始大小 */ private static final int INITIAL_CAPACITY = 16; /** 魔数 -> 可以让生成出来的值或者说ThreadLocal的Index均匀的分布在2^n的数组大小中 */ private static final int HASH_INCREMENT = 0x61c88647; /** 魔数 */ private final int threadLocalHashCode = nextHashCode(); /** 定义一个线程安全的原子类AtomicInteger,用于魔数的累加 */ private static AtomicInteger nextHashCode = new AtomicInteger();
nextHashCode()方法:
/** 计算下一个code(魔数累加) */ private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }
上面的魔数与斐波拉契散列有关,它可以让生成出来的值或者说ThreadLocal在table的Index均匀的分布在2^n的数组大小中,我们通过计算的值再取模数组的length-1,就能得到ThreadLocal在ThreadLocalMap的Entry中的索引下标。下面通过自己写一个测试案例来简单的讲述下这个魔数和计算数组索引:
package com.lizba.currency.threadlocal; import java.util.concurrent.atomic.AtomicInteger; /** *
* 通过魔数0x61c88647来计算数组索引下标 *
* * @Author: Liziba * @Date: 2021/7/2 22:02 */ public class ThreadLocal0x61c88647 { /** 定义数组的初始大小 */ private static final int INITIAL_CAPACITY = 16; /** 魔数 -> 可以让生成出来的值或者说ThreadLocal的Index均匀的分布在2^n的数组大小中 */ private static final int HASH_INCREMENT = 0x61c88647; /** 魔数 */ private final int threadLocalHashCode = nextHashCode(); /** 定义一个线程安全的原子类AtomicInteger,用于魔数的累加 */ private static AtomicInteger nextHashCode = new AtomicInteger(); /** 计算下一个code(魔数累加) */ private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } /** * 根据生成的均匀分布的随机数threadLocalHashCode 取模(%) (数组大小INITIAL_CAPACITY-1(因为数组索引从0开始)) * * @return */ public int index() { return this.threadLocalHashCode & (INITIAL_CAPACITY - 1); } }测试上述代码:
public static void main(String[] args) { // 输出16次,模拟ThreadLocal中的默认初始大小 for (int i = 0; i < 16; i++) { ThreadLocal0x61c88647 demo = new ThreadLocal0x61c88647(); System.out.println(demo.index()); } }
输出结果:
魔数计算数组索引下标顺序图示:
public void set(T value) { // 获取当前线程 Thread t = Thread.currentThread(); // 获取当前线程的ThreadLocalMap -> getMap(t)方法在下面 ThreadLocalMap map = getMap(t); // ThreadLocalMap不为空,表示已经初始化 -> 这里两个分支是ThreadLocal的重点 if (map != null) map.set(this, value); // 直接设值,key为当前ThreadLocal对象,value为set传入的值T else createMap(t, value); // 为空则需要初始化,再设值 }
获取当前线程的ThreadLocalMap -> getMap(Thread t)
/** * java.lang.Thread类中定义了ThreadLocal.ThreadLocalMap threadLocals = null; * t.threadLocals为获取当前线程对象的ThreadLocalMap */ ThreadLocalMap getMap(Thread t) { // 获取当前线程的ThreadLocalMap return t.threadLocals; }
ThreadLocalMap为空初始化 -> createMap(t, value)
void createMap(Thread t, T firstValue) { // 实例化一个ThreadLocalMap,赋值给当前线程的threadLocals成员变量 // new ThreadLocalMap(this, firstValue) -> 源码分析放到后面ThreadLocalMap中去讲解,这里只需要明白这是初始化一个ThreadLocalMap即可,加上第二节中三者的说明,也能理解其中的原理。 t.threadLocals = new ThreadLocalMap(this, firstValue); }
public T get() { // 获取当前线程t Thread t = Thread.currentThread(); // 获取当选线程的ThreadLocalMap -> 下面贴出了代码 ThreadLocalMap map = getMap(t); // 如果不为空 if (map != null) { // 从ThreadLocalMap的table中取出Entry ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") // 返回entry 存储的值 (entry key为ThreadLocal对象,value为存储的值) T result = (T)e.value; return result; } } // 初始化ThreadLocalMap或构建value为null一个entry到table中 // 具体逻辑在下面展示 get() -> setInitialValue() return setInitialValue(); }
get() -> getMap(Thread t)方法:
ThreadLocalMap getMap(Thread t) { // 获取当选线程的ThreadLocalMap return t.threadLocals; }
get() -> setInitialValue()方法:
/** * 这个方法于set方法逻辑一致,只是初始化的value为null */ private T setInitialValue() { // initialValue()返回null T value = initialValue(); // 后续操作与set()方法是完全相同的 // 这个方法是私有的无法被子类重写 -> 相当于set()方法的一个副本,子类重写了set()方法,还可以使用这个方法来初始化 Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); return value; }
4、ThreadLocalMap源码分析
ThreadLocalMap是整篇文章的重点,ThreadLocalMap是ThreadLocal的内部类,它提供了真正数据存取的能力;ThreadLocalMap为每个Thread都维护了一个table,这个table中的每一个Entry代表一个ThreadLocal(注意一个线程可以定义多个ThreadLocal,此时它们会存储在table中不同的下标位置)和vlaue的组合。接下来通过源码一层层的分析ThreadLocalMap的原理及实现。
Entry是ThreadLocalMap的静态内部类,它是一个负责元素存储的key-value键值对数据结构,key是ThreadLocal,value是ThreadLocal传入的相关的值。这里有一个重点知识,Entry继承了WeakReference,所以很明显的看出ThreadLocal> k将会是一个弱引用,弱引用容易被JVM垃圾收集器回收,因此可能导致内存泄露的问题(后续在详细分析,这里的重点是ThreadLocalMap的实现)。
static class Entry extends WeakReference
在ThreadLocal中3.3 set()方法源码分析中留下来createMap(t, value)的疑问,在获取线程的ThreadLocalMap为空时,通过调用createMap(t, value)方法对ThreadLocalMap进行了初始化。
// ThreadLocal中set()方法调用的createMap方法 void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); }
ThreadLocalMap(ThreadLocal> firstKey, Object firstValue)源码:
ThreadLocalMap(ThreadLocal> firstKey, Object firstValue) { // 实例化一个大小为16的Entry数组,赋值给Entry[] table table = new Entry[INITIAL_CAPACITY]; // 根据当前的ThreadLocal计算其在table中的数组下标,这里不懂看前面3.2 int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); // 通过传入的ThreadLocal和value值,构造一个Entry赋值给table的指定位置的值 table[i] = new Entry(firstKey, firstValue); // 记录table中Entry的个数,也拥有触发扩容,初始化时为1 size = 1; // 设置扩容阈值len * 2 / 3 setThreshold(INITIAL_CAPACITY); }
在ThreadLocal的set()方法中,当ThreadLocalMap不为空时,也就是说在上面4.2初始化之后,当前线程再次调用ThreadLocal的set()方法将会执行的是下面的逻辑。set()方法中有三个重点知识:
当计算的Entry下标位置不存在数据时,直接插入
当存在数据时,通过线性探测来解决hash冲突
当table中的Entry个数达到扩容阈值时,进行扩容处理
private void set(ThreadLocal> key, Object value) { Entry[] tab = table; int len = tab.length; // 计算数组下标 int i = key.threadLocalHashCode & (len-1); // 线性探测 // for循环中的内容就是从当前产生hash冲突的位置往后找 // 找到不为null的Entry 有两种情况 1、key相等则更新 2、key=null则需要做replaceStaleEntry处理 // 如果为null,结束for循环 for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { // 获取当前节点的key -> ThreadLocal 对象 ThreadLocal> k = e.get(); // 如果key相同则直接替换,结束循环 if (k == key) { e.value = value; return; } // Entry存在,但是Entry的key为空,表示引用被垃圾回收器回收了 // 此时需要做比较复杂的处理,这个处理请看后面我的详细分析,此处你可以理解为就是找个能放的索引位置放进去,然后结束循环 if (k == null) { replaceStaleEntry(key, value, i); return; } } // 在table[i] = null 的位置插入新的entry tab[i] = new Entry(key, value); // size + 1 int sz = ++size; // 如果没有需求清理的key = null的entry,并且size到达扩容阈值 if (!cleanSomeSlots(i, sz) && sz >= threshold) // 扩容处理 rehash(); }
set() -> nextIndex(i, len)方法:
/** * 这里是线性探测的思想,一直往后遍历 * 当到达数组的最后一个位置仍未找到满足条件的,再从数组的最前面开始遍历 */ private static int nextIndex(int i, int len) { // 当数组下标不越界的情况下 返回 i+1 否则返回 0 return ((i + 1 < len) ? i + 1 : 0); }
set() -> replaceStaleEntry(key, value, i)方法:
向前搜索,寻找其他同样key为null被GC的Entry节点,并记录下最后遍历到的Entry索引,遍历结束条件是Entry为null。这样的好处是为了清理这些Entry的key被GC了的Entry节点。
向后遍历,ThreadLocal不同于hashmap,它是开放地址法,因此当前索引位置不一定就是这个Entry存放的位置,可能第一次存放的时候发生了hash碰撞,Entry的存储发生了后移,因此要向后遍历,寻找当前与Entry的key相等的槽。
关于replaceStaleEntry(key, value, i)方法,我画了一个简图,图中并未包含所有场景,具体请详细阅读源码(非常精彩的设计思路),假设进入这个方法时staleSlot = 8,并且key的hashcode = 0xx68
源码分析:
private void replaceStaleEntry(ThreadLocal> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // 将当前索引的值赋值给slotToExpunge,用于清理 int slotToExpunge = staleSlot; // 向前搜索,知道tab[i] == null // 如果tab[i] 不为空,但是tab[i]的key为空,也就是和当前节点一样的情况,key被GC了,那么将当前索引下标的值赋值给slotToExpunge,记录最小的索引值,后续从这里开始清理 for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; // 向后遍历,直到tab[i]==null for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { // 获取当前索引位置Entry的key ThreadLocal> k = e.get(); // 如果key相等,证明当前这个节点后移到这里了,需要替换value // 替换的时候我们可以做一些优化,因为我们第一次命中的索引出存在Entry但是Entry的key被GC了,也就是说无法被访问了,而我们这个节点是因为后移才存储在这里,这个时候我们这个节点是不是可以重新放回去呢?放回去后下次不是一次就命中了么?就不需要往后遍历寻找了么? if (k == key) { // 更新value e.value = value; // tab[i] 与 tab[staleSlot]交换位置 tab[i] = tab[staleSlot]; tab[staleSlot] = e; // 如果往前探索的第一个key=null的索引下标和当前替换回去的索引相同 // 由于做了交换,我们又能保证前面不存在key == null的节点了,那么只需将替换后的i的值赋值给slotToExpunge,这样可以减少清理的循环次数 if (slotToExpunge == staleSlot) slotToExpunge = i; // 做清理工作和rehash cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } // 初始进来的时候我们有这句代码 slotToExpunge == staleSlot // 所以如果slotToExpunge == staleSlot仍然成立,并且当前的key == null,那么我们就把当前的下标值赋值给slotToExpunge,很好理解还是为了缩小清理的范围,大师们对提升性能总是那么极致 if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; } // 执行到了这里,说明替换失败了,没找到要么就是它的key也被GC了,要么就是它是第一次set // 但是当前Entry的key是null,那我们就放这里吧,毕竟这个Entry也用不了 tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); // slotToExpunge != staleSlo表名需要清理key为null的Entry if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); }
关于replaceStaleEntry(ThreadLocal> key, Object value, int staleSlot)往前探索并未发现满足条件的Entry时,也就是代码40行slotToExpunge == staleSlot满足时,会做slotToExpunge = i操作,这个如果不清楚我做了图来便于大家理解:
expungeStaleEntry(int staleSlot)源码分析:
expungeStaleEntry(int staleSlot)主要做了三件事
从staleSlot索引开始往后遍历到第一个Entry节点不为空的下标这段区间中key=null的Entry节点清空处理
在遍历中如果key != null 需要做rehash处理,因为前面可能存在节点被清空了,重新根据k.threadLocalHashCode & (len - 1)计算索引,往后遍历寻找第一个为null的Entry移动到这里
返回i,这个i是从staleSlot往后遍历到的第一个为null的Entry,这个值返回为了cleanSomeSlots(int i, int n),去清理后面的Entry,这里你可能会疑问为啥不直接用expungeStaleEntry(int staleSlot)方法直接全部遍历一遍得了,但是你可以发现源码这分块的清理做了优化,具体实现请看后面的cleanSomeSlots(int i, int n)讲解
private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // 当前staleSlot索引处的Entry清空,注意不仅需要清空Entry还需要清空value,key本身已经为null了不需要再清空了 tab[staleSlot].value = null; tab[staleSlot] = null; size--; // 注意要及时的记录table中Entry的个数 Entry e; int i; // 1、循环到第一个Entry不为空的位置 清空key == null的Entry和Entry的value for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal> k = e.get(); // key == null 清空Entry和Entry的value if (k == null) { e.value = null; tab[i] = null; size--; // 注意要及时的记录table中Entry的个数 } else { // 2、由于做了清空处理,我们要对Entry做rehash。因为他可能可以前移 int h = k.threadLocalHashCode & (len - 1); // 如果计算的h和当前的索引i不相等,尝试从h开始往后寻找空的Entry if (h != i) { // 清空当前Entry tab[i] = null; // 循环找到第一个为空的Entry,并记录它的索引 while (tab[h] != null) h = nextIndex(h, len); // tab[i]的值移到新的槽(可能是同一个) tab[h] = e; } } } // 3、返回i,这个i就是第一个为null的Entry return i; }
cleanSomeSlots(int i, int n)源码分析:
cleanSomeSlots(int i, int n)也是对上面expungeStaleEntry(int staleSlot)方法中找到的第一个为null的Entry节点到table.legth的区间范围内,Entry不为空但Entry的key为空的节点进行清理,这个清理不一定会进行到table的最后,因为它做了一个(n >>>= 1) != 0判断,如果在n无符号右移1 == 0 时,并且这右移的期间没有发现满足清理的Entry那么就会结束往后寻找。
n >>>=1 相当于 n= n>>>1,位运算右移一位相当于除以2
举个例子,如果i=5,n=16,此时如果在往后遍历四次,也就是到i=9,仍然没有满足e != null && e.get() == null的Entry,那么后续10-16就不再遍历了,这些都是对算法的优化。
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]; // 找到满足条件的做两个操作 // 1、重置n // 2、调用expungeStaleEntry(i)清理 if (e != null && e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } } while ( (n >>>= 1) != 0); // n = n >>> 1 相当于 除以2 return removed; }
rehash()包含两个部分的逻辑
从table数组的第一个节点到最后一个节点中e != null && e.get() == null的Entry执行上面的expungeStaleEntry(int staleSlot)方法
当达到扩容阈值,进行扩容处理
private void rehash() { // 处理table中Entry的key被GC了的元素,后面将 expungeStaleEntries(); // 这里使用的双倍阈值,也就是threshold在计算了一次threshold if (size >= threshold - threshold / 4) resize(); }
expungeStaleEntries()源码非常简单,从table数组的第一个节点到最后一个节点中e != null && e.get() == null的Entry执行上面的expungeStaleEntry(int staleSlot)方法。
private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j < len; j++) { Entry e = tab[j]; // 如果e != null && e.get() == null 即 Entry的key被GC了 // 执行expungeStaleEntry(int staleSlot)方法 -> 上面详细分析了 if (e != null && e.get() == null) expungeStaleEntry(j); } }
resize()的源码也比较简单,主要做了三个操作:
实例化一个原先大小两倍的数组newTab
遍历原先的旧数组中的每一个节点,将不为空的Entry节点计算其在新数组中的下标,放入新的数组中,放入的方式与set一致,使用线性探测解决hash冲突,注意如果节点不为空,key为空,需要将节点和节点的value置为空,帮助GC
设置新的扩容阈值,记录新的size,替换table的引用
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; }
private Entry getEntry(ThreadLocal> key) { // 计算数组下标 int i = key.threadLocalHashCode & (table.length - 1); // 取出Entry Entry e = table[i]; // 如果Entry不为空,且key相等直接返回 if (e != null && e.get() == key) return e; // 返回 else return getEntryAfterMiss(key, i, e); // 当前节点未命中 }
getEntryAfterMiss(key, i, e)源码分析:
进入这个方法存在多种情况:
节点发生了hash冲突,节点插入后移了(这种情况也有可能会被GC)
节点为发送hash冲突,但是key被GC了
private Entry getEntryAfterMiss(ThreadLocal> key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal> k = e.get(); // key相等则直接返回 if (k == key) return e; // key为空,要做清除和rehash if (k == null) expungeStaleEntry(i); // 往下遍历直至末尾在从前开始 ((i + 1 < len) ? i + 1 : 0) else i = nextIndex(i, len); e = tab[i]; } // 可能未匹配上 return null; }
5、ThreadLocal内存泄漏
ThreadLocal内存泄漏是我们谈及ThreadLocal存在的问题中所提及的最频繁的一个,那么我们接下来就从为什么会内存泄漏和如何解决内存泄漏这两个点来分析这个问题:
当Thread中存在一个ThreadLocal的内存分布和引用情况的简图如下:
我们知道Entry extends WeakReference
防止内存泄露的处理方式很简单,ThreadLocal提供了remove()方法,供程序员主动清除Entry
ThreadLocal的remove()方法:
public void remove() { // 获取当前线程的ThreadLocalMap ThreadLocalMap m = getMap(Thread.currentThread()); // 不为空则调用ThreadLocalMap的remove(ThreadLocal> key)方法进行清理操作 if (m != null) m.remove(this); }
ThreadLocalMap的remove(ThreadLocal> key)方法:
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(); // 对其他key为null的Entry做清理和不为null的节点做rehash expungeStaleEntry(i); return; } } }
clear()方法源码:
public void clear() { // 引用置空 this.referent = null; }
Java 任务调度
版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。