synchronized 的超多干貨!
synchronized 這個(gè)關(guān)鍵字的重要性不言而喻,幾乎可以說是并發(fā)、多線程必須會(huì)問到的關(guān)鍵字了。synchronized 會(huì)涉及到鎖、升級(jí)降級(jí)操作、鎖的撤銷、對(duì)象頭等。所以理解 synchronized 非常重要,本篇文章就帶你從 synchronized 的基本用法、再到 synchronized 的深入理解,對(duì)象頭等,為你揭開 synchronized 的面紗。
淺析 synchronized
synchronized 是 Java 并發(fā)模塊 非常重要的關(guān)鍵字,它是 Java 內(nèi)建的一種同步機(jī)制,代表了某種內(nèi)在鎖定的概念,當(dāng)一個(gè)線程對(duì)某個(gè)共享資源加鎖后,其他想要獲取共享資源的線程必須進(jìn)行等待,synchronized 也具有互斥和排他的語義。
什么是互斥?我們想必小時(shí)候都玩兒過磁鐵,磁鐵會(huì)有正負(fù)極的概念,同性相斥異性相吸,相斥相當(dāng)于就是一種互斥的概念,也就是兩者互不相容。
synchronized 也是一種獨(dú)占的關(guān)鍵字,但是它這種獨(dú)占的語義更多的是為了增加線程安全性,通過獨(dú)占某個(gè)資源以達(dá)到互斥、排他的目的。
在了解了排他和互斥的語義后,我們先來看一下 synchronized 的用法,先來了解用法,再來了解底層實(shí)現(xiàn)。
synchronized 的使用
關(guān)于 synchronized 想必你應(yīng)該都大致了解過
synchronized 修飾實(shí)例方法,相當(dāng)于是對(duì)類的實(shí)例進(jìn)行加鎖,進(jìn)入同步代碼前需要獲得當(dāng)前實(shí)例的鎖
synchronized 修飾靜態(tài)方法,相當(dāng)于是對(duì)類對(duì)象進(jìn)行加鎖
synchronized 修飾代碼塊,相當(dāng)于是給對(duì)象進(jìn)行加鎖,在進(jìn)入代碼塊前需要先獲得對(duì)象的鎖
下面我們針對(duì)每個(gè)用法進(jìn)行解釋
synchronized 修飾實(shí)例方法
synchronized 修飾實(shí)例方法,實(shí)例方法是屬于類的實(shí)例。synchronized 修飾的實(shí)例方法相當(dāng)于是對(duì)象鎖。下面是一個(gè) synchronized 修飾實(shí)例方法的例子。
public synchronized void method()
{
// ...
}
像如上述 synchronized 修飾的方法就是實(shí)例方法,下面我們通過一個(gè)完整的例子來認(rèn)識(shí)一下 synchronized 修飾實(shí)例方法
public class TSynchronized implements Runnable{
static int i = 0;
public synchronized void increase(){
i++;
System.out.println(Thread.currentThread().getName());
}
@Override
public void run() {
for(int i = 0;i < 1000;i++) {
increase();
}
}
public static void main(String[] args) throws InterruptedException {
TSynchronized tSynchronized = new TSynchronized();
Thread aThread = new Thread(tSynchronized);
Thread bThread = new Thread(tSynchronized);
aThread.start();
bThread.start();
aThread.join();
bThread.join();
System.out.println("i = " + i);
}
}
上面輸出的結(jié)果 i = 2000 ,并且每次都會(huì)打印當(dāng)前現(xiàn)成的名字
來解釋一下上面代碼,代碼中的 i 是一個(gè)靜態(tài)變量,靜態(tài)變量也是全局變量,靜態(tài)變量存儲(chǔ)在方法區(qū)中。increase 方法由 synchronized 關(guān)鍵字修飾,但是沒有使用 static 關(guān)鍵字修飾,表示 increase 方法是一個(gè)實(shí)例方法,每次創(chuàng)建一個(gè) TSynchronized 類的同時(shí)都會(huì)創(chuàng)建一個(gè) increase 方法,increase 方法中只是打印出來了當(dāng)前訪問的線程名稱。Synchronized 類實(shí)現(xiàn)了 Runnable 接口,重寫了 run 方法,run 方法里面就是一個(gè) 0 - 1000 的計(jì)數(shù)器,這個(gè)沒什么好說的。在 main 方法中,new 出了兩個(gè)線程,分別是 aThread 和 bThread,Thread.join 表示等待這個(gè)線程處理結(jié)束。這段代碼主要的作用就是判斷 synchronized 修飾的方法能夠具有獨(dú)占性。
synchronized 修飾靜態(tài)方法
synchronized 修飾靜態(tài)方法就是 synchronized 和 static 關(guān)鍵字一起使用
public static synchronized void increase(){}
當(dāng) synchronized 作用于靜態(tài)方法時(shí),表示的就是當(dāng)前類的鎖,因?yàn)殪o態(tài)方法是屬于類的,它不屬于任何一個(gè)實(shí)例成員,因此可以通過 class 對(duì)象控制并發(fā)訪問。
這里需要注意一點(diǎn),因?yàn)?synchronized 修飾的實(shí)例方法是屬于實(shí)例對(duì)象,而 synchronized 修飾的靜態(tài)方法是屬于類對(duì)象,所以調(diào)用 synchronized 的實(shí)例方法并不會(huì)阻止訪問 synchronized 的靜態(tài)方法。
synchronized 修飾代碼塊
synchronized 除了修飾實(shí)例方法和靜態(tài)方法外,synchronized 還可用于修飾代碼塊,代碼塊可以嵌套在方法體的內(nèi)部使用。
public void run() {
synchronized(obj){
for(int j = 0;j < 1000;j++){
i++;
}
}
}
上面代碼中將 obj 作為鎖對(duì)象對(duì)其加鎖,每次當(dāng)線程進(jìn)入 synchronized 修飾的代碼塊時(shí)就會(huì)要求當(dāng)前線程持有obj 實(shí)例對(duì)象鎖,如果當(dāng)前有其他線程正持有該對(duì)象鎖,那么新到的線程就必須等待。
synchronized 修飾的代碼塊,除了可以鎖定對(duì)象之外,也可以對(duì)當(dāng)前實(shí)例對(duì)象鎖、class 對(duì)象鎖進(jìn)行鎖定
// 實(shí)例對(duì)象鎖
synchronized(this){
for(int j = 0;j < 1000;j++){
i++;
}
}
//class對(duì)象鎖
synchronized(TSynchronized.class){
for(int j = 0;j < 1000;j++){
i++;
}
}
synchronized 底層原理
在簡單介紹完 synchronized 之后,我們就來聊一下 synchronized 的底層原理了。
我們或許都有所了解(下文會(huì)細(xì)致分析),synchronized 的代碼塊是由一組 monitorenter/monitorexit 指令實(shí)現(xiàn)的。而Monitor 對(duì)象是實(shí)現(xiàn)同步的基本單元。
啥是
Monitor對(duì)象呢?
Monitor 對(duì)象
任何對(duì)象都關(guān)聯(lián)了一個(gè)管程,管程就是控制對(duì)象并發(fā)訪問的一種機(jī)制。管程 是一種同步原語,在 Java 中指的就是 synchronized,可以理解為 synchronized 就是 Java 中對(duì)管程的實(shí)現(xiàn)。
管程提供了一種排他訪問機(jī)制,這種機(jī)制也就是 互斥。互斥保證了在每個(gè)時(shí)間點(diǎn)上,最多只有一個(gè)線程會(huì)執(zhí)行同步方法。
所以你理解了 Monitor 對(duì)象其實(shí)就是使用管程控制同步訪問的一種對(duì)象。
對(duì)象內(nèi)存布局
在 hotspot 虛擬機(jī)中,對(duì)象在內(nèi)存中的布局分為三塊區(qū)域:
對(duì)象頭(Header)實(shí)例數(shù)據(jù)(Instance Data)對(duì)齊填充(Padding)
這三塊區(qū)域的內(nèi)存分布如下圖所示

我們來詳細(xì)介紹一下上面對(duì)象中的內(nèi)容。
對(duì)象頭 Header
對(duì)象頭 Header 主要包含 MarkWord 和對(duì)象指針 Klass Pointer,如果是數(shù)組的話,還要包含數(shù)組的長度。

在 32 位的虛擬機(jī)中 MarkWord ,Klass Pointer 和數(shù)組長度分別占用 32 位,也就是 4 字節(jié)。
如果是 64 位虛擬機(jī)的話,MarkWord ,Klass Pointer 和數(shù)組長度分別占用 64 位,也就是 8 字節(jié)。
在 32 位虛擬機(jī)和 64 位虛擬機(jī)的 Mark Word 所占用的字節(jié)大小不一樣,32 位虛擬機(jī)的 Mark Word 和 Klass Pointer 分別占用 32 bits 的字節(jié),而 64 位虛擬機(jī)的 Mark Word 和 Klass Pointer 占用了64 bits 的字節(jié),下面我們以 32 位虛擬機(jī)為例,來看一下其 Mark Word 的字節(jié)具體是如何分配的。


用中文翻譯過來就是

無狀態(tài)也就是
無鎖的時(shí)候,對(duì)象頭開辟 25 bit 的空間用來存儲(chǔ)對(duì)象的 hashcode ,4 bit 用于存放分代年齡,1 bit 用來存放是否偏向鎖的標(biāo)識(shí)位,2 bit 用來存放鎖標(biāo)識(shí)位為 01。偏向鎖中劃分更細(xì),還是開辟 25 bit 的空間,其中 23 bit 用來存放線程ID,2bit 用來存放 epoch,4bit 存放分代年齡,1 bit 存放是否偏向鎖標(biāo)識(shí), 0 表示無鎖,1 表示偏向鎖,鎖的標(biāo)識(shí)位還是 01。輕量級(jí)鎖中直接開辟 30 bit 的空間存放指向棧中鎖記錄的指針,2bit 存放鎖的標(biāo)志位,其標(biāo)志位為 00。重量級(jí)鎖中和輕量級(jí)鎖一樣,30 bit 的空間用來存放指向重量級(jí)鎖的指針,2 bit 存放鎖的標(biāo)識(shí)位,為 11GC標(biāo)記開辟 30 bit 的內(nèi)存空間卻沒有占用,2 bit 空間存放鎖標(biāo)志位為 11。
其中無鎖和偏向鎖的鎖標(biāo)志位都是 01,只是在前面的 1 bit 區(qū)分了這是無鎖狀態(tài)還是偏向鎖狀態(tài)。
關(guān)于為什么這么分配的內(nèi)存,我們可以從 OpenJDK 中的markOop.hpp類中的枚舉窺出端倪

來解釋一下
age_bits 就是我們說的分代回收的標(biāo)識(shí),占用4字節(jié)
lock_bits 是鎖的標(biāo)志位,占用2個(gè)字節(jié)
biased_lock_bits 是是否偏向鎖的標(biāo)識(shí),占用1個(gè)字節(jié)。
max_hash_bits 是針對(duì)無鎖計(jì)算的 hashcode 占用字節(jié)數(shù)量,如果是 32 位虛擬機(jī),就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虛擬機(jī),64 - 4 - 2 - 1 = 57 byte,但是會(huì)有 25 字節(jié)未使用,所以 64 位的 hashcode 占用 31 byte。
hash_bits 是針對(duì) 64 位虛擬機(jī)來說,如果最大字節(jié)數(shù)大于 31,則取 31,否則取真實(shí)的字節(jié)數(shù)
cms_bits 我覺得應(yīng)該是不是 64 位虛擬機(jī)就占用 0 byte,是 64 位就占用 1byte
epoch_bits 就是 epoch 所占用的字節(jié)大小,2 字節(jié)。
在上面的虛擬機(jī)對(duì)象頭分配表中,我們可以看到有幾種鎖的狀態(tài):無鎖(無狀態(tài)),偏向鎖,輕量級(jí)鎖,重量級(jí)鎖,其中輕量級(jí)鎖和偏向鎖是 JDK1.6 中對(duì) synchronized 鎖進(jìn)行優(yōu)化后新增加的,其目的就是為了大大優(yōu)化鎖的性能,所以在 JDK 1.6 中,使用 synchronized 的開銷也沒那么大了。其實(shí)從鎖有無鎖定來講,還是只有無鎖和重量級(jí)鎖,偏向鎖和輕量級(jí)鎖的出現(xiàn)就是增加了鎖的獲取性能而已,并沒有出現(xiàn)新的鎖。
所以我們的重點(diǎn)放在對(duì) synchronized 重量級(jí)鎖的研究上,當(dāng) monitor 被某個(gè)線程持有后,它就會(huì)處于鎖定狀態(tài)。在 HotSpot 虛擬機(jī)中,monitor 的底層代碼是由 ObjectMonitor 實(shí)現(xiàn)的,其主要數(shù)據(jù)結(jié)構(gòu)如下(位于 HotSpot 虛擬機(jī)源碼 ObjectMonitor.hpp 文件,C++ 實(shí)現(xiàn)的)

這段 C++ 中需要注意幾個(gè)屬性:_WaitSet 、 _EntryList 和 _Owner,每個(gè)等待獲取鎖的線程都會(huì)被封裝稱為 ObjectWaiter 對(duì)象。

_Owner 是指向了 ObjectMonitor 對(duì)象的線程,而 _WaitSet 和 _EntryList 就是用來保存每個(gè)線程的列表。
那么這兩個(gè)列表有什么區(qū)別呢?這個(gè)問題我和你聊一下鎖的獲取流程你就清楚了。
鎖的兩個(gè)列表
當(dāng)多個(gè)線程同時(shí)訪問某段同步代碼時(shí),首先會(huì)進(jìn)入 _EntryList 集合,當(dāng)線程獲取到對(duì)象的 monitor 之后,就會(huì)進(jìn)入 _Owner 區(qū)域,并把 ObjectMonitor 對(duì)象的 _Owner 指向?yàn)楫?dāng)前線程,并使 _count + 1,如果調(diào)用了釋放鎖(比如 wait)的操作,就會(huì)釋放當(dāng)前持有的 monitor ,owner = null, _count - 1,同時(shí)這個(gè)線程會(huì)進(jìn)入到 _WaitSet 列表中等待被喚醒。如果當(dāng)前線程執(zhí)行完畢后也會(huì)釋放 monitor 鎖,只不過此時(shí)不會(huì)進(jìn)入 _WaitSet 列表了,而是直接復(fù)位 _count 的值。

Klass Pointer 表示的是類型指針,也就是對(duì)象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)通過這個(gè)指針來確定這個(gè)對(duì)象是哪個(gè)類的實(shí)例。
你可能不是很理解指針是個(gè)什么概念,你可以簡單理解為指針就是指向某個(gè)數(shù)據(jù)的地址。

實(shí)例數(shù)據(jù) Instance Data
實(shí)例數(shù)據(jù)部分是對(duì)象真正存儲(chǔ)的有效信息,也是代碼中定義的各個(gè)字段的字節(jié)大小,比如一個(gè) byte 占 1 個(gè)字節(jié),一個(gè) int 占用 4 個(gè)字節(jié)。
對(duì)齊 Padding
對(duì)齊不是必須存在的,它只起到了占位符(%d, %c 等)的作用。這就是 JVM 的要求了,因?yàn)?HotSpot JVM 要求對(duì)象的起始地址必須是 8 字節(jié)的整數(shù)倍,也就是說對(duì)象的字節(jié)大小是 8 的整數(shù)倍,不夠的需要使用 Padding 補(bǔ)全。
鎖的升級(jí)流程
先來個(gè)大體的流程圖來感受一下這個(gè)過程,然后下面我們?cè)俜珠_來說

無鎖
無鎖狀態(tài),無鎖即沒有對(duì)資源進(jìn)行鎖定,所有的線程都可以對(duì)同一個(gè)資源進(jìn)行訪問,但是只有一個(gè)線程能夠成功修改資源。

無鎖的特點(diǎn)就是在循環(huán)內(nèi)進(jìn)行修改操作,線程會(huì)不斷的嘗試修改共享資源,直到能夠成功修改資源并退出,在此過程中沒有出現(xiàn)沖突的發(fā)生,這很像我們?cè)谥拔恼轮薪榻B的 CAS 實(shí)現(xiàn),CAS 的原理和應(yīng)用就是無鎖的實(shí)現(xiàn)。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。
偏向鎖
HotSpot 的作者經(jīng)過研究發(fā)現(xiàn),大多數(shù)情況下,鎖不僅不存在多線程競爭,還存在鎖由同一線程多次獲得的情況,偏向鎖就是在這種情況下出現(xiàn)的,它的出現(xiàn)是為了解決只有在一個(gè)線程執(zhí)行同步時(shí)提高性能。

可以從對(duì)象頭的分配中看到,偏向鎖要比無鎖多了線程ID 和 epoch,下面我們就來描述一下偏向鎖的獲取過程
偏向鎖獲取過程
首先線程訪問同步代碼塊,會(huì)通過檢查對(duì)象頭 Mark Word 的
鎖標(biāo)志位判斷目前鎖的狀態(tài),如果是 01,說明就是無鎖或者偏向鎖,然后再根據(jù)是否偏向鎖的標(biāo)示判斷是無鎖還是偏向鎖,如果是無鎖情況下,執(zhí)行下一步線程使用 CAS 操作來嘗試對(duì)對(duì)象加鎖,如果使用 CAS 替換 ThreadID 成功,就說明是第一次上鎖,那么當(dāng)前線程就會(huì)獲得對(duì)象的偏向鎖,此時(shí)會(huì)在對(duì)象頭的 Mark Word 中記錄當(dāng)前線程 ID 和獲取鎖的時(shí)間 epoch 等信息,然后執(zhí)行同步代碼塊。
全局安全點(diǎn)(Safe Point):全局安全點(diǎn)的理解會(huì)涉及到 C 語言底層的一些知識(shí),這里簡單理解 SafePoint 是 Java 代碼中的一個(gè)線程可能暫停執(zhí)行的位置。
等到下一次線程在進(jìn)入和退出同步代碼塊時(shí)就不需要進(jìn)行 CAS 操作進(jìn)行加鎖和解鎖,只需要簡單判斷一下對(duì)象頭的 Mark Word 中是否存儲(chǔ)著指向當(dāng)前線程的線程ID,判斷的標(biāo)志當(dāng)然是根據(jù)鎖的標(biāo)志位來判斷的。如果用流程圖來表示的話就是下面這樣

關(guān)閉偏向鎖
偏向鎖在Java 6 和Java 7 里是默認(rèn)啟用的。由于偏向鎖是為了在只有一個(gè)線程執(zhí)行同步塊時(shí)提高性能,如果你確定應(yīng)用程序里所有的鎖通常情況下處于競爭狀態(tài),可以通過JVM參數(shù)關(guān)閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認(rèn)會(huì)進(jìn)入輕量級(jí)鎖狀態(tài)。
關(guān)于 epoch
偏向鎖的對(duì)象頭中有一個(gè)被稱為 epoch 的值,它作為偏差有效性的時(shí)間戳。
輕量級(jí)鎖
輕量級(jí)鎖是指當(dāng)前鎖是偏向鎖的時(shí)候,資源被另外的線程所訪問,那么偏向鎖就會(huì)升級(jí)為輕量級(jí)鎖,其他線程會(huì)通過自旋的形式嘗試獲取鎖,不會(huì)阻塞,從而提高性能,下面是詳細(xì)的獲取過程。
輕量級(jí)鎖加鎖過程
緊接著上一步,如果 CAS 操作替換 ThreadID 沒有獲取成功,執(zhí)行下一步
如果使用 CAS 操作替換 ThreadID 失敗(這時(shí)候就切換到另外一個(gè)線程的角度)說明該資源已被同步訪問過,這時(shí)候就會(huì)執(zhí)行鎖的撤銷操作,撤銷偏向鎖,然后等原持有偏向鎖的線程到達(dá)
全局安全點(diǎn)(SafePoint)時(shí),會(huì)暫停原持有偏向鎖的線程,然后會(huì)檢查原持有偏向鎖的狀態(tài),如果已經(jīng)退出同步,就會(huì)喚醒持有偏向鎖的線程,執(zhí)行下一步檢查對(duì)象頭中的 Mark Word 記錄的是否是當(dāng)前線程 ID,如果是,執(zhí)行同步代碼,如果不是,執(zhí)行偏向鎖獲取流程 的第2步。
如果用流程表示的話就是下面這樣(已經(jīng)包含偏向鎖的獲取)

重量級(jí)鎖
重量級(jí)鎖其實(shí)就是 synchronized 最終加鎖的過程,在 JDK 1.6 之前,就是由無鎖 -> 加鎖的這個(gè)過程。
重量級(jí)鎖的獲取流程
接著上面偏向鎖的獲取過程,由偏向鎖升級(jí)為輕量級(jí)鎖,執(zhí)行下一步
會(huì)在原持有偏向鎖的線程的棧中分配鎖記錄,將對(duì)象頭中的 Mark Word 拷貝到原持有偏向鎖線程的記錄中,原持有偏向鎖的線程獲得輕量級(jí)鎖,然后喚醒原持有偏向鎖的線程,從安全點(diǎn)處繼續(xù)執(zhí)行,執(zhí)行完畢后,執(zhí)行下一步,當(dāng)前線程執(zhí)行第 4 步
執(zhí)行完畢后,開始輕量級(jí)解鎖操作,解鎖需要判斷兩個(gè)條件
判斷對(duì)象頭中的 Mark Word 中鎖記錄指針是否指向當(dāng)前棧中記錄的指針

拷貝在當(dāng)前線程鎖記錄的 Mark Word 信息是否與對(duì)象頭中的 Mark Word 一致。
如果上面兩個(gè)判斷條件都符合的話,就進(jìn)行鎖釋放,如果其中一個(gè)條件不符合,就會(huì)釋放鎖,并喚起等待的線程,進(jìn)行新一輪的鎖競爭。
在當(dāng)前線程的棧中分配鎖記錄,拷貝對(duì)象頭中的 MarkWord 到當(dāng)前線程的鎖記錄中,執(zhí)行 CAS 加鎖操作,會(huì)把對(duì)象頭 Mark Word 中鎖記錄指針指向當(dāng)前線程鎖記錄,如果成功,獲取輕量級(jí)鎖,執(zhí)行同步代碼,然后執(zhí)行第3步,如果不成功,執(zhí)行下一步
當(dāng)前線程沒有使用 CAS 成功獲取鎖,就會(huì)自旋一會(huì)兒,再次嘗試獲取,如果在多次自旋到達(dá)上限后還沒有獲取到鎖,那么輕量級(jí)鎖就會(huì)升級(jí)為
重量級(jí)鎖

如果用流程圖表示是這樣的

根據(jù)上面對(duì)于鎖升級(jí)細(xì)致的描述,我們可以總結(jié)一下不同鎖的適用范圍和場景。

synchronized 代碼塊的底層實(shí)現(xiàn)
為了便于方便研究,我們把 synchronized 修飾代碼塊的示例簡單化,如下代碼所示
public class SynchronizedTest {
private int i;
public void syncTask(){
synchronized (this){
i++;
}
}
}
我們主要關(guān)注一下 synchronized 的字節(jié)碼,如下所示

從這段字節(jié)碼中我們可以知道,同步語句塊使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令指向同步代碼塊的結(jié)束位置。
那么為什么會(huì)有兩個(gè) monitorexit 呢?
不知道你注意到下面的異常表了嗎?如果你不知道什么是異常表,那么我建議你讀一下這篇文章
看完這篇Exception 和 Error,和面試官扯皮就沒問題了
synchronized 修飾方法的底層原理
方法的同步是隱式的,也就是說 synchronized 修飾方法的底層無需使用字節(jié)碼來控制,真的是這樣嗎?我們來反編譯一波看看結(jié)果
public class SynchronizedTest {
private int i;
public synchronized void syncTask(){
i++;
}
}
這次我們使用 javap -verbose 來輸出詳細(xì)的結(jié)果

從字節(jié)碼上可以看出,synchronized 修飾的方法并沒有使用 monitorenter 和 monitorexit 指令,取得代之是ACC_SYNCHRONIZED 標(biāo)識(shí),該標(biāo)識(shí)指明了此方法是一個(gè)同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標(biāo)志來辨別一個(gè)方法是否聲明為同步方法,從而執(zhí)行相應(yīng)的同步調(diào)用。這就是 synchronized 鎖在同步代碼塊上和同步方法上的實(shí)現(xiàn)差別。
完
往期推薦
??
另外,cxuan 肝了六本 PDF,公號(hào)回復(fù) cxuan ,領(lǐng)取作者全部 PDF 。

