Java NIO相关

1、概述

传统的流IO对于大部分的IO场景, 能适应的比较好。但是, 由于它的阻塞性, 也就是说每一个流的读写都需要占用一个线程。这意味着, 流IO的可伸缩性很差。因此, 就需要引入非阻塞IO。实际上, NIO就是非阻塞IO在Java中的实现。使用非阻塞IO, IO的伸缩性大大提高, 使用单个线程, 就可以处理大量的IO对象。

Java NIO主要包含通道(Channel)、缓冲区(Buffer)、选择器(Selector)这几个核心组件。其他组件基本上都是为了这3个组件服务的。 其中, Channels是输入/输出的管道, 所有的读写操作都需要通过它来完成。Channel读写的粒度是Block, 而不是像流IO一样, 提供一个字节流或者字符流的抽象。这个Block的抽象即Buffer. 所有的读操作会由Channel将数据读入Buffer, 然后用户来处理Buffer, 所有的写操作需要先将数据填到Buffer中, 再由Channel来消费Buffer中的数据. NIO的第三个核心组件是Selector, 它是一个事件监控器, 将它注册所感兴趣的IO事件, 并且对其进行Polling, 来确定事件是否发生, 发生则做相应的IO操作. 其中, Selector所监控的对象是Channel, 在Selector上声明所关心的Channel的什么事件, Selector会监控这些Channels, 并在事件发生时进行通知。

nio 框架

2、缓冲区(Buffers)

Buffers是NIO的核心概念, NIO的处理基本上都是关于怎么把数据在buffers中搬进搬出。主要的抽象层是java.nio.Buffer ,实作层是java.nio.ByteBuffer等,代表包含了bytes的buffer。除了ByteBuffer,还有其它六种除了boolean 类型以外其他基础类型对应的buffer以及一种用于直接内存映射(direct memory mapping)的buffer。Buffer类别的好处在于相较于一般的array, buffer将数据内容以及数据的信息封装在对象里。

根据对内存存取方式的不同, Buffer可以分为以下两种:

① 非直接映射缓冲区(Non-direct Buffers) : 这种buffer使用了一种被作业系统管理的中介buffer(intermediate buffer).这个中介buffer之所以会被需要的原因是因为作业系统其实并不能很有效率的存取Java 堆中的buffers ,或是说是用户空间中的buffers。所有在Java heap中的对象都是被JVM管理的,而且这些对象都有可能会改变它们在内存中的位置是在用户区和内核区存在两份拷贝。

非直接映射缓冲区

如上图所示,如果应用需要从硬盘的上读取一个文件内容.在这个情境下,应用会向系统要求说要读某个文件的数据,然后系统内核区又会指示硬盘控制器硬盘里面拷贝位元数据至中介buffer。最后,这些在中介buffer的数据又会被拷贝到Java heap buffer里面,然后应用程式就可以透过java.nio.IntBuffer等Buffer从JVM 堆里面读数据了。

② 直接映射缓冲区(Direct Buffers) :

这种buffer相较于non-direct buffer来说,核心就是移除了中介buffer.其主要思想是从Java heap里把底层的buffer memory移出去.在buffer上的内存位置都是固定的状态下,而且GC也不会去处理这些内存的时候,系统就可以很安全地存取这些内存了。虽然这样一来对buffer的读写可以更有效率,但这其实也意味着在其上创建和销毁的代价会更昂贵。这部分在Java中的buffer具体的类主要为: MappedByteBuffer。

直接映射缓冲区

2.1 缓冲区层次结构

java.nio.Buffer是Java NIO buffer的高阶抽象, 这个类别对所有类型的buffer定义了一些共同的操作, 譬如说当前的position, limit, capacity, flipping, marking以及rewinding ,具体下面会讨论。java.nio.Buffer有很多的子类别, 分别对应除了boolean类型外的每种基础类型。这些类型都是上面所说的非直接映射缓冲。

NIO缓冲层次结构

除此之外, ByteBuffer还有一个子类别叫做MappedByteBuffer ,这个类别是用来跟直接映射缓冲协作的。ByteBuffer是最重要的buffer type, 其既可以用作直接映射缓冲,也可以用作非直接映射缓冲,因为操作系统基本上就是在字节层面上进行操作的。 其它类型的buffer则是根据不同的基础类型提供了一个方便的接口. 不过当需要与操作系统互动时, 就需要使用ByteBuffer。

2.2 创建缓冲区

一个新的buffer基本上可以通过两种方式去建构:

指定容量(capacity): allocation, allocateDirect

事先提供建立好的阵列: wrapping

2.2.1 Allocation

通过这种方式,只需要指定buffer的容量(capacity)即可 ,之后就交给NIO去建立内部要用来储存数据的结构即可。以下例子就是用allocate创建一个容量为100 byte的buffer。


ByteBuffer byteBuffer = ByteBuffer.allocate(100);

2.2.2 Wrapping

这种方式下,要先提供一个用来储存数据的数组 ,例子如下:


byte[] backingArray = new byte[100];

ByteBuffer byteBuffer = ByteBuffer.wrap(backingArray)

这样的操作方法可以很自然想到,任何试图透过put()方法去改变buffer对象的操作,都会使得对应的数组进行修改,反之也是一样的。

2.2.3 AllocateDirect

上面有提到ByteBuffer分为两种: direct与non-direct . Direct buffer基本上是映射在java heap之外的,而且可以直接让操作系统存取,因此其速度是比使用数组的non-direct buffer要来得快。 Non-direct buffer是透过allocate()来建立的,且其基本上都会有一个数组(array)作为备份数据的空间,故这个array又称为backing array。而direct buffer则是透过allocateDirect()来建立,例子如下:


ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100);

要判断一个buffer是否含有backing array, 可以调用hasArray(), 而要取得其所持有的backing array, 可以调用array(), 若是对没有backing array的buffer(如direct buffer)调用array(), 会抛出UnsupportedOperationException。

2.3 容量(capacity)、限制(Limit)、位置(Position)和标记(Mark)

了解Java NIO buffer的关键, 在于了解buffer state以及flipping。这边会先讨论四个关于buffer state的主要特性(properties): capacity, limit, position以及mark。四者的数值关系一般是0 <= mark <= position <= limit <= capacity 。除此之外, 还有一个remaining()方法可以用来计算可供消耗的数据数量, 其计算依据为: limit() - position(). 若想要直接得知是否还有可供消耗的资料, 也可使用hasRemaining()方法.

2.3.1 Capacity

Capacity是一个buffer可以持有的数据的最大上限.举个例子,如果通过new byte[10]这个数组来建立一个buffer,那这个buffer的capacity就是10 bytes. Capacity在buffer建立后就不会再改变了。

2.3.2 Limit

Limit是一个以0为初始的索引,用来辨别第一个”不应该”被读取/写入的数据. Limit可以用来决定数据可否从buffer中被读取.介于索引0与limit(不包含)的数据是可以被读取的,介于索引limit(包含)与capacity的数据则是无效数据。

2.3.3 Position

Position也是一个以0为初始的索引,用来辨别下一个数据是否可以被读取/写入。当数据从buffer中被读取或是被写入buffer时, position索引会递增。

2.3.4 Mark

Mark是一个被标记的位置,当调用mark()方法时,会把position的值设定给mark,调用reset()方法时,会把mark的值设定给position. Mark的值要设定才会有。

2.4 读写数据

每一个Buffer的实例类都提供了get/put方法用来从buffer读取/写入数据。 以ByteBuffer来说, 其提供的get/put如下:


public abstract byte get();

public abstract ByteBuffer put(byte b);

public abstract byte get(int index);

public abstract ByteBuffer put(int index, byte b);

要注意的是, 这两组get/put依照性质可分为相对操作与绝对操作, 相对操作就是不需要传入index参数的方法. 当相对操作被调用, position会递增, 若是调用put(),会在position递增过头时抛出BufferOverflowException; 若是调用get(), 会在position大于limit时抛出BufferUnderflowException。绝对操作就是要传入index参数的方法, 其并不会影响buffer的position, 但当传入的index越界了, 就有可能抛出java.lang.IndexOutOfBoundsException。

在前以小节提到的特性中, position在读取/写入的操作中扮演了很关键的角色。get/put方法会根据当前position所指到的索引来读取/写入数据, 并且在每次完成一个操作后, 都把position索引渐增(+1), 用于下一次的操作。

2.5 缓冲的生命周期

Java NIO buffer基本上是一种允许数据交换的结构, 除了读取, 也可用在写入。在概念层面上来说, 一个Java NIO buffer有两种操作模式:

① Filling Mode :由一个生产者(producer)写入至buffer

② Draining Mode :由一个消耗者(consumer)从buffer中读取

在一个典型的Java NIO buffer的生命周期里, buffer在一开始就被建立成空的以预备给producer写入/填入(fill)资料, 此时的buffer就是处于filling 模式下. 在producer结束写入数据的操作后, 这个buffer就会通过flip(翻转)进入draining 模式. 在这个模式下, buffer已经预备好要让consumer来读数据了, 当读取数据的操作结束后, buffer就会通过clear的操作来恢复至预备给producer写入的状态. 简单的示意图如下:

缓冲区生命周期

2.5.1 填充缓冲区

在Java NIO里要写入资料, 可以通过put()方法. 以下例子简单地示范了怎么样填充数据至buffer中然后再把这些数据读出来:


        //创建缓冲区

        ByteBuffer byteBuffer = ByteBuffer.allocate(6);

        // 填充天缓冲区

        byteBuffer.put((byte) 9);

        byteBuffer.put((byte) 4).put((byte) 8).put((byte) 7);

        // 反转缓冲区

        byteBuffer.flip();

         // 排空缓冲区

        System.out.println(byteBuffer.get());

        while (byteBuffer.hasRemaining()) {

            System.out.println(byteBuffer.get());

        }

        // 清除缓冲区

        byteBuffer.clear();

首先, 建立一个capacity为6的buffer. 建立完后, 这个buffer就是空的且预备好要被填充了, 同时, limit与capacity这时候都是指到index 为6的位置, 而position是在0的位置, 如下图:

初始化缓冲区

第一次的put()会在index 0的位置写入一个byte, 并且将position渐增(+1)至index 1. 再来的操作是加入三个bytes, 在这个操作之后, position会移动到index 4的位置, 并且留下2个字节的剩余空间直到完全地把这个buffer填满为止, 示意图如下:

调用put()后

2.5.2 反转缓冲区

一但producer结束了把数据写入至buffer的操作 就可以把这个buffer给”翻转(flip)”过来, 好让consumer可以开始读取数据。如果没有做flip的操作, get()方法就会直接从当前的position读取数据. 在上面的例子中, 就是从index 4的位置读数据, 但这个index上根本没有数据。flip()方法会将buffer调整成可以读取其中内容的状态, 它会把limit设定到当前position的位置用以标示buffer中没有数据内容的区域, 然后再把position重设到index 0的位置. 如此一来, get()方法就可以从头开始消耗(读取)数据了, 这时候状态如下图所示:

反转缓冲区

实际上, flip()跟以下操作是等价的:


buffer.limit(buffer.position()).position(0);

要注意的是, 不要连续flip两次以上, 这会造成buffer里面的数据都变成无效数据,而且可以写入的大小为0。此时若调用get()会得到BufferUnderflowException, 调用set会得到BufferOverflowException, 因为limit这时候是0。

2.5.3 排空缓冲区

在buffer被反转(flipped)后, 就可以准备使用get()来读数据了, 上面的代码例子把byte数据”9”从index 0中读出来, 并且递增position至index 1。当全部读取完成后position和limit指向同一个字节位置。

用get()获取第一个字节后
全部读取完成后

2.5.4 清空缓冲区

一但buffer被排空了(drained), 下一步就是要准备让buffer再次被填充. 这时候可以调用clear()方法。clear()方法会把position设定到index 0的位置, 并且把limit移动到capacity的位置。要注意的是, clear()并不会把buffer中的数据移除, 它只是单纯的改变了position跟limit的位置而已, 调用完clear()后的状态如下:

调用clear()后

clear()方法跟以下操作是等价的:


buffer.position(0).limit(buffer.capacity());

2.6 其他操作

2.6.1 压缩缓冲区

在某些情况下, 可能只会从buffer里drain出部分的数据而并非全部, 然后又要继续填充这个buffer. 在这种情境下, 那些还没被drain出的数据就必须要被移动到buffer中的最前端(从index = 0开始), 这时候就可以使用compact()方法来达到这个目的. 这其实有点像是把buffer当作一个先进先出的队列在用。调用了compact()后,若想要再进行drain的动作,记得要先flip ,不管之后有没有要再继续往里面加入新资料.原因是因为compact会做的事情是:

① 把剩余的数据copy至数组的开头

② 把position设定成remaining的数量

③ 把limit设定成capacity的大小

④ 把mark消除

2.6.2 比较缓冲区

有时候会需要去比较两个buffer的内容, 对所有类型的buffer来说, 其都有提供标准化的equals()以及compareTo()。对于equals() 在两个buffer的剩余元素都是一致的状况下, 会回传true, 反之则回传false。尽管两个buffer各自的当前position不一样, 但剩余的元素都相等的话, 也算是相等的buffer。而对于compareTo()看两边buffer的当前元素什么时候出现差异, 出现差异时, 元素的值大于对方的值, 就回传正整数; 相等则回传0; 小于则回传负整数。若前面比较都相等则若remaining比对方少,也会参考值比对的做法回传相应的数值。

3、管道(Channels)

Channels代表的是一种支援诸如对数据的读/写的I/O操作的连接(connection)。Channel基本上都会跟buffer一起用, 以达到有效的数据串流(streaming of data)。一个channel会跟操作系统协调以存取数据, 而buffer则是用来传输(transfer)以及持有(hold)数据。

Channel与Buffer关系

Channels被定义于java.nio.channel 包之中, 主要抽象层是java.nio.channel.Channel接口. 其主要实作有四种:

① FileChannel:用来处理文档读写的channel

② SocketChannel :用来在socket上串流(stream)数据的channel

③ ServerSocketChannel:可用来绑定至socket且监听连接的channel

④ DatagramChannel:用来通过UDP读写网络中的数据的channel

其配合Buffer的基本例子如下:


// 获取文件
RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");

// 获取文件Channel

FileChannel inChannel = aFile.getChannel();

// 创建buffer

ByteBuffer buf = ByteBuffer.allocate(48);

// 通过FileChannel将文件中数据读入buffer

int bytesRead = inChannel.read(buf);

while (bytesRead != -1) {

        System.out.println("Read " + bytesRead);

        buf.flip();

        while(buf.hasRemaining()){

                System.out.print((char) buf.get());

        }

        buf.clear();

        bytesRead = inChannel.read(buf);

}

// 关闭文件

aFile.close();

这边用到了上一节提到的关于buffer的写入与读取。

3.1 分散(scatter)与聚集(scatter)

scatter/gather用于描述从Channel中读取或者写入到Channel的操作。scatter / gather经常用于需要将传输的数据分开处理的场合,例如传输一个由消息头和消息体组成的消息,可能会将消息体和消息头分散到不同的buffer中,这样可以方便的处理消息头和消息体。

3.1.1 scatter 读

从Channel中读取是指在读操作时将读取的数据写入多个buffer中。也就是说可以将从Channel中读取的数据分散(scatter)到多个Buffer中。代码例子如下:


ByteBuffer header = ByteBuffer.allocate(128);

ByteBuffer body  = ByteBuffer.allocate(1024);

ByteBuffer[] bufferArray = { header, body };

channel.read(bufferArray);

buffer首先被插入到数组,然后再将数组作为channel.read() 的输入参数。read()方法按照buffer在数组中的顺序将从channel中读取的数据写入到buffer,当一个buffer被写满后,channel紧接着向另一个buffer中写。Scattering Reads在移动下一个buffer前,必须填满当前的buffer,这也意味着它不适用于大小不固定数据。换句话说,如果存在消息头和消息体,消息头必须完成填充(例如 128byte),Scattering Reads才能正常填写消息体。

3.1.2 gather 写

写入Channel是指在写操作时将多个buffer的数据写入同一个Channel,因此,Channel 将多个Buffer中的数据“聚集(gather)”后发送到Channel。例子代码如下:


ByteBuffer header = ByteBuffer.allocate(128);

ByteBuffer body  = ByteBuffer.allocate(1024);

ByteBuffer[] bufferArray = { header, body };

channel.write(bufferArray);

buffers数组是write()方法的入参,write()方法会按照buffer在数组中的顺序,将数据写入到channel,注意只有position和limit之间的数据才会被写入。因此,如果一个buffer的容量为128byte,但是仅仅包含58byte的数据,那么这58byte的数据将被写入到channel中。因此与Scattering Reads相反,Gathering Writes能较好的处理不固定大小的数据。

3.2 通道之间的传输

在Java NIO中,如果两个通道中有一个是FileChannel,则可以直接将数据从一个channel传输到另外一个channel。与之相关的方法为transferFrom()和transferTo()。

3.2.1 transferFrom

FileChannel的transferFrom()方法可以将数据从源通道传输到FileChannel中。例子如下:


RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");

FileChannel      fromChannel = fromFile.getChannel();

RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");

FileChannel      toChannel = toFile.getChannel();

long position = 0;

long count = fromChannel.size();

toChannel.transferFrom(position, count, fromChannel);

方法的输入参数position表示从position处开始向目标文件写入数据,count表示最多传输的字节数。如果源通道的剩余空间小于 count 个字节,则所传输的字节数要小于请求的字节数。在SoketChannel的实现中,SocketChannel只会传输此刻准备好的数据(可能不足count字节)。因此,SocketChannel可能不会将请求的所有数据(count个字节)全部传输到FileChannel中。

3.2.2 transferTo

transferTo()方法用于将数据从FileChannel传输到其他的channel中。例子如下:


RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");

FileChannel      fromChannel = fromFile.getChannel();

RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");

FileChannel      toChannel = toFile.getChannel();

long position = 0;

long count = fromChannel.size();

fromChannel.transferTo(position, count, toChannel);

这个例子和前面那个例子特别相似,除了调用方法的FileChannel对象不一样外,其他的都一样。上面所说的关于SocketChannel的问题在transferTo()方法中同样存在。SocketChannel会一直传输数据直到目标buffer被填满。

4、选择器(Selectors)

Selector可以让有效的监控很多的Java NIO channel并且识别出这些channel什么时候准备好去传输/接收数据。这意味着可以用单一的Selector去管理很大数量的channel, 进而降低系统的负载压力, 因为这除了降低线程的成本之外, 也降低了Selector对内存的利用程度。

Selector主要是利用了操作系统的非阻塞模式存取。非阻塞模式存取允许操作系统在无阻塞的状态下去确认一个串流是否已经准备好了 所以, selector就可以请操作系统去监控一组channel, 并且在任一或是多个channel准备好进行处理的时候得到通知。其中向选择器注册的任何通道必须是SelectableChannel的子类。这些是一种特殊类型的通道,可以置于非阻塞模式。可以通过调用Selector类的静态open方法来创建选择器,该方法将使用系统的默认选择器提供程序来创建新的选择器:


Selector selector = Selector.open();

4.1 注册通道

为了使选择器监视通道,必须使用选择器注册这些通道。通过调用可选择通道的register()来完成此操作。并且在使用选择器注册通道之前,它必须处于非阻塞模式,比如FileChannel就不能使用,因为它不能切换到非阻塞模式:


channel.configureBlocking(false);

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

register函数第一个参数是创建的Selector对象,第二个参数定义了一个兴趣集,这意味着感兴趣的事件是通过选择器在被监视的通道中监听的。

每种事件都由SelectionKey类中的常量表示:

① OP_CONNECT(连接):当客户端尝试连接到服务器时触发。

② OP_ACCEPT(接受):当服务器接受来自客户端的连接时触发。

③ OP_READ(读取):服务器准备好从通道读取时触发。

④ OP_WRITE(写入):服务器准备好写入通道时触发。

返回的对象SelectionKey表示可选择的通道与选择器的注册,下面会详细讨论。

4.2 SelectionKey

正如在上一节中所提到,当使用选择器注册一个通道时,会得到一个SelectionKey对象。该对象保存表示通道注册的数据。它包含一些重要的属性,下面依次来进行讨论。

4.2.1 Interest 设置

Interest集定义了希望选择器在此频道上关注的事件集。它是一个整数值。可以通过以下方式获取此信息。


int interestSet = selectionKey.interestOps();

boolean isInterestedInAccept  = interestSet & SelectionKey.OP_ACCEPT;

boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;

boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;

boolean isInterestedInWrite  = interestSet & SelectionKey.OP_WRITE;

如上面代码所示,首先,通过SelectionKey的interestOps方法返回兴趣集。然后通过与上一小结提到的常量与的方式来获取是否在监听该事件。

4.2.2 Ready 设置

Ready集定义了通道准备好的事件集。它也是一个整数值。可以通过以下方式获取此信息。


selectionKey.isAcceptable();

selectionKey.isConnectable();

selectionKey.isReadable();

selectionKey.isWriteable();

4.2.3 附加对象

可以将一个对象附加到SelectionKey。有时可能希望为通道提供自定义ID或附加可能想要跟踪的任何类型的Java对象。附加对象是一种方便的方法。以下是如何从SelectionKey附加和获取对象。


key.attach(Object);

Object object = key.attachment();

4.3 获取选择器管理的通道

到目前为止,已经讨论了如何创建选择器,向其注册通道以及检查SelectionKey对象的属性,该对象表示通道对选择器的注册。这只是整个过程的一半,现在通过执行一个连续的过程来选择前面提到的Ready集。使用selector的select方法进行选择。


int channels = selector.select();

该方法阻塞直到至少一个通道准备好进行操作。返回的整数表示其通道已准备好进行操作的SelectionKey的数量。接下来,通常检索一组选定的键进行处理。


Set<SelectionKey> selectedKeys = selector.selectedKeys();

获得的集合是SelectionKey对象,每个SelectionKey代表一个已准备好进行操作的注册通道。在此之后,通常迭代这个集合,对于每个SelectionKey,可以通过其获得通道并执行Interest 集合中出现的任何操作。在通道的生命周期中,可以多次选择它,因为其键出现在不同事件的就绪集中。正因为这样需要有一个连续的循环来捕获和处理它们发生时的事件。

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

推荐阅读更多精彩内容