Android JNI 编程实践

前言

JNI 的全称是:Java Native Interface,即连接 Java 虚拟机和本地代码的接口,它允许 Java 和本地代码之间互相调用,在 Android 平台,此处的本地代码是指使用 C/C++ 或汇编语言编写的代码,编译后将以动态链接库(.so)的形式供 Java 虚拟机加载,并按 JNI 规范互相调用。如果工作中需要大量运用 JNI,强烈建议通读 《JNI官方规范》,并结合 Google 的《JNI Tips》 一节以了解在 Android 平台的 JNI 实现有什么限制和不同。
如果只是想快速上手,同时规避一些常见问题,可以先阅读本文——本文的定位是操作手册,告知新手怎样做及为什么,并提供一些最佳实践建议。

1 从 Java 调用 Native

1.1 通过 javah 生成头文件:

1.1.1 Java 层实现

public class HelloJNI {
   static {
      System.loadLibrary("hello"); // Load native library at runtime
   }
 
   // Declare a native method sayHello() that receives nothing and returns void
   public native void sayHello();
}

1.1.2 Native 实现

HelloJNI.h

#include <jni.h>
/* Header for class HelloJNI */
 
#ifndef _Included_HelloJNI
#define _Included_HelloJNI

#ifdef __cplusplus
extern "C" {
#endif

/*
 * Class:     HelloJNI
 * Method:    sayHello
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *, jobject);
 
#ifdef __cplusplus
}
#endif

#endif

HelloJNI.c

#include <jni.h>
#include <stdio.h>
#include "HelloJNI.h"
 
// Implementation of native method sayHello() of HelloJNI class
JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *env, jobject thisObj) {
   printf("Hello World!\n");
}

Tips: jni.h 里会使用 #if defined(__cplusplus) 来为 JNIEnv 提供不同的 typedef,尽量不要同时在 C 和 C++ 两种语言包含的头文件里都引用 JNIEnv,避免在两种语言间传递 JNIEnv 导致类型不兼容。

1.2 注册 JNI 函数表

1.2.1 Java 层实现(略)

1.2.2 Native 实现

HellocJNI.c

#include <jni.h>

// Package name of Java class
static const char *const PACKAGE_NAME = "java/HelloJNI";

void JNICALL nativeSayHello(JNIEnv*, jobject) {
   printf("Hello World!\n");
}

// Native method table
static JNINativeMethod methods[] = {
    /* {"Method name", "Signature", FunctionPointer}, */
    { "sayHello", "()V", (void*)nativeSayHello },
};

jint registerNativeMethods(JNIEnv* env, const char *class_name, JNINativeMethod *methods, int num_methods) {
    jclass clazz = env->FindClass(class_name);
    if (NULL != clazz) {
        return env->RegisterNatives(clazz, methods, num_methods);
    }
    return JNI_ERR;
}

// Invoked when System.loadLibrary()
jint JNI_OnLoad(JavaVM *vm, void *) {
    JNIEnv *env;
    if (JNI_OK != vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6)) {
        return JNI_ERR;
    }
    if (JNI_OK != registerNativeMethods(env, PACKAGE_NAME, methods, 1)) {
        return JNI_ERR;
    }
    return JNI_VERSION_1_6;
}

相比起第一种方式方法名以包名为前缀的做法,上面源码中的 PACKAGE_NAME 可以很容易修改,更加灵活通用,推荐使用。

Tips: 注册 Native 方法的合适时机是上面代码里的 jint JNI_OnLoad(JavaVM* vm, void* reserved) 函数,它会在 Java 层 System.loadLibrary() 加载动态链接库之后被首先调用,适用于执行初始化逻辑。

2 Native 调用 Java

2.1 持有 JNIEnv 指针

从 Native 层调用 Java 方法,前提是 Native 持有 JNIEnv 指针,通过类似以下代码即可调用 Java 方法:

jstring getPackageName(JNIEnv* env, jobject contextObject) {
    if (NULL != env && NULL != contextObject) {
        jclass contextClazz = env->FindClass("android/content/Context");
        jmethodID methodId = env->GetMethodID(contextClazz, "getPackageName", "()Ljava/lang/String;");
        return (jstring) env->CallObjectMethod(contextObject, methodId);
    }
    return NULL;
}

Tips: GetMethodID/GetFieldID/CallXXXMethod 等方法均不接受 NULL 参数,否则程序会异常退出,在获取非文档化的类或成员后一定要先对返回值进行判空再使用。

2.2 没有 JNIEnv 指针

JNIEnv 实例保存在线程本地存储 TLS(Thread-Local Storage)中,因此不能在线程间直接共享 JNIEnv 指针。如果线程的 TLS 里存有 JNIEnv 实例,只是没有引用该实例的指针,可以通过 JavaVM 指针调用 GetEnv() 来获取指向线程自有 JNIEnv 的指针。因为 Android 下的 JavaVM 实例是全进程唯一的,所以可以被所有线程共享。

还有一种更特殊的情况:即线程根本没有 JNIEnv 实例(如代码中通过 pthread_create() 创建的原生线程),这种情况下需要先调用 JavaVM->AttachCurrentThread() 将线程依附于 JavaVM 以获得 JNIEnv 实例(Attach 到 VM 后就被视为 Java 线程)。当线程退出时要配对调用 JavaVM->DetachCurrentThread() 以释放 JVM 里的资源。

Tips: 为避免 DetachCurrentThread 未配对调用,可以通过 int pthread_key_create(pthread_key_t *key, void (*destructor)(void*)); 创建一个 TLS 数据的 key,并注册一个 destructor 回调函数,它会在线程退出前被调用,因此很适合用于执行类似 DetachCurrentThread 的清理工作。另外还可以使用 key 调用 pthread_setspecific 函数,将 JNIEnv 指针保存到 TLS 中,这样一来不仅可随用随取,而且当 destructor 函数被调用时 JNIEnv 指针会作为参数传入,方便调用 Java 层的一些清理方法。部分示例如下:


JavaVM* gVM; // Global VM reference
pthread_key_t gKey; // Global TLS data key

void onThreadExit(void* tlsData) {
    JNIEnv* env = (JNIEnv*)tlsData;
    // Do some JNI calls with env if needed ...
    gVM->DetachCurrentThread();
}

// Invoked when System.loadLibrary()
jint JNI_OnLoad(JavaVM *vm, void *) {
    // ignore some initialize code ...
    
    gVM = vm;
    // Create thread-specific data key and register thread-exit callback
    pthread_key_create(&gKey, onThreadExit);
    return JNI_VERSION_1_6;
}

JNIEnv* getJNIEnv(JavaVM* vm) {
    JNIEnv *env = (JNIEnv *) pthread_getspecific(gKey);  // gKey created by pthread_key_create() before
    if (NULL == env) {
        if (JNI_OK != vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6)) {
            if (JNI_OK == vm->AttachCurrentThread(&env, NULL)) {
                pthread_setspecific(gKey, env); // Save JNIEnv* to TLS with gKey
            }
        }
    }
    return env;
}

3 对象引用

3.1 本地引用

每个传给 Native 方法的参数(对象),和几乎所有 JNI 函数返回的对象都是本地引用(Local reference)。这意味着它们只在当前线程的当前 native 方法内有效,一旦该方法返回则失效(哪怕被引用的对象仍然存在)。所以正常情况下开发者无须手动调用 DeleteLocalRef 释放,除非以下几种情况:

  1. Native 方法内创建大量的本地引用,例如在循环中反复创建,因为虚拟机保存本地引用的空间是有限的(Android 为512个),一旦循环中创建的引用数超出限制就会导致异常:ReferenceTable overflow (max=512);
  2. 通过 AttachCurrentThread() 依附到 JVM 的线程内的所有本地引用均不会被自动释放,直到调用 DetachCurrentThread() 才会统一释放,为避免线程中创建太多本地引用建议及时做手动释放;
  3. Native 方法本地引用了一个非常大的对象,用完后还要进行较长时间的其它运算才能返回,本地引用会阻止该对象被 GC。为降低 OutOfMemory(OOM) 风险用完后应该及时手动释放。

上面所说的对象是指 jobject 及其子类,包括 jclass, jstring, jarray,不包括 GetStringUTFChars 和 GetByteArrayElements 这类函数的返回值(皆返回原始数据指针),也不包括 jmethodID 和 jfieldID,这两者在 Android 下只要类加载之后就一直有效。

Tips: GetStringUTFChars / Get<PrimitiveType>ArrayElements 等函数返回的原始数据指针可以跨线程使用,并且必须手动调用对应的 ReleaseStringUTFChars / Release<PrimitiveType>ArrayElements 函数释放,否则会造成内存泄漏。

3.2 全局引用

与本地引用不同,全局引用可以跨方法跨线程使用,通过 NewGlobalRef 或 NewWeakGlobalRef 方法创建之后,会一直有效直到调用 DeleteGlobalRef/DeleteWeakGlobalRef 销毁。这个特性常用于缓存一些获取起来较耗时的对象,比如 jclass 通过 FindClass 获取时有反射的开销,对于同一个类而言获取一次缓存起来备用会更高效:

jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

Tips: 如果想在一个在加载时将 Native jclass、jmethodID、jfieldID 缓存起来备用,可以像下面代码一样在 Java 层的静态域内调用 nativeInit 方法,该方法的 Native 层实现可以通过 FindClass、GetFieldID、GetMethodID 等方法把所有后续要使用的类对象和成员都缓存起来,避免每次使用前都查找带来的性能开销。

    /*
     * We use a class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs.
     */
    private static native void nativeInit();

    static {
        nativeInit();
    }

3.3 引用比较

比较两个引用是否指向同个对象需要使用 jboolean IsSameObject(JNIEnv *env, jobject ref1, jobject ref2); 方法。要注意的是 JNI 中的 NULL 指向 JVM 中的 null 对象,IsSameObject 用于弱全局引用(WeakGlobalRef)与 NULL 比较时,返回值的意义表示其引用的对象是否已经回收(JNI_TRUE 代表已回收,该弱引用已无效)。

4 线程安全

由于 Android 下的 JVM 线程底层基于 POSIX Threads,因此有两种使用对象同步(synchronized)的方式:基于 Java 的同步和基于 POSIX 的同步:

4.1 基于 Java 的同步

A. JNI 提供了类似 synchronized 语句的同步块函数:

image.png

B. 也可以直接在 Java 层用 synchronized 关键词修饰 native 方法:

   public native synchronized void sayHello();

这种用法可以确保 Java 对 sayHello() 的调用是同步的,但通常不建议这么用,因为可能带来以下问题:

  1. 对整个 Native 方法做同步的粒度较大,可能影响性能;
  2. Native 和 Java 的方法声明在不同的位置,可能出现方法声明更改(如 synchronized 关键词被删除),会导致方法不再线程安全;
  3. 如果该 sayHello() 在 Java 之外被其它 Native 函数调用,则不是线程安全的

Tips: 在上述同步方案中 Object.wait()/notify()/notifyAll() 等方法同样可以使用,只需从 Native 层调用对象对应的 Java 方法即可。

4.2 基于 POSIX 的同步

无论是通过 pthread 或者 Java 创建的线程,均可使用 pthread 提供的线程控制函数来实现 Native 层的同步,如: pthread_mutex_lock/pthread_mutex_unlock/pthread_cond_wait/pthread_cond_signal 等等。

5 字符编码

Java 内部是使用 UTF-16 处理字符,但 JNI 对外提供了一套函数用于将 UTF-16 转换为 UTF-8 的一个变种 Modified UTF-8(以 0xc0 0x80 而不是 0x00 来编码 \u0000),使用这个变种的好处是能兼容以 0x00 作为结束符的 C 字符处理函数,缺点是与标准或其它 UTF-8 变种之间有细微的差异,存在潜在的兼容性问题。所以在从网络或文件读入文本后,必须确认或处理为符合 Modified UTF-8 编码才能传给 NewStringUTF 方法,否则可能无法得到预期的结果。

6 数组访问

6.1 随机访问数组

对于对象数组(Array of objects) JNI 提供了 GetObjectArrayElement/SetObjectArrayElement 函数允许每次访问数组中的一个对象。而对于原始类型的 Java 数组则提供了映射为 C 数组的 NativeType *Get<PrimitiveType>ArrayElements(JNIEnv *env, ArrayType array, jboolean *isCopy) 函数族 ,让我们可以像访问 C 数组那样读写 Java 数组的内容,该函数族的完整列表如下:

PrimitiveType ArrayType NativeType
GetBooleanArrayElements() jbooleanArray jboolean
GetByteArrayElements() jbyteArray jbyte
GetCharArrayElements() jcharArray jchar
GetShortArrayElements() jshortArray jshort
GetIntArrayElements() jintArray jint
GetLongArrayElements() jlongArray jlong
GetFloatArrayElements() jfloatArray jfloat
GetDoubleArrayElements() jdoubleArray jdouble

如果调用成功 Get<PrimitiveType>ArrayElements 函数族会返回指向 Java 数组的堆地址或新申请的副本的指针(视 JVM 的具体实现,在 ART 里数组的堆空间若可被移动则返回副本,可以传递非 NULL 的 isCopy 指针来确认返回值是否副本),如果指针指向是 Java 数组的堆地址而非副本,Release<PrimitiveType>ArrayElements 之前此 Java 数组都无法被 GC 回收,所以 Get<PrimitiveType>ArrayElementsRelease<PrimitiveType>ArrayElements 必须配对调用以避免内存泄漏。另外 Get<PrimitiveType>ArrayElements 可能因内存不足创建副本失败而返回 NULL,应先对返回值判空后再使用。
Release<PrimitiveType>ArrayElements 原型如下:

void Release<PrimitiveType>ArrayElements(JNIEnv *env, ArrayType array, NativeType *elems, jint mode);

它最后一个参数 mode 仅对 elems 为副本时有效,它可以用于避免一些非必要的副本拷贝,共有以下三种取值:

  • 0:将 elems 内容回写到 Java 数组并释放 elems 占用的空间;
  • JNI_COMMIT:将 elems 内容回写到 Java 数组,但不释放 elems 的空间
  • JNI_ABORT:不回写 elems 内容到 Java 数组,释放 elems 的空间。

一般来说 mode 参数直接传 0 是最安全的选择,这样不论 Get<PrimitiveType>ArrayElements 返回的是否副本都不会发生泄漏。但也有一些情况为了性能等因素考虑会使用非零值,比方说对于一个尺寸很大的数组,如果获取指针之后通过 isCopy 确认是副本,且之后没有修改过内容,那么完全可以使用 JNI_ABORT 避免回写以提高性能。
另一种可能的情况是 Native 修改数组和 Java 读取数组在交替进行(如多线程环境),如果通过 isCopy 确认获取的数组是副本,可以通过 JNI_COMMIT 调用 Release<PrimitiveType>ArrayElements 来提交修改,由于 JNI_COMMIT 不会释放副本,所以最终还需要使用别的 mode 值再调用 Release 以避免副本泄漏。

Tips: 一种常见的错误用法是当 isCopy 为 false 时跳过使用 Release,此时虽未创建副本,但 Java 数组的堆内存被引用后会阻止 GC 回收,因此也必须配对调用 Release 函数。

6.2 块拷贝

上一节讲解了如何访问 Java 数组,考虑一下这种场景:Native 层需要从/往 Java 数组拷贝一块内容,根据上面的内容很容易写出以下代码:

    jbyte* data = env->GetByteArrayElements(javaArray, NULL);
    if (data != NULL) {
        memcpy(buffer, data, len);
        env->ReleaseByteArrayElements(javaArray, data, JNI_ABORT);
    }

先获取指向 Java 数组堆内存(或者副本)的指针,将头 len 个字节拷贝到 buffer 后调用 Release 释放。由于没有改变数组内容,因此使用 JNI_ABORT 避免回写开销。
但其实有更简单的做法,就是调用块拷贝函数:

env->GetByteArrayRegion(javaArray, 0, len, buffer);

相比前一种方式,块拷贝有以下优点:

  1. 只需要一次 JNI 调用,减少开销;
  2. 无需创建副本或引用 Java 数组的内存(不影响 GC)
  3. 降低编程出错的风险——不会因漏调用 Release 函数而引起泄漏。

对于字符串也有类似的拷贝函数,下面是原型:

// Region copy for Array.
// Throw ArrayIndexOutOfBoundsException if one of the indexes in the region is not valid
void Get<PrimitiveType>ArrayRegion(JNIEnv *env, ArrayType array, jsize start, jsize len, NativeType *buf);
void Set<PrimitiveType>ArrayRegion(JNIEnv *env, ArrayType array, jsize start, jsize len, const NativeType *buf);

// Region copy for String.
// Throws StringIndexOutOfBoundsException on index overflow
void GetStringRegion(JNIEnv *env, jstring str, jsize start, jsize len, jchar *buf);
void GetStringUTFRegion(JNIEnv *env, jstring str, jsize start, jsize len, char *buf);

前两个函数族的 PrimitiveType、ArrayType、NativeType 之定义请参考上一节的表格。

6.3 性能敏感场景

上面两种数组访问方式都会涉及到拷贝(Get<PrimitiveType>ArrayElements 虽不一定创建副本,但开发者无法控制),在性能敏感的场景下拷贝带来的耗时往往不可接受,因此需要一种无拷贝的方式来访问数组。在 Android 下可以使用以下两种方式:

6.3.1 临界访问

void* GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy);
void  ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode);

如上所示,JNI 提供了数组临界访问函数,虽然从参数上仍保留了 iSCopy 和 mode,但使用这对函数时有非常严格的限制:Get 和 Release 之间被视为临界区,临界区里的代码应该尽快执行完,而且不允许调用其它 JNI 函数,以及任何可能导致当前线程阻塞并等待另一个 Java 线程的系统调用(比如当前线程不能调用 read 函数读取另一个 Java 线程正在写的流)。
这些严格的限制实际是为了便于 VM 直接返回数组堆内存的指针,比如采用 Moving GC 的 VM 可以在临界区内暂停 GC 来保证 Get 返回的数组地址不会改变。

6.3.2 Direct ByteBuffer

上一种方式虽然可以应付性能敏感的场景但限制颇多。JNI 还提供了 Direct ByteBuffer 方案,可以通过 java.nio.ByteBuffer.allocateDirect 方法或 JNI 函数 NewDirectByteBuffer 创建,它和普通的 ByteBuffer 差异在于其内部使用的内存不是在 Java 堆上分配的,而可以通过 GetDirectBufferAddress 函数获取地址后直接在 Native 代码访问,从 Java 层访问可能会比较慢。

以上两种方式的选择取决于以下因素:

  1. 数据主要是在 Java 还是 C/C++ 代码访问?
    如果主要是在 C/C++ 里访问首选 DirectByteBuffer,速度快限制少。
  2. 如果数据最终要被传回 Java API,是作为什么类型的参数传递的?
    如果 Java API 需要一个 byte[] 参数,那么就不要使用 DirectByteBuffer(调用其 byte[] array () 方法会抛 UnsupportedOperationException 异常)。
  3. 如果上两种方式都可以使用且没有明显的优劣,建议优先选用 DirectByteBuffer,没有临界区的限制代码扩展性更好,且随着 JVM 实现的优化,从 Java 层访问的性能也会得到提升。

7 异常处理

部分 JNI 调用可能会抛出异常,当异常发生后 Native 代码仍可继续执行,但此时绝大多数 JNI 函数都不能被调用(调用即Crash),直到异常被 Native 或 Java 层处理。一般在 Native 调用可能产生异常的 Java 方法都应该进行异常检查和处理,避免程序非正常退出。一个常见的异常处理逻辑如下:

    // ...
    env->CallVoidMethod(clazz, methodName, params); // Call a Java method which may throws exception
    if (env->ExceptionCheck()) { // If exception occurred, ExceptionCheck() return JNI_TRUE
        if (Native can handle exception) {
            // handle it
            // ...
            // Clear the exception, so program can continue
            env->ExceptionClear();
        } else {
            // Native can't handle exception, return and let Java code do that
            return ;
        }
    }
    // If not clear exception in line 8, then program will crash when it calls next JNI function:
    env->NewStringUTF("WhatEver");

Tips: 只有以下 JNI 函数可以在异常未处理时调用而不会导致 Crash:

  • DeleteGlobalRef
  • DeleteLocalRef
  • DeleteWeakGlobalRef
  • ExceptionCheck
  • ExceptionClear
  • ExceptionDescribe
  • ExceptionOccurred
  • MonitorExit
  • PopLocalFrame
  • PushLocalFrame
  • Release<PrimitiveType>ArrayElements
  • ReleasePrimitiveArrayCritical
  • ReleaseStringChars
  • ReleaseStringCritical
  • ReleaseStringUTFChars

8 扩展检查

JNI 几乎没有错误检查,出错通常都会导致崩溃。Android 额外提供了一种名为 CheckJNI 的模式,该模式下会将 JavaVM 和 JNIEnv 的函数表指针重定向到带检查能力的函数表,该表里函数会先执行扩展检查再调用实际的 JNI 函数。
扩展检查项包括:

  • 数组:尝试分配一个负数长度的数组;
  • 错误的指针:将错误的jarray / jclass / jobject / jstring传递给JNI调用,或者将NULL指针传递给具有不可空参数的JNI调用;
  • 类名称:将错误样式的类名传递给JNI调用;
  • 临界调用:在临界区中进行 JNI 调用;
  • Direct ByteBuffers:将错误的参数传递给NewDirectByteBuffer;
  • 异常:在有待处理异常时进行 JNI 调用;
  • JNIEnv指针:跨线程使用 JNIEnv;
  • jfieldIDs:使用 NULL jfieldID 或使用 jfieldID 设置值时类型不正确,或使用 jfieldID 设置未持有该 jfieldID 的类成员等;
  • jmethodIDs:同 jfieldIDs;
  • 引用:在错误的引用类型上调用 DeleteGlobalRef/DeleteLocalRef;
  • Release modes:调用 Release 时传入错误的 mode 参数(例如传入除 0,JNI_ABORT,JNI_COMMIT 之外的值);
  • 类型安全:Native 方法返回一个与声明不兼容的类型;
  • UTF-8:将一个非法的 Modified UTF-8 字符串传给 JNI 调用。

以下方式可以打开扩展检查能力:

  • 如果使用模拟器,则默认开启了全局 CheckJNI;
  • 如果编译的是Debug版本的App,也默认开启了;
  • root过的手机可以用以下命令开启:
adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start
  • 未 root 的可以用:adb shell setprop debug.checkjni 1
    通过以下 Logcat 内容可以确认是否开启成功:
    D AndroidRuntime: CheckJNI is ON

9 附录

一、Android NDK Stable API

image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容