并發隊列-無界阻塞隊列LinkedBlockingQueue原理探究

一、前言

前面介紹了使用CAS實現的非阻塞隊列ConcurrentLinkedQueue,下面就來介紹下使用獨占鎖實現的阻塞隊列LinkedBlockingQueue的實現

阿里巴巴長期招聘Java研發工程師p6,p7,p8等上不封頂級別,有意向的可以發簡歷給我,注明想去的部門和工作地點:1064454834@qq.com

二、 LinkedBlockingQueue類圖結構

image.png

如圖LinkedBlockingQueue中也有兩個Node分別用來存放首尾節點,并且里面有個初始值為0的原子變量count用來記錄隊列元素個數,另外里面有兩個ReentrantLock的獨占鎖,分別用來控制元素入隊和出隊加鎖,其中takeLock用來控制同時只有一個線程可以從隊列獲取元素,其他線程必須等待,putLock控制同時只能有一個線程可以獲取鎖去添加元素,其他線程必須等待。另外notEmpty和notFull用來實現入隊和出隊的同步。 另外由于出入隊是兩個非公平獨占鎖,所以可以同時又一個線程入隊和一個線程出隊,其實這個是個生產者-消費者模型。

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

/** Current number of elements */
private final AtomicInteger count = new AtomicInteger(0);

public static final int   MAX_VALUE = 0x7fffffff;

public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

  public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    //初始化首尾節點
    last = head = new Node<E>(null);
}

如圖默認隊列容量為0x7fffffff;用戶也可以自己指定容量。

三、必備基礎

3.1 ReentrantLock

可以參考 https://www.atatech.org/articles/80539?flag_data_from=active

3.2 條件變量(Condition)

條件變量這里使用的是takeLock.newCondition()獲取也就是說調用ReentrantLock的方法獲取的,那么可預見Condition使用了ReentrantLock的state。上面的參考沒有提到所以這里串串講下

  • 首先看下類圖結構
image.png

如圖ConditionObject中兩個node分別用來存放條件隊列的首尾節點,條件隊列就是調用條件變量的await方法被阻塞后的節點組成的單向鏈表。另外ConditionObject還要依賴AQS的state,ConditionObject是AQS類的一個內部類。

  • awaitNanos操作
public final long awaitNanos(long nanosTimeout)
        throws InterruptedException {

    //如果中斷標志被設置了,則拋異常
    if (Thread.interrupted())
        throw new InterruptedException();

    //添加當前線程節點到條件隊列,
    Node node = addConditionWaiter();

    //當前線程釋放獨占鎖
    int savedState = fullyRelease(node);
    long lastTime = System.nanoTime();
    int interruptMode = 0;

    while (!isOnSyncQueue(node)) {
        if (nanosTimeout <= 0L) {
            transferAfterCancelledWait(node);
            break;
        }
        //掛起當前線程直到超時
        LockSupport.parkNanos(this, nanosTimeout);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;

        long now = System.nanoTime();
        nanosTimeout -= now - lastTime;
        lastTime = now;
    }

    //unpark后,當前線程重新獲取鎖,有可能獲取不到被放到AQS的隊列
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null)
        unlinkCancelledWaiters();
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
    return nanosTimeout - (System.nanoTime() - lastTime);
}


    final int fullyRelease(Node node) {
        boolean failed = true;
        try {
            int savedState = getState();

            //釋放鎖,如果失敗則拋異常
            if (release(savedState)) {
                failed = false;
                return savedState;
            } else {
                throw new IllegalMonitorStateException();
            }
        } finally {
            if (failed)
                node.waitStatus = Node.CANCELLED;
        }
    }

首先如果當前線程中斷標志被設置了,直接拋出異常。添加當前線程節點(狀態為:-2)到條件隊列。

然后嘗試釋放當前線程擁有的鎖并保存當前計數,可知如果當前線程調用awaitNano前沒有使用當前條件變量所在的Reetenlock變量調用lock或者lockInterruptibly獲取到鎖,會拋出IllegalMonitorStateException異常。

然后調用park掛起當前線程直到超時或者其他線程調用了當前線程的unpark方法,或者調用了當前線程的interupt方法(這時候會拋異常)。

如果超時或者其他線程調用了當前線程的unpark方法,則當前線程從掛起變為激活,獲取cpu資源后會繼續執行,會重新獲取鎖。

  • signal操作

public final void signal() {

    //如果當前線程沒有持有鎖,拋異常
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();

    //從條件隊列找第一個狀態為CONDITION的,然后把狀態變為0
    Node first = firstWaiter;
    if (first != null)
        doSignal(first);
}

private void doSignal(Node first) {
    do {
        if ( (firstWaiter = first.nextWaiter) == null)
            lastWaiter = null;
        first.nextWaiter = null;
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

final boolean transferForSignal(Node node) {
   

    //狀態為CONDITION的,然后把狀態變為0
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
        return false;
    

    //把條件隊列的上面狀態為0的節點放入AQS阻塞隊列
    Node p = enq(node);
    int ws = p.waitStatus;

    //調用unpark激活掛起的線程
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
        LockSupport.unpark(node.thread);
    return true;
}

首先看調用signal的線程是不是持有了獨占鎖,沒有則拋出異常。
然后獲取在條件隊列里面待的時間最長的node,把它移動到線程持有的鎖所在的AQS隊列。

其中enq方法就是把當前節點放入了AQS隊列,但是這時候該節點還是在條件隊列里面那,那么什么時候從條件隊列移除那?其實在await里面的unlinkCancelledWaiters方法。

總結: 無論是條件變量的await和singal都是需要先獲取獨占鎖才能調用,因為條件變量使用的就是獨占鎖里面的state管理狀態,否者會報異常。

四 、帶超時時間的offer操作-生產者

在隊尾添加元素,如果隊列滿了,那么等待timeout時候,如果時間超時則返回false,如果在超時前隊列有空余空間,則插入后返回true。

public boolean offer(E e, long timeout, TimeUnit unit)
    throws InterruptedException {

    //空元素拋空指針異常
    if (e == null) throw new NullPointerException();
    long nanos = unit.toNanos(timeout);
    int c = -1;
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;

    //獲取可被中斷鎖,只有一個線程克獲取
    putLock.lockInterruptibly();
    try {

        //如果隊列滿則進入循環
        while (count.get() == capacity) {
            //nanos<=0直接返回
            if (nanos <= 0)
                return false;
            //否者調用await進行等待,超時則返回<=0(1)
            nanos = notFull.awaitNanos(nanos);
        }
        //await在超時時間內返回則添加元素(2)
        enqueue(new Node<E>(e));
        c = count.getAndIncrement();

        //隊列不滿則激活其他等待入隊線程(3)
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        //釋放鎖
        putLock.unlock();
    }

    //c==0說明隊列里面有一個元素,這時候喚醒出隊線程(4)
    if (c == 0)
        signalNotEmpty();
    return true;
}

private void enqueue(Node<E> node) {   
    last = last.next = node;
}

    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }

如果獲取鎖前面有線程調用了putLock. interrupt(),并且后面沒有調用interrupted()重置中斷標志,調用lockInterruptibly時候會拋出InterruptedException異常。

隊列滿的時候調用notFull.awaitNanos阻塞當前線程,當前線程會釋放獲取的鎖,然后等待超時或者其他線程調用了notFull.signal()才會返回并重新獲取鎖,或者其他線程調用了該線程的interrupt方法設置了中斷標志,這時候也會返回但是會拋出InterruptedException異常。

如果超時則直接返回false,如果超時前調用了notFull.signal()則會退出循環,執行(2)添加元素到隊列,然后執行(3),(3)的目的是為了激活其他入隊等待線程。(4)的話c==0說明隊列里面已經有一個元素了,這時候就可以激活等待出隊線程了。

另外signalNotEmpty函數是先獲取獨占鎖,然后在調用的signal這也證明了3.2節的結論。

五、 帶超時時間的poll操作-消費者

獲取并移除隊首元素,在指定的時間內去輪詢隊列看有沒有首元素有則返回,否者超時后返回null

public E poll(long timeout, TimeUnit unit) throws InterruptedException {
    E x = null;
    int c = -1;
    long nanos = unit.toNanos(timeout);
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;

    //出隊線程獲取獨占鎖
    takeLock.lockInterruptibly();
    try {

        //循環直到隊列不為空
        while (count.get() == 0) {

            //超時直接返回null
            if (nanos <= 0)
                return null;
            nanos = notEmpty.awaitNanos(nanos);
        }

        //出隊,計數器減一
        x = dequeue();
        c = count.getAndDecrement();

        //如果出隊前隊列不為空則發送信號,激活其他阻塞的出隊線程
        if (c > 1)
            notEmpty.signal();
    } finally {
        //釋放鎖
        takeLock.unlock();
    }

    //當前隊列容量為最大值-1則激活入隊線程。
    if (c == capacity)
        signalNotFull();
    return x;
}

首先獲取獨占鎖,然后進入循環當當前隊列有元素才會退出循環,或者超時了,直接返回null。

超時前退出循環后,就從隊列移除元素,然后計數器減去一,如果減去1前隊列元素大于1則說明當前移除后隊列還有元素,那么就發信號激活其他可能阻塞到當前條件信號的線程。

最后如果減去1前隊列元素個數=最大值,那么移除一個后會騰出一個空間來,這時候可以激活可能存在的入隊阻塞線程。

六、put操作-生產者

與帶超時時間的poll類似不同在于put時候如果當前隊列滿了它會一直等待其他線程調用notFull.signal才會被喚醒。

七、 take操作-消費者

與帶超時時間的poll類似不同在于take時候如果當前隊列空了它會一直等待其他線程調用notEmpty.signal()才會被喚醒。

八、 size操作

當前隊列元素個數,如代碼直接使用原子變量count獲取。

    public int size() {
        return count.get();
    }

九、peek操作

獲取但是不移除當前隊列的頭元素,沒有則返回null


    public E peek() {
        //隊列空,則返回null
        if (count.get() == 0)
            return null;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            Node<E> first = head.next;
            if (first == null)
                return null;
            else
                return first.item;
        } finally {
            takeLock.unlock();
        }
    }

十、 remove操作

刪除隊列里面的一個元素,有則刪除返回true,沒有則返回false,在刪除操作時候由于要遍歷隊列所以加了雙重鎖,也就是在刪除過程中不允許入隊也不允許出隊操作

public boolean remove(Object o) {
    if (o == null) return false;

    //雙重加鎖
    fullyLock();
    try {

        //遍歷隊列找則刪除返回true
        for (Node<E> trail = head, p = trail.next;
             p != null;
             trail = p, p = p.next) {
            if (o.equals(p.item)) {
                unlink(p, trail);
                return true;
            }
        }
        //找不到返回false
        return false;
    } finally {
        //解鎖
        fullyUnlock();
    }
}

void fullyLock() {
    putLock.lock();
    takeLock.lock();
}

void fullyUnlock() {
    takeLock.unlock();
    putLock.unlock();
}

void unlink(Node<E> p, Node<E> trail) {
    
    p.item = null;
    trail.next = p.next;
    if (last == p)
        last = trail;
    //如果當前隊列滿,刪除后,也不忘記最快的喚醒等待的線程
    if (count.getAndDecrement() == capacity)
        notFull.signal();
}

十一、開源框架中使用

tomcat中任務隊列TaskQueue

11.1 類圖結構

image.png

可知TaskQueue繼承了LinkedBlockingQueue并且泛化類型固定了為Runnalbe.重寫了offer,poll,take方法。

11.2 TaskQueue

tomcat中有個線程池ThreadPoolExecutor,在NIOEndPoint中當acceptor線程接受到請求后,會把任務放入隊列,然后poller 線程從隊列里面獲取任務,然后就吧任務放入線程池執行。這個ThreadPoolExecutor中的的一個參數就是TaskQueue。

先看看ThreadPoolExecutor的參數如果是普通LinkedBlockingQueue是怎么樣的執行邏輯:
當調用線程池方法 execute() 方法添加一個任務時:

  • 如果當前運行的線程數量小于 corePoolSize,則創建新線程運行該任務
  • 如果當前運行的線程數量大于或等于 corePoolSize,則將這個任務放入阻塞隊列。
  • 如果當前隊列滿了,并且當前運行的線程數量小于 maximumPoolSize,則創建新線程運行該任務;
  • 如果當前隊列滿了,并且當前運行的線程數量大于或等于 maximumPoolSize,那么線程池將會拋出RejectedExecutionException異常。
    如果線程執行完了當前任務,那么會去隊列里面獲取一個任務來執行,如果任務執行完了,并且當前線程數大于corePoolSize,那么會根據線程空閑時間keepAliveTime回收一些線程保持線程池corePoolSize個線程。

首先看下線程池中exectue添加任務時候的邏輯:

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
 
    //當前工作線程個數小于core個數則開新線程執行(1)
    int c = ctl.get();
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    //放入隊列(2)
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }

    //如果隊列滿了則開新線程,但是個數要不超過最大值,超過則返回false
    //然后執行reject handler(3)
    else if (!addWorker(command, false))
        reject(command);
}

可知當當前工作線程個數為corePoolSize后,如果在來任務會把任務添加到隊列,隊列滿了或者入隊失敗了則開啟新線程。

然后看看TaskQueue中重寫的offer方法的邏輯:

public boolean offer(Runnable o) {
    // 如果parent為null則直接調用父類方法
    if (parent==null) return super.offer(o);
    //如果當前線程池中線程個數達到最大,則無條件調用父類方法
    if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o);
    //如果當前提交的任務小于當前線程池線程數,說明線程用不完,沒必要重新開線程
    if (parent.getSubmittedCount()<(parent.getPoolSize())) return super.offer(o);
    //如果當前線程池線程個數>core個數但是小于最大個數,則開新線程代替放入隊列
    if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false;
    //到了這里,無條件調用父類
    return super.offer(o);
}

可知parent.getPoolSize()<parent.getMaximumPoolSize()普通隊列會把當前任務放入隊列,TAskQueue則是返回false,因為這會開啟新線程執行任務,當然前提是當前線程個數沒有達到最大值。

然后看下Worker線程中如果從隊列里面獲取任務執行的:

 final void runWorker(Worker w) {
               ...
        try {
            while (task != null || (task = getTask()) != null) {
               ...
            }
            completedAbruptly = false;
        } finally {
              ...
        }
}

private Runnable getTask() {
        boolean timedOut = false; // Did the last poll() time out?

        for (;;) {
            int c = ctl.get();
            int rs = runStateOf(c);
            ...

            int wc = workerCountOf(c);

            ...

            try {
                //根據timed決定調用poll還是take
                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }
        }
}

十二、總結

12.1 并發安全總結

仔細思考下阻塞隊列是如何實現并發安全的維護隊列鏈表的,先分析下簡單的情況就是當隊列里面有多個元素時候,由于同時只有一個線程(通過獨占鎖putLock實現)入隊元素并且是操作last節點(,而同時只有一個出隊線程(通過獨占鎖takeLock實現)操作head節點,所以不存在并發安全問題。

image.png
  • 考慮當隊列為空的時候隊列狀態為:
image.png

這時候假如一個線程調用了take方法,由于隊列為空,所以count.get()==0所以當前線程會調用notEmpty.await()把自己掛起,并且放入notEmpty的條件隊列,并且釋放當前條件變量關聯的通過takeLock.lockInterruptibly()獲取的獨占鎖。由于釋放了鎖,所以這時候其他線程調用take時候就會通過takeLock.lockInterruptibly()獲取獨占鎖,然后同樣阻塞到notEmpty.await(),同樣會被放入notEmpty的條件隊列,也就說在隊列為空的情況下可能會有多個線程因為調用take被放入了notEmpty的條件隊列。

這時候如果有一個線程調用了put方法,那么就會調用enqueue操作,該操作會在last節點后面添加新元素并且設置last為新節點。然后count.getAndIncrement()先獲取當前隊列元個數為0保存到c,然后自增count為1,由于c==0所以調用signalNotEmpty激活notEmpty的條件隊列里面的阻塞時間最長的線程,這時候take中調用notEmpty.await()的線程會被激活await內部會重新去獲取獨占鎖獲取成功則返回,否者被放入AQS的阻塞隊列,如果獲取成功,那么count.get() >0因為可能多個線程put了,所以調用dequeue從隊列獲取元素(這時候一定可以獲取到),然后調用c = count.getAndDecrement() 把當前計數返回后并減去1,如果c>1 說明當前隊列還有其他元素,那么就調用 notEmpty.signal()去激活 notEmpty的條件隊列里面的其他阻塞線程。

  • 考慮當隊列滿的時候:
    當隊列滿的時候調用put方法時候,會由于notFull.await()當前線程被阻塞放入notFull管理的條件隊列里面,同理可能會有多個調用put方法的線程都放到了notFull的條件隊列里面。

這時候如果有一個線程調用了take方法,調用dequeue()出隊一個元素,c = count.getAndDecrement();count值減一;c==capacity;現在隊列有一個空的位置,所以調用signalNotFull()激活notFull條件隊列里面等待最久的一個線程。

12.2簡單對比

LinkedBlockingQueue與ConcurrentLinkedQueue相比前者前者是阻塞隊列使用可重入獨占的非公平鎖來實現通過使用put鎖和take鎖使得入隊和出隊解耦可以同時進行處理,但是同時只有一個線程可以入隊或者出隊,其他線程必須等待,另外引入了條件變量來進行入隊和出隊的同步,每個條件變量維護一個條件隊列用來存放阻塞的線程,要注意這個隊列和AQS的隊列不是一個東東。LinkedBlockingQueue的size操作通過使用原子變量count獲取能夠比較精確的獲取當前隊列的元素個數,另外remove方法使用雙鎖保證刪除時候隊列元素保持不變,另外其實這個是個生產者-消費者模型。

而ConcurrentLinkedQueue則使用CAS非阻塞算法來實現,使用CAS原子操作保證鏈表構建的安全性,當多個線程并發時候CAS失敗的線程不會被阻塞,而是使用cpu資源去輪詢CAS直到成功,size方法先比LinkedBlockingQueue的獲取的個數是不精確的,因為獲取size的時候是通過遍歷隊列進行的,而遍歷過程中可能進行增加刪除操作,remove方法操作時候也沒有對整個隊列加鎖,remove時候可能進行增加刪除操作,這就可能刪除了一個剛剛新增的元素,而不是刪除的想要位置的。

歡迎關注微信公眾號:‘技術原始積累’ 獲取更多技術干貨__

image.png
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容