ThreadLocal,即线程变量,其是为了解决多线程并发访问的问题,提供了一个线程局部变量,让访问某个变量的线程都拥有自己的线程局部变量值,这样线程对变量的访问就不存在竞争问题,也不需要同步。与对共享变量加锁,使得线程对共享变量进行串行访问不同,ThreadLocal相当于让每个线程拥有自己的变量副本,用空间换取时间。
每个线程都有一个ThreadLocal.ThreadLocalMap对象(属性名为:threadLocals)。在ThreadLocalMap中,Entry<key, value="">节点的key为:ThreadLocal t = new ThreadLocal(),value为:该线程的存放的变量值。其中,ThreadLocalMap的Entry节点的key指向ThreadLocal是弱引用,虚拟机只要发现就可以垃圾回收。
Threadlocal能够解决多线程并发访问问题的根本原因是:threadlocal是Thread的局部变量,别的线程无法进行访问,因此可以实现多线程并发中的数据隔离。
注意区分 thredLocals、ThreadLocal、ThreadLocalMap 在源码中,threadLocals是Thread的属性,threadLocals对应的数据类型是ThreadLocalMap,ThreadLocalMap中key的类型为ThreadLocal
总而言之,ThreadLocal是线程Thread中属性threadLocals的管理者。也就是说我们对于ThreadLocal的get、set、remove的操作结果都是针对当前线程Thread实例的threadLocals存、取、删除操作。
注意四种引用关系: 强引用:指new出来的对象,一般没有特别申明的对象都是强引用。这种对象只有在GCroots找不到它的时候才会被回收。 软引用(SoftReference的子类):GC后内存不足的情况将只有这种引用的对象回收。 弱引用(WeakReference的子类):GC时回收只有此引用的对象(无论内存是否不足)。 虚引用(PhantomReference子类):没有特别的功能,类似一个追踪符,配合引用队列来记录对象何时被回收。(实际上这四种引用都可以配合引用队列使用,只要在构造方法中传入需要关联的引用队列就行,在对象调用finalize方法的时候会被写入到队列当中)
在Thread类中,有两个属性:threadLocals、inheritableThreadLocals
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
/*
* InheritableThreadLocal values pertaining to this thread. This map is
* maintained by the InheritableThreadLocal class.
*/
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
而且,通过查找Thread类的属性,发现Thread并没有提供成员变量threadLocals的设置与访问的方法,那么每个线程的实例对threadLocals参数该如何操作呢?
这时便需要ThreadLocal了,ThreadLocal提供了对线程Thread中属性threadLocals的get、set、remove操作。
做个类比:
Thread:产品经理;threadLocals:普通程序员;ThreadLocal:技术leader
产品经理(Thread)不能直接左右普通程序员(threadLocals)的开发任务,只有通过技术leader(ThreadLocal)才能给程序员(threadLocals)分配任务。
// 获取下一个ThreadLocal实例的哈希魔数
private final int threadLocalHashCode = nextHashCode();
// 作为多个线程同时使用的原子计数器,AtomicInteger类提供了一些方法来原子地执行加减运算
private static AtomicInteger nextHashCode = new AtomicInteger();
// 哈希魔数(增长数),也是带符号的32位整型值黄金分割值的取正
private static final int HASH_INCREMENT = 0x61c88647;
// 生成下一个哈希魔数
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
这里需要注意一点,threadLocalHashCode是一个final的属性,而原子计数器变量nextHashCode和生成下一个哈希魔数的方法nextHashCode()是静态变量和静态方法,静态变量只会初始化一次。
每新建一个ThreadLocal实例,它内部的threadLocalHashCode就会增加0x61c88647。举个例子:
//t1中的threadLocalHashCode变量为0x61c88647
ThreadLocal t1 = new ThreadLocal();
//t2中的threadLocalHashCode变量为0x61c88647 + 0x61c88647
ThreadLocal t2 = new ThreadLocal();
//t3中的threadLocalHashCode变量为0x61c88647 + 0x61c88647 + 0x61c88647
ThreadLocal t3 = new ThreadLocal();
归纳公式为:
hashCode(i) = (i-1)*HASH_INCREMENT + HASH_INCREMENT (i>0),即每次新增一个元素(threadLocal)进Entry[],自增0x61c88647
则address(i)= hashCode & (length-1),即i位置元素的存储位置
threadLocalHashCode是下面的ThreadLocalMap结构中使用的哈希算法的核心变量,对于每个ThreadLocal实例,它的threadLocalHashCode是唯一的。
threadLocalHashCode利用黄金分割数实现了元素的完美散列。
在ThreadLocal类中,主要包含initialValue()初始化方方法及getMap()、set()、get()、remove()4个核心操作方法。
(1)initialValue():初始化方法
protected T initialValue() {
return null;
}
该方法将在线程第一次使用该 get() 方法访问变量时被调用。但如果线程先前调用了该 set(T) 方法,则不会为该线程调用 initialValue() 方法。
此实现仅返回null;如果程序员希望线程局部变量具有除null以外的初始值,则必须对ThreadLocal进行子类化,并覆盖此方法。通常,将使用匿名内部类。
(2)getMap(Thread t):获取ThreadLocalMap实例
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
通过getMap()可以获取每个子线程Thread持有的ThreadLocalMap实例,因此它们是不存在并发竞争的。
(3)get():获取存储的本地变量
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
@SuppressWarnings("unchecked"):涉及泛型转换,该注用来抑制由于未经检查的类型转换而生成的编译器警告。
返回此线程局部变量的当前线程副本中的值。如果该变量对于当前线程没有值,则首先将其初始化为调用该initialValue()方法返回的值。
(4)set(T value):设置本地变量
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
将此线程局部变量的当前线程副本设置为指定值。大多数子类将不需要覆盖此方法,仅依靠该initialValue() 方法来设置线程局部变量的值。
(5)remove():删除本地变量
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
删除此线程局部变量的当前线程值。
从这几个方法中可以看出,set、get、remove的操作对象都是ThreadLocalMap,其中,key=当前线程,value=当前线程局部变量缓存值。
ThreadLocalMap本质是一个HashMap,用于存储线程本地值。ThreadLocalMap类是包私有的,允许在Thread类中声明字段。
ThreadLocalMap的entry使用了弱引用,有助于GC回收。键值Key为ThreadLocal实例本身,这里使用了无限定的泛型通配符。
为什么要用 弱引用 呢?
因为当线程执行结束以后,我们希望能回收这部分产生的资源,包括所以就用了 弱引用,通过GC去回收资源。
注意,当 entry.get() == null 时,意味着不再引用该key,因此可以从表中删除该entry。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 初初始化容量大小,2^4=16
private static final int INITIAL_CAPACITY = 16;
// 根据需要调整大小,table.length 必须始终是 2 的幂
private Entry[] table;
// 初始化table大小为0
private int size = 0;
// 阈值,达到阈值进行扩容
private int threshold; // Default to 0
// 设置阈值,阈值=容量*装载因子(装载因子为2/3,统计下的最优装载因子大小)
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
// 通过i计算下一个索引位置,(i+1)mod len
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
// 通过i计算前一个索引位置,(i-1)mod len
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
(1)构造方法
初始化一个 (firstKey, firstValue) 的新映射。 ThreadLocalMaps 是惰性构造的,只有在至少有一个映射可以放入时才真正创建。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
// 计算映射位置,threadLocalHashCode与容量大小做位运算
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
从给定的父映射构造一个包含所有可继承ThreadLocals的新映射。 该方法仅由 createInheritedMap 调用。
private ThreadLocalMap(ThreadLocalMap parentMap) {
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
table = new Entry[len];
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) {
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++;
}
}
}
}
(2)getEntry()
获取与key对应的Entry。 此方法本身仅处理直接hash,即直接命中现有key的情况;如果不hit,则会通过getEntryAfterMiss()方法查找
这旨在最大限度地提高直接命中的性能,部分原因是使该方法易于内联。
参数:key – 线程本地对象
返回:与 key 关联的entry,如果没有,则为 null
private Entry getEntry(ThreadLocal<?> key) {
// 计算索引位置
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 找到,直接返回
if (e != null && e.get() == key)
return e;
else
// 找不到,基于线性探测法,从i位置开始向后遍历
return getEntryAfterMiss(key, i, e);
}
(3)getEntryAfterMiss()
当在其直接hash槽中找不到key时使用的查找Entry的方法
参数:key – 线程本地对象
i – key的hashcode的表索引
e – table[i] 中的entry
返回:与 key 关联的entry,如果没有,则为 null
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 向后遍历
while (e != null) {
// 获取该entry的key
ThreadLocal<?> k = e.get();
// 找到,返回
if (k == key)
return e;
// 该entry上的key为null,触发一次连续段清理
if (k == null)
expungeStaleEntry(i);
// 不为null,不相等,计算下一个索引,接着进行判断
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
(4)set(ThreadLocal key, Object value)
将变量保存到 ThreadLocalMap 中。
private void set(ThreadLocal<?> key, Object value) {
// 备份,backup
Entry[] tab = table;
// 获取table的长度
int len = tab.length;
// 获取对应ThreadLocal在table当中的下标
int i = key.threadLocalHashCode & (len-1);
/**
* 从该下标开始循环遍历
* 1、如遇相同key,则直接替换value
* 2、如果该key已经被回收失效,则替换该失效的key
*/
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 如遇相同key,则直接替换value
if (k == key) {
e.value = value;
return;
}
// 如果 k 为null,则替换当前失效的k所在Entry节点
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 找到空的位置,创建Entry对象并插入
tab[i] = new Entry(key, value);
// table内元素size自增
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
(5)remove(ThreadLocal key)
rivate 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) {
// 将引用设置null,方便GC
e.clear();
// 从该位置开始进行一次连续段清理
expungeStaleEntry(i);
return;
}
}
}
(6)replaceStableEntry(ThreadLocal key, Object value, int staleSlo)
将设置操作期间遇到的失效entry替换为指定key的entry。 在 value 参数中传递的值存储在entry中,无论指定key的entry是否已经存在。
此方法会清除包含失效entry的两个null slot之间中的所有失效entry
参数:key,value
staleSlot – 搜索key时遇到的第一个失效entry的索引
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
// 备份
Entry[] tab = table;
// 获取table的长度
int len = tab.length;
Entry e;
// 记录当前失效的节点下标
int slotToExpunge = staleSlot;
// 由staleSlot下标开始向前扫描,查找并记录最前位置value为null的下标
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
// 由staleSlot下标开始向后扫描,查找并记录最后位置value为null的下标
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
// 获取Entry节点对应的ThreadLocal对象
ThreadLocal<?> k = e.get();
// 如果与新的key对应,直接赋值value,并直接交换i与staleSlot上的值
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 重新记录失效的节点下标
if (slotToExpunge == staleSlot)
slotToExpunge = i;
/**
* 在调用cleanSomeSlots进行启发式清理之前
* 会先调用expungeStaleEntry方法从slotToExpunge到table下标所在为null的连续段进行一次清理
* 返回值便是table[]为null的下标
* 然后,从该下标到len之间 进行一次启发式清理
* 最终里面的方法实际上还是调用了expungeStaleEntry
* 可以看出expungeStaleEntry方法是ThreadLocal核心的清理函数
*/
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
/**
* 如果当前下标所在已经失效,并且向后扫描过程当中没有找到失效的Entry节点
* 则slotToExpunge赋值为当前位置
*/
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// 如果并没有在table当中找到该key,则直接在当前位置new一个Entry
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
/**
* 在上面的for循环探测过程当中
* 如果发现任何无效的Entry节点,则slotToExpunge会被重新赋值
* 就会触发连续段清理和启发式清理
*/
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
(7)expungeStableEntry(int staleSlot)
对于线程来说,隔离的 本地变量,并且使用的是 弱引用,有可能在 GC 的时候就被回收了。
参数:staleSlot – 已知具有空键的槽的索引
返回:staleSlot 之后的下一个空槽的索引(所有在 staleSlot 和这个槽之间的都将被检查以进行清除)。
private int expungeStaleEntry(int staleSlot) {
// 备份
Entry[] tab = table;
// 获取长度
int len = tab.length;
// 将指定位置的value以及数组下标所在位置设置null
tab[staleSlot].value = null;
tab[staleSlot] = null;
// table的大小-1
size--;
Entry e;
int i;
// 遍历指定节点所有后续节点,将被回收的ThreadLocal节点依次删除
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 如果该ThreadLocal节点为null,则将value以及数组下标所在位置设置null,方便GC,并把size-1
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
// 不为null,重新计算该节点的下标位置
int h = k.threadLocalHashCode & (len - 1);
// 如果新的下标位置不是当前位置
if (h != i) {
tab[i] = null;
// 重新从i开始找到下一个为null的坐标进行赋值
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
(8)cleanSomeSlots(int i, int n)
启发式地清理被回收的Entry,i对应的Entry是非无效的,有可能是失效被回收了,也有可能是null
会有两个地方调用到这个方法
1、set方法,在判断是否需要resize之前,会清理并rehash一遍
2、替换失效的节点时候,也会进行一次清理
参数:
i – 已知不会持有失效entry的位置, 扫描从 i 之后的元素
n – 扫描控制:扫描log2(n)位置,直到找到失效的entry
返回:如果已删除任何失效entry,则为 true。
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];
// Entry对象不为空,但是ThreadLocal这个key已经为null
if (e != null && e.get() == null) {
n = len;
removed = true;
/**
* 调用该方法进行回收
* 实际上不是只回收 i 这一个节点而已
* 而是对 i 开始到table所在下标为null的范围内,对那些节点都进行一次清理和rehash
*/
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
(9)rehash()
重新包装/重新调整table的大小。 首先扫描整个表,删除失效的Entry。 如果这不能充分缩小table的大小,则需要对table进行扩容。
private void rehash() {
expungeStaleEntries();
// 使用阈值3/4为界限,当清理完table后,剩余容量仍小于该界限则进行扩容
if (size >= threshold - threshold / 4)
resize();
}
(10)resize()
对table进行扩容,扩容1倍,容量为原来的两倍,因为要保证table的长度是2的
private void resize() {
// 获取旧table的长度,并且创建一个长度为旧长度2倍的Entry数组
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
// 记录插入的有效Entry节点数
int count = 0;
/**
* 从下标0开始,逐个向后遍历插入到新的table当中
* 1、如遇到key已经为null,则value设置null,方便GC回收
* 2、通过hashcode & len - 1计算下标,如果该位置已经有Entry数组,则通过线性探测向后探测插入
*/
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
size = count;
// 指向新的Entry数组
table = newTab;
}
(11)expungeStableEntries()
清除表中的所有失效的Entry(引用对象已被程序或垃圾收集器清除的Entry)
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
// e.get()返回此引用对象的引用对象。 如果此引用对象已被程序或垃圾收集器清除,则此方法返回null 。
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}
(1)踩坑:引起内存泄漏
注意区分内存溢出与内存泄漏:
(1) 内存溢出:Memory overflow,没有足够的内存提供申请者使用。
(2) 内存泄漏:Memory leak,程序申请内存后,无法释放已申请的内存空间,内存泄漏的堆积终将导致内存溢出。
如开篇的那张图所示,在作为key的ThreadLocal对象没有外部强引用,下一次GC必将产生key值为null的数据,若线程没有及时结束,则会出现一条强引用链:Thread Ref–>Thread–>ThreadLocalMap–>Entry,而这将导致内存泄漏
使用ThreadLocal时会发生内存泄漏的前提条件:
我们看到ThreadLocal出现内存泄漏条件还是很苛刻的,所以我们只要破坏其中一个条件就可以避免内存泄漏,单但为了更好的避免这种情况的发生我们使用ThreadLocal时遵守以下两个小原则:
① ThreadLocal申明为private static final。
② ThreadLocal使用后务必调用remove方法。
(2)踩坑:线程重用导致信息混乱
ThreadLocal线程独占方式解决线程安全ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景。
程序运行在 Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于线程池的。也就是说,线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。这时,ThreadLocal 中的用户信息就是其他用户的信息。
在代码的 finally 代码块中,显式清除 ThreadLocal 中的数据。这样一来,新的请求过来即使使用了之前的线程也不会获取到错误的用户信息了。
因此,在使用类似 ThreadLocal 工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据。
(1)事务操作
在事务管理中,在service类中的涉及到事务的方法,每个事务的上下文都应该是独立拥有数据库的connection连接的,否则在数据提交回滚过程中就会产生冲突(以保证每次拿连接的时候都拿到的是当前线程的同一个connection)。
Spring中使用ThreadLocal来设计TransactionSynchronizationManager类,实现了事务管理与数据访问服务的解耦,同时也保证了多线程环境下connection的线程安全问题。
(2)pipeline操作
使用Threadlocal可以保证在pipeline操作时,使用同一个连接批量传输数据,然后等待服务端的返回结果
(3)多数据源切换
(4)存储Redis配置信息
redis本身虽然是单线程,但业务如果是多线程操作redis的话,就可能造成线程不安全。
使用到ThreadLocal可以用于jedis的存储,以此实现单线程操作redis,用于线程间的数据隔离。
// ThreadLocal存储redis客户端对象
private static ThreadLocal<Jedis> LOCAL_JEDIS = new ThreadLocal<>();
public static redis.clients.jedis.Jedis getJedisClient() {
Jedis jedis = LOCAL_JEDIS.get();
if (Objects.isNull(jedis)) {
jedis = jedisPool.getResource();
LOCAL_JEDIS.set(jedis);
}
return jedis;
}
public static void releaseJedisClient() {
// get 获取redis客户端
Jedis jedis = LOCAL_JEDIS.get();
if (Objects.nonNull(jedis)) {
jedis.close();
}
LOCAL_JEDIS.remove();
}
(5)创建SimpleDataFormat对象
calender 是 SimpleDateFormat 的成员变量,SimpleDataFormat在 format() 方法中先将日期存放到该 calendar 中,但在随后调用 subFormat() 时会再次用到成员变量 calendar。因此,在多线程环境下,如果两个线程都使用同一个 SimpleDateFormat 实例,那么就有可能存在其中一个线程修改了 calendar 后,另一个线程也修改了 calendar,由此造成线程不安全。在 parse() 方法中也会存在相应的问题。
使用ThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象,那么自然也就不存在竞争问题了。
public class DateUtil {
private static ThreadLocal<SimpleDateFormat> local = new ThreadLocal<SimpleDateFormat>();
private static SimpleDateFormat getDateFormat() {
SimpleDateFormat dateFormat = local.get();
// 通过懒加载的方式创建SimpleDateFormat对象
if (dateFormat == null) {
dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
local.set(dateFormat);
}
return dateFormat;
}
public static String format(Date date) {
return getDateFormat().format(date);
}
public static Date parse(String dateStr) throws ParseException {
return getDateFormat().parse(dateStr);
}
}
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。