NIO整理

目录

一、通道和缓冲区

1.缓冲区(Buffer)

1.1. Buffer的基本实现原理

1.2.直接缓冲区与非直接缓冲区:

2.字符编码(Charset)

3.通道(Channel)

3.1.通道的主要实现类

3.2.获取通道的方式

3.3.通道之间传输数据(以拷贝文件为例)

3.4.分散(Scatter)与聚集(Gather)

二、NIO网络通信

1.SelectableChannel类

2.ServerSocketChannel类

3.SocketChannel类

4.Selector类

5.SelectionKey类

6.Socket选项

7.非阻塞服务器端程序示例

8.非阻塞客户端程序示例


Java NIO ( New IO 或 Non Blocking IO)是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区的、基于通道的IO操作。NIO将以更加高效的方式进行文件的读写操作。

传统的IO是单向的。往程序中读取数据需要输入流。从程序中写出数据需要输出流。它是直接面向流的。直接从流中读取和写入数据。
NIO是双向的,它是面向缓冲区的。在数据源与目的地之间建立传输通道。利用通道将缓冲区中的数据读取和写入,双端之间的数据都是存在于缓冲区中。通道主要是负责连接的,真正的数据是在缓冲区中。

一、通道和缓冲区

Java NIO系统的核心在于: 通道(Channel)和缓冲区(Buffer)
通道表示打开到IO设备(例如:文件、套接字)的连接。Channel类似于传统的“流”。只不过Channel本身不能直接访问数据,Channel 只能与Buffer进行交互。
若需要使用NIO系统,需要获取用于连接IO设备的通道以及用于容纳数据的缓冲区。然后操作缓冲区,对数据进行处理。
简而言之,Channel 负责传输,Buffer 负责存储

1.缓冲区(Buffer)

在Java NIO中负责数据的存取。缓冲区就是数组。用于存储不同数据类型的数据。数据的输入和输出往往是比较耗时的操作。缓冲区从两个方面提高I/O操作的效率。

  • 减少实际的物理读写次数;
  • 缓冲区在创建时被分配内存,这块内存区域一直被重用,这可以减少动态分配和回收内存区域的次数。

旧的I/O类库(java.io)中的BufferedInputStream、BufferedOutputStream、BufferedReader和BufferedWriter在实现中都用到了缓冲区,java.nio包公开了Buffer API,使得Java程序可以直接控制和运用缓冲区。

1.1. Buffer的基本实现原理

Java中上述缓冲区共同继承Buffer类,其主要有四个核心属性:

//标记,表示记录当前position的位置。可以通过reset()恢复到mark的位置。mark初始值是-1
private int mark = -1;
//位置,表示缓冲区中正在操作数据的位置。
private int position = 0;
//界限,表示缓冲区中可以操作数据的大小。(limit 后数据不能进行读写)
private int limit;
容量,表示缓冲区中最大存储数据的容量。一旦声明不能改变。
private int capacity;

position <= limit <= capacity

测试示例:

public static void main(String[] args) {
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
		
    System.out.println("容量:" + byteBuffer.capacity());
    System.out.println("当前操作位置:" + byteBuffer.position());
    System.out.println("界限:" + byteBuffer.limit());
		
    System.out.println("-----------------------------------------------------------");
    byteBuffer.put("你好".getBytes());	//4字节
		
    System.out.println("容量:" + byteBuffer.capacity());
    System.out.println("当前操作位置:" + byteBuffer.position());
    System.out.println("界限:" + byteBuffer.limit());
		
    byteBuffer.flip();		//转换为读数据模式:  limit、position、mark的值变化情况
    System.out.println("==============切换到读数据模式================");
    System.out.println("容量:" + byteBuffer.capacity());
    System.out.println("当前操作位置:" + byteBuffer.position());
    System.out.println("界限:" + byteBuffer.limit());
		
    byte[] bytes = new byte[byteBuffer.limit()];
    byteBuffer.get(bytes);
    System.out.println("读取到数据:" + new String(bytes));
    System.out.println("--------------读取数据之后的变化----------------------------");
    System.out.println("容量:" + byteBuffer.capacity());
    System.out.println("当前操作位置:" + byteBuffer.position());
    System.out.println("界限:" + byteBuffer.limit());
}

对于mark() reset() 和 flip()方法在Buffer中定义如下:

//这里其实是对三个量的更改。
public final Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
}
	    
//mark和reset配合使用,否则可能会导致异常。标记位置,操作在哪个位置,那个位置值就会保存在mark中
//只不过调用flip()会重置
public final Buffer mark() {
    mark = position;
    return this;
}
		    
public final Buffer reset() {
    int m = mark;
    if (m < 0)
	throw new InvalidMarkException();
    position = m;
    return this;
}
		    
//判断当前操作是否达到界限
public final boolean hasRemaining() {
    return position < limit;
}

Buffer是一个抽象类,根据数据类型不同(boolean除外),Java提供了相应类型的缓冲区,它们的管理方式几乎一致:

  1. ByteBuffer
  2. CharBuffer
  3. ShortBuffer
  4. IntBuffer
  5. LongBuffer
  6. FloatBuffer
  7. DoubleBuffer

 这些具体的缓冲区类都有一个能够返回自身实例的静态方法allocate(int capacity)方法。所有具体的缓冲区类都提供了读写缓冲区的方法

get(): 相对读。从缓冲区的当前位置读取一个单元的数据,读完后把位置加1。
get(int index):绝对读。从参数index指定的位置读取一个单元数据。
put(单元数据类型 data):相对写。向缓冲区的当前位置写入一个单元的数据,写完后把位置加1.
put(int index, 单元数据类型 data): 绝对写,向参数index指定的位置写入一个单元的数据。

ByteBuffer类比较重要。它不仅可以读取和写入一个单元的字节,还可以读取和写入int、char、float和double等基本类型的数据。而且还提供了用于获得其他缓冲区视图的方法,例如:

ShortBuffer asShortBuffer()
CharBuffer asCharBuffer()
……

还有一种缓冲区是MappedByteBuffer,它是ByteBuffer的子类,它能把缓冲区和文件中的某个区域直接映射。

1.2.直接缓冲区与非直接缓冲区:

非直接缓冲区:通过allocate()方法(静态方法)分配缓冲区,将缓冲区建立在JVM的内存中。
直接缓冲区:通过allocateDirect()方法(静态方法)分配直接缓冲区,将缓冲区建立在物理内存中。它与当前操作系统能够很好的耦合,所以可以提高I/O操作的效率(但是也存在安全性问题,因为其直接操作的是物理内存,数据写入物理内存缓冲区中,程序就丧失了对这些数据的管理,即什么时候这些数据被最终写入从磁盘只能由操作系统来决定,应用程序无法再干涉)。但是分配直接缓冲区的系统开销很大,因此只有在缓冲区较大并且长期存在,或者需要经常重用时,才使用这种缓冲区。

通过图可以看到,磁盘中的数据没有办法直接读取到程序中。需要经过操作系统(用户态到内核态的转变),传统的IO和allocate()都是需要经历图中的过程,在allocate()源码中也体现出来,开辟的是堆内存空间。

 public static ByteBuffer allocate(int capacity) {
    if (capacity < 0)
        throw new IllegalArgumentException();
    return new HeapByteBuffer(capacity, capacity);    //堆内存
}

直接缓冲区是建立了一个内核区与用户区的一个映射文件。应用程序通过它可直接操作物理内存。这就没有了用户态和内核态的转换。

物理内存和虚拟内存

所谓物理内存就是我们通常所说的RAM (随机存储器)。在计算机中,还有个存储单元叫寄存器,它用于存储计算单元执行指令(如浮点、整数等运算时)的中间结果。寄存器的大小决定了一次计算可使用的最大数值。

连接处理器和寄存器的是地址总线,这个地址总线的宽度影响了物理地址的索引范围,因为总线的宽度决定了处理器一次可以从寄存器或者内存中获取多少个bit。同时也决定了处理器最大可以寻址的地址空间,如32位地址总线可以寻址的范围为0x0000 0000-0xffff ffff这个范围是2^32=4294967296个内存位置,每个地址会引用一个字节,所以32位总线宽度可以有4GB的内存空间。

通常情况下,地址总线和寄存器或者RAM有相同的位数,因为这样更容易传输数据,但是也有不一致的情况,如x86的32位寄存器宽度的物理地址可能有两种大小,分别是32位物理地址和36位物理地址,拥有36位物理地址的是Pentium Pro和更高型号。

除了在学校的编译原理的实践课或者要开发硬件程序的驱动程序时需要直接通过程序访问存储器外,我们大部分情况下都调用操作系统提供的接口来访问内存,在Java中甚至不需要写和内存相关的代码。

不管是在Windows系统还是Linux系统下,我们要运行程序,都要向操作系统先申请内存地址。通常操作系统管理内存的申请空间是按照进程来管理的,每个进程拥有一段独立的地址空间,每个进程之间不会相互重合,操作系统也会保证每个进程只能访问自己的内存空间。这主要是从程序的安全性来考虑的,也便于操作系统来管理物理内存。

其实上面所说的进程的内存空间的独立主要是指逻辑上独立,也就是这个独立是由操作系统来保证的,但是真正的物理空间是不是只能由一个进程来使用就不一定了。因为随着程序越来越庞大和设计的多任务性,物理内存无法满足程序的需求,在这种情况下就有了虚拟内存的出现。

虚拟内存的出现使得多个进程在同时运行时可以共享物理内存,这里的共享只是空间上共享,在逻辑上它们仍然是不能相互访问的。虚拟地址不但可以让进程共享物理内存、提高内存利用率,而且还能够扩展内存的地址空间,如一个虚拟地址可能被映射到一段物理内存、文件或者其他可以寻址的存储上。一个进程在不活动的情况下,操作系统将这个物理内存中的数据移到一个磁盘文件中( 也就是通常Windows 系统上的页面文件,或者Linux系统上的交换分区),而真正高效的物理内存留给正在活动的程序使用。在这种情况下,在我们重新唤醒一个很长时间没有使用的程序时,磁盘会吱吱作响,并且会有一个短暂的停顿得到印证,这时操作系统又会把磁盘上的数据重新交互到物理内存中。但是我们必须要避免这种情况的经常出现,如果操作系统频繁地交互物理内存的数据和磁盘数据,则效率将会非常低,尤其是在Linux服务器上,我们要关注Linux 中swap的分区的活跃度。如果swap分区被频繁使用,系统将会非常缓慢,很可能意味着物理内存已经严重不足或者某些程序没有及时释放内存。
 

内核空间与用户空间 

一个计算机通常有一定大小的内存空间, 如使用的计算机是4GB的地址空间,但是程序并不能完全使用这些地址空间,因为这些地址空间被划分为内核空间和用户空间。程序只能使用用户空间的内存,这里所说的使用是指程序能够申请的内存空间,并不是程序真正访问的地址空间。

内核空间主要是指操作系统运行时所使用的用于程序调度、虚拟内存的使用或者连接硬件资源等的程序逻辑。为何需要内存空间和用户空间的划分呢? 很显然和前面所说的每个进程都独立使用属于自己的内存一样,为了保证操作系统的稳定性,运行在操作系统中的用户程序不能访问操作系统所使用的内存空间。
这也是从安全性上考虑的,如访问硬件资源只能由操作系统来发起,用户程序不允许直接访问硬件资源。如果用户程序需要访问硬件资源,如网络连接等,可以调用操作系统提供的接口来实现,这个调用接口的过程也就是系统调用。每次系统调用都会存在两个内存空间的切换,通常的网络传输也是一次系统调用,通过网络传输的数据先是从内核空间接收到远程主机的数据,然后再从内核空间复制到用户空间,供用户程序使用。这种从内核空间到用户空间的数据复制很费时,虽然保住了程序运行的安全性和稳定性,但是也牺牲了一部分效率。 

但是现在已经出现了很多其他技术能够减少这种从内核空间到用户空间的数据复制的方式,如Linux系统提供了sendfile文件传输方式。

内核空间和用户空间的大小如何分配也是一个问题, 是更多地分配给用户空间供用户程序使用,还是首先保住内核有足够的空间来运行,这要平衡一下。如果是一台登录服务器,很显然,要分配更多的内核空间,因为每一个登录用户操作系统都会初始化一个用户进程,这个进程大部分都在内核空间里运行。在Windows 32位操作系统中默认内核空间和用户空间的比例是1:1 (2GB的内核空间,2GB的用户空间),而在32位Linux系统中默认的比例是1:3 (1GB的内核空间,3GB的用户空间)。

NIO 使用java.nio.ByteBuffer.allocateDirect()方法分配内存,这种方式也就是通常所说的NIO direct memory

ByteBuffer.allocateDirect()分配的内存使用的是本机内存而不是Java堆上的内存,这也进一步说明每次分配内存时会调用操作系统的os::malloc()函数。另外一方面直接ByteBuffer产生的数据如果和网络或者磁盘交互都在操作系统的内核空间中发生,不需要将数据复制到Java内存中,很显然执行这种I/O操作要比一般的从操作系统的内核空间到Java堆上的切换操作要快的多,因为他们可以避免在Java堆于本机之间赋值数据。

2.字符编码(Charset)

java.nio.Charset类的每个实例代表特定的字符编码类型。

Charset类的实例通过自身的静态方法获取forName(String charsetName),参数表示编解码类型。如下:

public static Charset forName(String charsetName) {
    //……
}

//返回代表本地平台的默认字符编码的Charset对象
public static Charset defaultCharset() {
    //……
}

该类提供了编码与解码的方法,如下:

//对参数str指定的字符串进行编码,把得到的字节序列存放在一个ByteBuffer对象中,并将其返回。
public final ByteBuffer encode(String str) {
    //……
}
//对参数cb指定的字符缓冲区中的字符进行编码,把得到的字节序列放在一个ByteBuffer对象中,并将其返回。
public final ByteBuffer encode(CharBuffer cb) {
    //……
}
//对参数bb指定的ByteBuffer中的字节序列进行解码,把得到的字符序列放在一个Charset对象中,并将其返回。
public final CharBuffer decode(ByteBuffer bb) {
    //……
}

3.通道(Channel)

Java应用程序对磁盘进行读写的时候,需要经过操作系统。最早期的操作系统。IO接口全是由CPU负责处理的。使得CPU占用率很高,去执行其他程序的能力下降。然后经过转变引入了DMA,不再是CPU直接管理所有IO接口,当应用程序向操作系统发起读写请求时,DMA向CPU申情权限。如果CPU给它权限,则那些IO接口就全权由DMA负责。即给CPU处理其他程序节省了时间。不过这种还存在问题,当有大量的读写请求时,DMA向CPU申请权限然后建立DMA总线,如果总线过多会造成总线冲突的问题。总线冲突也会影响性能。再到后来的通道。

public interface Channel extends Closeable {
    public boolean isOpen();        //判断通道是否打开
    public void close() throws IOException;    //关闭通道
}
//通道再创建时被打开,一旦关闭,就不能重新打开。

 Channel接口的两个最重要的子接口是ReadableByteChannelWritableByteChannel。这两接口定义如下:

public interface WritableByteChannel extends Channel{
    //将参数指定的src中的数据写入到目的地(数据汇)
    public int write(ByteBuffer src) throws IOException;
}

public interface ReadableByteChannel extends Channel {
    //将数据源中的数据读取到dst中
    public int read(ByteBuffer dst) throws IOException;
}

3.1.通道的主要实现类

实现java.nio.channels.Channel接口: 

  1. FileChannel(用于操作本地文件)
  2. SocketChannel(用于TCP通信)
  3. ServerSocketChannel(用于TCP通信)
  4. DatagramChannel(用于UDP通信)

3.2.获取通道的方式

  1.  Java 针对原始I/O流支持通道的类提供了getChannel()方法。本地IO:FileInputStream/File0utputStream 和 RandomAccessFile。     网络IO:Socket、ServerSocket 和 DatagramSocket。
  2. 在JDK 1.7中的NIO.2 针对各个通道提供了静态方法open()
  3. 在JDK 1.7中的NIO.2 的Files工具类的newByteChannel()

NIO还提供了通道的工具类Channels,Channels提供了通道与传统基于I/O流、Reader和Writer之间进行转换的静态方法。

3.3.通道之间传输数据(以拷贝文件为例)

利用通道完成文件的复制(非直接缓冲区)

//利用通道完成文件的复制(非直接缓冲区)
public static void test1() {
    FileInputStream fis = null;
    FileOutputStream fos = null;
    FileChannel inChannel = null;    
    FileChannel outChannel = null;
    try {
	fis = new FileInputStream("C:\\Users\\Administrator\\Desktop\\leftHand.mp3");
	fos = new FileOutputStream("C:\\Users\\Administrator\\Desktop\\leftHandCopy.mp3");
			 
	inChannel = fis.getChannel();
	outChannel = fos.getChannel();
	//非直接缓冲区		 
	ByteBuffer byteBuffer = ByteBuffer.allocate(1024); 
			 
	//将通道中的数据存入缓冲区
	while(inChannel.read(byteBuffer) != -1) {
	    byteBuffer.flip();	//从缓冲区中读取数据,将缓冲区切换到读数据模式
	    outChannel.write(byteBuffer);
	    byteBuffer.clear(); //清空缓冲区
	}
    } catch (Exception e) {
	e.printStackTrace();
    }finally {
	if(inChannel != null) {
	    try {
	        inChannel.close();
	    } catch (IOException e) {
	        e.printStackTrace();
	    }
        }
	if(outChannel != null) {
	    try {
	        outChannel.close();
	    } catch (IOException e) {
		e.printStackTrace();
	    }
	}
	if(fis != null) {
	    try {
	        fis.close();
	    } catch (IOException e) {
	        e.printStackTrace();
	    }
	}
	if(fos != null) {
	    try {
	        fos.close();
	    } catch (IOException e) {
	        e.printStackTrace();
	    }
	}
    }
}

直接缓冲区方式一:

//直接缓冲区方式一(MappedByteBuffer)
public static void test2() {
    FileChannel inChannel = null;
    FileChannel outChannel = null;
    try {
	inChannel = FileChannel.open(Paths.get("C:\\Users\\Administrator\\Desktop\\leftHand.mp3"), 
					StandardOpenOption.READ);
        outChannel = FileChannel.open(Paths.get("C:\\Users\\Administrator\\Desktop\\leftHandCopy.mp3"), 
					StandardOpenOption.WRITE, StandardOpenOption.READ,StandardOpenOption.CREATE);
			
	//内存映射文件
	MappedByteBuffer inMappedBuffer = inChannel.map(MapMode.READ_ONLY, 0, inChannel.size());
	MappedByteBuffer outMappedBuffer = outChannel.map(MapMode.READ_WRITE, 0, inChannel.size());
			
	//直接对缓冲区进行文件的读写
	byte[] bytes = new byte[inMappedBuffer.limit()];
        inMappedBuffer.get(bytes);	//获取缓冲区中的数据
        outMappedBuffer.put(bytes);		//将数据写入到缓冲区
    } catch (IOException e) {
	e.printStackTrace();
    }finally{
	if(inChannel != null) {
	    try {
	        inChannel.close();
	    } catch (IOException e) {
	        e.printStackTrace();
	    }
	}
	if(outChannel != null) {
	    try {
	        outChannel.close();
	    } catch (IOException e) {
	        e.printStackTrace();
	    }
	}
    }
}

直接缓冲区方式二:

//直接缓冲区方式二(transferTo和transferFrom)
public static void test3() {
    FileChannel inChannel = null;
    FileChannel outChannel = null;
    try {
	inChannel = FileChannel.open(Paths.get("C:\\Users\\Administrator\\Desktop\\leftHand.mp3"), 
						StandardOpenOption.READ);
	outChannel = FileChannel.open(Paths.get("C:\\Users\\Administrator\\Desktop\\leftHandCopy.mp3"), 
						StandardOpenOption.WRITE, StandardOpenOption.READ,StandardOpenOption.CREATE);
			
        inChannel.transferTo(0, inChannel.size(), outChannel);
    } catch (IOException e) {
	e.printStackTrace();
    }finally{
	if(inChannel != null) {
	    try {
	        inChannel.close();
	    } catch (IOException e) {
	        e.printStackTrace();
	    }
        }
	if(outChannel != null) {
	    try {
	        outChannel.close();
	    } catch (IOException e) {
	        e.printStackTrace();
	    }
	}
    }
			
}

3.4.分散(Scatter)与聚集(Gather)

  1. 分散读取(Scattering Reads) :将通道中的数据按顺序分散到多个缓冲区中
  2. 聚集写入(Gathering Writes) :将多个缓冲区中的数据按顺序聚集到通道中

Java提供的分散通道和聚集通道定义如下:

//分散通道。允许分散读取(单个读取操作能够填充多个缓冲区)
public interface ScatteringByteChannel extends ReadableByteChannel{
    public long read(ByteBuffer[] dsts, int offset, int length) throws IOException;
    //把从数据源中读取的数据依次填充到参数指定的ByteBuffer[]数组中的每个ByteBuffer中
    public long read(ByteBuffer[] dsts) throws IOException;
}
//聚集通道。允许集中写入数据(单个操作能把多个缓冲区的数据写到数据汇中)
public interface GatheringByteChannel  extends WritableByteChannel{
    public long write(ByteBuffer[] srcs, int offset, int length) throws IOException;
    //依次把参数指定的ByteBuffer[]数组中的每个ByteBuffer中的数据写到目的地(数据汇)。
    public long write(ByteBuffer[] srcs) throws IOException;
}

二、NIO网络通信

java.nio.channels包提供了支持非阻塞通信的类:

  1. SelectableChannel:一种支持阻塞I/O和非阻塞I/O的通道。在非阻塞模式下,读写数据不会阻塞,并且SelectableChannel可以向Selector注册读就绪事件和写就绪事件。Selector负责监控这些事件,等到事件发生时,比如发生了读就绪事件,SelectableChannel就可以执行读操作了。
  2. ServerSocketChannel:   SeverSoket的替代类,支持阻塞通信与非阻塞通信。
  3. SocketChannel:   Socket的替代类,支持阻塞通信与非阻塞通信。
  4. Selector:   为ServerSocketChannel监听接收连接就绪事件,为SocketChannel监听连接就绪、读就绪和写就绪事件。这个类就是用来监听事件的。
  5. SelectionKey: 代表ServerSocketChannel 以及SocketChannel 通过register()方法向 Selector注册事件时,register()方法会创建一个SelectionKey对象,这个SelectionKey对象用来跟踪注册事件的句柄。

如上图,ServerSocketChannel和SocketChannel都是SelectableCahnnel的子类。SelectableCahnnel及其子类都能委托Selector来监控他们可能发生的一些事件,这种委托过程也被称为注册事件过程。通过register()方法注册事件。而该方法具体是在AbstractSelectableChannel类中实现的。

 上面所示四个事件中,ServerSocketChannel只可能发生一种事件,即接收连接就绪事件;而SocketChannel则可能发生其他三种事件。

1.SelectableChannel类

configureBlocking(boolean block)

参数block为true时,表示把SelectableChannel 设为阻塞模式,反之为非阻塞模式。默认为阻塞模式。该方法返回SelectableChannel对象本身的引用,相当于“return this"。

isBlocking()判断SelectableChannel是否处于阻塞模式,如果返回true,则表示处于阻塞模式,否则表示处于非阻塞模式。
register(Selector sel, int ops, Object att)

该方法返回值为SelectionKey对象。用于跟踪被注册的事件。

参数一:Selector对象。

参数二:表示事件的类型。如果需要同时注册多个事件可以用  “按位或”运算符连接

参数三:API中表述为此参数为该方法返回值SelectionKey对象关联的一个“附件”该对象可以用来处理该事件该事件被触发时可以通过SelectionKey获取到该对象。我们可以将处理该事件的方法定义在此参数对象中,触发事件后进行处理。

2.ServerSocketChannel类

它从SelectableChannel中继承了configureBlocking方法和register方法。同时它也是ServerSocket的替代类,也具有负责接收客户连接的accept()方法。

  • 当处于阻塞模式时,若没有客户端连接,则accept()会一直阻塞下去;
  • 当处于非阻塞模式时,若没有客户端连接,则accept()会返回null;

该类的对象必须通过它的静态方法open()来创建(open()方法返回的自身实例没有与任何本地端口绑定,并且处于阻塞模式)。每个ServerSocketChannel对象都一个ServerSocket 对象关联。ServerSocketChannel的socket()方法返回与它关联的ServerSocket对象。创建服务器时需要设置其端口号,可以通过如下方法:

serverSocketChannel.socket().bind(port)

3.SocketChannel类

SocketChannel也通过自身静态方法open()open(SocketAddress remote)创建自身实例,同样的它创建的实例默认处于阻塞模式,带参构造方法会尝试建立与服务器的连接,与connect(SocketAddress remote)方法类似。关于建立连接的操作如下:

boolean connect(SocketAddress remote): 使底层的Socket建立远程连接。
    当SocketChannel处于非阻塞模式时:
        1.如果立即连接成功,则该方法返回true;
        2.如果不能立即连接成功,则返回false,程序稍后必须通过调用finishConnect()方法完成连接。
    当SocketChannel处于阻塞模式时:
        1.如果立即连接成功,则该方法返回true。
        2.如果不能立即连接成功,则进入阻塞状态,直到连接成功或者出现I/O异常

boolean finishConnect(): 试图完成连接远程服务器的操作。
    在非阻塞模式下,建立连接从调用SocketChannel的connect()方法开始,到调用finishConnect()结束。
        1.如果finishConnect()方法顺利完成连接,或者在调用此方法之前已经建立连接了,则此方法立即返回true;
        2.如果连接操作还没有完成,则立即返回false。
        3.如果连接操作中遇到异常而失败,则抛出相应的I/O异常
    在阻塞模式下,如果连接操作还没有完成则一直阻塞,直到完成,或者出现I/O异常。

由于该类继承了ByteChannel,所以具有如下读写方法:

read(ByteBuffer buffer)从该通道中读取数据,将读取到的数据存放到指定的ByteBuffer中(字节缓冲区)。
write(ByteBuffer buffer)写入数据,将指定ByteBuffer中的数据写入到该通道中。

4.Selector类

只要ServerSocketChannel和SocketChannel向Selector注册了特定的事件,Selector就会监控这些事件是否发生。SelectionKey对象是用于跟踪这些被注册事件的句柄。一个Selector对象中会包含3种类型的SelectionKey的集合:

all-keys 集合当前所有向Selector注册的SelectionKey的集合,selector的keys()方法返回该集合。
selected-keys集合相关事件已经被Selector捕获的SelectionKey的集合。selector的selectedKeys()方法返回该集合。
cancelled-keyes 集合已经被取消的SelectionKey的集合。selector 没有提供访问这种集合的方法

以上第2种和第3种集合都是第1种集合的子集。对于一个新建的Selector对象,它的上述集合都为空。 

其余几个重要方法如下:

open() 这是Selector的静态工厂方法,创建一个Selector对象。
isOpen()判断Selector是否处于打开状态。Selector对象创建后就处于打开状态,当调用了Selector对象的close()方法,它就进入关闭状态。
select() 和 select(long timeout)

返回相关事件已经发生的SelecionKey对象的数目。该方法采用阻塞的工作方式,如果一个事件也没触发, 就进入阻塞状态,直到出现以下情况之一,就会从select()方法中返回:

  1. 至少有一个 SelectionKey的相关事件已经发生。
  2. 其他线程调用了Selector 的wakeup()方法,导致执行select()方法的线程立即从select()方法中返回。
  3. 当前执行select()方法的线程被其他线程中断。
  4. 超出了等待时间:该时间由select(long timeout)方法的参数timeout设定,单位为ms。如果等待超时,就会正常返回,但不会抛出超时异常。
    如果程序调用的是不带参数的select()方法,那么永远不会超时,这意味着执行select方法的线程进入阻塞状态后,永远不会因为超时而中断。
wakeup()唤醒执行Selector的select()方法(也同样适用于select(long timeout)方法)的线程。当线程A执行Selector对象的wakeup()方法时,如果线程B正在执行同一个Selector对象的select()方法,或者线程B稍后会执行这个Selector对象的select()方法,那么线程B在执行select()方法时,会立即从select()方法中返回,而不会被阻塞,假如线程B已经在select()方法中阻塞了,也会立刻被唤醒,从select()方法中返回。
wakeup()方法只能唤醒执行select()方法的线程B一次。 如果线程B在执行select()方法时被唤醒后,以后再执行select()方法, 则仍旧按照阻塞方式工作,除非线程A再次调用Selector对象的wakeup()方法。
close()关闭Selector。如果有其他线程正在执行这个Selector的select()方法并且处于阻塞状态,那么这个线程会立即返回。close()方法使得Selector占用的所有资源都被释放,所有与Selector关联的SelectionKey都会被取消。

当执行SelectableChannel的register方法时,该方法会新建一个SelectionKey, 并把它加入Selector的all-keys集合中。如果关闭了与SelctionKey对象关联的Channel对象,或者调用了SelectionKey对象的cancel()方法,那么这个SelectionKey 对象就会被加入cancelled-keys 集合中,表示这个SelectionKey对象已经被取消,在程序下一次执行Selector的select()方法时,被取消的SeletionKey对象将从所有的集合(包括all-keys集合、slected-keys 集合和cancelled-keys集合)中被删除。在执行Selector的select()方法时, 如果与SeletionKey相关的事件发生了,这个SeletionKey对象就被加入selected-keys集合中。程序直接调用selected-keys集合的remove()方法,或者调用它的Iterator的remove()方法,都可以从selected-keys集合中删除一个SelectionKey对象。程序不允许直接通过集合接口的remove()方法删除all-keys 集合中的SelectionKey对象,否则会导致异常

5.SelectionKey类

SelectionKey对象用来跟踪注册事件的句柄。在SelectionKey对象的有效期内,Selector会一直监控与SelectionKey对象相关的事件,如果事件发生,就会把SelectionKey对象加入selected-keys集合中。在以下情况下SelectionKey对象会失效,这意味着Selector再也不会监控与它相关的事件。

  • 调用SelectionKey的cancel()方法;
  • 关闭SelectionKey关联的Channel;
  • 与SelectionKey关联的Selector被关闭。

SelectionKey类的一些静态常量表示事件类型,如下:

SelectionKey.OP_ACCEPT

接收连接就绪事件,表示至少有了一个客户连接,服务器可以接收这个连接。

这个事件只可能在ServerSocketChannel中发生

SelectionKey.OP_CONNECT连接就绪事件,表示客户与服务器的连接已经建立成功。
SelectionKey.OP_READ读就绪事件,表示输入通信信道中已经有了可读数据,可以进行读取了。
SelectionKey.OP_WRITE写就绪事件,表示已经可以向输出通信信道中写数据了。

以上常量分别占据不同的二进制位,因此可以通过二进制的或运算"|",来将他们进行任意组合。

该类的主要方法如下:

1.channel():返回与这个SelectionKey对象关联的SelectableChannel对象
2.selector():返回与这个SelectionKey对象关联的Selector对象
3.isValid():判断当前SelectionKey是否有效。
4.interestOps():返回这个SelectionKey感兴趣的事件。(也就是注册的所有事件)
5.readyOps():返回已经就绪的事件。
6.attach(Object ob): 使SelectionKey关联一个附件。一个SelectionKey只能关联一个Object类型的附件。如果多次调用该方法,则只有最后一个附件与之关联。
7.attachment(): 返回与SelectionKey对象关联的附件。

6.Socket选项

从JDK7开始,SocketChannelServerSocketChannelAsynchronousSocketChannelAsynchronousServerSocketChannelDatagramChannel都实现了新的NetworkChannel接口。该接口的主要作用是设置和读取各种Socket选项。

public interface NetworkChannel extends Channel{
    NetworkChannel bind(SocketAddress local) throws IOException;
    SocketAddress getLocalAddress() throws IOException;
    
    //设置特定的Socket选项。
    <T> NetworkChannel setOption(SocketOption<T> name, T value) throws IOException;
    //获取特定的Socket选项值
    <T> T getOption(SocketOption<T> name) throws IOException;
    //获取所有支持的Socket选项
    Set<SocketOption<?>> supportedOptions();
}
//SocketOption<T>中的“T”代表特定选项取值类型,可选值包括Integer、Boolean、NetworkInterface。
//StandardSocketOptions类提供了表示特定选项的常量。如:SocketOption<Interger>  StandardSocketOptions.IP_TOS

7.非阻塞服务器端程序示例

服务器端主要处理三个事件:接收连接就绪事件、读就绪事件、写就绪事件。

启动服务器

public class EchoServer {
    private ServerSocketChannel serverSocketChannel;
    private Selector selector;
    private Charset charset = Charset.forName("GBK");

    public EchoServer() {}

    // 启动服务器
    public void startUp(int port) throws IOException {
        serverSocketChannel = ServerSocketChannel.open();
        // 设置为非阻塞模式
        serverSocketChannel.configureBlocking(false);
	selector = Selector.open();
	// 绑定端口
	serverSocketChannel.bind(new InetSocketAddress(port));
	// 保证重启服务器时端口不被占用,可以顺利绑定到相同端口
	serverSocketChannel.socket().setReuseAddress(true);
        System.out.println("服务器启动成功");
	
        service();
    }
}

上述方法主要是初始化成员,核心内容(事件的注册、监听、处理)在service()方法中,如下:

public void service() throws IOException {
    // 注册连接就绪事件
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    // 当事件发生时进行处理(若没有事件发生则该方法阻塞)
    while (selector.select() > 0) {
        SelectionKey selectionKey = null;
        try {
	    // 获取已经发生被捕获的事件集合selected-key集合
	    Set<SelectionKey> selectedKeys = selector.selectedKeys();
	    Iterator<SelectionKey> iterator = selectedKeys.iterator();
	    while (iterator.hasNext()) {
	        selectionKey = iterator.next();
	        // 将该对象从selected-key集合中删除
	        iterator.remove();
	        if (selectionKey.isAcceptable()) {
		    // 处理接收连接就绪事件
		    dealAcceptable();
		} else if (selectionKey.isReadable()) {
		    // 处理读就绪事件
		    dealReadable(selectionKey);
		} else if (selectionKey.isWritable()) {
		    // 处理写就绪事件
		    dealWritable(selectionKey);
		}
	    }
        } catch (IOException e) {
            if (selectionKey != null) {
	        // 使这个SelectionKey失效
	        selectionKey.cancel();
	        // 关闭与这个SelectionKey关联的SocketChannel
	        selectionKey.channel().close();
	    }
        }
    }
}

当客户端刚连上服务器时,首先触发的是接收连接就绪事件; 处理接收连接就绪事件

private void dealAcceptable() throws IOException {
    SocketChannel socketChannel = serverSocketChannel.accept();
    System.out.println(
        "接收到客户端连接,来自:" + socketChannel.socket().getInetAddress() + ":" + socketChannel.socket().getPort());
    // 将SocketChannel设置为非阻塞模式
    socketChannel.configureBlocking(false);
    // 创建收发数据的缓冲区。
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    // 注册读写就绪事件,将该缓冲区作为附件与负责读写的SelectionKey关联
    socketChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE, buffer);
}

只要连接就绪就必须注册读写就绪事件,此处还将ByteBuffer作为附件添加进去,主要是用于存放接收到客户端的数据和服务器回送消息的数据容器,即客户端的消息会被读取到该buffer对象中,服务器端将该buffer中的数据稍作修改后回送给客户端。注册成功后,写就绪事件是一直被触发的,因为刚连接成功后,服务器是一直可以发消息的。 故dealWritable()方法会一直被触发。

处理写就绪事件

private void dealWritable(SelectionKey selectionKey) throws IOException {
    ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
    // 切换为读模式(把极限设置为位置,把位置设置为0)
    buffer.flip();
    String message = charset.decode(buffer).toString();
    // 如果没有接收到一行就返回
    if (message.indexOf("\r\n") == -1) {
        return;
    }
    
    System.out.println("接收到客户端的消息: " + message);
    ByteBuffer outputBuffer = charset.encode("echo: " + message);
    // 输出outputBuffer中的所有字节
    while (outputBuffer.hasRemaining()) {
        socketChannel.write(outputBuffer);
    }

    // 删除buffer中已经处理的数据
    buffer.compact();

    if (message.equals("bye\r\n")) {
        selectionKey.cancel();
        socketChannel.close();
        System.out.println("关闭与客户端的连接");
    }
}

客户端与服务器通信的基本流程是:客户端连接成功之后就可以给服务器发消息,假设客户端发送”XXX“,则服务器端回送”echo:XXX“的消息。

上述方法内做了"\r\n"的判断,这个是非常必要的。由于连接成功之后该方法会一直被触发,所以必须有一个判断来规定何时给客户端发送消息。如果没有这个判断那么客户端会一直收到”echo: “的消息。另外,write(ByteBuffer buffer)方法并不保证一次性将buffer中的所有数据全部发送完毕,所以此处需要借助hasReaining()方法判断。特别的,发送数据是从ByteBuffer中读取数据,所以一定要注意执行filp()方法,将缓冲区切换到“读数据模式”。当客户端发送“bye\r\n”的消息,则将连接关闭(这里调用socketChannel的close()方法,连接并不会马上关闭,客户端是捕获不到)

处理写就绪事件

private void dealReadable(SelectionKey selectionKey) throws IOException {
    ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
    // 获取该SelectionKey关联的socketChannel对象
    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();   
    ByteBuffer readBuffer = ByteBuffer.allocate(32);
    socketChannel.read(readBuffer);

    // 将readBuffer切换到读数据模式
    readBuffer.flip();
    buffer.limit(buffer.capacity());
    buffer.put(readBuffer);
}

将读到的数据存放至buffer对象中。注意这个附件buffer对象的内容变化是这样的:

8.非阻塞客户端程序示例

连接服务器

public class EchoClient {
    private SocketChannel socketChannel;
    private Selector selector;
    private Charset charset = Charset.forName("GBK");
    private ByteBuffer sendBuffer = ByteBuffer.allocate(1024);
    private ByteBuffer receiveBuffer = ByteBuffer.allocate(1024);

    public EchoClient() {}
    public void connect(int port) throws IOException {
        socketChannel = SocketChannel.open();
	if (socketChannel.isConnected()) {
	    return;
	}
	// 这里连接服务器采用阻塞模式
	socketChannel.connect(new InetSocketAddress(port));
	System.out.println("连接服务器成功");
	// 设置为非阻塞模式
	socketChannel.configureBlocking(false);    \
        selector = Selector.open();

        talk();
    }
}

客户端连接服务器的操作是采用阻塞模式,如果连接不上就一直阻塞在connect()方法中,当连接成功之后再将其设置为非阻塞模式。那么收发消息将不会阻塞。上述采用两个缓冲区分别作为接收消息和发送消息的缓冲区。

// 接收和发送数据
public void talk() throws IOException {
    socketChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
    while (selector.select() > 0) {
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = selectionKeys.iterator();
        while (iterator.hasNext()) {
	    SelectionKey selectionKey = null;
	    try {
		selectionKey = iterator.next();
		iterator.remove();
		if (selectionKey.isReadable()) {
		    // 处理读就绪事件
		    dealReadable(selectionKey);
		} else if (selectionKey.isWritable()) {
		    // 处理写就绪事件
		    dealWritable(selectionKey);
		}
	    } catch (IOException e) {
	        e.printStackTrace();
	        if (selectionKey != null) {
		    selectionKey.cancel();
		    selectionKey.channel().close();
		}
	    }
	}
    }
}

注册和监听事件的操作与服务器端基本类似,如下为处理写就绪事件

private void dealWritable(SelectionKey selectionKey) throws IOException {
    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
    synchronized (sendBuffer) {
        // 切换到读模式(让通道读取缓冲区中的数据并发送)
        sendBuffer.flip();
	socketChannel.write(sendBuffer);
	// 删除已经发送的数据
	sendBuffer.compact();
    }
}

这里采用synchronized是因为,客户端通过控制台输入消息,将控制台的输入先读取到sendBuffer中,然后再将sendBuffer中的数据发送给服务器。这基于控制台有输入,读取控制台的字符串是采用单独的线程去做,而给服务器发送消息是在另外一个线程中。所以需保证sendBuffer的线程安全。如下为从控制台读取字符串的方法:

public void receiveFromConsole() {
    Scanner scanner = new Scanner(System.in);
    String message = null;
    while (scanner.hasNext()) {
        synchronized (sendBuffer) {
	    message = scanner.nextLine();
            //注意字符串后面加“\r\n”,这是消息结束的约定
	    sendBuffer.put(charset.encode(message + "\r\n"));
	}
	if (message.equals("bye")) {
	    break;
	}
    }
    scanner.close();
}

public static void main(String[] args) {
    EchoClient echoClient = new EchoClient();
    new Thread(() -> {
	echoClient.receiveFromConsole();
    }).start();
    // 主线程负责与服务器交互
    try {
        echoClient.connect(54199);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

处理读就绪事件

private void dealReadable(SelectionKey selectionKey) throws IOException {
    // 接收服务器发送的数据
    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
    socketChannel.read(receiveBuffer);
    receiveBuffer.flip();
    
    String message = charset.decode(receiveBuffer).toString();
    System.out.println("收到服务器的消息:" + message);
    if (message.equals("echo: bye\r\n")) {
        selectionKey.cancel();
        socketChannel.close();
        System.exit(0); // 结束程序
    }
    ByteBuffer tempBuffer = charset.encode(message);
    receiveBuffer.position(tempBuffer.limit());
    receiveBuffer.compact();
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值