目录结构java
一、应用场景及做用 二、结构关系 2.一、三者关系类图 2.二、ThreadLocalMap结构图 2.三、 内存引用关系 2.四、存在内存泄漏缘由 三、源码分析 3.一、重要代码片断 3.二、重要方法分析 3.三、set(T): void 3.四、get():T 3.五、remove():void 3.六、总结
-1做用、ThreadLocal 为了实现线程之间数据隔离,每一个线程中有独立的变量副本,操做互不干扰。区别于线程同步中,同步在为了保证正确使用同一个共享变量,须要加锁。 -2应用场景: 1)能够对一次请求过程,作一个过程日志追踪。如slf4j的MDC组件的使用,能够在日志中每次请求过程加key,方便定位一次请求流程问题。 2)解决线程中全局数据传值问题。
要理清ThreadLocal的原理做用,能够先了解Thread, ThreadLocal, ThreadLocalMap三者之间的关系。简单类图关系以下算法
一、Thread 类中有ThreadLocalMap类型的成员变量 threadLocals 二、ThreadLocalMap是ThreadLocal的静态内部类 三、Thread 与 ThreadLocal怎么关联? 线程对象中threadLocals中存储的键值对 key--> ThreadLocal对象,value --> 线程须要保存的变量值
ThreadLocalMap 底层实现实质是一个Entry对象数组, 默认容量是16,在存储元素到数组中,本身实现了一个算法来寻址(计算数组下标), 与Map集合中的HashMap有所不一样。 Entry对象中 key是ThreadLocal对象。 误区:在不了解原理前,会想线程之间要实现数据隔离,那这个集合中key应该是Thread对象,这样在存的时候,以当前线程对象为key,value为要保存的值,这样在获取的时候,经过线程对象去get获取相应的值。
-1,同一个ThreadLocal对象可被多个线程引用,每一个线程之间本地变量副本存储,实现数据独立性,可见每一个线程内部都有单独的map集合,即便引用的ThreadLocal同一个,value能够不一样,如图中ThreadLocal1对象,同时被线程A,B引用做为key -2,一个线程能够存储多个ThreadLocal,因线程中存储的只能存储同一个ThreadLocal对象一次,再次存储相同的Threadlocal对象,由于key相同,会覆盖原来的value,value能够是基本数据类型的值,也能够是引用数据类型(如封装的对象)
ThreadLocal对象没有外部强引用后,只存在弱引用,下一次GC会被回收。以下:数组
-1,上图实线箭头表明强引用,虚线表明弱引用; JVM存在四种引用:强引用,软引用,弱引用,虚引用,弱引用对象,会在下一次GC(垃圾回收)被回收。 -2,上图可见Entry的 Key指向的ThreadLocal对象的引用是弱引用,一旦tl的强引用断开,没有外部的强引用后,在下一次JVM垃圾回收时,ThreadLocal对象被回收了,此时 key--> null,而此时 Entry对象,是有一条强引用链的,th--> Thread对象-->ThreadLocalMap--> Entry,可达性性分析是可达的,这时ThreadLocalMap集合,即在数组的某一个索引是有Entry引用的,可是该Entry的key为null,value依然有值,但再也用不了了,这时的Entry称为staleEntry(我理解为失效的Entry),形成内存泄漏。 -3,内存泄漏是指分配的内存,gc回收不了,本身也用不了; 内存溢出,是指内存不够,若有剩余2M内存,这时有一个对象建立须要3M,内存不够,致使溢出。内存泄漏可能会致使内存溢出,由于内存泄漏就会有人占着茅坑不拉屎,可用空间愈来愈少,gc也回收不了,最终致使内存溢出。 -4,那线程对象被回收了,这条引用链断了就没事了,下次Gc就会把ThreadLocalMap集合中对象所有回收了,就不存在内存泄漏问题了;但开发环境,线程通常会在线程池建立来节约资源,每一个线程是被重复使用的,生命周期很长,线程对象长时间是存在内存中的,而ThreadLocalMap和Thread生命周期相同,只有线程结束,它内部持有的ThreadLocalMap对象才会销毁,以下Thread#exit: private void exit() { if (group != null) { group.threadTerminated(this); group = null; } /* Aggressively null out all reference fields: see bug 4006245 */ target = null; //线程退出时,才断开ThreadLocalMap引用 threadLocals = null; inheritableThreadLocals = null; inheritedAccessControlContext = null; blocker = null; uncaughtExceptionHandler = null; }
本次源码分析,主要分析ThreadLocal的set,get,remove三个方法,分别以此为入口,一步步深刻每一个代码方法查看其实现原理,在这分析以前,先捡几个我理解比较重要的方法或者代码片断先解释一下,有一个初步的理解,后面会更顺畅。源码分析
//1、ThreadLocalMap的寻址,因其底层是数组,在存放元素如何定位索引i存储? //两个要求:1)求的索引位置必定要在数组大小内 // 2)索引足够均分分散,要求hashcode足够散列,目的减小hash冲突。 //firstKey.threadLocalHashCode,就是为了达到要求2,均分分散 // &(INITIAL_CAPACITY - 1) 为了落在数组范围内,经常使用进行模运算,这里是巧妙运用位运算,效率更高, %2^n与 &(2^n-1)等价,因此要求数组的容量要为2的幂; int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); // -------------------> firstKey.threadLocalHashCode, //传入的ThreadLocal对象,作了 0x61c88647的增量后求得hash值,为何要加0x61c88647呢,与斐波那契数列有关,反正是一个神奇的魔法值,目的就是使的hash值更分散,减小hash冲突。 private final int threadLocalHashCode = nextHashCode(); private static final int HASH_INCREMENT = 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } //2、如何ThreadLocalMap中,出现hash冲突了,即2个ThreadLocal对象的hash计算出来是相同的下标,这里解决hash冲突使用线性探测法,即这个位置冲突,就寻找下一个位置,若是到数组终点了呢,从0再开始,因此这里数组逻辑上是一个首尾相接的环形数组。 //1,向后遍历,获取索引位置 private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); } //2,向前遍历 private static int prevIndex(int i, int len) { return ((i - 1 >= 0) ? i - 1 : len - 1); } 以下图:
cleanSomeSlots 原理图1以下:this
//一,分析清理失效Entry方法,清理起始位置是staleSlot //已经知道某个Entry的key==null了,那么数组该位置的引用应该被清除 private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; //Entry的value引用也清除,方便gc回收 tab[staleSlot].value = null; // 清理数组当前位置的Entry tab[staleSlot] = null; size--; // Rehash until we encounter null Entry e; int i; //向后循环遍历,直到遇到 null //作2件事: //1)遇到其余失效Entry,顺手清除 //2)没有失效的Entry,从新hash一下,安排新位置;由于可能以前某些位置有hash冲突,致使根据key生成hash的值与当前的位置i不一致(冲突,会日后顺延,这里是逻辑上日后,达到数组长度,从0开始),而这时又清理了很多失效的Entry,可能会有空位了,因此从新hash调一下顺序,提升效率。 for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == null) { //1,失效Entry,清除 e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode & (len - 1); //2,hash值与当前数组索引位置不一样 if (h != i) { tab[i] = null; //3,向后遍历,找合适空位置插入 while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } //返回i位置, Entry ==null return i; } //2、可伸缩性遍历某段范围失效的Entry cleanSomeSlots(int i, int n),原理如上图1 //为何要有伸缩性,我理解仍是为了效率,若是发现这范围内有须要清理的失效Entry,才把范围放大一些查找清除,源代码以下: 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,当n传入的是size,即数组实际容纳的数量,n扩大到数组长度了, //影响在于,原来清理遍历的只是数组的一个小范围,一会儿扩大到了整个数组。我理解这样作为了提升执行效率,没有检测到失效entry就小范围清理一下,检测到就大范围清理。 if (e != null && e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } // n >>>= 1,即 n向右位移1位,即 n/2, 可循环次数log2n次 } while ( (n >>>= 1) != 0); return removed; } //3、当在set时,发现当前生成的数组位置已经被其余Entry占了,可是它失效了,key==null,这时须要把它给替换了吧,replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot),较难理解,多看几遍哈,原理图看下面,分析了2种状况,还有先后遍历都发现有失效的Entry状况,请自行脑补了哈。 //源代码以下: private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // 1,要清除的位置 int slotToExpunge = staleSlot; //2,从i 向前遍历,找到左边第一个失效的位置(指的是Entry !=null,key==null) for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; //3,从i向后遍历 for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); //4,传入 staleSlot的 key==null,是一个失效的Entry, 从staleSlot+1个向后遍历,若是 //碰见 k==key,将staleSlot索引位置与此处i替换位置,即将失效的Entry日后面放 if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; // Start expunge at preceding stale entry if it exists //若是相等,staleSlot左边没有失效的entry,赋值为此处i,此处已经替换为失效Entry了,若是不相等,那么就清除失效Entry,以staleSlot最左边那一个失效entry开始清除 if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); //cleanSomeSlots的目的:expungeStaleEntry返回的是entry ==null的索引i, //清理i到len这一段的失效entry,中间会有null的状况吗? return; } //5,slotToExpunge == staleSlot 表示 左边没有失效entry, 右边碰见第一失效entry,标记此处索引,以便后文肯定从哪里开始清除无效entry if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; } //解除value的引用,gc会回收 tab[staleSlot].value = null; //数组失效Entry位置,赋值新的Entry tab[staleSlot] = new Entry(key, value); //6,不相等,确定有失效索引须要清理,执行清除 if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); }
replaceStaleEntry 情景图1以下:线程
replaceStaleEntry 情景图2以下:debug
set 方法,是代码最多的,也是最重要的。其中expungeStaleEntry, replaceStaleEntry,cleanSomeSlots 三个方法较为主要,目的是找出、清理失效的Entry的过程,其中replaceStaleEntry 较难理解。设计
//1、从 set() 着手,入口 public void set(T value) { //1,获取当前线程对象 Thread t = Thread.currentThread(); //2,获取当前线程的map, 每一个线程持有一个threadLocals对象,经过该map来实现线程之间数据的隔离,达到每一个线程拥有本身独立的局部变量。 见代码分析二 ThreadLocalMap map = getMap(t); if (map != null) // 见代码分析四 map.set(this, value); else //3,若是当前线程持有的map为空,建立map,见代码分析三 createMap(t, value); } //2、 获取ThreadLocalMap 方法,获取当前线程持有的map对象 ThreadLocalMap getMap(Thread t) { return t.threadLocals; } // Thread类中持有hreadLocalMap类型的对象,该map是ThreadLocal的静态内部类 ThreadLocal.ThreadLocalMap threadLocals = null; //3、代码分析 void createMap(Thread t, T firstValue) { // 建立 map对象,下见ThreadLocalMap的构造方法,很关键,该map与经常使用的HashMap等不一样 t.threadLocals = new ThreadLocalMap(this, firstValue); } //构造方法,ThreadLocalMap 可以实现key-value的map集合结构,底层实际是一个数组,Entry为其每一个节点对象,Entry 包含key和value ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { //1,初始化容量为16的Entry[]数组 table = new Entry[INITIAL_CAPACITY]; //2,这一步目的就是根据传入的ThreadLocal对象做为key,为了求放在数组下的索引位置,肯定放在哪 //两个要求:1)求的索引位置必定要在数组大小内(这里即0-15范围) // 2)索引足够均分分散,要求hashcode足够散列,目的减小hash冲突。 //firstKey.threadLocalHashCode,就是为了达到要求2 // &(INITIAL_CAPACITY - 1) 为了落在数组范围内,经常使用进行模运算,这里是巧妙运用位运算,效率更高, %2^n与 &(2^n-1)等价 int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); //求得索引位置,放入数组中 table[i] = new Entry(firstKey, firstValue); size = 1; //设置数组容量阈值,即填充因子,用于后续判断是否须要扩容 setThreshold(INITIAL_CAPACITY); } //为了使传入的ThreadLocal对象求在数组索引位置,求的其hashcode,加上了0x61c88647增量,目的是为了足够分散 private static final int HASH_INCREMENT = 0x61c88647; /** * Returns the next hash code. */ private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } //设置数组扩容阈值 private void setThreshold(int len) { //初始填充因子为2/3,数组容量的2/3,即 16*2/3=10 threshold = len * 2 / 3; } //4、代码分析 set(key,value) private void set(ThreadLocal<?> key, Object value) { Entry[] tab = table; int len = tab.length; //求数组索引位置 int i = key.threadLocalHashCode & (len-1); //这里用for循环,是为了解决hash冲突时,查找下一个可用 slot(卡槽,位置; 即生成的索引i,发现已有Entry占用了,找下一个位置插入,这里解决hash冲突方式不一样于hashmap的拉链法(在冲突位置,以链表形式串接),这里采用的是线性寻址法,即数组当前i位置被占用了,看第i+1个位置,若是i+1已经大于等于数组length,再从数组下标0 从头开始,从该i = nextIndex(i, len)可知道是逻辑上这是一个首尾循环式数组) for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { //1,从i向后遍历,若Entry为null,跳出循环; 不为null,获取Entry的key,线程初始化 threadLocalMap集合就有3个 Entry(此处不解?debug看了) ThreadLocal<?> k = e.get(); //2,若是数组当前位置key与将要设值的 threadlocal对象相等,覆盖原value,返回 if (k == key) { e.value = value; return; } if (k == null) { //3,若是数组当前位置key为空,须要替换失效的Entry(stale:不新鲜的,Entry的key ==null) //见代码分析五 replaceStaleEntry(key, value, i); return; } } //4,若是上面for循环,出现hash冲突了,跳出循环,此时索引i位置 Entry==null,在此插入新Entry tab[i] = new Entry(key, value); int sz = ++size; //5,cleanSomeSlots,顺便清理一下失效的Entry,避免内存泄漏,见代码分析六 if (!cleanSomeSlots(i, sz) && sz >= threshold) // 6,清理失败且当前数组的Entry数量达到设定阈值了,执行 rehash,见代码分析八 rehash(); } //5、 分析 replaceStaleEntry(key, value, i) 替换失效的Entry private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // 1,要清除的位置 int slotToExpunge = staleSlot; //2,从i 向前遍历,找到左边第一个失效的位置(指的是Entry !=null,key==null) for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; //3,从i向后遍历 for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); //4,传入 staleSlot的 key==null,是一个失效的Entry, 从staleSlot+1个向后遍历,若是 //碰见 k==key,将staleSlot索引位置与此处i替换位置,即将失效的Entry日后面放, if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; // Start expunge at preceding stale entry if it exists //若是相等,staleSlot左边没有失效的entry,赋值为此处i,此处已经替换为失效Entry了,若是不相等,那么就清除失效Entry,以staleSlot最左边那一个失效entry开始清除 if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); //cleanSomeSlots的目的:expungeStaleEntry返回的是entry ==null的索引i, //清理i到len这一段的失效entry,中间会有null的状况吗? return; } //5,slotToExpunge == staleSlot 表示 左边没有失效entry, 右边碰见第一失效entry,标记此处索引,以便后文肯定从哪里开始清除无效entry if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; } //解除value的引用,gc会回收 tab[staleSlot].value = null; //数组失效Entry位置,赋值新的Entry tab[staleSlot] = new Entry(key, value); //6,不相等,确定有失效索引须要清理,执行清除 if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); } //6、分析 cleanSomeSlots(int i, int n),清理某些失效的Entry方法 //i为 失效位置, n分2种传入场景 // 1)数组的实际Entry数量 size // 2) 数组的容量 length 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,当n传入的是size,即数组实际容纳的数量,n扩大到数组长度了, //影响在于,原来清理遍历的只是数组的一个小范围,一会儿扩大到了整个数组。我理解这样作为了提升执行效率,没有检测到失效entry就小范围清理一下,检测到就大范围清理。 if (e != null && e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } // n >>>= 1,即 n向右位移1位,即 n/2, 可循环次数log2n次 } while ( (n >>>= 1) != 0); return removed; } //7、分析清理失效Entry方法,清理起始位置是staleSlot private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // 清理当前位置的Entry tab[staleSlot].value = null; tab[staleSlot] = null; size--; // Rehash until we encounter null Entry e; int i; //向后循环遍历,直到遇到 null //作2件事: //1)遇到其余失效Entry,顺手清除 //2)没有失效的Entry,从新hash一下,安排新位置;由于可能以前某些位置有hash冲突,致使根据key生成hash的值与当前的位置i不一致(冲突,会日后顺延,这里是逻辑上日后,达到数组长度,从0开始),而这时又清理了很多失效的Entry,可能会有空位了,因此从新hash调一下顺序,提升效率。 for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == null) { //1,失效Entry,清除 e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode & (len - 1); //2,hash值与当前数组索引位置不一样 if (h != i) { tab[i] = null; //3,向后遍历,找合适空位置插入 while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } //返回i位置, Entry ==null return i; } //8、rehash //首先扫描全表,清除全部失效的Entry, 若是这还不能充分地缩小数组的大小,扩容为当前的2倍 private void rehash() { //1,清除全部失效的entry,见代码分析九 expungeStaleEntries(); //2,threshold = length * 2/ 3 //size >= threshold - threshold / 4 = threshold*3/4 , //即size >= length *2/3 *3/4= length* 1/2, 只要数组的大小>=于数组容量的一半,就扩容。 if (size >= threshold - threshold / 4) //见代码分析十 resize(); } //9、遍历数组所有节点,清除失效的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); } } //10、扩容为原来的2倍 private void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; // 旧数组数据向新数组迁移,顺便清除失效的entry的value,帮助Gc容易发现它,直接回收 //Entry不清除了吗?这里旧数组以后就没有被人引用了,下次Gc会直接回收 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; }
//1、从get() 着手 public T get() { //1,获取当前线程对象 Thread t = Thread.currentThread(); //2,获取该线程的 map集合,每一个线程都有单独的map ThreadLocalMap map = getMap(t); if (map != null) { //3,this指的是 ThreadLocal对象,以它为key,去map中获取相应的Entry, //易混淆:ThreadLocalMap 中存储的Entry键值对,key是ThreadLocal对象,而不是线程对象。 //此处 map.getEntry(this) 下面代码二 分析 ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { //4,返回value @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } //该线程若没有map对象, 返回初始默认值,详见代码分析四 return setInitialValue(); } // 2、分析 map.getEntry(this) private Entry getEntry(ThreadLocal<?> key) { //1,获取数组索引位置 int i = key.threadLocalHashCode & (table.length - 1); Entry e = table[i]; if (e != null && e.get() == key) //2,直接就命中,没有hash冲突,返回 return e; else //3,遍历其余Entry,见代码分析三 return getEntryAfterMiss(key, i, e); } //3、根据key获取Entry,没有直接命中,继续遍历查找 private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal<?> k = e.get(); if (k == key) //1,命中返回,为啥重复判断一次? 由于这是在while循环,会日后执行再判断 return e; if (k == null) //2,当前位置Entry失效,清除 expungeStaleEntry(i); else //3,hash冲突,获取下一个索引 i = nextIndex(i, len); e = tab[i]; } //4,数组中没有找到该key return null; } //4、没有map,返回默认值,初始化操做 private T setInitialValue() { //1,调用默认的初始化方法, 以下,通常用来被重写的,给定一个初始值 T value = initialValue(); Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); return value; } protected T initialValue() { return null; }
// 1、入口 public void remove() { //1,获取当前线程的map集合 ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) //2,见代码分析二 m.remove(this); } // 2、 m.remove(this); private void remove(ThreadLocal<?> key) { Entry[] tab = table; int len = tab.length; //一、获取该key在数组中索引位置 int i = key.threadLocalHashCode & (len-1); //2,从i位置向后循环判断,考虑hash冲突 for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { //3,找到该key, if (e.get() == key) { //4,引用置空 e.clear(); // 5,从i开始清除失效的Entry,避免内存泄漏 expungeStaleEntry(i); return; } } } //引用置空 public void clear() { this.referent = null; }
从set,get,remove代码可见,每一个方法都会去清除失效的Entry,说明设计者也考虑到内存泄漏的问题,因此建议在使用完ThreadLocal,及时执行remove方法清除一下,避免潜在的内存泄漏问题。