目录
前言
在NIO中有几个核心组件:选择器(Selector)、缓冲区(Buffer)、通道(Channel),具体了解一下,三者关系大致如下:
1、选择器selector
在Java io演进的文章中我们知道,传统的 Server/Client 模式会基于TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。这种模式带来的一个问题就是线程数量的剧增,大量的线程会增大服务器的开销。大多数的实现为了避免这个问题, 都采用了线程池模型,并设置线程池线程的最大数量,这又带来了新的问题,如果线程池中有10个线程,而有10个用户都在进行大文件读写操作,会导致第11个用户的请求还是无法及时处理,即便第11个用户只是耗时极短的请求。
所以为例满足高并发的需求,改进升级出了非阻塞NIO通信模型,NIO中非阻塞I/O采用了基于Reactor模式的工作方式,I/O调用不会被阻塞。NIO中实现非阻塞I/O的核心对象就是
Selector,Selector就是注册各种感兴趣的I/O事件SelectionKey地方,相反是注册感兴趣的特定I/O事件,当可读数据准备就绪或者新的套接字连接等发生特定事件时,Selector再通知应用程序去处理,其关系如下:
从图中可以看出,当有读或写等任何注册的事件发生时,可以从Selector中获得相应的SelectionKey,同时从SelectionKey中可以找到发生的事件和该事件所发生的具体的Channel,以获得客户端发送过来的数据。使用NIO中非阻塞I/O编写服务器处理程序,大体上可以分为下面三个步骤:
-
1. 向Selector对象注册感兴趣的事件;
-
2. 从Selector中监听获取感兴趣的事件;
-
3. 根据不同的事件进行相应的处理Handler;
接下来我们用一个简单的示例来说明这三个执行过程。
1.1、事件的注册
首先是向 Selector 对象注册感兴趣的事件,注册事件具体流程:
-
(1). 创建了ServerSocketChannel对象;
-
(2). 并调用configureBlocking()方法,配置为非阻塞模式;
-
(3). 接下来把通道绑定到指定端口;
-
(4). 最后向Selector中注册事件,此处指定的是参数是OP_ACCEPT,即指定我们想要监听accept事件,也就是新的连接发生时所产生的事件;
事件类型:
public abstract class SelectionKey {
//读事件
public static final int OP_READ = 1 << 0;
//写事件
public static final int OP_WRITE = 1 << 2;
//连接事件
public static final int OP_CONNECT = 1 << 3;
//接受连接事件
public static final int OP_ACCEPT = 1 << 4;
}
注:对于 ServerSocketChannel通道来说,我们唯一可以指定的参数就是OP_ACCEPT。
代码示例:
/*
* 注册事件
*
*/
private Selector getSelector() throws IOException {
// 创建 Selector 对象
Selector sel = Selector.open();
// 创建可选择通道,并配置为非阻塞模式
ServerSocketChannel server = ServerSocketChannel.open();
//设置通信模式为非阻塞模型;
server.configureBlocking(false);
// 绑定通道到指定端口
ServerSocket socket = server.socket();
InetSocketAddress address = new InetSocketAddress(port);
socket.bind(address);
// 向 Selector 中注册感兴趣的事件
server.register(sel, SelectionKey.OP_ACCEPT);
return sel;
}
1.2、事件的监听
从Selector中获取感兴趣的事件,即开始监听,进入内部循环,在非阻塞I/O中,内部循环模式基本都是遵循这种方式。
-
(1). 首先调用select()方法,该方法会阻塞,直到至少有一个事件发生;
-
(2). 然后再使用selectedKeys()方法获取发生事件的SelectionKey;
-
(3). 再使用迭代器进行循环;
代码示例:
/*
* 开始监听
*/
public void listen() {
System.out.println("listen on " + port);
try {
while (true) {
// 该调用会阻塞,直到至少有一个事件发生
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = (SelectionKey) iter.next();
iter.remove();
handler(key);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
1.3、事件的处理
最后一步就是根据不同的事件判断是接受请求、读数据还是写事件,分别作不同的处理。
/*
* 根据不同的事件类型做不同的处理
*/
private void handler(SelectionKey key) throws IOException {
// 接收请求
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel channel = server.accept();
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_READ);
}
// 读信息请求处理
else if (key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
int len = channel.read(buffer);
if (len > 0) {
buffer.flip();
content = new String(buffer.array(), 0, len);
SelectionKey sKey = channel.register(selector, SelectionKey.OP_WRITE);
sKey.attach(content);
} else {
channel.close();
}
buffer.clear();
}
// 写事件处理
else if (key.isWritable()) {
SocketChannel channel = (SocketChannel) key.channel();
String content = (String) key.attachment();
ByteBuffer block = ByteBuffer.wrap(("输出内容:" + content).getBytes());
if (block != null) {
channel.write(block);
} else {
channel.close();
}
}
}
}
在Java1.4之前的I/O系统中,提供的都是面向流的I/O系统,系统一次一个字节地处理数据,一个输入流产生一个字节的数据,一个输出流消费一个字节的数据,面向流的I/O速度非常慢,而在 Java 1.4中推出了NIO,这是一个面向块的I/O系统,系统以块的方式处理处理,每一个操作在 一步中产生或者消费一个数据库,按块处理要比按字节处理数据快的多。
2、缓冲区Buffer
缓冲区实际上是一个容器对象,更直接的说,其实就是一个数组,在NIO库中,所有数据都是用缓冲区处理的。在读 取数据时,它是直接读到缓冲区中的; 在写入数据时,它也是写入到缓冲区中的;任何时候访问NIO中的数据,都 是将它放到缓冲区中。而在面向流I/O系统中,所有数据都是直接写入或者直接将数据读取到Stream对象中。在NIO中,所有的缓冲区类型都继承于抽象类Buffer,最常用的就是 ByteBuffer,对于Java中的基本类型,基本都有 一个具体Buffer类型与之相对应,它们之间的继承关系如下图所示:
2.1、buffer的基本原理
我们说缓冲区对象本质上是一个数组,但它其实是一个特殊的数组,缓冲区对象内置了一些机制,能够让我们跟踪和记录缓冲区的数据状态变化情况,如果我们使用get()方法从缓冲区获取数据或者使用put()方法把数据写入缓冲区时,都会引起缓冲区状态的变化。在缓冲区中,最重要的属性有三个,它们一起合作完成对缓冲区内部状态的变化跟踪:
-
position:缓冲区当前操作的元素索引位置,指定下一个将要被写入或者读取的元素索引,它的值由 get()/put()方法自动更新,最大可为capacity – 1;
-
limit: 表示缓冲区最多可操作的数据元素大小,指定还有多少数据需要取出(从缓冲区写入通道),或者还有多少空间可以放入数据(从通道读入缓冲区);
-
capacity: 缓冲区的总容量大小;作为一个内存块,Buffer数组有一个固定的大小值,只能往里写capacity个byte、long,char等类型;
Buffer源码如下:
public abstract class Buffer {
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
// Creates a new buffer with the given mark, position, limit, and capacity,
// after checking invariants.
//
Buffer(int mark, int pos, int lim, int cap) { // package-private
if (cap < 0)
throw new IllegalArgumentException("Negative capacity: " + cap);
this.capacity = cap;
limit(lim);
position(pos);
if (mark >= 0) {
if (mark > pos)
throw new IllegalArgumentException("mark > position: ("
+ mark + " > " + pos + ")");
this.mark = mark;
}
}
}
/*标记(Mark):一个备忘位置。
标记在设定前是未定义的(undefined)。使用场景是,假设缓冲区中有 10 个元素,position 目前的位置为 2(也就是如果get的话是第三个元素),
现在只想发送 6 - 10 之间的缓冲数据,此时我们可以 buffer.mark(buffer.position()),即把当前的 position 记入 mark 中,
然后 buffer.postion(6),此时发送给 channel 的数据就是 6 - 10 的数据。
发送完后,我们可以调用 buffer.reset() 使得 position = mark,因此这里的 mark 只是用于临时记录一下位置用的。
*/
2.2、buffer的操作
以上三个属性值之间有一些相对大小的关系
:0 <= position <= limit <= capacity。例如如果我们创建一个新的容量大小为8的ByteBuffer对象,在初始化的时候,position设置为0,limit和capacity被设置为8,当写数据到Buffer中时,position表示当前的位置,当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。在以后使用ByteBuffer对象过程中,capacity也就是缓冲区总容量大小的值不会再发生变化,而其它两个通过调用flip()随读写的使用而发生变化。下图展示了在缓冲区操作过程中position、limit和capacity这几个值的变化过程:
对缓冲区的操作及三个属性值的变化代码示例:
public class BufferDemo {
public static void main(String args[]) throws Exception {
//读取一个文件,文件IO处理
FileInputStream fin = new FileInputStream("bufferTest.txt");
//创建文件的操作管道
FileChannel fc = fin.getChannel();
//分配一个8个大小缓冲区,说白了就是分配一个8个大小的byte数组
ByteBuffer buffer = ByteBuffer.allocate(8);
output("初始化", buffer);
//将文件数据写到缓冲区
fc.read(buffer);
output("调用read()", buffer);
//准备操作之前,先锁定操作范围,转换为读操作;
buffer.flip();
output("调用flip()", buffer);
//判断有没有可读数据
while (buffer.remaining() > 0) {
byte b = buffer.get();
// System.out.print(((char)b));
}
output("调用get()", buffer);
//清空缓冲区
buffer.clear();
output("调用clear()", buffer);
//最后把管道关闭
fin.close();
}
//把这个缓冲里面实时状态给打印出来
public static void output(String step, ByteBuffer buffer) {
System.out.println(step + " : ");
//容量,数组大小
System.out.print("capacity: " + buffer.capacity() + ", ");
//当前操作数据所在的位置,也可以叫做游标
System.out.print("position: " + buffer.position() + ", ");
//锁定值,flip,数据操作范围索引只能在position - limit 之间
System.out.println("limit: " + buffer.limit());
System.out.println();
}
}
2.3、缓冲区的分配
在使用的过程中,可以了解到,创建一个缓冲区对象时,会调用静态方法allocate()来指定缓冲区的容量,其实调用allocate()相当于创建了一个指定大小的数组,并把它包装为缓冲区对象,或者我们也可以直接将一个现有的数组,包装为缓冲区对象。
代码如下所示:
/**
* 手动分配缓冲区
*/
public class BufferWrap {
public void myMethod() {
// 分配指定大小的缓冲区
ByteBuffer buffer1 = ByteBuffer.allocate(8);
// 包装一个现有的数组
byte array[] = new byte[8];
ByteBuffer buffer2 = ByteBuffer.wrap( array );
}
}
2.4、缓冲区分片
在NIO中,除了可以分配或者包装一个缓冲区对象外,还可以根据现有的缓冲区对象来创建一个子缓冲区,即在现有缓冲区上切出一片来作为一个新的缓冲区,但现有的缓冲区与创建的子缓冲区在底层数组层面上是数据共享的,也就是说,子缓冲区相当于是现有缓冲区的一个子视图窗口,我们可以对缓冲区的部分数据进行灵活的操作,调用slice()方法就可以创建一个子缓冲区。
代码示例如下:
/**
* 缓冲区分片
*/
public class BufferSlice {
static public void main( String args[] ) throws Exception {
ByteBuffer buffer = ByteBuffer.allocate(8);
// 缓冲区中的数据0-7
for (int i=0; i<buffer.capacity(); ++i) {
buffer.put( (byte)i );
}
// 创建子缓冲区
buffer.position( 3 );
buffer.limit( 7 );
ByteBuffer slice = buffer.slice();
// 改变子缓冲区的内容
for (int i=0; i<slice.capacity(); ++i) {
byte b = slice.get( i );
b *= 2;
slice.put( i, b );
}
buffer.position( 0 );
buffer.limit( buffer.capacity() );
while (buffer.remaining()>0) {
System.out.print( buffer.get()+ " " );
}
}
}
结果:
0 1 2 6 8 10 12 7。//可以看到3-4-5-6,变成了6-8-10-12。
2.5、只读缓冲区
只读缓冲区非常简单,可以读取它们,但是不能向它们写入数据。可以通过调用缓冲区的asReadOnlyBuffer()方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区,并与原缓冲区共享数据,只不过它是只读的;
但是如果原缓冲区的内容发生了变化,只读缓冲区的内容也随之发生变化。
代码示例如下:
/**
* 只读缓冲区
*/
public class ReadOnlyBuffer {
static public void main( String args[] ) throws Exception {
ByteBuffer buffer = ByteBuffer.allocate( 8 );
// 缓冲区中的数据0-7
for (int i=0; i<buffer.capacity(); ++i) {
buffer.put( (byte)i );
}
// 创建只读缓冲区
ByteBuffer readonly = buffer.asReadOnlyBuffer();
readonly.position(0);
readonly.limit(buffer.capacity());
// 原缓冲区数据改变之前,只读缓冲区的内容
System.out.print( "before change:");
while (readonly.remaining()>0) {
System.out.print( readonly.get() + " ");
}
System.out.print("\n");
// 改变原缓冲区的内容
for (int i=0; i<buffer.capacity(); ++i) {
byte b = buffer.get( i );
b *= 2;
buffer.put( i, b );
}
readonly.position(0);
readonly.limit(buffer.capacity());
//原缓冲区的数据改变,只读缓冲区的内容也随之改变
System.out.print( "after change:");
while (readonly.remaining()>0) {
System.out.print( readonly.get() + " ");
}
}
}
结果可以看到对原缓冲区的修改,制度缓冲区也被修改:
before change:0 1 2 3 4 5 6 7
after change:0 2 4 6 8 10 12 14
如果尝试修改只读缓冲区的内容,则会报ReadOnlyBufferException异常。只读缓冲区对于保护数据很有用。在将缓冲区传递给某 个 对象的方法时,无法知道这个方法是否会修改缓冲区中的数据。创建一个只读的缓冲区可以保证该缓冲区不会被修改。只可以把常规缓冲区转换为只读缓冲区,而不能将只读的缓冲区转换为可写的缓冲区。
2.6、直接缓冲区
这里的直接缓冲区也就是我们常说的零拷贝技术,直接缓冲区是为加快I/O速度,使用一种特殊方式为其分配内存的缓冲区。正常的java数据处理都需要将数据从磁盘文件拷贝到JVM的堆内存中进行,但是直接缓冲区避免了这一操作,直接使用堆外内存,对系统的内存进行操作,屏蔽了数据拷贝的性能消耗,jvm中存储的只是数据地址的引用,没有真正的数据。要分配直接缓冲区,需要调用allocateDirect()方法,而不是allocate()方法。
代码示例如下:
/**
* 直接缓冲区
* Zero-Copy 减少了数据拷贝
*/
public class DirectBuffer {
static public void main( String args[] ) throws Exception {
//在Java里面存的只是缓冲区的引用地址
//首先我们从磁盘上读取刚才我们写出的文件内容
String infile = "DirectBufferTest.txt";
FileInputStream fin = new FileInputStream( infile );
FileChannel fcin = fin.getChannel();
//把刚刚读取的内容写入到一个新的文件中
String outfile = String.format("DirectBufferTestCopy.txt");
FileOutputStream fout = new FileOutputStream(outfile);
FileChannel fcout = fout.getChannel();
// 使用allocateDirect,而不是allocate,使用直接缓存不经过jvm的缓冲区内存,直接使用了操作系统的内存,少了中间环节;
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
while (true) {
buffer.clear();
int r = fcin.read(buffer);
if (r==-1) {
break;
}
buffer.flip();
//写如拷贝文件
fcout.write(buffer);
}
}
}
堆外内存的回收
jvm对堆外内存的申请和释放没有堆内内存管理的效率高,
因为对于堆外内存,jvm存储的只是数据的地址引用,并不是真正的数据。JDK中使用DirectByteBuffer对象来表示堆外内存,可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存,每个DirectByteBuffer对象在初始化时,都会创建一个对应的Cleaner对象,在Cleaner对象回收的时候就会回收这部分堆外内存。这里的Cleaner对象其实是PhantomReference的一个子类,通过ReferenceQueue来保存需要回收的Cleaner对象,
当对象不可达时地址指向的内存通过(allocate-free)进行内存释放回收。
2.7、IO内存映射
内存映射是一种效率更高的读写文件数据的方法,它可以比常规的基于流或者基于通道的I/O快的多。
它能够将内存中更新操作直接持久化,反应到磁盘上,我们不需要操作文件,内存映射文件I/O是通过将磁盘文件中的数据与操作系统的内存关联,直接操作内存即可,通过操作系统的page缓存来实现物理内存的直接映射,完成映射后,对物理内存的操作修改会直接同步到磁盘文件上,效率很高。
代码示例:
/**
* IO内存映射缓冲区
*/
public class MappedBuffer {
static private final int start = 0;
static private final int size = 9;
static public void main( String args[] ) throws Exception {
RandomAccessFile raf = new RandomAccessFile( "MapperByteBuffertest.txt", "rw" );
FileChannel fc = raf.getChannel();
//把缓冲区跟文件系统进行一个映射关联,创建内存映射缓冲区
MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE,start, size );
//在缓存中改变前两个字节的内容,磁盘文件MapperByteBuffertest.txt也随之改变,在ASSIC中65-A, 66-B,
mbb.put( 0, (byte)65 ); //A
mbb.put( 1, (byte)66 ); //B
raf.close();
}
}
3、通道channel
Channel是Java NIO的一个基本构造。它代表一个实体(如一个硬件设备,一个文件,一个网络套接字或者一个能够执行一个或者多个不同的I/O操作的程序组件)的开放连接,如读操作和写操作。它是一个管道,用于连接字节缓冲区Buf和另一端的实体,这个实例可以是Socket,也可以是File, 在Nio网络编程模型中, 服务端和客户端进行IO数据交互(得到彼此推送的信息)的媒介就是Channel。我们不会将字节直接写入通道中,相反是将数据写入包含一个或者多个字节的缓冲区;同样不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
在NIO中,提供了多种通道对象,而所有的通道对象都实现了 Channel 接口。它们之间的继承关系如下图所示:
3.1、NIO读数据
任何时候读取数据,都不是直接从通道读取,而是从通道读取到缓冲区。所以使用NIO读取数据可以分为下面三个步骤:
-
1. 从FileInputStream获取Channel
-
2. 创建缓冲区Buffer;
-
3. 将数据从Channel读取到Buffer中;
代码示例如下:
public class FileInputDemo {
static public void main( String args[] ) throws Exception {
FileInputStream fin = new FileInputStream("input.txt");
// 1、获取通道
FileChannel fc = fin.getChannel();
// 2、创建缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 3、读取数据到缓冲区
fc.read(buffer);
buffer.flip();
while (buffer.remaining() > 0) {
//获取数据
byte b = buffer.get();
System.out.print(((char)b));
}
fin.close();
}
}
3.2、NIO写数据
使用NIO写入数据与读取数据的过程类似,同样数据不是直接写入通道,而是写入缓冲区,可以分为下面四个步骤:
-
1. 从FileInputStream获取Channel;
-
2. 创建 Buffer;
-
3. 将数据写入到Buffer中;
-
4. 通过channel写入到文件;
代码实现如下:
public class FileOutputDemo {
static private final byte message[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
static public void main( String args[] ) throws Exception {
FileOutputStream fout = new FileOutputStream( "output.txt" );
//1、从FileOutputStream中获取channel
FileChannel fc = fout.getChannel();
//2、创建缓冲区buffer;
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
//3、将数据写入到buffer;
for (int i=0; i<message.length; ++i) {
buffer.put( message[i] );
}
//改变缓冲区position/limit的数据
buffer.flip();
//4、通过channel写入到文件;
fc.write( buffer );
fout.close();
}
}
3.3、Netty中的channel
Netty对Jdk原生的ServerSocketChannel进行了封装和增强封装成了NioXXXChannel,例如客户端和服务端:
-
服务端: NioServerSocketChannel
-
客户端: NioSocketChannel
相对于原生的JdkChannel, Netty的Channel增加了如下的组件信息:
-
id 标识唯一身份信息;
-
可能存在的parent Channel;
-
管道pipeline;每个channel有且仅有一个pipeline与其对应,类似责任链模式实现了事件的传播处理;
-
用于数据读写的unsafe内部类,读写;
-
关联上与channel相伴终生的NioEventLoop,一个channel的所有请求都只会是一个线程来处理,加上pipeline的串行设计思路,避免了线程安全问题;
源码如下:
public abstract class AbstractChannel extends DefaultAttributeMap implements Channel {
protected AbstractChannel(Channel parent) {
this.parent = parent;
id = newId();
unsafe = newUnsafe();
pipeline = newChannelPipeline();
}
}
每个channel都是唯一的,这里看一个channelId的生成算法,为了保证唯一性过程还是比较严谨的。
//channelid的初始化:id.init();
private void init() {
int i = 0;
// machineId
System.arraycopy(MACHINE_ID, 0, data, i, MACHINE_ID_LEN);
i += MACHINE_ID_LEN;
// processId
i = writeInt(i, PROCESS_ID);
// sequence
i = writeInt(i, nextSequence.getAndIncrement());
// timestamp (kind of)
i = writeLong(i, Long.reverse(System.nanoTime()) ^ System.currentTimeMillis());
// random
int random = ThreadLocalRandom.current().nextInt();
hashCode = random;
i = writeInt(i, random);
assert i == data.length;
}
4、Netty的高性能
4.1、异步非阻塞通信模式
在IO编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者IO多路复用技术进行处理。IO多路复用技术通过把多个IO的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型比
,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。
JDK1.4提供了对非阻塞IO(NIO)的支持,JDK1.5版本使用epoll替代了传统的select/poll,极大的提升了NIO通信的性能。
Netty的IO线程NioEventLoop聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端Channel,由于读写操作都是非阻塞的,这就可以充分提升IO线程的运行效率,避免由于频繁IO阻塞导致的线程挂起。另外,
由于Netty采用了异步通信模式,一个IO线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 IO “一个连接一个线程”模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。
4.2、零拷贝
Netty 的“零拷贝”主要体现在如下三个方面:
-
(1). Netty的接收和发送ByteBuffer采用直接缓存Direct Buffer,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存Heap Buffer进行Socket读写,JVM会将堆内存Buffer拷贝一份到堆内存中,然后再油堆内存写入到Socket中,相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝;
-
(2). Netty提供了组合分片Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer;
-
(3). Nett 的文件传输采用了transferTo()方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write()方式导致的内存拷贝问题;
4.3、高效的Reactor线程模型
-
Reactor单线程模型;
-
Reactor多线程模型;
-
Reactor主从线程模型;
上节在
Java IO演进详细介绍了,这节省略了。
4.4、无锁化串行设计
(1). 串行设计结构-责任链模式
在大多数场景下,并行多线程处理可以提升系统的并发性能。但是,如果对于共享资源的并发访问处理不当,会带来 严重的锁竞争,这最终会导致性能的下降。为了尽可能的避免锁竞争带来的性能损耗,可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。 为了尽可能提升性能,Netty采用了串行无锁化设计,利用类似于双向链表的结构使所有的handler处理在IO线程内部进行串行传播完成,采用
责任链模式处理每个请求handler,避免多线程竞争导致的性能下降。其大致运行逻辑结构图如下:
(2). EventLoop的任务调度
Netty线程模型的卓越性能取决于对于当前提交的任务的执行Thread身份的确定 ,也就是说,确定它是否是分配给当前Channel以及它的EventLoop的那一个线程。
因为EventLoop将负责处理一个 Channel 的整个生命周期内的所有事件。
如果当前调用线程正是支撑 EventLoop 的线程,那么所提交的代码块将会被直接执行。否则, EventLoop将调度该任务以便稍后执行,并将它放入到内部队列中,当EventLoop下次处理它的事件时,它会执行队列中的那些任务/事件。这也就解释了任何的Thread是如何与Channel直接交互而无需在ChannelHandler中进行额外同步的。
注意,每个 EventLoop 都有它自已的任务队列,独立于任何其他的 EventLoop 。EventLoop调度任务的执行逻辑图如下(图片参考Netty实战第七章):
注意:远不要将一个长时间运行的任务放入到执行队列中,因为它将阻塞需要在同一线程上执行的任何 其他任务。”如果必须要进行阻塞调用或者执行长时间运行的任务,我们建议使用一个专门的EventExecutor 。
(3). EventLoop-线程的分配
服务于Channel的I/O和事件的EventLoop包含在EventLoopGroup中。根据不同的传输实现,EventLoop的创建和分配方式也不同。
异步传输实现只使用了少量的EventLoop以及和它们相关联的Thread,而且在当前的线程模型中,它们可能会被多个Channel所共享。这使得可以通过尽可能少量的Thread来支撑大量的Channel,而不是每个Channel分配一个Thread 。非阻塞传输的EventLoop分配方式逻辑图如下(图片参考Netty实战第七章):
上图展示了一个EventLoopGroup,它具有3个固定大小的EventLoop,每个EventLoop都由一个Thread来支撑,在创建EventLoopGroup时就直接分配了 EventLoop及绑定的Thread,以确保在需要时它们是可用的。EventLoopGroup负责为每个新创建的Channel分配一个EventLoop。在当前实现中,使用
顺序循环(round-robin)的方式进行分配以获取一个均衡的分布,并且相同的EventLoop可能会被分配给多个Channel。
一旦一个Channel被分配给一个EventLoop,它将在它的整个生命周期中都使用这个EventLoop。这点很重要,因为它可以使你从担忧你的Channel-Handler实现中的线程安全和同步问题中解脱出来。
4.5、高效的序列化
序列化的影响因素:
-
(1). 序列化后的码流大小(网络带宽的占用);
-
(2). 序列化&反序列化的性能(CPU 资源占用);
-
(3). 是否支持跨语言(异构系统的对接和开发语言切换);
Netty默认提供了对Google Protobuf的支持,通过扩展Netty的编解码接口,用户可以实现其它的高性能序列化框架,扩展非常灵活。
4.6、高效的并发编程
Netty的实现也是封装了JUC里的很多并发操作类,好的高效的框架一定是有很多优良细节设计的体现,主要体现在:
-
(1). volatile 的大量、正确使用;
-
(2). CAS 和原子类的广泛使用;
-
(3). 线程安全容器的使用;
-
(4). 通过读写锁及无锁编程设计提升并发性能。
5、小结
此篇主要介绍了NIO的三个核心的组件selector、buffer、channel的具体使用和实现,这是实现非阻塞io的基石,以及Netty的使用和高性能,简单了解一下。
OK---志不强者智不达,言不信者行不果。
水滴石穿,积少成多。学习笔记,内容简单,用于复习,梳理巩固。
参考资料:
《Netty实战》