Java多線程中ThreadLocal解讀
一、ThreadLocal簡(jiǎn)介
1、介紹
多線程訪問同一個(gè)共享變量的時(shí)候容易出現(xiàn)并發(fā)問題,因此為了保證線程安全性,我們都會(huì)采用加鎖的方式。
而ThreadLocal是除加鎖方式之外的另一種保證線程安全性的方法。
ThreadLocal通過每個(gè)線程保存一份資源的副本,且這個(gè)副本只能被當(dāng)前線程訪問。這樣的話每個(gè)線程都擁有一份,那么自然而然也就不需要對(duì)資源競(jìng)爭(zhēng)了。
在JDK8之前,每個(gè)ThreadLocal都創(chuàng)建一個(gè)ThreadLocalMap,用線程作為ThreadLocalMap的key,要存儲(chǔ)的局部變量作為ThreadLocalMap的value,這樣就實(shí)現(xiàn)了各個(gè)線程的局部變量的隔離作用。
而在JDK8之后,每個(gè)線程維護(hù)一個(gè)ThreadLocalMap,這個(gè)ThreadLocalMap的key是ThreadLocal實(shí)例本身,value才是真正要存儲(chǔ)的變量副本。線程內(nèi)部的Map由ThreadLocal維護(hù),由ThreadLocal負(fù)責(zé)向map獲取和設(shè)置線程的變量值。對(duì)于不同的線程,每次獲取副本值時(shí),別的線程并不能獲取到當(dāng)前線程的副本值,形成了副本的隔離,互不干擾。
好處:
- 減少ThreadLocalMap存儲(chǔ)的Entry數(shù)量:因?yàn)橹暗拇鎯?chǔ)數(shù)量由Thread的數(shù)量決定,現(xiàn)在是由ThreadLocal的數(shù)量決定。在實(shí)際運(yùn)用當(dāng)中,往往ThreadLocal的數(shù)量要少于Thread的數(shù)量
- 當(dāng)Thread銷毀之后,對(duì)應(yīng)的ThreadLocalMap也會(huì)隨之銷毀,能減少內(nèi)存的使用(但是不能避免內(nèi)存泄漏問題,解決內(nèi)存泄漏問題應(yīng)該在使用完后及時(shí)調(diào)用remove()對(duì)ThreadMap里的Entry對(duì)象進(jìn)行移除,由于Entry繼承了弱引用類,會(huì)在下次GC時(shí)被JVM回收)
2、常用方法
在ThreadLocal類中,有以下幾個(gè)比較常用的方法:
- get:用于獲取 ThreadLocal 在當(dāng)前線程中保存的變量副本。
- set:用于設(shè)置當(dāng)前線程中變量的副本。
- remove:用于刪除當(dāng)前線程中變量的副本。如果此線程局部變量隨后被當(dāng)前線程讀取,則其值將通過調(diào)用其 initialValue 方法重新初始化,除非其值由中間線程中的當(dāng)前線程設(shè)置。 這可能會(huì)導(dǎo)致當(dāng)前線程中多次調(diào)用 initialValue 方法。
- initialValue:為 ThreadLocal 設(shè)置默認(rèn)的 get 初始值,需要重寫 initialValue 方法 。
3、案例
public class TestThreadLocal { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } public static void main(String[] args) { TestThreadLocal testThreadLocal = new TestThreadLocal(); for (int i = 0; i < 5; i++) { new Thread(new Runnable() { @Override public void run() { testThreadLocal.setName(Thread.currentThread().getName() + "的信息"); System.out.println(Thread.currentThread().getName() + ": " + testThreadLocal.getName()); } }).start(); } } }
執(zhí)行結(jié)果:
從結(jié)果可以看出多個(gè)線程在訪問同一個(gè)變量的時(shí)候出現(xiàn)的異常,線程間的數(shù)據(jù)沒有隔離。下面我們來看下采用 ThreadLocal 的方式來解決這個(gè)問題的例子。
public class TestThreadLocal { private static ThreadLocal<String> t = new ThreadLocal<>(); private String name; public String getName() { return t.get(); } public void setName(String name) { t.set(name); } public static void main(String[] args) { TestThreadLocal testThreadLocal = new TestThreadLocal(); for (int i = 0; i < 5; i++) { new Thread(new Runnable() { @Override public void run() { testThreadLocal.setName(Thread.currentThread().getName() + "的信息"); System.out.println(Thread.currentThread().getName() + ": " + testThreadLocal.getName()); } }).start(); } } }
執(zhí)行結(jié)果:
二、ThreadLocal原理分析
1、ThreadLocal的存儲(chǔ)結(jié)構(gòu)
在Thread類中維護(hù)著ThreadLocal.ThreadLocalMap類型的成員threadLocals,這個(gè)成員用來存儲(chǔ)當(dāng)前線程獨(dú)占的變量副本。ThreadLocalMap是ThreadLocal的靜態(tài)內(nèi)部類,它維護(hù)者一個(gè)Entry數(shù)組,用來存儲(chǔ)鍵值對(duì)。
public class Thread implements Runnable { /* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class. */ ThreadLocal.ThreadLocalMap threadLocals = null; } static class ThreadLocalMap { 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對(duì)象,value則是傳遞進(jìn)行的對(duì)象,即變量副本,且Entry繼承了WeakReference。
2、set(T value)方法源碼
源碼如下:
public void set(T value) { //獲取當(dāng)前線程 Thread t = Thread.currentThread(); //獲取threalLocals ThreadLocalMap map = getMap(t); //將ThreadLocal和值一起存入當(dāng)前線程的ThreadLocalMap中 if (map != null) //如果存在就調(diào)用map.set(),這里的this指的是調(diào)用此方法的ThreadLocal對(duì)象 map.set(this, value); else createMap(t, value); } //獲取當(dāng)前線程維護(hù)的ThreadLocalMap ThreadLocalMap getMap(Thread t) { return t.threadLocals; } //創(chuàng)建當(dāng)前線程 void createMap(Thread t, T firstValue) { //這里的this是調(diào)用此方法的threadLocal t.threadLocals = new ThreadLocalMap(this, firstValue); }
在ThreadLocal的set方法中,首先獲取當(dāng)前線程,然后調(diào)用getMap(Thread t)獲取當(dāng)前線程中ThreaLocalMap類型的threadLocals字段。然后將ThreadLocal一塊放入到當(dāng)前線程的ThreadLocalMap中。如果獲取到的Map不為空,則將參數(shù)設(shè)置到Map中,否則就創(chuàng)建Map,并設(shè)置初值。
3、get()方法
源碼如下:
public T get() { //獲取當(dāng)前線程 Thread t = Thread.currentThread(); //拿到threadLocals字段 ThreadLocalMap map = getMap(t); //如果map存在 if (map != null) { //獲取對(duì)應(yīng)的存儲(chǔ)實(shí)體 ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") //獲取存儲(chǔ)尸體對(duì)應(yīng)的value值 T result = (T)e.value; return result; } } //執(zhí)行當(dāng)前代碼的兩種情況:1、map不存在,說明此線程沒有ThreadLocalMap對(duì)象;2、map存在,但是沒有與當(dāng)前ThreadLocal關(guān)聯(lián)的Entry return setInitialValue(); } private T setInitialValue() { // 調(diào)用initialValue獲取初始化的值 // 此方法可以被子類重寫, 如果不重寫默認(rèn)返回null T value = initialValue(); // 獲取當(dāng)前線程對(duì)象 Thread t = Thread.currentThread(); // 獲取此線程對(duì)象中維護(hù)的ThreadLocalMap對(duì)象 ThreadLocalMap map = getMap(t); // 判斷map是否存在 if (map != null) // 存在則調(diào)用map.set設(shè)置此實(shí)體entry map.set(this, value); else // 1)當(dāng)前線程Thread 不存在ThreadLocalMap對(duì)象 // 2)則調(diào)用createMap進(jìn)行ThreadLocalMap對(duì)象的初始化 // 3)并將 t(當(dāng)前線程)和value(t對(duì)應(yīng)的值)作為第一個(gè)entry存放至ThreadLocalMap中 createMap(t, value); // 返回設(shè)置的值value return value; }
執(zhí)行流程:先獲取到當(dāng)前線程,并根據(jù)當(dāng)前線程獲取一個(gè)Map。如果獲取的map不為空,則在Map中以ThreadLocal的引用作為key在map中獲取對(duì)應(yīng)的Entry。如果entry不為空則返回e.value;如果e為空或者M(jìn)ap為空,則通過initialValue函數(shù)獲取初始值value,然后用ThreadLocal的引用和value作為firstKey和firstValue創(chuàng)建一個(gè)新的Map。
4、remove()方法
源碼如下:
public void remove() { // 獲取當(dāng)前線程對(duì)象中維護(hù)的ThreadLocalMap對(duì)象 ThreadLocalMap m = getMap(Thread.currentThread()); // 如果此map存在 if (m != null) // 存在則調(diào)用map.remove // 以當(dāng)前ThreadLocal為key刪除對(duì)應(yīng)的實(shí)體entry m.remove(this); }
執(zhí)行流程:首先獲取當(dāng)前線程,并根據(jù)當(dāng)前線程獲取一個(gè)Map。如果獲取的map不為空,則移除當(dāng)前ThreadLocal對(duì)象對(duì)應(yīng)的Entry。
5、initialValue()方法
源碼如下:
/** * 返回當(dāng)前線程對(duì)應(yīng)的ThreadLocal的初始值 * 此方法的第一次調(diào)用發(fā)生在,當(dāng)線程通過get方法訪問此線程的ThreadLocal值時(shí) * 除非線程先調(diào)用了set方法,在這種情況下,initialValue 才不會(huì)被這個(gè)線程調(diào)用。 * 通常情況下,每個(gè)線程最多調(diào)用一次這個(gè)方法。 * * <p>這個(gè)方法僅僅簡(jiǎn)單的返回null {@code null}; * 如果想ThreadLocal線程局部變量有一個(gè)除null以外的初始值, * 必須通過子類繼承{@code ThreadLocal} 的方式去重寫此方法 * 通常, 可以通過匿名內(nèi)部類的方式實(shí)現(xiàn) * * @return 當(dāng)前ThreadLocal的初始值 */ protected T initialValue() { return null; }
該方法作用是返回該線程局部變量的初始值。從上面的代碼我們得知,在set方法還未調(diào)用而先調(diào)用了get方法時(shí)才執(zhí)行,并且僅執(zhí)行1次,這個(gè)方法缺省實(shí)現(xiàn)直接返回一個(gè)null。如果想要一個(gè)除null之外的初始值,可以重寫此方法。(備注: 該方法是一個(gè)protected的方法,顯然是為了讓子類覆蓋而設(shè)計(jì)的)
6、如何解決hash沖突問題
從前面可以看到ThreadLocalMap是類似于Map的數(shù)據(jù)結(jié)構(gòu),但是它并沒有實(shí)現(xiàn)Map接口,那么它也就不支持Map接口中的next方法。因此可以推斷出:ThreadLocalMap并不是通過拉鏈法來解決hash沖突問題的。實(shí)際上,ThreadLocalMap是使用線性探測(cè)的方式來解決hash沖突的,即:根據(jù)初始key的hashcode值來確定元素在table數(shù)組中的位置,如果這個(gè)位置已經(jīng)被其他的key占據(jù),那么就通過算法尋找下一個(gè)位置,直到找到能夠存放的位置為止。
在前面查看set()方法源碼的時(shí)候,當(dāng)map為空時(shí)調(diào)用了cerateMap()方法,而該方法中調(diào)用了有參構(gòu)造:ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)
/* * firstKey : 本ThreadLocal實(shí)例(this) * firstValue : 要保存的線程本地變量 */ ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { //初始化table table = new ThreadLocal.ThreadLocalMap.Entry[INITIAL_CAPACITY]; //計(jì)算索引(重點(diǎn)代碼) int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); //設(shè)置值 table[i] = new ThreadLocal.ThreadLocalMap.Entry(firstKey, firstValue); size = 1; //設(shè)置閾值 setThreshold(INITIAL_CAPACITY); }
構(gòu)造函數(shù)首先創(chuàng)建一個(gè)長(zhǎng)度為16的Entry數(shù)組,然后計(jì)算出firstKey對(duì)應(yīng)的索引,然后存儲(chǔ)到table中,并設(shè)置size和threshold。
重點(diǎn)看一下計(jì)算索引這一塊:
private final int threadLocalHashCode = nextHashCode(); private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } //AtomicInteger是一個(gè)提供原子操作的Integer類,通過線程安全的方式操作加減,適合高并發(fā)情況下的使用 private static AtomicInteger nextHashCode = new AtomicInteger(); //特殊的hash值 private static final int HASH_INCREMENT = 0x61c88647;
這里定義了一個(gè)AtomicInteger類型,每次獲取當(dāng)前值并加上HASH_INCREMENT,HASH_INCREMENT = 0x61c88647,這個(gè)值跟斐波那契數(shù)列(黃金分割數(shù))有關(guān),其主要目的就是為了讓哈希碼能均勻的分布在2的n次方的數(shù)組里, 也就是Entry[] table中,這樣做可以盡量避免hash沖突。
關(guān)于& (INITIAL_CAPACITY - 1)
計(jì)算hash的時(shí)候里面采用了hashCode & (size - 1)的算法,這相當(dāng)于取模運(yùn)算hashCode % size的一個(gè)更高效的實(shí)現(xiàn)。正是因?yàn)檫@種算法,我們要求size必須是2的整次冪,這也能保證在索引不越界的前提下,使得hash發(fā)生沖突的次數(shù)減小。
來看下ThreadLocalMap中的set方法:
private void set(ThreadLocal<?> key, Object value) { ThreadLocal.ThreadLocalMap.Entry[] tab = table; int len = tab.length; //計(jì)算索引(重點(diǎn)代碼,剛才分析過了) int i = key.threadLocalHashCode & (len-1); /** * 使用線性探測(cè)法查找元素(重點(diǎn)代碼) */ for (ThreadLocal.ThreadLocalMap.Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); //ThreadLocal 對(duì)應(yīng)的 key 存在,直接覆蓋之前的值 if (k == key) { e.value = value; return; } // key為 null,但是值不為 null,說明之前的 ThreadLocal 對(duì)象已經(jīng)被回收了, // 當(dāng)前數(shù)組中的 Entry 是一個(gè)陳舊(stale)的元素 if (k == null) { //用新元素替換陳舊的元素,這個(gè)方法進(jìn)行了不少的垃圾清理動(dòng)作,防止內(nèi)存泄漏 replaceStaleEntry(key, value, i); return; } } //ThreadLocal對(duì)應(yīng)的key不存在并且沒有找到陳舊的元素,則在空元素的位置創(chuàng)建一個(gè)新的Entry。 tab[i] = new Entry(key, value); int sz = ++size; /** * cleanSomeSlots用于清除那些e.get()==null的元素, * 這種數(shù)據(jù)key關(guān)聯(lián)的對(duì)象已經(jīng)被回收,所以這個(gè)Entry(table[index])可以被置null。 * 如果沒有清除任何entry,并且當(dāng)前使用量達(dá)到了負(fù)載因子所定義(長(zhǎng)度的2/3),那么進(jìn)行 * rehash(執(zhí)行一次全表的掃描清理工作) */ if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); } /** * 獲取環(huán)形數(shù)組的下一個(gè)索引 */ private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); }
綜上所述,代碼執(zhí)行流程如下:
- 首先根據(jù)key計(jì)算出索引 i,然后查找i位置上的Entry
- 若是Entry已經(jīng)存在并且key等于傳入的key,那么這時(shí)候直接給這個(gè)Entry賦新的value值
- 若是Entry存在,但是key為null,則調(diào)用replaceStaleEntry來更換這個(gè)key為空的Entry
- 不斷循環(huán)檢測(cè),直到遇到為null的地方,這時(shí)候要是還沒在循環(huán)過程中return,那么就在這個(gè)null的位置新建一個(gè)Entry,并且插入,同時(shí)size增加1
- 最后調(diào)用cleanSomeSlots,清理key為null的Entry,最后返回是否清理了Entry,接下來再判斷sz 是否>= thresgold達(dá)到了rehash的條件,達(dá)到的話就會(huì)調(diào)用rehash函數(shù)執(zhí)行一次全表的掃描清理
三、ThreadLocalMap的內(nèi)存泄漏問題
在前面我們看到了,ThreadLocalMap的Entry繼承了WeakReference,其中key是弱引用,而value是強(qiáng)引用。如果ThreadLocal對(duì)象沒有外部強(qiáng)引用來引用它,那么ThreadLocal對(duì)象會(huì)在下次GC時(shí)候被回收。此時(shí),如果Entry中的key已經(jīng)被回收,但是value又是強(qiáng)引用,并不會(huì)被垃圾收集器回收。如果創(chuàng)建ThreadLocal的線程一直持續(xù)運(yùn)行,那么value就會(huì)一直得不到回收,從而產(chǎn)生內(nèi)存泄漏。
在Java中有四種引用類型,這里介紹一下強(qiáng)引用和弱引用:
- ? 強(qiáng)引用(“Strong” Reference),就是我們最常見的普通對(duì)象引用,只要還有強(qiáng)引用指向一個(gè)對(duì)象,就能表明對(duì)象還“活著”,垃圾回收器就不會(huì)回收這種對(duì)象。?
- 弱引用(WeakReference),垃圾回收器一旦發(fā)現(xiàn)了只具有弱引用的對(duì)象,不管當(dāng)前內(nèi)存空間足夠與否,都會(huì)回收它的內(nèi)存。
1、如果key使用強(qiáng)引用
如下圖:
假設(shè)在業(yè)務(wù)代碼中使用完ThreadLocal ,threadLocal Ref被回收了。但是因?yàn)閠hreadLocalMap的Entry強(qiáng)引用了threadLocal,造成threadLocal無法被回收。
在沒有手動(dòng)刪除這個(gè)Entry以及CurrentThread依然運(yùn)行的前提下,始終有強(qiáng)引用鏈 threadRef->currentThread->threadLocalMap->entry,Entry就不會(huì)被回收(Entry中包括了ThreadLocal實(shí)例和value),導(dǎo)致Entry內(nèi)存泄漏。
也就是說,ThreadLocalMap中的key使用了強(qiáng)引用, 是無法完全避免內(nèi)存泄漏的。
2、如果key使用了弱引用
如下圖:
假設(shè)在業(yè)務(wù)代碼中使用完ThreadLocal ,threadLocal Ref被回收了。由于ThreadLocalMap只持有ThreadLocal的弱引用,沒有任何強(qiáng)引用指向threadlocal實(shí)例, 所以threadlocal就可以順利被gc回收,此時(shí)Entry中的key=null。但是在沒有手動(dòng)刪除這個(gè)Entry以及CurrentThread依然運(yùn)行的前提下,也存在有強(qiáng)引用鏈 threadRef->currentThread->threadLocalMap->entry -> value ,value不會(huì)被回收, 而這塊value永遠(yuǎn)不會(huì)被訪問到了,導(dǎo)致value內(nèi)存泄漏。也就是說,ThreadLocalMap中的key使用了弱引用, 也有可能內(nèi)存泄漏。
既然弱引用和強(qiáng)引用都無法阻止內(nèi)存泄漏的問題,那么為啥還要選擇弱引用?
根據(jù)剛才的分析, 我們知道了: 無論ThreadLocalMap中的key使用哪種類型引用都無法完全避免內(nèi)存泄漏,跟使用弱引用沒有關(guān)系。
? 要避免內(nèi)存泄漏有兩種方式:
- 使用完ThreadLocal,調(diào)用其remove方法刪除對(duì)應(yīng)的Entry
- 使用完ThreadLocal,當(dāng)前Thread也隨之運(yùn)行結(jié)束
相對(duì)第一種方式,第二種方式顯然更不好控制,特別是使用線程池的時(shí)候,線程結(jié)束是不會(huì)銷毀的。也就是說,只要記得在使用完ThreadLocal及時(shí)的調(diào)用remove,無論key是強(qiáng)引用還是弱引用都不會(huì)有問題。那么為什么key要用弱引用呢?
? 事實(shí)上,在ThreadLocalMap中的set/getEntry方法中,會(huì)對(duì)key為null(也即是ThreadLocal為null)進(jìn)行判斷,如果為null的話,那么是會(huì)對(duì)value置為null的。這就意味著使用完ThreadLocal,CurrentThread依然運(yùn)行的前提下,就算忘記調(diào)用remove方法,弱引用比強(qiáng)引用可以多一層保障:弱引用的ThreadLocal會(huì)被回收,對(duì)應(yīng)的value在下一次ThreadLocalMap調(diào)用set,get,remove中的任一方法的時(shí)候會(huì)被清除,從而避免內(nèi)存泄漏。
解決辦法:使用ThreadLocal的set方法,顯式調(diào)用remove方法。
ThreadLocal<String> threadLocal = new ThreadLocal(); try { threadLocal.set("xxx"); // ... } finally { threadLocal.remove(); }
四、總結(jié)
- threadLocal操作的是每個(gè)線程自己的ThreadLocalMap,因此不會(huì)有線程的競(jìng)爭(zhēng)。key是當(dāng)前的threadlocal對(duì)象,value是當(dāng)前線程放在這個(gè)threadlocal里面的值。
- ThreadLocalMap是基于開放定址法(線性探測(cè)再散列)實(shí)現(xiàn)的hash表,這里沒有采用HashMap的數(shù)組加鏈表的實(shí)現(xiàn)方式是因?yàn)檫@里的場(chǎng)景決定了hash表中不會(huì)有太多的值,通過采用獨(dú)特的斐波那契散列求hash值,可以極大的降低hash沖突概率,訪問數(shù)據(jù)速度也比HashMap快。
- ThreadLocalMap的實(shí)現(xiàn)類似WeakHashMap實(shí)現(xiàn),都通過WeakReference封裝了key值,防止內(nèi)存泄漏;
- ThreadLocalMap實(shí)現(xiàn)的挺復(fù)雜的,主要是為了避免內(nèi)存泄露,加了好多處理過期數(shù)據(jù)的操作,這就在線程中添加了多余的操作。所以如果確定ThreadLocal沒有用的話,可以調(diào)用ThreadLocal的remove()方法,這樣就避免了在ThreadLocalMap中查找過期數(shù)據(jù)并處理的操作。
到此這篇關(guān)于Java多線程中ThreadLocal解讀的文章就介紹到這了,更多相關(guān)ThreadLocal解讀內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
java的socket請(qǐng)求和響應(yīng)方式
這篇文章主要介紹了java的socket請(qǐng)求和響應(yīng)方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-09-09java 根據(jù)經(jīng)緯度獲取地址實(shí)現(xiàn)代碼
這篇文章主要介紹了 java 根據(jù)經(jīng)緯度獲取地址實(shí)現(xiàn)代碼的相關(guān)資料,需要的朋友可以參考下2017-05-05Java Servlet中Response對(duì)象的使用方法
本文介紹了Java Servlet中Response對(duì)象的使用方法,包括設(shè)置響應(yīng)頭、設(shè)置響應(yīng)編碼、向客戶端發(fā)送數(shù)據(jù)、重定向等操作,同時(shí)介紹了常用的響應(yīng)狀態(tài)碼和響應(yīng)類型,幫助讀者更好地理解和掌握Servlet中Response對(duì)象的用法2023-05-05Java8 LocalDateTime極簡(jiǎn)時(shí)間日期操作小結(jié)
這篇文章主要介紹了Java8-LocalDateTime極簡(jiǎn)時(shí)間日期操作整理,通過實(shí)例代碼給大家介紹了java8 LocalDateTime 格式化問題,需要的朋友可以參考下2020-04-04