深入解析volatile關(guān)鍵字

文章已同步發(fā)表于微信公眾號JasonGaoH,深入解析volatile關(guān)鍵字

volatile關(guān)鍵字synchronized關(guān)鍵字一樣,在Java多線程開發(fā)中,是一道必須要跨越的檻。之前有篇文章已經(jīng)分析過synchronized關(guān)鍵字的原理,synchronized關(guān)鍵字的原理,這一次,我們來一步一步分析下volatile關(guān)鍵字的工作原理。

本文篇幅稍微有點長,希望您能耐心看下去,并有所收獲。

volatile關(guān)鍵字的使用

首先,我們從一個簡單的程序來入手。

public class VolatileFoo {
    //init_value的最大值
    final static int MAX = 5;
    //init_value的初始值
    static int init_value = 0;

    public static void main(String[] args) {
        //啟動一個Reader線程,當發(fā)現(xiàn)local_value和init_value不同時,
        //則輸出init_value被修改的信息
        new Thread(() -> {
            int localValue = init_value;
            while(localValue < MAX) {
                if(init_value != localValue) {
                    System.out.println("this init_value is updated to " + init_value);
                    //對local_value重新賦值
                    localValue = init_value;
                }
            }
        },"Readder").start();
    
        new Thread(() -> {
            int localValue = init_value;
            while(localValue < MAX) {
                System.out.println("this init_value will be changed to " + ++localValue);
                //對local_value重新賦值
                init_value = localValue;
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"Updater").start();
    }
}

上面的程序分別啟動了兩個線程,一個線程負責對變量進行修改,一個線程負責對變量進行輸出。

運行程序,輸出結(jié)果如下:

this init_value will be changed to 1
this init_value is updated to 1
this init_value will be changed to 2
this init_value will be changed to 3
this init_value will be changed to 4
this init_value will be changed to 5

從輸出信息我們發(fā)現(xiàn),Reader線程沒有感知到init_value的變化,我們期望的是在Updater進程更新init_value的值之后,Reader進程能夠打印出變化的init_value的值,但結(jié)果并不是我們期望的那樣。

我們嘗試在init_value前面加上volatile

static volatile int init_value = 0;

接著我們再運行下這個程序,輸出結(jié)果如下:

this init_value will be changed to 1
this init_value is updated to 1
this init_value will be changed to 2
this init_value is updated to 2
this init_value will be changed to 3
this init_value is updated to 3
this init_value will be changed to 4
this init_value is updated to 4
this init_value will be changed to 5
this init_value is updated to 5

這個時候Reader線程就能夠感受到init_value的值的變化了,并且在條件不滿足時程序就退出了運行。

那么為什么加了個volatile就正常了呢, volatile關(guān)鍵字的作用到底是什么呢?

想要徹底搞清楚volatile關(guān)鍵字,還需要具備Java內(nèi)存模型、CPU緩存模型、匯編指令等相關(guān)知識的,接下來,我們接下來一步一步來拆解問題。

CPU緩存模型

要想對volatile有比較深刻的理解,首先我們需要對CPU的緩存模型有一定的認識。

在計算機中,所有的運算操作都是由CPU的寄存器來完成的,CPU指令的執(zhí)行過程需要涉及數(shù)據(jù)的讀取和寫入操作,CPU所能訪問的所有數(shù)據(jù)只能是計算機的主存(通常是指RAM),雖然CPU的發(fā)展頻率不斷得到提升,但受制于制造工藝以及成本的限制,計算機的內(nèi)存反倒在訪問速度上沒有多大的突破,因此CPU的處理速度和內(nèi)存的訪問速度之間的差距越拉越大,通常這種差距可以達到上千倍,極端情況下甚至會在上萬倍以上。

由于兩邊速度嚴重的不對等,通過傳統(tǒng)FSB直連內(nèi)存的訪問方式會導致CPU資源受到極大的限制,降低CPU整體的吞吐量,于是就有了CPU和主內(nèi)存直接增加緩存的設計,現(xiàn)在緩存數(shù)量都可以增加到3級了,最靠近CPU的緩存為L1,然后依次是L2,L3和主內(nèi)存,CPU緩存模型圖如下所示:


CPU緩存模型圖

Cache的出現(xiàn)是為了解決CPU直接訪問內(nèi)存效率低下的問題,程序在運行的過程中,會將運算所需要的數(shù)據(jù)從主內(nèi)存復制一份到CPU Cache中,這樣CPU計算時就可以直接對CPU Cache中的數(shù)據(jù)進行讀取和寫入,當運算結(jié)束之后,再將CPU Cache中最新的數(shù)據(jù)刷新到主內(nèi)存當中,CPU通過直接訪問Cache的方式提到直接訪問主內(nèi)存的方式極大地提高了CPU的吞吐能力,有個CPU Cache之后,整體的CPU和主內(nèi)存之間的交互的架構(gòu)大致如下圖所示:


CPU與主內(nèi)存交互圖

Java內(nèi)存模型

由于緩存的出現(xiàn),極大地提高了CPU的吞吐能力,但是同時也引入了緩存不一致的問題。在多處理器系統(tǒng)中,每個處理器都有自己的的高速緩存,而它們又共享同一主內(nèi)存,當多個處理器的運算任務都設計到同一塊內(nèi)存區(qū)域時,將可能導致各自的緩存數(shù)據(jù)不一致,這個時候就需要通過緩存一致性協(xié)議來保證數(shù)據(jù)的正確性,不同的操作系統(tǒng)使用緩存一致性協(xié)議都各不相同。

因為各種硬件和操作系統(tǒng)的內(nèi)存訪問是有差異的,Java為了程序能在各種平臺下運行達到一致的內(nèi)存訪問效果,于是定義了Java內(nèi)存模型(Java Memory Mode,JMM)來對特定內(nèi)存或高速緩存的讀寫訪問過程進行抽象。

Java內(nèi)存模型定義了線程和主內(nèi)存之間的抽象關(guān)系,具體如下。

  • 共享變量存儲于主內(nèi)存之中,每個線程都可以訪問。
  • 每個線程都有私有的工作內(nèi)存和本地內(nèi)存。
  • 工作內(nèi)存值存儲該線程對共享變量的副本。
  • 線程不能直接操作主內(nèi)存,只有先操作了工作內(nèi)存之后才能寫入主內(nèi)存。
  • 工作內(nèi)存和Java內(nèi)存模型一樣也是一個抽象的概念,它其實并不存在,它涵蓋了緩存、寄存器、編譯優(yōu)化以及硬件等。
    image

    Java內(nèi)存模型定義了一套主內(nèi)存和工作內(nèi)存的交互協(xié)議,即一個變量如何從主內(nèi)存拷貝到工作內(nèi)存、如何從工作內(nèi)存同步到主內(nèi)存之類的實現(xiàn)細節(jié)。具體有8種操作來完成,分別為lock、unlock、read、load、use、assign、store和write。除此之外,Java內(nèi)存模型還規(guī)定在執(zhí)行這8種操作的時候必須滿足8種規(guī)則,由于篇幅問題,這里就不一一列舉了,具體可參看深入理解Java虛擬機第12章的Java內(nèi)存模型與線程。

Java內(nèi)存模型是一個抽象的概念,其與計算機硬件的結(jié)構(gòu)并不完全一樣,比如計算機物理內(nèi)存不會存在棧內(nèi)存和堆內(nèi)存的劃分,無論是堆內(nèi)存還是虛擬機棧內(nèi)存都會對應到物理的主內(nèi)存,當然也有一部分堆棧內(nèi)存數(shù)據(jù)可能會存入CPU Cache寄存器中。具體可參考下圖:

image

對于volatile變量的特殊規(guī)則

介紹了CPU緩存模型以及Java內(nèi)存模型之后,我們再來說volatile關(guān)鍵字,這樣更能加深我們對于volatile關(guān)鍵字的理解。
volatile關(guān)鍵字是Java虛擬機提供的最輕量級的同步機制,很多人由于對它理解不夠,往往更愿意使用synchronized來做同步。

Java內(nèi)存模型volatile關(guān)鍵字定義了一些特殊的訪問規(guī)則,當一個變量被volatile修飾后,它將具備兩種特性,或者說volatile具有下列兩層語義:

  • 第一、保證了不同線程對這個變量進行讀取時的可見性, 即一個線程修改了某個變量的值, 這新值對其他線程來說是立即可見的。 (volatile 解決了線程間共享變量的可見性問題)。
  • 第二、禁止進行指令重排序, 阻止編譯器對代碼的優(yōu)化。

針對第一點,volatile保證了不同線程對這個變量進行讀取時的可見性,具體表現(xiàn)為:

  • 第一: 使用 volatile 關(guān)鍵字會強制將在某個線程中修改的共享變量的值立即寫入主內(nèi)存。
  • 第二: 使用 volatile 關(guān)鍵字的話, 當線程 2 進行修改時, 會導致線程 1 的工作內(nèi)存中變量的緩存行無效(反映到硬件層的話, 就是 CPU 的 L1或者 L2 緩存中對應的緩存行無效);
  • 第三: 由于線程 1 的工作內(nèi)存中變量的緩存行無效, 所以線程 1再次讀取變量的值時會去主存讀取。

基于這一點,所以我們經(jīng)常會看到文章中或者書本中會說volatile 能夠保證可見性。

volatile 能夠保證可見性,但是volatile不能保證程序的原子性。

public class VolatileTest {
    public static volatile int race = 0;
    
    public static void increase() {
        race ++;
    }
    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for(int i =0 ;i<THREAD_COUNT;i++) {
            threads[i] = new Thread(() ->{
                for(int j =0;j< 10000;j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        
        while(Thread.activeCount() > 1)
            Thread.yield();
        
        System.out.println(race);
    }
}

這段代碼發(fā)起了20個線程,每個線程對race變量進行10000次自增操作,如果這段代碼能夠正確并發(fā)的話,最后輸出的結(jié)果應該是200000。我們運行完這段代碼之后,并沒有獲得期望的結(jié)果,而且發(fā)現(xiàn)每次運行程序。輸出的結(jié)果都不一樣,都是一個小于200000的數(shù)字。

問題就出在自增運算”race++“之中,我們用javap反編譯這段代碼后發(fā)現(xiàn)只有一行代碼的increase()方法在Class文件中是由4條字節(jié)碼指令構(gòu)成的。

  public static void increase();
    Code:
       0: getstatic     #13                 // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #13                 // Field race:I
       8: return

從字節(jié)碼層面上很容易分析出原因了:當getstatic指令把race的值取到操作棧時,volatile關(guān)鍵字保證了race的值此時是正確的,但是在執(zhí)行iconst_1、iAdd這些指令的時候,其他線程可能已經(jīng)把race的值加大了,而在操作棧訂的值就變成了過期的數(shù)據(jù),所以putstati指令執(zhí)行后就可能把較小的值同步回主內(nèi)存中去了。

其實這里我們通過字節(jié)碼來分析這個問題是不嚴謹?shù)?,因為即使編譯出來的只有一條字節(jié)指令,也并不意味執(zhí)行這條指令就是一個原子操作。一條字節(jié)碼指令在解釋執(zhí)行時,解釋器將要運行許多行代碼才能實現(xiàn)它的語義,如果是編譯執(zhí)行,一條字節(jié)碼指令也可能轉(zhuǎn)化成若干條本地機器碼指令。關(guān)于解釋執(zhí)行和編譯執(zhí)行,我們還會再講到。

由于volatile變量只能保證可見性,在不符合以下兩條規(guī)則的運算場景中,我們?nèi)匀灰ㄟ^加鎖(synchronized或java.util.concurrent中的原子類)來保證原子性。

  • 運輸結(jié)果并不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。
  • 變量不需要與其他狀態(tài)變量共同參與不變約束。

類似下面的場景就時候采用volatile來控制并發(fā)。

volatile boolean shutdownRequested;
public void shutdown() {
    shutdownRequested = true;
}
public void doWork() {
    while(!shutdownRequested) {
        //do stuff
    }
}

如果我們想讓上面的那個自增操作保持原子性,我們可以使用AtomicInteger,具體程序如下,這里就不多做介紹了。

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileTest {
//  public static volatile int race = 0;
    public static AtomicInteger race =new  AtomicInteger(0);
    
    public static void increase() {
//      race ++;
        race.incrementAndGet();
    }
    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for(int i =0 ;i<THREAD_COUNT;i++) {
            threads[i] = new Thread(() ->{
                for(int j =0;j< 10000;j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        while(Thread.activeCount() > 1)
            Thread.yield();
        
        System.out.println(race.get());
    }
}

回到volatile關(guān)鍵字的第二層語義:禁止指令重排。
普通的變量僅僅會保證在該方法的執(zhí)行過程中所有依賴賦值結(jié)果的地方都能獲取到正確的結(jié)果,而不能保證變量賦值操作的順序與程序代碼中的執(zhí)行順序一致。

我們用一段偽代碼來幫助下理解:

Map configOptions;
char[] configText;
//此變量必須定義為volatile
volatile boolean initialized = false;

//假設一下代碼在線程A中執(zhí)行
//模擬讀取配置信息,當讀取完成后將initialized設置為true以通知其他線程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processCongigOptions(configText,configOptions);
initialized = true

//假設一下代碼在線程B中執(zhí)行
//等待initialized為true,代表線程A已經(jīng)吧配置信息初始化完成
while(!initialized) {
    sleep();
}
//使用線程A中初始化好的配置信息
doSomethingWithConfig();

上面這段代碼如果定義的initialized沒有使用volatile來修飾,就可能會由于指令重排序的優(yōu)化,導致位于線程A中最后一句代碼initialized = true被提前執(zhí)行(這里雖然使用Java作為偽代碼,但所指的重排序優(yōu)化是機器級的優(yōu)化操作,提前執(zhí)行時值這句話對于的匯編代碼被提前執(zhí)行),這樣在線程B中使用配置信息的代碼就可能出現(xiàn)錯誤,而volatile能避免此類情況的發(fā)生。

volatile關(guān)鍵字深入解析

上面講到volatile關(guān)鍵字的兩層語義,那么volatile保證可見性以及有序性到底是如何做到的呢?它的底層邏輯是什么呢?

這里我們嘗試獲得Java程序的匯編代碼,通過比較變量加入volatile修飾和未加入volatile修飾的區(qū)別。

這里主要使用的是HSDIS插件,HSDIS是一個Sun官方推薦的HotSpot虛擬機JIT編譯代碼的反匯編插件,網(wǎng)上有關(guān)于這個插件的下載,不過有的鏈接已經(jīng)失效,我這里是從這里獲取的,hsdis,再把這個clone下來之后,編譯成功之后,使用下面這個命令拷貝到jre的server目錄,具體可以查看這個repo中README文件,里面寫的很詳細。

sudo cp build/macosx-amd64/hsdis-amd64.dylib /Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home/jre/lib/server/

接下來就可以嘗試反匯編了。

public class Singleton {
    private static Singleton instance;
    
    public static Singleton getInstance() {
        if(instance ==null) {
            synchronized(Singleton.class) {
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    
    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

上面這個是我們嘗試反匯編的程序代碼,如果是命令行,我們可以使用下面這個指令。

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Singleton

如果是eclipse,在下圖的VM arguments中添加XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly,然后運行程序,這樣在控制臺就會輸出匯編代碼。

eclipse_jit.png

程序運行后,在控制臺會輸出很多內(nèi)容,由于輸出太大,所以截取了前面一段輸出。

Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
Loaded disassembler from /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/server/hsdis-amd64.dylib
Decoding compiled method 0x0000000112e9ad50:
Code:
[Disassembling for mach='i386:x86-64']
[Entry Point]
[Constants]
  # {method} {0x000000010ce1f000} 'hashCode' '()I' in 'java/lang/String'
  #           [sp+0x40]  (sp of caller)
  0x0000000112e9aec0: mov    0x8(%rsi),%r10d
  0x0000000112e9aec4: shl    $0x3,%r10
  0x0000000112e9aec8: cmp    %rax,%r10
  0x0000000112e9aecb: jne    0x0000000112de0e60  ;   {runtime_call}
  0x0000000112e9aed1: data16 data16 nopw 0x0(%rax,%rax,1)
  0x0000000112e9aedc: data16 data16 xchg %ax,%ax
[Verified Entry Point]
  0x0000000112e9aee0: mov    %eax,-0x14000(%rsp)
  .....

得到這個輸出之后,我使用Singleton全局搜索了下,發(fā)現(xiàn)還無結(jié)果。

XmXvXX.png

反編譯的卻沒有得到相應的內(nèi)容,這是什么問題呢?
帶著這個問題Google了好久,終于搞明白原因了。

于是我們又要來補充些虛擬機編譯的知識了。

image

我們在使用java -version查看JDK版本的時候,可以看到最后有個mixed mode,這里其實表明的是Java 虛擬機的編譯方式,在HotSpot虛擬機中,提供了兩種編譯模式:解釋執(zhí)行 和 即時編譯(JIT,Just-In-Time),即時編譯也可以稱為編譯執(zhí)行,解釋執(zhí)行即逐條翻譯字節(jié)碼為可運行的機器碼,而即時編譯則以方法為單位將字節(jié)碼翻譯成機器碼。

我們在反編譯Singleton這個類的時候,因為虛擬機使用的是解釋執(zhí)行,這樣我們是得不到匯編代碼的。在深入理解Java虛擬機一書中介紹可以加上-Xcomp來觸發(fā)JIT編譯,但是我用的是JDK1.8,這個 -Xcomp`已經(jīng)被移除了,具體哪個版本被移除了,目前我也沒仔細研究過了。

那要怎樣才能觸發(fā)JIT編譯呢?答案是循環(huán)。通過足夠多次數(shù)的循環(huán)來觸發(fā)JIT編譯。我們需要確保寫的Java方法被調(diào)用的次數(shù)足夠多,以觸發(fā)C1(客戶端)編譯,并大約10000次觸發(fā)C2(服務器)編譯器并打開高級優(yōu)化。換句話說,要想查看匯編代碼,我們所寫的Java源代碼文件不能太過于簡單,要足夠復雜。

注意:C1,C2都是HotSpot虛擬機內(nèi)置的即時編譯器。C1:即Client編譯器,面向?qū)有阅苡幸蟮目蛻舳薌UI程序,采用的優(yōu)化手段比較簡單,因此編譯的時間較短。C2:即Server編譯器,面向?qū)π阅芊逯涤幸蟮姆斩顺绦?,采用的?yōu)化手段復雜,因此編譯時間長,但是在運行過程中性能更好。

public class Singleton {
    private static Singleton instance;
    
    public static Singleton getInstance() {
        if(instance ==null) {
            synchronized(Singleton.class) {
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    
    public static void main(String[] args) {
        for(int i=0;i<100;i++) {
            print();
        }
    }
    private static void print() {
        for(int i =0;i<=1000;i++) {
            Singleton.getInstance();
        }
    }
}

于是我在代碼里加上了兩層循環(huán),然后在嘗試獲取一些匯編代碼。

這次發(fā)現(xiàn)終于能得到Singleton相關(guān)的匯編代碼了。

于是我們分別編譯了兩次,第一個是沒有使用volatile關(guān)鍵字修飾instance,第二個是使用volatile關(guān)鍵字,然后我們分別取出Singleton::getInstance這一段來進行比較。

 // 未使用volatile修飾
  0x000000010d29e931: movabs $0x7955f12a8,%rsi  ;   {oop(a 'java/lang/Class' = 'main/Singleton')}
  0x000000010d29e93b: mov    %rax,%r10
  0x000000010d29e93e: shr    $0x3,%r10
  0x000000010d29e942: mov    %r10d,0x68(%rsi)
  0x000000010d29e946: shr    $0x9,%rsi
  0x000000010d29e94a: movabs $0xfe403000,%rax
  0x000000010d29e954: movb   $0x0,(%rsi,%rax,1)  ;*putstatic instance
                                                ; - main.Singleton::getInstance@24 (line 10)
// 使用volatile修飾
 0x000000011435394f: movabs $0x7955f12a8,%rsi  ;   {oop(a 'java/lang/Class' = 'main/Singleton')}
  0x0000000114353959: mov    %rax,%r10
  0x000000011435395c: shr    $0x3,%r10
  0x0000000114353960: mov    %r10d,0x68(%rsi)
  0x0000000114353964: shr    $0x9,%rsi
  0x0000000114353968: movabs $0x10db6e000,%rax
  0x0000000114353972: movb   $0x0,(%rsi,%rax,1)
  0x0000000114353976: lock addl $0x0,(%rsp)     ;*putstatic instance
                                                ; - main.Singleton::getInstance@24 (line 10)

雖然對于匯編指令了解不多,但還是能從兩個對比中看出差異所在。
很明顯,在movb $0x0,(%rsi,%rax,1) 之后,加了volatile修飾的匯編代碼后面多了一條匯編指令lock addl $0x0,(%rsp),這個操作相當于一個內(nèi)存屏障,指令重排時不能把后面的指令重排序到內(nèi)存屏障之前的位置,當只有一個CPU訪問內(nèi)存時,并不需要內(nèi)存屏障,當如果有兩個或多個CPU訪問同一塊內(nèi)存,且其中有一個在觀測另一個,就需要內(nèi)存屏障來保證一致性了。lock addl $0x0,(%rsp) 表示把rsp的寄存器的值加0,這顯然是一個空操作,關(guān)鍵在于lock前綴。

L6wWZt.jpg

查詢IA32手冊,lock前綴會強制執(zhí)行原子操作,它的作用是是的本CPU的Cache寫入了內(nèi)存,該寫入動作會引起別的CPU無效化其Cache。所有通過這樣一個空操作,可讓前面volatile變量的便是對其他CPU可見。

那為什么說它能禁止指令重排呢?從硬件架構(gòu)上講,指令重排序是指CPU采用了運行將多條指令不按程序規(guī)定的順序分開發(fā)送給各相應的點了單元處理,但并不是指令任意重排,CPU需要能正確處理指令依賴情況以保障程序能得出正確的執(zhí)行結(jié)果。lock addl $0x0,(%rsp) 指令把修改同步到內(nèi)存時,意味著所有之前的操作都已經(jīng)執(zhí)行完成,這樣便形成了" 指令重排序無法越過內(nèi)存屏障"的效果。

總結(jié)來說,內(nèi)存屏障有兩個作用:
先于這個內(nèi)存屏障的指令必須先執(zhí)行, 后于這個內(nèi)存屏障的指令必須后執(zhí)行。
如果你的字段是volatile,在讀指令前插入讀屏障,可以讓高速緩存中的數(shù)據(jù)失效,重新從主內(nèi)存加載數(shù)據(jù)。在寫指令之后插入寫屏障,能讓寫入緩存的最新數(shù)據(jù)寫回到主內(nèi)存。

關(guān)于volatile關(guān)鍵字的介紹就到這里了,感謝,如果覺得還可以請幫忙點個贊,有問題歡迎留言討論。

原文鏈接

參考

[深入理解Java虛擬機]
[Java高并發(fā)編程詳解]

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容