NIO 与 Netty 程式设计1(NIO)
多执行绪程式设计
基本知识回顾
执行绪安全
执行绪间通讯
BIO 程式设计
NIO 程式设计
概述
档案IO
网络IO
AIO 和IO 总结
Netty 框架概述
核心API
入门案例
网络聊天案例
编码和解码
自定义RPC
整体分析
设计和实现**
一.多执行绪程式设计
1.1基本知识回顾执行绪是比程序更小的能独立执行的基本单位,它是程序的一部分,一个程序可以拥有多 个执行绪,但至少要有一个执行绪,即主执行执行绪(Java 的 main 方法)。我们既可以编写单执行绪应用,也可以编写多执行绪应用。
一个程序中的多个执行绪可以并发(同时)执行,在一些执行时间长、需要等待的任务上(例 如:档案读写和网络传输等),多执行绪就比较有用了。
怎么理解多执行绪呢?来两个例子:
1.程序就是一个工厂,一个执行绪就是工厂中的一条生产线,一个工厂至少有一条生产线,只有一条生产线就是单执行绪应用,拥有多条生产线就是多执行绪应用。多条生产线可 以同时执行。
2.我们使用迅雷可以同时下载多个视讯,迅雷就是程序,多个下载任务就是执行绪,这几个执行绪可以同时执行去下载视讯。
多执行绪可以共享内存、充分利用 CPU,通过提高资源(内存和 CPU)使用率从而提高程式的执行效率。CPU 使用抢占式排程模式在多个执行绪间进行着随机的高速的切换。对于 CPU 的一个核而言,某个时刻,只能执行一个执行绪,而CPU 在多个执行绪间的切换速度相对我们的感觉要快很多,看上去就像是多个执行绪或任务在同时执行。
Java 天生就支援多执行绪并提供了两种程式设计方式,一个是继承 Thread 类,一个是实现
Runnable 界面,接下来咱们通过两个案例快速复习回顾一下。
方式一:继承 Thread 类
public class ThreadFor1 extends Thread{
public void run() {
for (int i = 0; i System.out.println(this.getName()+":"+i);
}
}
}
上述程式码自定义了一个类去继承 Thread 类,并重写了 run 方法,在该方法内实现具体业务功能,这里用一个 for 循环模拟一下。
public class ThreadFor2 extends Thread{
public void run() {
for (int i = 51; i System.out.println(this.getName()+":"+i);
}
}
上述程式码又自定义了一个类去继承 Thread 类,并重写了 run 方法,在该方法内实现另一个
业务功能,这里仍用一个 for 循环模拟一下。
public class TestThreadFor {
public static void main(String[] args) {
ThreadFor1 tf1=new ThreadFor1();
tf1.setName(" 线 程 A");
ThreadFor2 tf2=new ThreadFor2();
tf2.setName("执行绪 B");
tf1.start();
tf2.start();
}
}
上述程式码建立了两个执行绪物件并分别启动,执行效果如下图所示,我们能够清晰观察到,CPU 在两个执行绪之间快速随机切换,也就是我们平时说的在同时执行。

方式二:实现 Runnable 界面
public class RunnableFor1 implements Runnable{
public void run() {
for (int i = 0; i { System.out.println(Thread.currentThread().getName()+":"+i);
}
}
}
上述程式码自定义一个类去实现 Runnable 界面,并实现了 run 方法,在该方法内实现具体业务功能,这里用一个 for 循环模拟一下。
public class RunnableFor2 implements Runnable{
public void run() {
for (int i = 51; i System.out.println(Thread.currentThread().getName()+":"+i);
}
}
}
上述程式码建立两个执行绪物件并分别启动,执行效果如下图所示,我们能够清晰观察到,CPU 在两个执行绪之间快速随机切换,也就是我们平时说的在同时执行。

1.2执行绪安全
1.2.1产生执行绪安全问题的原因
在进行多执行绪程式设计时,要注意执行绪安全问题,我们先通过一个案例了解一下什么是执行绪 安全问题。该案例模拟用两个售票视窗同时卖火车票,具体程式码如下所示:
public class SaleWindow implements Runnable {
private int id = 10; //表示 10 张火车票 这是共享资源
//卖 10 张火车票
public void run() {
for (int i = 0; i if (id > 0) {
System.out.println(Thread.currentThread().getName()
+ "卖了编号为" + id + "的火车票");
id--; try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
}
}
}
上述程式码定义了一个类去实现 Runnable 界面,该类中有一个属性 id,表示 10 张火车票,并在 run 方法中通过一个 for 循环销售火车票,为了让效果明显一些,中间用 sleep 方法停顿半秒钟。
public class TestSaleWindow {
public static void main(String[] args) { SaleWindow sw=new SaleWindow();
Thread t1=new Thread(sw); Thread t2=new Thread(sw);
t1.setName("视窗 A");
t2.setName("视窗 B");
t1.start();
t2.start();
}
}
上述程式码建立两个执行绪物件模拟两个售票视窗同时卖票,执行结果如下图所示:

我们看到,10 张火车票都卖出去了,但是出现了重复售票,这就是执行绪安全问题造成的。这 10 章火车票是共享资源,也就是说任何视窗都可以进行操作和销售,问题在于视窗A 把某一张火车票卖出去之后,视窗 B 并不知道,因为这是两个执行绪,所以视窗 B 也可能会再卖出去一张相同的火车票。
多个执行绪操作的是同一个共享资源,但是执行绪之间是彼此独立、互相隔绝的,因此就会 出现数据(共享资源)不能同步更新的情况,这就是执行绪安全问题。
1.2.2解决执行绪安全问题
Java 中提供了一个同步机制(锁)来解决执行绪安全问题,即让操作共享资料的程式码在某一时间段,只被一个执行绪执行(锁住),在执行过程中,其他执行绪不可以参与进来,这样共享数 据就能同步了。简单来说,就是给某些程式码加把锁。
锁是什么?又从哪儿来呢?锁的专业名称叫监视器 monitor,其实 Java 为每个物件都自动内建了一个锁(监视器 monitor),当某个执行绪执行到某程式码块时就会自动得到这个物件的锁,那么其他执行绪就无法执行该程式码块了,一直要等到之前那个执行绪停止(释放锁)。需要 特别注意的是:多个执行绪必须使用同一把锁(物件)。
Java 的同步机制提供了两种实现方式:
同步程式码块:即给程式码块上锁,变成同步程式码块
同步方法:即给方法上锁,变成同步方法
接下来我们分别用这两种方式解决卖火车票案例的执行绪安全问题,其实这两种方式本质 上差不多,都是通过 synchronized 关键字来实现的。
方式一:同步程式码块
public class SaleWindow1 implements Runnable {
private int id = 10; //表示 10 张火车票 共享资源
//卖 10 张火车票
public void run() {
for (int i = 0; i synchronized (this) {
if (id > 0) {
System.out.println(Thread.currentThread().getName()
+ "卖了编号为" + id + "的火车票");
id--; try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
}
}
}
}
其实大家也发现了,上述程式码和之前的只有一点差别,就是多了红色部分的程式码,同步程式码块的语法是:synchronized(锁){…业务程式码…}。
public class TestSaleWindow1 {
public static void main(String[] args) { SaleWindow1 sw=new SaleWindow1();
Thread t1=new Thread(sw); Thread t2=new Thread(sw);
t1.setName("视窗 A");
t2.setName("视窗 B");
t1.start();
t2.start();
}
}
我们同样建立两个执行绪物件模拟两个售票视窗同时卖票,这次执行结果如下图所示:

我们看到 10 张火车票都卖出去了,这次没有问题,我们不关心这 10 张票都是哪个视窗卖出去的,我们关心的是没有重复卖票。
方式二:同步方法
public class SaleWindow2 implements Runnable { private int id = 10; // 表示 10 张火车票 共享资源
public synchronized void saleOne(){ //该方法内是上面同步程式码块中的程式码if (id > 0) {
System.out.println(Thread.currentThread().getName() + "卖了编号为"
+ id + "的火车票");
id--;
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
}
// 卖 10 张火车票
public void run() {
for (int i = 0; i }
}
}
第二种方式是把原来同步程式码块中的程式码抽取出来放到一个方法中,然后给这个方法加上synchronized 关键字修饰,锁住的程式码是一样的,因此本质上和第一种方式没什么区别。
public class TestSaleWindow2 {
public static void main(String[] args) { SaleWindow2 sw=new SaleWindow2();
Thread t1=new Thread(sw); Thread t2=new Thread(sw);
t1.setName("视窗 A");
t2.setName("视窗 B");
t1.start();
t2.start();
}
}
我们同样还是建立两个执行绪物件模拟两个售票视窗同时卖票,这次执行结果如下图所示:

1.2.3Java API 中的执行绪安全问题
我们平时在使用 Java API 进行程式设计时,经常遇到说哪个类是执行绪安全的,哪个类是不保证执行绪安全的,例如:StringBuffer / StringBuilder 和 Vector / ArrayList ,谁是执行绪安全的? 谁不是执行绪安全的?我们查一下它们的源代码便可知晓。




通过检视源代码,我们发现 StringBuffer 和 Vector 类中的大部分方法都是同步方法,所以证明
这两个类在使用时是保证执行绪安全的;而 StringBuilder 和ArrayList 类中的方法都是普通方法, 没有使用 synchronized 关键字进行修饰,所以证明这两个类在使用时不保证执行绪安全。执行绪安全和效能之间不可兼得,保证执行绪安全就会损失效能,保证效能就不能满足执行绪安全。
1.3执行绪间通讯
多个执行绪并发执行时, 在预设情况下 CPU 是随机性的线上程之间进行切换的,但是有时候我们希望它们能有规律的执行, 那么,多执行绪之间就需要一些协调通讯来改变或控制 CPU 的随机性。Java 提供了等待唤醒机制来解决这个问题,具体来说就是多个执行绪依靠一个同步锁,然后借助于 wait()和 notify()方法就可以实现执行绪间的协调通讯。
同步锁相当于中间人的作用,多个执行绪必须用同一个同步锁(认识同一个中间人),只有同一个锁上的被等待的执行绪,才可以被持有该锁的另一个执行绪唤醒,使用不同锁的执行绪之间 不能相互唤醒,也就无法协调通讯。
Java 在 Object 类中提供了一些方法可以用来实现执行绪间的协调通讯,我们一起来了解一下:
public final void wait(); 让当前执行绪释放锁
public final native void wait(long timeout); 让当前执行绪释放锁,并等待 xx 毫秒
public final native void notify(); 唤醒持有同一锁的某个执行绪
public final native void notifyAll(); 唤醒持有同一锁的所有执行绪
需要注意的是:在呼叫 wait 和 notify 方法时,当前执行绪必须已经持有锁,然后才可以呼叫,否则将会丢掷 IllegalMonitorStateException 异常。接下来咱们通过两个案例来演示一下具体如何程式设计实现执行绪间通讯。
案例 1: 一个执行绪输出 10 次 1,一个执行绪输出 10 次 2,要求交替输出“1 2 1 2 1 2…”或“2 1
2 1 2 1…”
public class MyLock {
// 锁
public static Object o=new Object();
}
为了保证两个执行绪使用的一定是同一个锁,我们建立一个物件作为静态属性放到一个类中,这个物件就用来充当锁。
public class ThreadForNum1 extends Thread{ public void run() {
for (int i = 0; i System.out.println("1"); MyLock.o.notify(); //唤醒另一个执行绪try {
MyLock.o.wait(); //让自己休眠并释放锁
} catch (InterruptedException e) {
}
}
}
}
}
该执行绪输出十次 1,使用 MyLock.o 作为锁,每输出一个 1 就唤醒另一个执行绪,然后自己休眠并释放锁。
public class ThreadForNum2 extends Thread{ public void run() {
for (int i = 0; i System.out.println("2"); MyLock.o.notify(); //唤醒另一个执行绪try {
MyLock.o.wait(); //让自己休眠并释放锁
} catch (InterruptedException e) {
}
}
}
}
}
该执行绪输出十次 2,也使用 MyLock.o 作为锁,每输出一个 2 就唤醒另一个执行绪,然后自己休眠并释放锁。
public class TestNum {
public static void main(String[] args) {
new ThreadForNum1().start();
new ThreadForNum2().start();
}
}
我们建立两个执行绪物件分别执行,效果如下图所示:

案例 2:生产者消费者模式
该模式在现实生活中很常见,在专案开发中也广泛应用,它是执行绪间通讯的经典应用。 生产者是一堆执行绪,消费者是另一堆执行绪,内存缓冲区可以使用 List 集合储存资料。该模式的关键之处是如何处理多执行绪之间的协调通讯,内存缓冲区为空的时候,消费者必须等待, 而内存缓冲区满的时候,生产者必须等待,其他时候可以是个动态平衡。
下面的案例模拟实现农夫采摘水果放到筐里,小孩从筐里拿水果吃,农夫是一个执行绪, 小孩是一个执行绪,水果筐放满了,农夫停;水果筐空了,小孩停
public class Kuang {
//这个集合就是水果筐 假设最多存 10 个水果
public static ArrayList kuang=new ArrayList();
}
上述程式码定义一个静态集合作为内存缓冲区用来储存资料,同时这个集合也可以作为锁去被 多个执行绪使用。
public class Farmer extends Thread { public void run() {
while (true) {
synchronized (Kuang.kuang) {
//1.筐放满了就让农夫休息if (Kuang.kuang.size() == 10) {
try {
Kuang.kuang.wait();
} catch (InterruptedException e) {
}
}
//2.往筐里放水果Kuang.kuang.add("apple");
System.out.println("农夫放了一个水果,目前筐里有" + Kuang.kuang.size()
+ "个水果");
//3.唤醒小孩继续吃Kuang.kuang.notify();
}
//4.模拟控制速度try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
}
}
上述程式码就是农夫执行绪,不断的往集合(筐)里放水果,当筐满了就停,同时释放锁
public class Child extends Thread { public void run() {
while (true) {
synchronized (Kuang.kuang) {
//1.筐里没水果了就让小孩休息if (Kuang.kuang.size() == 0) {
try {
Kuang.kuang.wait();
} catch (InterruptedException e) {
}
}
//2.小孩吃水果Kuang.kuang.remove("apple");
System.out.println("小孩吃了一个水果,目前筐里有" + Kuang.kuang.size() + "个水果");
//3.唤醒农夫继续放水果Kuang.kuang.notify();
}
//4.模拟控制速度try {
Thread.sleep(400);
} catch (InterruptedException e) { e.printStackTrace();
}
}
}
}
上述程式码是小孩执行绪,不断的从集合(筐)里拿水果吃,当筐空了就停,同时释放锁。
public class TestFarmerChild {
public static void main(String[] args) { new Farmer().start();
new Child().start();
}
}
我们建立两个执行绪同时执行,可以通过双方执行绪里的 sleep 方法模拟控制速度,当农夫往框里放水果的速度快于小孩吃水果的速度时,执行效果如下图所示:

当小孩吃水果的速度快于农夫往框里放水管的速度时,执行效果如下图所示:

二.BIO 程式设计
BIO 有的称之为basic(基本) IO,有的称之为block(阻塞) IO,主要应用于档案 IO 和网络IO,这里不再说档案 IO, 大家对此都非常熟悉,本次课程主要讲解网络 IO。在 JDK1.4 之前,我们建立网络连线的时候只能采用 BIO,需要先在服务端启动一个ServerSocket,然后在客户端启动 Socket 来对服务端进行通讯,预设情况下服务端需要对每个请求建立一个执行绪等待请求,而客户端传送请求后,先咨询服务端是否有执行绪响应,如果没有则会一直等待或者遭到拒绝,如果有的话,客户端执行绪会等待请求结束后才继续执行, 这就是阻塞式 IO。
接下来通过一个例子复习回顾一下 BIO 的基本用法(基于 TCP)。
//BIO 服务器端程式
public class TCPServer {
public static void main(String[] args) throws Exception {
//1.建立ServerSocket 物件
ServerSocket ss=new ServerSocket(9999);
while (true) {
//2.监听客户端
Socket s = ss.accept(); //阻塞
//3.从连线中取出输入流来接收讯息InputStream is = s.getInputStream(); //阻塞byte[] b = new byte[10];
is.read(b);
String clientIP = s.getInetAddress().getHostAddress(); System.out.println(clientIP + "说:" + new String(b).trim());
//4.从连线中取出输出流并回话OutputStream os = s.getOutputStream(); os.write("没钱".getBytes());
//5.关闭
s.close();
}
}
}
上述程式码编写了一个服务器端程式,系结埠号 9999,accept 方法用来监听客户端连线, 如果没有客户端连线,就一直等待,程式会阻塞到这里。
//BIO 客户端程式
public class TCPClient {
public static void main(String[] args) throws Exception { while (true) {
//1.建立 Socket 物件
Socket s = new Socket("127.0.0.1", 9999);
//2.从连线中取出输出流并发讯息OutputStream os = s.getOutputStream(); System.out.println("请输入:");
Scanner sc = new Scanner(System.in); String msg = sc.nextLine(); os.write(msg.getBytes());
//3.从连线中取出输入流并接收回话InputStream is = s.getInputStream(); //阻塞byte[] b = new byte[20];
is.read(b);
System.out.println("老板说:" + new String(b).trim());
//4.关闭s.close();
}
}
}
上述程式码编写了一个客户端程式,通过 9999 埠连线服务器端,getInputStream 方法用来等待服务器端返回资料,如果没有返回,就一直等待,程式会阻塞到这里。执行效果如下图 所示:


三.NIO 程式设计
3.1概述java.nio 全称 java non-blocking IO,是指 JDK 提供的新 API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO)。新增了许多用于处理输入输出的类,这些类都被放在 java.nio 包及子包下,并且对原 java.io 包中的很多类进行改写,新增了满足 NIO 的功能。

NIO 和 BIO 有着相同的目的和作用,但是它们的实现方式完全不同,BIO 以流的方式处理资料,而 NIO 以块的方式处理资料,块 I/O 的效率比流 I/O 高很多。另外,NIO 是非阻塞式的,这一点跟 BIO 也很不相同,使用它可以提供非阻塞式的高伸缩性网络。
NIO 主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)。传统的 BIO 基于字节流和字元流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进行操作,资料总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择区)用于监听多个通道的事件(比如:连线请求,资料到达等),因此使用单个执行绪就可以监听多个客户端通道。
3.2档案 IO
3.2.1概述和核心 API
缓冲区(Buffer):实际上是一个容器,是一个特殊的阵列,缓冲区物件内建了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel 提供从档案、网络读取资料的渠道, 但是读取或写入的资料都必须经由 Buffer,如下图所示:

在 NIO 中,Buffer 是一个顶层父类,它是一个抽象类,常用的 Buffer 子类有:
ByteBuffer,储存字节资料到缓冲区
ShortBuffer,储存字串资料到缓冲区
CharBuffer,储存字元资料到缓冲区
IntBuffer,储存整数资料到缓冲区
LongBuffer,储存长整型资料到缓冲区
DoubleBuffer,储存小数到缓冲区
FloatBuffer,储存小数到缓冲区
对于 Java 中的基本资料型别, 都有一个 Buffer 型别与之相对应,最常用的自然是
ByteBuffer 类(二进位制资料),该类的主要方法如下所示:
public abstract ByteBuffer put(byte[] b); 储存字节资料到缓冲区
public abstract byte[] get(); 从缓冲区获得字节资料
public final byte[] array(); 把缓冲区资料转换成字节阵列
public static ByteBuffer allocate(int capacity); 设定缓冲区的初始容量
public static ByteBuffer wrap(byte[] array); 把一个现成的阵列放到缓冲区中使用
public final Buffer flip(); 翻转缓冲区,重置位置到初始位置
通道(Channel):类似于 BIO 中的 stream,例如 FileInputStream 物件,用来建立到目标(档案,网络套接字,硬件装置等)的一个连线,但是需要注意:BIO 中的 stream 是单向的,例如 FileInputStream 物件只能进行读取资料的操作,而 NIO 中的通道(Channel)是双向的, 既可以用来进行读操作,也可以用来进行写操作。常用的 Channel 类有:FileChannel、DatagramChannel、ServerSocketChannel 和 SocketChannel。FileChannel 用于档案的资料读写, DatagramChannel 用于 UDP 的资料读写,ServerSocketChannel 和 SocketChannel 用于 TCP 的资料读写。

这里我们先讲解 FileChannel 类,该类主要用来对本地档案进行 IO 操作,主要方法如下所示:
public int read(ByteBuffer dst) ,从通道读取资料并放到缓冲区中
public int write(ByteBuffer src) ,把缓冲区的资料写到通道中
public long transferFrom(ReadableByteChannel src, long position, long count),从目标通道中复制资料到当前通道
public long transferTo(long position, long count, WritableByteChannel target),把资料从当前通道复制给目标通道
3.2.2案例
接下来我们通过 NIO 实现几个案例,分别演示一下本地档案的读、写和复制操作,并和 BIO 做个对比。
1.往本地档案中写资料
@Test
public void test1() throws Exception{ String str="hello,nio,我是博学谷";
FileOutputStream fos=new FileOutputStream("basic.txt"); FileChannel fc=fos.getChannel();
ByteBuffer buffer=ByteBuffer.allocate(1024); buffer.put(str.getBytes());
buffer.flip(); fc.write(buffer); fos.close();
}
NIO 中的通道是从输出流物件里通过 getChannel 方法获取到的,该通道是双向的,既可以读,又可以写。在往通道里写资料之前,必须通过 put 方法把资料存到 ByteBuffer 中,然后通过通道的 write 方法写资料。在 write 之前,需要呼叫 flip 方法翻转缓冲区,把内部重置到初始位置,这样在接下来写资料时才能把所有资料写到通道里。执行效果如下图所示:

2.从本地档案中读资料
@Test
public void test2() throws Exception{ File file=new File("basic.txt");
FileInputStream fis=new FileInputStream(file); FileChannel fc=fis.getChannel();
ByteBuffer buffer=ByteBuffer.allocate((int)file.length()); fc.read(buffer);
System.out.print(new String(buffer.array())); fis.close();
}
上述程式码从输入流中获得一个通道,然后提供 ByteBuffer 缓冲区,该缓冲区的初始容量和档案的大小一样,最后通过通道的 read 方法把资料读取出来并存储到了 ByteBuffer 中。执行效果如下图所示:

3.复制档案
通过 BIO 复制一个视讯档案,程式码如下所示:
@Test
public void test3() throws Exception{
FileInputStream fis=new FileInputStream("C:\Users\zdx\Desktop\oracle.mov"); FileOutputStream fos=new FileOutputStream("d:\oracle.mov");
byte[] b=new byte[1024]; while (true) {
int res=fis.read(b); if(res==-1){
break;
}
fos.write(b,0,res);
}
fis.close();
fos.close();
}
上述程式码分别通过输入流和输出流实现了档案的复制,这是通过传统的 BIO 实现的,大
家都比较熟悉,不再多说。
通过 NIO 复制相同的视讯档案,程式码如下所示:
@Test
public void test4() throws Exception{
FileInputStream fis=new FileInputStream("C:\Users\zdx\Desktop\oracle.mov"); FileOutputStream fos=new FileOutputStream("d:\oracle.mov");
FileChannel sourceCh = fis.getChannel(); FileChannel destCh = fos.getChannel(); destCh.transferFrom(sourceCh, 0, sourceCh.size()); sourceCh.close();
destCh.close();
}
上述程式码分别从两个流中得到两个通道,sourceCh 负责读资料,destCh 负责写资料,然后直接呼叫 transferFrom 方法一步到位实现了档案复制。
3.3网络 IO
3.3.1概述和核心 API
前面在进行档案 IO 时用到的 FileChannel 并不支援非阻塞操作,学习 NIO 主要就是进行网络 IO,Java NIO 中的网络通道是非阻塞 IO 的实现,基于事件驱动,非常适用于服务器需要维持大量连线,但是资料交换量不大的情况,例如一些即时通讯的服务等等…
在 Java 中编写 Socket 服务器,通常有以下几种模式:
一个客户端连线用一个执行绪,优点:程式编写简单;缺点:如果连线非常多,分配的线 程也会非常多,服务器可能会因为资源耗尽而崩溃。
把每一个客户端连线交给一个拥有固定数量执行绪的连线池,优点:程式编写相对简单, 可以处理大量的连线。确定:执行绪的开销非常大,连线如果非常多,排队现象会比较严重。
使用 Java 的 NIO,用非阻塞的 IO 方式处理。这种模式可以用一个执行绪,处理大量的客户端连线。
1.Selector(选择器),能够检测多个注册的通道上是否有事件发生,如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单执行绪去管理多个通道,也就是管理多个连线。这样使得只有在连线真正有读写事件发生时,才会呼叫函式来进行读写, 就大大地减少了系统开销,并且不必为每个连线都建立一个执行绪,不用去维护多个执行绪,并且避免了多执行绪之间的上下文切换导致的开销。

该类的常用方法如下所示:
public static Selector open(),得到一个选择器物件
public int select(long timeout),监控所有注册的通道,当其中有 IO 操作可以进行时,将对应的 SelectionKey 加入到内部集合中并返回,引数用来设定超时时间
public Set selectedKeys(),从内部集合中得到所有的 SelectionKey
2.SelectionKey,代表了 Selector 和网络通道的注册关系,一共四种:
int OP_ACCEPT:有新的网络连线可以 accept,值为 16
int OP_CONNECT:代表连线已经建立,值为 8
int OP_READ 和 int OP_WRITE:代表了读、写操作,值为 1 和 4
该类的常用方法如下所示:
public abstract Selector selector(),得到与之关联的 Selector 物件
public abstract SelectableChannel channel(),得到与之关联的通道
public final Object attachment(),得到与之关联的共享资料
public abstract SelectionKey interestOps(int ops),设定或改变监听事件
public final boolean isAcceptable(),是否可以 accept
public final boolean isReadable(),是否可以读
public final boolean isWritable(),是否可以写
3.ServerSocketChannel,用来在服务器端监听新的客户端 Socket 连线,常用方法如下所示:
public static ServerSocketChannel open(),得到一个 ServerSocketChannel 通道
public final ServerSocketChannel bind(SocketAddress local),设定服务器端埠号
public final SelectableChannel configureBlocking(boolean block),设定阻塞或非阻塞模式, 取值 false 表示采用非阻塞模式
public SocketChannel accept(),接受一个连线,返回代表这个连线的通道物件
public final SelectionKey register(Selector sel, int ops),注册一个选择器并设定监听事件
4.SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 总是把缓冲区的资料写入通道,或者把通道里的资料读到缓冲区。常用方法如下所示:
public static SocketChannel open(),得到一个 SocketChannel 通道
public final SelectableChannel configureBlocking(boolean block),设定阻塞或非阻塞模式, 取值 false 表示采用非阻塞模式
public boolean connect(SocketAddress remote),连线服务器
public boolean finishConnect(),如果上面的方法连线失败,接下来就要通过该方法完成连线操作
public int write(ByteBuffer src),往通道里写资料
public int read(ByteBuffer dst),从通道里读资料
public final SelectionKey register(Selector sel, int ops, Object att),注册一个选择器并设定监听事件,最后一个引数可以设定共享资料
public final void close(),关闭通道

3.3.2入门案例
API 学习完毕后,接下来我们使用 NIO 开发一个入门案例,实现服务器端和客户端之间的资料通讯(非阻塞)。

//网络服务器端程式public class NIOServer {
public static void main(String[] args) throws Exception{
//1. 得到一个 ServerSocketChannel 物件 老大
ServerSocketChannel serverSocketChannel=ServerSocketChannel.open();
//2. 得到一个 Selector 物件 间谍
Selector selector=Selector.open();
//3. 系结一个埠号
serverSocketChannel.bind(new InetSocketAddress(9999));
//4. 设定非阻塞方式
serverSocketChannel.configureBlocking(false);
//5. 把 ServerSocketChannel 物件注册给 Selector 物件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//6. 干活
while(true){
//6.1 监控客户端
if(selector.select(2000)==0){ //nio 非阻塞式的优势System.out.println("Server:没有客户端搭理我,我就干点别的事"); continue;
}
//6.2 得到 SelectionKey,判断通道里的事件Iterator keyIterator=selector.selectedKeys().iterator(); while(keyIterator.hasNext()){
SelectionKey key=keyIterator.next(); if(key.isAcceptable()){ //客户端连线请求事件
System.out.println("OP_ACCEPT");
SocketChannel socketChannel=serverSocketChannel.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector,SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
if(key.isReadable()){ //读取客户端资料事件SocketChannel channel=(SocketChannel) key.channel(); ByteBuffer buffer=(ByteBuffer) key.attachment(); channel.read(buffer);
System.out.println("客户端发来资料:"+new String(buffer.array()));
}
// 6.3 手动从集合中移除当前 key,防止重复处理
keyIterator.remove();
}
}
}
}
上面程式码用 NIO 实现了一个服务器端程式,能不断接受客户端连线并读取客户端发过来的资料。
//网络客户端程式public class NIOClient {
public static void main(String[] args) throws Exception{
//1. 得到一个网络通道
SocketChannel channel=SocketChannel.open();
//2. 设定非阻塞方式
channel.configureBlocking(false);
//3. 提供服务器端的 IP 地址和埠号
InetSocketAddress address=new InetSocketAddress("127.0.0.1",9999);
//4. 连线服务器端
if(!channel.connect(address)){
while(!channel.finishConnect()){ //nio 作为非阻塞式的优势
System.out.println("Client:连线服务器端的同时,我还可以干别的一些事情");
}
}
//5. 得到一个缓冲区并存入资料
String msg="hello,Server";
ByteBuffer writeBuf = ByteBuffer.wrap(msg.getBytes());
//6. 传送资料channel.write(writeBuf); System.in.read();
}
}
上面程式码通过 NIO 实现了一个客户端程式,连线上服务器端后传送了一条资料,执行效果如下图所示:

3.3.3网络聊天案例
刚才我们通过 NIO 实现了一个入门案例,基本了解了 NIO 的工作方式和执行流程,接下来我们用 NIO 实现一个多人聊天案例,具体程式码如下所示:
public class ChatServer { private Selector selector;
private ServerSocketChannel listenerChannel; private static final int PORT = 9999; //服务器埠
public ChatServer() { try {
// 得到选择器
selector = Selector.open();
// 开启监听通道
listenerChannel = ServerSocketChannel.open();
// 系结埠
listenerChannel.bind(new InetSocketAddress(PORT));
// 设定为非阻塞模式
listenerChannel.configureBlocking(false);
// 将选择器系结到监听通道并监听 accept 事件listenerChannel.register(selector, SelectionKey.OP_ACCEPT); printInfo("Chat Server is ready");
} catch (IOException e) { e.printStackTrace();
}
}
public void start() { try {
while (true) { //不停轮询
int count = selector.select();//获取就绪channel if (count > 0) {
Iterator iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 监听到 accept
if (key.isAcceptable()) {
SocketChannel sc = listenerChannel.accept();
//非阻塞模式sc.configureBlocking(false);
// 注 册 到 选 择 器 上 并 监 听 read sc.register(selector, SelectionKey.OP_READ);
System.out.println(sc.getRemoteAddress().toString().substring(1)+"上线了");
//将此对应的 channel 设定为 accept,接着准备接受其他客户端请求
key.interestOps(SelectionKey.OP_ACCEPT);
}
//监听到 read
if (key.isReadable()) {
readMsg(key); //读取客户端发来的资料
}
} else {
}
//一定要把当前 key 删掉,防止重复处理
iterator.remove();
System.out.println("独自在寒风中等候...");
}
}
} catch (IOException e) { e.printStackTrace();
}
}
private void readMsg(SelectionKey key) { SocketChannel channel = null;
try {
// 得到关联的通道
channel = (SocketChannel) key.channel();
//设定 buffer 缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//从通道中读取资料并存储到缓冲区中int count = channel.read(buffer);
//如果读取到了资料if (count > 0) {
//把缓冲区资料转换为字串
String msg = new String(buffer.array()); printInfo(msg);
//将关联的 channel 设定为 read,继续准备接受资料key.interestOps(SelectionKey.OP_READ);
BroadCast(channel, msg); //向所有客户端广播资料
}
buffer.clear();
} catch (IOException e) { try {
//当客户端关闭 channel 时,进行异常如理
printInfo(channel.getRemoteAddress().toString().substring(1) + "下线了..."); key.cancel(); //取消注册
channel.close(); //关闭通道
} catch (IOException e1) {
e1.printStackTrace();
}
}
}
public void BroadCast(SocketChannel except, String msg) throws IOException {
System.out.println("传送广播...");
//广播资料到所有的 SocketChannel 中
for (SelectionKey key : selector.keys()) { Channel targetchannel = key.channel();
//排除自身
if (targetchannel instanceof SocketChannel && targetchannel != except) {
SocketChannel dest = (SocketChannel) targetchannel;
//把资料储存到缓冲区中
ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
//往通道中写资料dest.write(buffer);
}
}
}
private void printInfo(String str) { //往控制台打印讯息
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println("[" + sdf.format(new Date()) + "] -> " + str);
}
public static void main(String[] args) {
ChatServer server = new ChatServer(); server.start();
}
}
上述程式码使用 NIO 编写了一个聊天程式的服务器端,可以接受客户端发来的资料,并能把
资料广播给所有客户端。
public class ChatClient {
private final String HOST = "127.0.0.1"; //服务器地址private int PORT = 9999; //服务器埠
private Selector selector;
private SocketChannel socketChannel; private String userName;
public ChatClient() throws IOException {
//得到选择器
selector = Selector.open();
//连线远端服务器
socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", PORT));
//设定非阻塞socketChannel.configureBlocking(false);
// 注 册 选 择 器 并 设 置 为 read socketChannel.register(selector, SelectionKey.OP_READ);
//得到客户端 IP 地址和埠资讯,作为聊天使用者名称使用
userName = socketChannel.getLocalAddress().toString().substring(1); System.out.println("---------------Client(" + userName + ") is ready ");
}
//向服务器端传送资料
public void sendMsg(String msg) throws Exception {
//如果控制台输入 bye 就关闭通道,结束聊天
if (msg.equalsIgnoreCase("bye")) { socketChannel.close(); socketChannel = null;
return;
}
msg = userName + "说: " + msg; try {
//往通道中写资料socketChannel.write(ByteBuffer.wrap(msg.getBytes()));
} catch (IOException e) { e.printStackTrace();
}
}
//从服务器端接收资料public void receiveMsg() {
try {
int readyChannels = selector.select(); if (readyChannels > 0) { //有可用通道
Set selectedKeys = selector.selectedKeys(); Iterator keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) {
SelectionKey sk = (SelectionKey) keyIterator.next(); if (sk.isReadable()) {
//得到关联的通道
SocketChannel sc = (SocketChannel) sk.channel();
//得到一个缓冲区
ByteBuffer buff = ByteBuffer.allocate(1024);
//读取资料并存储到缓冲区sc.read(buff);
//把缓冲区资料转换成字串
String msg = new String(buff.array()); System.out.println(msg.trim());
}
keyIterator.remove(); //删除当前SelectionKey,防止重复处理
}
} else {
System.out.println("人呢?都去哪儿了?没人聊天啊...");
}
} catch (IOException e) { e.printStackTrace();
}
}
}
上述程式码通过 NIO 编写了一个聊天程式的客户端,可以向服务器端传送资料,并能接收服
务器广播的资料。
public class TestChat {
public static void main(String[] args) throws Exception {
//建立一个聊天客户端物件
ChatClient chatClient = new ChatClient();
new Thread() { //单独开一个执行绪不断的接收服务器端广播的资料public void run() {
while (true) {
chatClient.receiveMsg(); try { //间隔 3 秒
Thread.currentThread().sleep(3000);
} catch (InterruptedException e) { e.printStackTrace();
}
}
}
}.start();
Scanner scanner = new Scanner(System.in);
//在控制台输入资料并发送到服务器端while (scanner.hasNextLine()) {
String msg = scanner.nextLine(); chatClient.sendMsg(msg);
}
}
}
上述程式码运行了聊天程式的客户端,并在主执行绪中传送资料,在另一个执行绪中不断接收服务 器端的广播资料,该程式码执行一次就是一个聊天客户端,可以同时执行多个聊天客户端,聊 天效果如下图所示:



3.4AIO 程式设计
JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 程式设计中,常用到两种模式:Reactor 和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理。
AIO 即 NIO2.0,叫做异步不阻塞的 IO。AIO 引入异步通道的概念,采用了 Proactor 模式, 简化了程式编写,一个有效的请求才启动一个执行绪,它的特点是先由操作系统完成后才通知服务端程式启动执行绪去处理,一般适用于连线数较多且连线时间较长的应用。
目前 AIO 还没有广泛应用,并且也不是本课程的重点内容,这里暂不做讲解。
3.5IO 对比总结
IO 的方式通常分为几种:同步阻塞的 BIO、同步非阻塞的 NIO、异步非阻塞的 AIO。
BIO 方式适用于连线数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4 以前的唯一选择,但程式直观简单易理解。
NIO 方式适用于连线数目多且连线比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,程式设计比较复杂,JDK1.4 开始支援。
AIO 方式使用于连线数目多且连线比较长(重操作)的架构,比如相簿服务器,充分呼叫 OS 参与并发操作,程式设计比较复杂,JDK7 开始支援。
举个例子再理解一下:
同步阻塞:你到饭馆点餐,然后在那等著,啥都干不了,饭馆没做好,你就必须等著!
同步非阻塞:你在饭馆点完餐,就去玩儿了。不过玩一会儿,就回饭馆问一声:好了没 啊!
异步非阻塞:饭馆打电话说,我们知道您的位置,一会给你送过来,安心玩儿就可以了, 类似于现在的外卖。

