密集計算場景下的 JNI 實戰

在 Java 發展歷程中,JNI 一直都是一個不可或缺的角色,但是在實際的項目開發中,JNI 這項技術應用的很少。在筆者經過艱難的踩坑之后,終于將 JNI 運用到了項目實戰,本文筆者將簡單介紹 JNI 技術,并介紹簡單的原理和性能分析。通過分享我們的實踐過程,帶各位讀者體驗 JNI 技術的應用。

一、 背景

計算密集型場景中,Java 語言需要花費較多時間優化 GC 帶來的額外開銷。并且在一些底層指令優化方面,C++ 這種“親核性”的語言有著較好的優勢和大量的業界實踐經驗。那么作為一個多年的 Java 程序員,能否在 Java 服務上面運行 C++ 代碼呢?答案是肯定的。

JNI (Java Native Interface) 技術正是應對該場景而提出的解決方案。雖然 JNI 技術讓我們能夠進行深度的性能優化,其較為繁瑣的開發方式也不免讓新人感到頭疼。本文通過 step by step 的方式介紹如何完成 JNI 的開發,以及我們優化的效果和思考。

開始正文前我們可以思考三個問題:

  1. 為什么選擇使用 JNI 技術?

  2. 如何在 Maven 項目中應用 JNI 技術?

  3. JNI 真的好用嗎?

二、關于 JNI:為什么會選擇它?

2.1 JNI 基本概念

JNI 的全稱叫做 Java Native Interface ,翻譯過來就是 Java 本地接口。愛看 JDK 源碼的小伙伴會發現,JDK 中有些方法聲明是帶有 native 修飾符的,并且找不到具體實現,其實是在非 Java 語言上,這就是 JNI 技術的體現。

早在 JDK1.0 版本就已經有了 JNI,官方給 JNI 的定義是:

Java Native Interface (JNI) is a standard programming interface for writing Java native methods and embedding the Java virtual machine into native applications. The primary goal is binary compatibility of native method libraries across all Java virtual machine implementations on a given platform.

JNI 是一種標準的程序接口,用于編寫 Java 本地方法,并且將 JVM 嵌入 Native 應用程序中。是為了給跨平臺上的 JVM 實現本地方法庫進行二進制兼容。

JNI 最初是為了保證跨平臺的兼容性,而設計出來的一套接口協議。并且由于 Java 誕生很早,所以 JNI 技術絕大部分情況下調用的是 C/C++ 和系統的 lib 庫,對其他語言的支持比較局限。隨著時間的發展,JNI 也逐漸被開發者所關注,比如 Android 的 NDK,Google 的 JNA,都是對 JNI 的擴展,讓這項技術能夠更加輕松的被開發者所使用。

我們可以看一下在 JVM 中 JNI 相關的模塊,如圖 1:


圖1 - JVM 內存和引擎執行關系

在 JVM 的內存區域,Native Interface 是一個重要的環節,連接著執行引擎和運行時數據區。本地接口 (JNI) 的方法在本地方法棧中管理 native 方法,在 Execution Engine 執行時加載本地方法庫。

JNI 就像是打破了 JVM 的束縛,擁有著和 JVM 同樣的能力,可以直接使用處理器中的寄存器,不僅可以直接使用處理器中的寄存器,還可以直接找操作系統申請任意大小的內存,甚至能夠訪問到 JVM 虛擬機運行時的數據,比如搞點堆內存溢出什么的:)

2.2 JNI 的功能

JNI 擁有著強大的功能,那它能做哪些事呢?官方文檔給出了參考答案。

  1. 標準 Java 類庫不支持應用程序所需的平臺相關特性。
  1. 您已經有一個用另一種語言編寫的庫,并希望通過 JNI 使其可供 Java 代碼訪問。
  1. 您想用較低級別的語言(例如匯編)實現一小部分耗時短的代碼。

當然還有一些擴充,比如:

  1. 不希望所寫的 Java 代碼被反編譯;
  1. 需要使用系統或已有的 lib 庫;
  1. 期望使用更快速的語言去處理大量的計算;
  1. 對圖像或本地文件操作頻繁;
  1. 調用系統驅動的接口。

或許還有別的場景,可以使用到 JNI,可以看到 JNI 技術有著非常好的應用潛力。

三、JNI 實戰:探究踩坑的全過程

我們的業務中存在一個計算密集型場景,需要從本地加載數據文件進行模型推理。項目組在 Java 版本進行了幾輪優化后發現沒有什么大的進展,主要表現為推理耗時較長,并且加載模型時存在性能抖動。經過調研,如果想進一步提高計算和加載文件的速度,可以使用 JNI 技術去編寫一個 C++ 的 lib 庫,由 Java native 方法進行調用,預計會有一定的提升。

然而項目組目前也沒有 JNI 的實踐經驗,最終性能是否能有提升,還是要打個問號。本著初生牛犢不怕虎的精神,我鼓起勇氣主動認領了這個優化任務。下面就分享一下我實踐 JNI 的過程和遇到的問題,給大家拋磚引玉。

3.1 場景準備

實戰就不從 Hello world 開始了,我們直接敲定場景,思考該讓 C++ 實現哪部分邏輯。

場景如下:


圖2 實戰場景

在計算服務中,我們將離線計算數據轉換成 map 結構,輸入一組 key 在 map 中查找并對 value 應用算法公式求值。通過分析 JVM 堆棧信息和火焰圖 (flame graph),發現性能瓶頸主要在大量的邏輯回歸運算和 GC 上面,由于緩存了量級很大的 Map 結構,導致占用 heap 內存很大,因此 GC Mark-and-Sweep 耗時很長,所以我們決定將加載文件和邏輯回歸運算兩個方法改造為 native 方法。

代碼如下:

/**
 * 加載文件
 * @param path 文件本地路徑
 * @return C++ 創建的類對象的指針地址
 */
public static native long loadModel(String path);

/**
 * 釋放 C++ 相關類對象
 * @param ptr  C++ 創建的類對象的指針地址
 */
public static native void close(long ptr);

/**
 * 執行計算
 * @param ptr C++ 創建的類對象的指針地址
 * @param keys 輸入的列表
 * @return 輸出的計算結果
 */
public static native float compute(long ptr, long[] keys);

那么,我們為什么要傳遞指針呢,并且設計了一個 close 方法呢?

  1. 便于兼容現有實現的考慮:雖然整個計算過程都在 C++ 運行時中進行,但對象的生命周期管理是在 Java 中實現的,所以我們選擇回傳加載并初始化后的模型對象指針,之后每次求值時僅傳遞該指針即可;
  1. 內存正確釋放的考慮:利用 Java 自身的 GC 和模型管理器代碼機制,在模型卸載時顯式調用 close 方法釋放 C++ 運行時管理的內存,防止出現內存泄漏。

當然,這個建議只適用于需要 lib 執行時將部分數據緩存在內存中的場景,只使用 native 方法進行計算,無需考慮這種情況。

3.2 環境搭建

下面簡單介紹一下我們所使用的環境和項目結構,這部分介紹的不是很多,如果有疑問可以參考文末的參考資料或者在網上進行查閱。

我們使用的是簡單的 maven 項目,使用 Docker 的 ubuntu-20.04 容器進行編譯和部署,需要在容器中安裝 GCC,Bazel,Maven,openJDK-8 等。如果是在 Windows 下進行開發,也可以安裝相應的工具并編譯成 .dll 文件,效果是一樣的。

我們創建好 maven 項目的目錄,如下:

/src # 主目錄
-/main
--/cpp  # c++ 倉庫目錄
---export_jni.h  # java 導出的文件
---computer.cc  # 具體的 C++ 代碼
---/third_party  # 三方庫
---WORKSPACE  # bazel 根目錄
---BUILD  # bazel 構建文件
--/java  # java 倉庫目錄
---/com
----/vivo
-----/demo
------/model
-------ModelComputer.java  # java 代碼
--/resources  # 存放 lib 的資源目錄
-/test
--/java
----ModelComputerTest.java  # 測試類
pom.xml  # maven pom

3.3 實戰過程

都已經準備好了,那么就直入正題:

package com.vivo.demo.model;
import java.io.*;

public class ModelComputer implements Closeable {
    static {
        // 加載 lib 庫
        loadPath("export_jni_lib");
    }

    /**
     * C++ 類對象地址
     */
    private Long ptr;

    public ModelComputer(String path) {
        // 構造函數,調用 C++ 的加載
        ptr = loadModel(path);
    }

    /**
     * 加載 lib 文件
     *
     * @param name lib名
     */
    public static void loadPath(String name) {
        String path = System.getProperty("user.dir") + "\\src\\main\\resources\\";
        path += name;
        String osName = System.getProperty("os.name").toLowerCase();
        if (osName.contains("linux")) {
            path += ".so";
        } else if (osName.contains("windows")) {
            path += ".dll";
        }
        // 如果存在本文件,直接加載,并返回
        File file = new File(path);
        if (file.exists() && file.isFile()) {
            System.load(path);
            return;
        }
        String fileName = path.substring(path.lastIndexOf('/') + 1);
        String prefix = fileName.substring(0, fileName.lastIndexOf(".") - 1);
        String suffix = fileName.substring(fileName.lastIndexOf("."));

        // 創建臨時文件,注意刪除
        try {
            File tmp = File.createTempFile(prefix, suffix);
            tmp.deleteOnExit();

            byte[] buff = new byte[1024];
            int len;
            // 從jar中讀取文件流
            try (InputStream in = ModelComputer.class.getResourceAsStream(path);
                    OutputStream out = new FileOutputStream(tmp)) {
                while ((len = in.read(buff)) != -1) {
                    out.write(buff, 0, len);
                }
            }
            // 加載庫文件
            System.load(tmp.getAbsolutePath());
        } catch (Exception e) {
            throw new RuntimeException();
        }
    }

    // native 方法
    public static native long loadModel(String path);
    public static native void close(long ptr);
    public static native float compute(long ptr, long[] keys);

    @Override
    public void close() {
        Long tmp = ptr;
        ptr = null;
        // 關閉 C++ 對象
        close(tmp);
    }

    /**
     * 計算
     * @param keys 輸入的列表
     * @return 輸出的結果
     */
    public float compute(long[] keys) {
        return compute(ptr, keys);
    }
}

  • 踩坑1:啟動時報 java.lang.UnsatisfiedLinkError 異常

這是因為 lib 文件在壓縮包中,而加載 lib 的函數尋找的是系統路徑下的文件,通過 InputStream 和 File 操作從壓縮包中讀取該文件到臨時文件夾,獲取其路徑,再進行加載就可以了。上文中 getPath 方法作為解決辦法的示例可以參考:System.load() 函數輸入的路徑必須是全路徑下的文件名,也可以使用 System.loadLibrary() 加載 java.library.path 下的lib庫,不需要 lib 文件的后綴。

保存上文的 Java 代碼,通過 Javah 指令可以生成對應的 C++ 頭文件,前文目錄結構中的 export_jni.h 就是通過該指令生成的。

javah -jni -encoding utf-8 -classpath com.vivo.demo.model.ModelComputer -o ../cpp/extern_jni.h
# -classpath 表示所在的package
# -d 表示輸出的文件名

打開可以看到生成出來的文件如下:

#include <jni.h>  // 引入的頭文件, 該頭文件在 $JAVA_HOME/include 下,隨Java版本變化而改變
#ifndef _Included_com_vivo_demo_model_ModelComputer // 宏定義 格式 _Included_包名_類名
#define _Included_com_vivo_demo_model_ModelComputer
#ifdef __cplusplus
extern "C" {  // 保證函數、變量、枚舉等在所有的源文件中保持一致,這里應用于導出的函數名稱不被改變
#endif
// 生成的loadModel函數,可以看到JNI的修飾和jlong返回值,函數名稱格式為 Java_包名_類名_函數名
// 函數的前兩個參數是 JNIEnv 表示當前線程的 JVM 環境參數,jclass 表示調用的 class 對象,可以通過這兩個參數去操作 Java 對象。
JNIEXPORT jlong JNICALL Java_com_vivo_demo_model_ModelComputer_loadModel
  (JNIEnv *, jclass, jstring);

JNIEXPORT void JNICALL Java_com_vivo_demo_model_ModelComputer_close
  (JNIEnv *, jclass, jlong);

JNIEXPORT jfloat JNICALL Java_com_vivo_demo_model_ModelComputer_compute
  (JNIEnv *, jclass, jlong, jlongArray);

#ifdef __cplusplus
}
#endif
#endif

  • 踩坑2:Javah 運行失敗

如果生成失敗,可以參考上面 JNI 格式的 “.h” 文件手寫一個出來,只要格式無誤,效果是一樣的。其中 jni.h 是 JDK 路徑下的一個文件,里面定義了一些 JNI 的類型,返回值, 異常, JavaVM 結構體以及一些方法(類型轉化,字段獲取,JVM 信息獲取等)。jni.h 還依賴了一個 jni_md.h 文件,其中定義了 jbyte,jint 和 jlong,這三個類型在不同的機器下的定義是有差異的。

我們可以看下 JNI 常用數據類型與 Java 的對應關系:


圖3 JNI常用數據類型

如圖3,JNI 定義了一些基本數據類型和引用數據類型,可以完成 Java 和 C++ 的數據轉化。JNIEnv 是一個指向本地線程數據的接口指針,通俗的來講,我們通過 JNIEnv 中的方法,可以完成 Java 和 C++ 的數據轉化,通過它,可以使 C++ 訪問 Java 的堆內存。

對于基本的數據類型,通過值傳遞,可以進行強制轉化,可以理解為只是定義的名稱發生改變,和 java 基本數據類型差異不大。

而引用數據類型,JNI 定義了 Object 類型的引用,那么就意味著,java 可以通過引用傳遞任意對象到 C++ 中。對于像基礎類型的數組和 string 類型,如果通過引用傳遞,那么 C++ 就要訪問 Java 的堆內存,通過 JNIEnv 中的方法來訪問 Java 對象,雖然不需要我們關心具體邏輯,但是其性能消耗要高于 C++ 指針操作對象的。所以 JNI 將數組和 string 復制到本地內存(緩沖區)中,這樣不但提高了訪問速度,還減輕了 GC 的壓力,缺點就是需要使用 JNI 提供的方法進行創建和釋放。

// 可以使用下列三組函數,其中 tpye 為基本數據類型,后兩組有 Get 和 Release 方法,Release 方法的作用是提醒 JVM 釋放內存
// 數據量小的時候使用此方法,原理是將數據復制到C緩沖區,分配在 C 堆棧上,因此只適用于少量的元素,Set 操作是對緩存區進行修改
Get<type>ArrayRegion
Set<type>ArrayRegion
// 將數組的內容拷貝到本地內存中,供 C++ 使用
Get<type>ArrayElement
Release<type>ArrayElement
// 有可能直接返回 JVM 中的指針,否則的話也會拷貝一個數組出來,和 GetArrayElement 功能相同
GetPrimitiveArrayCritical
ReleasePrimitiveArrayCritical

通過這三組方法的介紹,也就大致了解了 JNI 的數據類型轉化,如果沒有 C++ 創建修改 Java Object 的操作的話,那編寫 C++ 代碼和正常的 C++ 開發無異,下面給出了 “export_jni.h” 代碼示例。

#include "jni.h" // 這里改為相對引用,是因為把 jni.h 和 jni_md.h 拷貝到項目中,方便編譯
#include "computer.cc"
#ifndef _Included_com_vivo_demo_model_ModelComputer
#define _Included_com_vivo_demo_model_ModelComputer
#ifdef __cplusplus
extern "C" {
#endif
    JNIEXPORT jlong JNICALL Java_com_vivo_demo_model_ModelComputer_loadModel
    (JNIEnv* env, jclass clazz, jstring path) {
        vivo::Computer* ptr = new vivo::Computer();
        const char* cpath = env->GetStringUTFChars(path, 0); // 將 String 轉為 char*
        ptr->init_model(cpath);
        env->ReleaseStringUTFChars(path, cpath); // 釋放String
        return (long)ptr;
    };

    JNIEXPORT void JNICALL Java_com_vivo_demo_model_ModelComputer_close
    (JNIEnv* env, jclass clazz, jlong ptr) {
        vivo::Computer* computer = (vivo::Computer*)ptr; // 獲取到對象
        delete computer; // 刪除對象
    };

    JNIEXPORT jfloat JNICALL Java_com_vivo_demo_model_ModelComputer_compute
    (JNIEnv* env, jclass clazz, jlong ptr, jlongArray array) {
        jlong* idx_ptr = env->GetLongArrayElements(array, NULL); // 將 array 轉為 jlong*
        vivo::Computer* computer = (vivo::Computer*)ptr; // 獲取到 C++ 對象
        float result = computer->compute((long *)idx_ptr); // 執行 C++ 方法
        env->ReleaseLongArrayElements(array, idx_ptr, 0); // 釋放 array
        return result; // 返回結果
    };

#ifdef __cplusplus
}
#endif
#endif

C++ 代碼編譯完成后,把 lib 文件放到 resource 目錄指定位置,如果為了方便,可以寫個 shell 腳本一鍵執行。

  • 踩坑3:服務器啟動時報java.lang.UnsatisfiedLinkError 異常

又是這個異常,前文已經介紹了一種解決方案,但在實際應用中仍然頻繁出現,比如:

  1. 運行環境有問題(比如在 linux 下編譯在 windows 上運行,這是不可以的);

  2. JVM 位數和 lib 的位數不一致 (比如一個是 32 位,一個是 64 位);

  3. C++ 函數名寫錯;

  4. 生成的 lib 文件中并沒有相對應的方法。

對于這些問題,只要認真分析異常日志,便可以逐一解決,也有工具可以協助我們解決問題。

使用 dumpbin/objdump 分析 lib,更快速地解決 UnsatisfiedLinkError。

對于 lib 庫中的函數檢查,不同操作系統也提供了不同的工具。

在 windows 下,可以使用 dumpbin 工具或者 Dependency Walker 工具分析 lib 中是否存在所編寫的 C++ 方法。dumpbin 指令如下:

dumpbin /EXPORTS xxx.dll

圖4 dumpbin 查看 dll 文件

而 Dependency Walker 只需要打開 dll 文件就可以看到相關信息了。


圖5 Dependency Walker 查看 dll 文件

在 Linux 下,可以使用 objdump 工具分析 so 文件中的信息。

objdump 指令如下:

objdump -t xxx.so

圖6 objdump 查看 so 文件

3.4 性能分析

根據之前的調研,我們注意到 Java 對 native 方法的調用本身也存在額外性能開銷,針對此我們用 JMH 進行了簡單測試。圖 7 展示的是 JNI 空方法調用和 Java 的對比:


圖7 - 空函數調用對比 (數據源自個人機器JMH測試,僅供參考)

其中 JmhTest.code 為調用 native 空方法, JmhTest.jcode 為調用 java 空方法,從中可以看出,直接調用 java 的方法要比調用 native 方法快十倍還要多。我們對堆棧調用進行了簡單分析,發現調用 native 的過程比直接調用 java 方法要繁瑣一些,進入了 ClassLoad 的 findNative 方法。

// Invoked in the VM class linking code.
// loader 為類加載器, name 為C++方法的 name,eg: Java_com_vivo_demo_model_ModelComputer_compute
static long findNative(ClassLoader loader, String name) {
    // 選擇 nativeLibary   
    Vector<NativeLibrary> libs =
        loader != null ? loader.nativeLibraries : systemNativeLibraries;
    synchronized (libs) {
        int size = libs.size();
        for (int i = 0; i < size; i++) {
            NativeLibrary lib = libs.elementAt(i);
            // 找到 name 持有的 handel
            long entry = lib.find(name); 
            if (entry != 0)
                // 返回 handel
                return entry;
        }
    }
    return 0;
}

堆棧信息如下:


圖8 調用 native 堆棧信息

find 方法是一個 native 方法,堆棧上也打印不出相關信息,但不難得出,通過 find 方法去調用 lib 庫中的方法,還要再經過至少一輪的映射才能找到對應的 C++ 函數執行,然后將結果返回。瞬間回想起圖一,這種調用鏈路,通過 Native Interface 來串起本地方法棧,虛擬機棧,nativeLibrary 和執行引擎之間的關系,邏輯勢必會復雜一些,相對的調用耗時也會增加。

做了這么多工作,差點忘了我們的目標:提高我們的計算和加載速度。經過上文的優化后,我們在壓測環境進行了全鏈路壓測,發現即使 native 的調用存在額外開銷,全鏈路的性能仍然有了較為明顯的提升。

我們的服務在模型推理的核心計算上耗時降低了 80%,加載和解析模型文件耗時也降低了 60%(分鐘級到秒級),GC 的平均耗時也降低了 30%,整體的收益非常明顯。


圖9 young GC 耗時對比

四、思考和總結:JNI 帶來的收益

JNI 在一些特定場景下的成功應用打開了我們的優化思路,尤其是在 Java 上進行了較多優化嘗試后并沒有進展時,JNI 確實值得一試。

又回到了最初的問題:JNI 真的好用嗎?我的答案是:它并不是很好用。如果是一名很少接觸 C++ 編程的工程師,那么在第一步的環境搭建和編譯上,就要耗費大量的時間,再到后續的代碼維護,C++ 調優等等,是一個非常頭疼的事情。但我還是非常推薦去了解這項技術和這項技術的應用,去思考這項技術能夠給自己的服務器性能帶來提升。

或許有一天,JNI 能為你所用!

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

推薦閱讀更多精彩內容