<bdo id="4g88a"><xmp id="4g88a">
  • <legend id="4g88a"><code id="4g88a"></code></legend>

    ThreadLocal 源碼淺析

    前言

    多線程在訪問同一個共享變量時很可能會出現并發問題,特別是在多線程對共享變量進行寫入時,那么除了加鎖還有其他方法避免并發問題嗎?本文將詳細講解 ThreadLocal 的使用及其源碼。


    一、什么是 ThreadLocal?

    ThreadLocal 是 JDK 包提供的,它提供了線程本地變量,也就是說,如果你創建了一個 ThreadLocal 變量,那么訪問這個變量的每一個線程,都創建這個變量的一個本地副本。

    這樣可以解決什么問題呢?當多個線程操作這個變量時,實際操作的是自己線程本地內存里的數據,從而避免線程安全問題。

    如下圖,線程表中的每個線程,都有自己 ThreadLocal 變量,線程操作這個變量只是在自己的本地內存在,跟其他線程是隔離的。

    image.png

    二、如何使用 ThreadLocal

    ThreadLocal 就是一個簡單的容器,使用起來也沒有難度,初始化后僅需通過 get/set 方法進行操作即可。

    如下代碼,開辟兩個線程對 ThreadLocal 變量進行操作,獲取的值是不同的。

    public class FuXing {
    
        /**
         * 初始化ThreadLocal
         */
        private static final ThreadLocal<String> myThreadLocal = new ThreadLocal<>();
    
        public static void main (String[] args) {
            // 線程1中操作 myThreadLocal
            new Thread(()->{
                myThreadLocal.set("thread 1");		//set方法設置值
                System.out.println(myThreadLocal.get());	//get方法獲取值"thread 1"
            },"thread 1").start();
    
            // 線程2中操作 myThreadLocal
            new Thread(()->{
                myThreadLocal.set("thread 2");		//set方法設置值
                System.out.println(myThreadLocal.get());	//get方法獲取值"thread 2"
            },"thread 2").start();
        }
    }
    

    三、ThreadLocal 實現原理

    ThreadLocal 是如何保證操作的對象只被當前線程進行訪問呢,我們通過源碼一起進行分析學習。

    一般分析源碼我們都先看它的構造方法是如何初始化的,接著通過對 ThreadLocal 的簡單使用,我們知道了關鍵的兩個方法 set/get,所以源碼分析也按照這個順序。

    1. 構造方法

    泛型類的空參構造,沒有什么特別的

    2. set 方法源碼

    源碼如下,ThreadLocalMap 是什么呢?由于比較復雜,這里先不做解釋,你暫時可以理解為是一個 HashMap,其中 key 為 ThreadLocal 當前對象,value 就是我們設置的值,后面會單獨解釋源碼。

    public void set(T value) {
        //獲取本地線程
        Thread t = Thread.currentThread();
    
        //獲取當前線程下的threadLocals對象,對象類型是ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null)
            //獲取到則添加值
            map.set(this, value);
        else
            //否則初始化ThreadLocalMap --第一次設置值
            createMap(t, value);
    }
    
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    

    3. get 方法源碼

    public T get() {
        //獲取本地線程
        Thread t = Thread.currentThread();
    
        //獲取當前線程下的threadLocals對象,對象類型是ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null) {
    
            //通過當前的ThreadLocal作為key去獲取對應value
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                //@SuppressWarnings忽略告警的注解
                //"unchecked"表示未經檢查的轉換相關的警告,通常出現在泛型編程中
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        //threadLocals為空或它的Entry為空時,需要對其進行初始化操作。
        return setInitialValue();
    }
    
    private T setInitialValue() {
        //初始化為null
        T value = initialValue();
        
        //獲取當前線程
        Thread t = Thread.currentThread();
        
        //獲取當前線程下的threadLocals對象,對象類型是ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        
        //返回的其實就是個null
        return value;
    }
    
    protected T initialValue() {
        return null;
    }
    

    4. remove 方法源碼

    核心也是 ThreadLocalMap 中的 remove 方法,會刪除 key 對應的 Entry,具體源碼后面統一在 ThreadLocalMap 源碼中分析。

    public void remove() {
        //獲取當前線程下的threadLocals對象,對象類型是ThreadLocalMap
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null)
            //通過當前的ThreadLocal作為key調用remove
            m.remove(this);
    }
    

    5. ThreadLocalMap 源碼

    ThreadLocalMap 是 ThreadLocal 的一個靜態內部類,看了上面的幾個源碼解釋,可以了解到 ThreadLocalMap 其實才是核心。

    簡單的說,ThreadLocalMap 與 HashMap 類似,如,初始容量 16,一定范圍內擴容,Entry 數組存儲等,那它與 HashMap 有什么不同呢,下面將對源碼進行詳解。

    ThreadLocalMap 的底層數據結構:

    image.png

    5.1 常量

    //初始容量,一定是2的冪等數。
    private static final int INITIAL_CAPACITY = 16;
    
    // Entry 數組
    private Entry[] table;
    
    //table的長度
    private int size = 0;
    
    //擴容閾值
    private int threshold; 
    
    //設置擴容閾值,長度的 2 / 3
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }
    
    //計算下一個存儲位置
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }
    
    // 計算前一個存儲位置
    private static int prevIndex(int i, int len) {
        return ((i - 1 >= 0) ? i - 1 : len - 1);
    }
    

    5.2 Entry 相關源碼

    由于 Entry 是底層核心源碼,所有的操作幾乎都是圍繞著它來進行的,所以關于 Entry 的源碼會比較多,我一一拆分進行分析講解。

    靜態內部類 Entry

    這個是 ThreadLocalMap 的底層數據結構,Entry 數組,每個 Entry 對象,這里的 Entry 繼承了 WeakReference,關于弱引用不懂得,可以看我的另一篇文章《Java 引用》。

    然后將 Entry 的 key 設置承了 弱引用,這有什么作用呢?作用是當 ThreadLocal 失去強引用后,在系統GC時,只要發現弱引用,不管系統堆空間使用是否充足,都會回收掉 key,進而 Entry 被內部清理。

    //靜態內部類Entry
    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;
        Entry(ThreadLocal<?> k, Object v) {
            // key為弱引用
            super(k);
            value = v;
        }
    }
    

    獲取 Entry

    拿到當前線程中對應的 ThreadLocal 所在的 Entry,找不到的話會重新尋找,因為當前的 Entry 可能已經擴容,擴容后會重新計算索引位置,詳情見擴容機制源碼。

    源碼中的計算索引位置的算法我沒有解釋,這個我會放在后面解釋,涉及到了如何解決 Hash 沖突的問題,這個和我們熟知的 HashMap 是不同的。

    //獲取Entry
    private Entry getEntry(ThreadLocal<?> key) {
        //計算索引位置
        int i = key.threadLocalHashCode & (table.length - 1);
        Entry e = table[i];
    
        //找到了就返回Entry
        if (e != null && e.get() == key)
                return e;
        else
            //沒找到則重新尋找,因為可能發生擴容導致索引重新計算
            return getEntryAfterMiss(key, i, e);
    }
    
    //重新獲取Entry --從當前索引i的位置向后搜索
    private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
        Entry[] tab = table;
        int len = tab.length;
    
        //循環遍歷,獲取對應的 ThreadLocal 所在的 Entry
        while (e != null) {
            //獲取Entry對象的弱引用,WeakReference的方法
            ThreadLocal<?> k = e.get();
            if (k == key)
                return e;
            if (k == null)
                //清除無效 Entry,詳解見下方
                expungeStaleEntry(i);
            else
                //計算下一個索引位置
                i = nextIndex(i, len);
            
            //可以理解為指針后移
            e = tab[i];
        }
        return null;
    }
    

    清除無效 Entry

    expunge 刪除,抹去,stale 陳舊的,沒有用的

    第 1 個方法:
    根據索引刪除對應的桶位,并從給定索引開始,遍歷清除無效的 Entry,何為無效?就是當 Entry 的 key 為 null 時,代表 key 已經被 GC 掉了,對應的 Entry 就無效了。

    第 2 個方法:
    刪除Entry數組中所有無效的Entry,方法中的e.get() == null,代表key被回收了。

    第 3 個方法:
    清除一些失效桶位,它執行對數數量的掃描,向后遍歷logn個位置,如8,4,2,1。

    方法 2、3 最后都通過方法 1 進行桶位的刪除。

    //根據索引刪除對應的桶位
    private int expungeStaleEntry(int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
    
        //刪除該桶位的元素,并將數組長度減1
        tab[staleSlot].value = null;
        tab[staleSlot] = null;
        size--;
    
        Entry e;
        int i;
        //從當前索引開始,直到當前 Entry為null才會停止遍歷
        for (i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = nextIndex(i, len)) {
            //獲取Entry對象的弱引用,WeakReference的方法
            ThreadLocal<?> k = e.get();
            if (k == null) {//說明key已失效
                //刪除該桶位的元素,并將數組長度減1
                e.value = null;
                tab[i] = null;
                size--;
            } else {//說明key有效,需要將其Rehash
                //計算rehash后索引位置
                int h = k.threadLocalHashCode & (len - 1);
                if (h != i) {
                    tab[i] = null;
                    //移動元素位置,若rehash后索引位置有其他元素,則繼續向后移動,直至為空
                    while (tab[h] != null)
                        h = nextIndex(h, len);
                    tab[h] = e;
                }
            }
        }
        //直到當前 Entry為null才會停止遍歷,i為其索引
        return i;
    }
    
    //刪除Entry數組中所有無效的Entry,用于rehash時
    private void expungeStaleEntries() {
        Entry[] tab = table;
        int len = tab.length;
        for (int j = 0; j < len; j++) {
            Entry e = tab[j];
            //獲取Entry對象的弱引用,Entry不為空而弱引用為空,代表被GC了
            if (e != null && e.get() == null)
                //根據索引刪除對應的桶位
                expungeStaleEntry(j);
        }
    }
    
    //清楚一些清除桶位,它執行對數數量的掃描
    private boolean cleanSomeSlots(int i, int n) {
        boolean removed = false;
        Entry[] tab = table;
        int len = tab.length;
        //向后遍歷logn個位置,如8,4,2,1
        do {
            i = nextIndex(i, len);
            Entry e = tab[i];
            //獲取Entry對象的弱引用,Entry不為空而弱引用為空,代表被GC了
            if (e != null && e.get() == null) {
                n = len;
                removed = true;
                //根據索引刪除對應的桶位
                i = expungeStaleEntry(i);
            }
        } while ( (n >>>= 1) != 0);//對數遞減
        return removed;
    }
    

    替換無效 Entry

    替換失效元素,用在對 Entry 進行 set 操作時,如果 set 的 key 是失效的,則需要用新的替換它。

    這里不僅僅處理了當前的失效元素,還會將其他失效的元素進行清理,因為這里是當 key 為 null 時才進行的替換操作。

    那什么時候 key 為 null 呢?這個除了主動的 remove 之外,就只有 ThreadLocal 的弱引用被 GC 掉了。

    這里是在 set 操作時出現的,還出現了 key 為 null 的無效元素,代表已經之前發生過 GC 了,很可能Entry 數組中還可能出現其他無效元素,所以源碼中會出現向前遍歷和向后遍歷的情況。

    向前遍歷好理解,就是通過遍歷找第一個失效元素的索引。向后遍歷比較難理解,這里我先簡單說一下 ThreadLocal 用的開放地址的方式來解決 hash 沖突的,具體原理我后面會在講 hash 沖突時單獨講。

    這種情況下,很可能當前的失效元素對應的并不是 hascode 在 staleSlot 的Entry。因為 hash 沖突后,Entry 會后移,那么此元素的 hascode 對應的桶位很有可能往后移了,所以我們要向后找到它,并且和當前的 staleSlot 進行替換。

    如果不進行此操作的話,很有可能在 set 操作時,在 ThreadLocalMap 中會出現兩個桶位,都被某個ThreadLocal 指向。

    private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                   int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;
    
        //記錄失效元素的索引
        int slotToExpunge = staleSlot;
        //從失效元素位置向前遍歷,直到當前 Entry為null才會停止遍歷
        for (int i = prevIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = prevIndex(i, len))
            if (e.get() == null)
                //更新失效元素的索引,目的是找第一個失效的元素
                slotToExpunge = i;
    
        //從失效元素向后遍歷
        for (int i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();
            //找到了對應key
            if (k == key) {
                //更新該位置的value
                e.value = value;
                //把失效元素換到當前位置
                tab[i] = tab[staleSlot];
                //把當前Entry移動到失效元素位置
                tab[staleSlot] = e;
                
                //slotToExpunge是第一個失效元素的索引,若條件成立,向前沒有失效元素
                if (slotToExpunge == staleSlot)
                    //從當前索引開始,清理失效元素
                    slotToExpunge = i;
                
                // 清理失效元素,詳情見清除無效Entry相關源碼
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }
            
            //代表向前遍歷沒有找到第一個失效元素的位置
            if (k == null && slotToExpunge == staleSlot)
                //所以條件成立的i是向后遍歷的的第一個失效元素的位置
                slotToExpunge = i;
        }
        
        //沒找到key,則在失效元素索引的位置,新建Entry
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);
        
        // 條件成立說明在找到了staleSlot前面找到了其他的失效元素
        if (slotToExpunge != staleSlot)
            
            // 清理失效元素,詳情見清除無效Entry相關源碼
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }
    

    5.3 構造方法

    還有一個基于 parentMap 的構造方法,由于目前僅在創建 InheritableThreadLocal 時調用,關于它這里不詳細展開,后續會針對該類進行詳解。

    ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        // 初始化數組
        table = new Entry[INITIAL_CAPACITY];
    
        //計算存儲位置
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    
        //存儲元素,并將size設置為1
        table[i] = new Entry(firstKey, firstValue);
        size = 1;
    
        //設置擴容閾值
        setThreshold(INITIAL_CAPACITY);
    }
    

    5.4 set 方法源碼

    設置 key,vlaue,key 就是 ThreadLocal 對象。

    private void set(ThreadLocal<?> key, Object value) {
        Entry[] tab = table;
        int len = tab.length;
        //計算索引位置
        int i = key.threadLocalHashCode & (len-1);
    
        //從當前索引開始,直到當前Entry為null才會停止遍歷
        for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            ThreadLocal<?> k = e.get();
    
            //如果key存在且等于當前key,代表之前存在的,直接覆蓋
            if (k == key) {
                e.value = value;
                return;
            }
            //如果key不存在,說明已失效,需要替換,詳情見替換無效Entry源碼
            if (k == null) {
                replaceStaleEntry(key, value, i);
                return;
            }
        }
    
        //沒有key則新建一個Entry即可
        tab[i] = new Entry(key, value);
        int sz = ++size;
    
        //清理一些失效元素,若清理失敗且達到常量中的擴容閾值,則進行rehash操作
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }
    
    //刪除Entry數組中所有無效的Entry并擴容
    private void rehash() {
        //刪除Entry數組中所有無效的Entry
        expungeStaleEntries();
        if (size >= threshold - threshold / 4)
            //擴容,詳情見下面的擴容機制源碼
            resize();
    }
    

    5.5 remove 方法源碼

    刪除key對應的entry

    private void remove(ThreadLocal<?> key) {
        Entry[] tab = table;
        int len = tab.length;
        //計算存儲位置
        int i = key.threadLocalHashCode & (len-1);
        
        //從當前索引開始,直到當前Entry為null才會停止遍歷
        for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            if (e.get() == key) {
                //清除該對象的強引用,下次在通過get方法獲取引用則返回null
                e.clear();
    
                //清除無效元素
                expungeStaleEntry(i);
                return;
            }
        }
    }
    

    5.6 擴容機制源碼

    將元素轉移到新的Entry 數組,長度是原來的兩倍。

    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) {	//key失效則值也順便設為null
                    e.value = null; 	// Help the GC
                } else {
                    //重新計算索引位置
                    int h = k.threadLocalHashCode & (newLen - 1);
    
                    //移動元素位置,若rehash后索引位置有其他元素,則繼續向后移動,直至為空
                    while (newTab[h] != null)
                        h = nextIndex(h, newLen);
                    newTab[h] = e;
                    count++;
                }
            }
        }
        setThreshold(newLen);
        size = count;
        table = newTab;
    }
    

    四、ThreadLocalMap 的 Hash 沖突

    Java 中大部分都是使用拉鏈法法解決 Hash 沖突的,而 ThreadLocalMap 是通過開放地址法來解決 Hash 沖突,這兩者有什么不同,下面我講介紹一下。

    1. 拉鏈法

    拉鏈法也叫鏈地址法,經典的就是 HashMap 解決 Hash 沖突的方法,如下圖。將所有的 hash 值相同的元素組成一個鏈表,除此外 HashMap 還進行了鏈表轉紅黑樹的優化。

    image.png

    2. 開放地址法

    原理是當發生hash沖突時,不引入額外的數據結構,會以當前地址為基準,通過“多次探測”來處理哈希沖突,探測方式主要包括線性探測、平方探測和多次哈希等,ThreadLocalMap 使用的是線性探測法。

    image.png

    簡單說,就是一旦發生了沖突,就去探測尋找下一個空的散列地址,根據上面的源碼也能大致了解該處理方式。
    源碼中的公式是key.threadLocalHashCode & (length - 1)。

    公式類似 HashMap 的尋址算法,詳情見HashMap源碼,由于數組長度是 2 的 n 次冪,所以這里的與運算就是取模,得到索引 i,這樣做是為了分布更均勻,減少沖突產生。

    threadLocalHashCode 源碼如下:

    private final int threadLocalHashCode = nextHashCode();
    
    //初始化線程安全的Integer
    private static AtomicInteger nextHashCode =
        new AtomicInteger();
    
    //斐波那契散列乘數 --結果分布更均勻
    private static final int HASH_INCREMENT = 0x61c88647;
    
    //自增返回下一個hash code
    private static int nextHashCode() {
        
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
    

    線性探測法的缺點:

    1. 不適用于存儲大量數據,容易產生“聚集現象”;
    2. 刪除元素需要清除無效元素;

    五、注意事項

    1. 關于內存泄漏

    在了解了 ThreadLocal 的內部實現以后,我們知道了數據其實存儲在 ThreadLocalMap 中。這就意味著,線程只要不退出,則引用一直存在。

    當線程退出時,Thread 類會對一些資源進行清理,其中就有threadLocals,源碼如下:

    private void exit() {
        if (group != null) {
            group.threadTerminated(this);
            group = null;
        }
        target = null;
        //加速一些資源的清理
        threadLocals = null;
        inheritableThreadLocals = null;
        inheritedAccessControlContext = null;
        blocker = null;
        uncaughtExceptionHandler = null;
    }
    

    因此,當使用的線程一直沒有退出(如使用線程池),這時如果將一些大對象放入 ThreadLocal 中,且沒有及時清理,就可能會出現內存泄漏的風險。

    所以我們要養成習慣每次使用完 ThreadLocal 都要調用 remove 方法進行清理。

    2. 關于數據混亂

    通過對內存泄漏的解釋,我們了解了當使用的線程一直沒有退出,而又沒有即使清理 ThreadLocal,則其中的數據會一直存在。

    這除了內存泄漏還有什么問題呢?我們在開發過程中,請求一般都是通過 Tomcat 處理,而其在處理請求時采用的就是線程池。

    這就意味著請求線程被 Tomcat 回收后,不一定會立即銷毀,如果不在請求結束后主動 remove 線程中的 ThreadLocal 信息,可能會影響后續邏輯,拿到臟數據。

    我在開發過程中就遇到了這個問題,詳情見ThreadLocal中的用戶信息混亂問題。所以無論如何,在每次使用完 ThreadLocal 都要調用 remove 方法進行清理。

    3. 關于繼承性

    同一個 ThreadLocal 變量,在父線程中被設置值后,在子線程其實是獲取不到的。通過源碼我們也知道,我們操作的都是當前線程下的 ThreadLocalMap ,所以這其實是正常的。

    測試代碼如下:

    public class FuXing {
    
        /**
         * 初始化ThreadLocal
         */
        private static final ThreadLocal<String> myThreadLocal = new ThreadLocal<>();
    
        public static void main (String[] args) {
            myThreadLocal.set("father thread");
            System.out.println(myThreadLocal.get()); 	//father thread
    
            new Thread(()->{
                System.out.println(myThreadLocal.get());	//null
            },"thread 1").start();
        }
    }
    

    那么這可能會導致什么問題呢?比如我們在本服務調用外部服務,或者本服務開啟新線程去進行異步操作,其中都無法獲取 ThreadLocal 中的值。

    雖然都有其他解決方法,但是有沒有讓子線程也能直接獲取到父線程的 ThreadLocal 中的值呢?這就用到了 InheritableThreadLocal。

    public class FuXing {
    
        /**
         * 初始化ThreadLocal
         */
        private static final InheritableThreadLocal<String> myThreadLocal 
                = new InheritableThreadLocal<>();
    
        public static void main (String[] args) {
            myThreadLocal.set("father thread");
            System.out.println(myThreadLocal.get()); 	//father thread
    
            new Thread(()->{
                System.out.println(myThreadLocal.get());	//father thread
            },"thread 1").start();
        }
    }
    

    InheritableThreadLocal 就是繼承了 ThreadLocal,在創建和獲取變量實例 inheritableThreadLocals 而不再是threadLocals,源碼如下。

    public class InheritableThreadLocal<T> extends ThreadLocal<T> {
    
        protected T childValue(T parentValue) {
            return parentValue;
        }
    
        ThreadLocalMap getMap(Thread t) {
           return t.inheritableThreadLocals;
        }
    
        void createMap(Thread t, T firstValue) {
            t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
        }
    }
    

    總結

    本文主要講述了 ThreadLocal 的使用以及對其源碼進行了詳解,了解了 ThreadLocal 可以線程隔離的原因。通過對 ThreadLocalMap 的分析,知道了其底層數據結構和如何解決 Hash 沖突的。

    最后通過對 ThreadLocal 特點的分析,了解到有哪些需要注意的點,避免以后開發過程中遇到類似問題,若發現其他問題歡迎指正交流。


    參考:

    [1] 翟陸續/薛賓田. Java并發編程之美.

    [2] 葛一鳴/郭超. 實戰Java高并發程序設計.

    [3] 靳宇棟. Hello 算法.

    posted @ 2024-06-24 12:37  fuxing.  閱讀(339)  評論(0編輯  收藏  舉報
    免费视频精品一区二区_日韩一区二区三区精品_aaa在线观看免费完整版_世界一级真人片
    <bdo id="4g88a"><xmp id="4g88a">
  • <legend id="4g88a"><code id="4g88a"></code></legend>