3.1 Java NIO简介

在1.4版本之前,Java IO类库是阻塞IO;从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为Java NIO。更多的人喜欢称Java NIO为非阻塞IO(Non-Blocking IO),称“老的”阻塞式Java IO为OIO(Old IO)。
Java NIO类库包含以下三个核心组件:

  1. Channel(通道)
  2. Buffer(缓冲区)
  3. Selector(选择器)

    3.1.1 NIO和OIO的对比

  4. OIO是面向流(Stream Oriented)的,NIO是面向缓冲区(Buffer Oriented)的

  5. OIO的操作是阻塞的,而NIO的操作是非阻塞的。
  6. OIO没有选择器(Selector)的概念,而NIO有选择器的概念。

    3.1.2 通道

    在OIO中,同一个网络连接会关联到两个流:一个是输入流(Input Stream),另一个是输出流(Output Stream)。Java应用程序通过这两个流不断地进行输入和输出的操作。
    在NIO中,一个网络连接使用一个通道表示,所有NIO的IO操作都是通过连接通道完成的。一个通道类似于OIO中两个流的结合体,既可以从通道读取数据,也可以向通道写入数据。

    3.1.3 选择器

    选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。

    3.1.4 缓冲区

    所谓通道的读取,就是将数据从通道读取到缓冲区中;所谓通道的写入,就是将数据从缓冲区写入通道中。缓冲区的使用是面向流进行读写操作的OIO所没有的,也是NIO非阻塞的重要前提和基础之一。

    3.2 详解NIO Buffer类及其属性

    NIO的Buffer本质上是一个内存块,既可以写入数据,也可以从中读取数据。Java NIO中代表缓冲区的Buffer类是一个抽象类,位于java.nio包中。
    Buffer类是一个非线程安全类。

    3.2.1 Buffer类

    Buffer类是一个抽象类,对应于Java的主要数据类型。在NIO中,有8种缓冲区类,分别是ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedByteBuffer。前7种Buffer类型覆盖了能在IO中传输的所有Java基本数据类型,第8种类型是一种专门用于内存映射的ByteBuffer类型。

    3.2.2 Buffer类的重要属性

    为了记录读写的状态和位置,Buffer类额外提供了一些重要的属性,其中有三个重要的成员属性:capacity(容量)、position(读写位置)和limit(读写的限制)。

  7. capacity属性

Buffer类的capacity属性表示内部容量的大小。一旦写入的对象数量超过了capacity,缓冲区就满了,不能再写入了。

  1. position属性

Buffer类的position属性表示当前的位置。position属性的值与缓冲区的读写模式有关。在不同的模式下,position属性值的含义是不同的,在缓冲区进行读写的模式改变时,position值会进行相应的调整。
在写模式下,position值的变化规则如下:
(1)在刚进入写模式时,position值为0,表示当前的写入位置为从头开始。
(2)每当一个数据写到缓冲区之后,position会向后移动到下一个可写的位置。
(3)初始的position值为0,最大可写值为limit-1。当position值达到limit时,缓冲区就已经无空间可写了。
在读模式下,position值的变化规则如下:
(1)当缓冲区刚开始进入读模式时,position会被重置为0。
(2)当从缓冲区读取时,也是从position位置开始读。读取数据后,position向前移动到下一个可读的位置。
(3)在读模式下,limit表示可读数据的上限。position的最大值为最大可读上限limit,当position达到limit时表明缓冲区已经无数据可读。
模式的切换,可以调用flip()方法
在从写模式到读模式的翻转过程中,position和limit属性值会进行调整,具体的规则是:
(1)limit属性被设置成写模式时的position值,表示可以读取的最大数据位置。
(2)position由原来的写入位置变成新的可读位置,也就是0,表示可以从头开始读。

  1. limit属性

Buffer类的limit属性表示可以写入或者读取的数据最大上限,其属性值的具体含义也与缓冲区的读写模式有关。在不同的模式下,limit值的含义是不同的,具体分为以下两种情况:
(1)在写模式下,limit属性值的含义为可以写入的数据最大上限。在刚进入写模式时,limit的值会被设置成缓冲区的capacity值,表示可以一直将缓冲区的容量写满。
(2)在读模式下,limit值的含义为最多能从缓冲区读取多少数据。
总结:

属性 说明
capacity 容量,即可以容纳的最大数据量,在缓冲区创建时设置并且不能改变
limit 读写的限制,缓冲区中当前的数据量
position 读写位置,缓冲区中下一个要被读或写的元素的索引
mark 调用mark()方法来设置mark=position在调用reset()position恢复到mark标记的位置

3.3 详解NIO Buffer类的重要方法

3.3.1 allocate()

在使用Buffer实例之前,我们首先需要获取Buffer子类的实例对象,并且分配内存空间。需要获取一个Buffer实例对象时,并不是使用子类的构造器来创建,而是调用子类的allocate()方法。

  1. package com.crazymakercircle.bufferDemo;
  2. import com.crazymakercircle.util.Logger;
  3. import java.nio.IntBuffer;
  4. public class UseBuffer
  5. {
  6. //一个整型的Buffer静态变量
  7. static IntBuffer intBuffer = null;
  8. public static void allocateTest()
  9. {
  10. //创建一个intBuffer实例对象
  11. intBuffer = IntBuffer.allocate(20);
  12. Logger.debug("------------after allocate------------------");
  13. Logger.debug("position=" + intBuffer.position());
  14. Logger.debug("limit=" + intBuffer.limit());
  15. Logger.debug("capacity=" + intBuffer.capacity());
  16. }
  17. //省略其他代码
  18. }
  19. //allocatTest |> ------------after allocate------------------
  20. //allocatTest |> position=0
  21. //allocatTest |> limit=20
  22. //allocatTest |> capacity=20

3.3.2 put()

在调用allocate()方法分配内存、返回了实例对象后,缓冲区实例对象处于写模式,可以写入对象,如果要把对象写入缓冲区,就需要调用put()方法。put()方法很简单,只有一个参数,即需要写入的对象,只不过要求写入的数据类型与缓冲区的类型保持一致。

  1. package com.crazymakercircle.bufferDemo;
  2. //省略import
  3. public class UseBuffer
  4. {
  5. //一个整型的Buffer静态变量
  6. static IntBuffer intBuffer = null;
  7. //省略了创建缓冲区的代码,具体查看前面小节的内容和随书源码
  8. public static void putTest()
  9. {
  10. for (int i = 0; i < 5; i++)
  11. {
  12. //写入一个整数到缓冲区
  13. intBuffer.put(i);
  14. }
  15. //输出缓冲区的主要属性值
  16. Logger.debug("------------after putTest------------------");
  17. Logger.debug("position=" + intBuffer.position());
  18. Logger.debug("limit=" + intBuffer.limit());
  19. Logger.debug("capacity=" + intBuffer.capacity());
  20. }
  21. //省略其他代码
  22. }
  23. //输出结果
  24. putTest |> ------------after putTest------------------
  25. putTest |> position=5
  26. putTest |> limit=20
  27. putTest |> capacity=20

3.3.3 flip()

向缓冲区写入数据之后,是否可以直接从缓冲区读取数据呢?不能!这时缓冲区还处于写模式,如果需要读取数据,要将缓冲区转换成读模式。flip()翻转方法是Buffer类提供的一个模式转变的重要方法,作用是将写模式翻转成读模式。

  1. package com.crazymakercircle.bufferDemo;
  2. //省略import
  3. public class UseBuffer
  4. {
  5. //一个整型的Buffer静态变量
  6. static IntBuffer intBuffer = null;
  7. //省略了缓冲区的创建、写入数据的代码,具体查看前面小节的内容和随书源码
  8. public static void flipTest()
  9. {
  10. //翻转缓冲区,从写模式翻转成读模式
  11. intBuffer.flip();
  12. //输出缓冲区的主要属性值
  13. Logger.info("------------after flip ------------------");
  14. Logger.info("position=" + intBuffer.position());
  15. Logger.info("limit=" + intBuffer.limit());
  16. Logger.info("capacity=" + intBuffer.capacity());
  17. }
  18. //省略其他代码
  19. }
  20. //输出结果
  21. flipTest |> ------------after flipTest ------------------
  22. flipTest |> position=0
  23. flipTest |> limit=5
  24. flipTest |> capacity=20

在读取完成后,如何再一次将缓冲区切换成写模式呢?
答案是:可以调用Buffer.clear()清空或者Buffer.compact()压缩方法,它们可以将缓冲区转换为写模式。
image.png

3.3.4 get()

调用flip()方法将缓冲区切换成读模式之后,就可以开始从缓冲区读取数据了。读取数据的方法很简单,可以调用get()方法每次从position的位置读取一个数据,并且进行相应的缓冲区属性的调整。

  1. package com.crazymakercircle.bufferDemo;
  2. //省略import
  3. public class UseBuffer
  4. {
  5. //一个整型的Buffer静态变量
  6. static IntBuffer intBuffer = null;
  7. //省略了缓冲区的创建、写入、翻转的代码,具体查看前面小节的内容和随书源码
  8. public static void getTest()
  9. {
  10. //先读2个数据
  11. for (int i = 0; i< 2; i++)
  12. {
  13. int j = intBuffer.get();
  14. Logger.info("j = " + j);
  15. }
  16. //输出缓冲区的主要属性值
  17. Logger.info("---------after get 2 int --------------");
  18. Logger.info("position=" + intBuffer.position());
  19. Logger.info("limit=" + intBuffer.limit());
  20. Logger.info("capacity=" + intBuffer.capacity());
  21. //再读3个数据
  22. for (int i = 0; i< 3; i++)
  23. {
  24. int j = intBuffer.get();
  25. Logger.info("j = " + j);
  26. }
  27. //输出缓冲区的主要属性值
  28. Logger.info("---------after get 3 int ---------------");
  29. Logger.info("position=" + intBuffer.position());
  30. Logger.info("limit=" + intBuffer.limit());
  31. Logger.info("capacity=" + intBuffer.capacity());
  32. }
  33. //…
  34. }
  35. //省略其他代码
  36. }
  37. getTest |> ------------after get 2 int ------------------
  38. getTest |> position=2
  39. getTest |> limit=5
  40. getTest |> capacity=20
  41. getTest |> ------------after get 3 int ------------------
  42. getTest |> position=5
  43. getTest |> limit=5
  44. getTest |> capacity=20

缓冲区是不是可以重复读呢?
答案是可以的,既可以通过倒带方法rewind()去完成,也可以通过mark()和reset()两个方法组合实现。

3.3.5 rewind()

已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。

  1. package com.crazymakercircle.bufferDemo;
  2. //省略import
  3. public class UseBuffer
  4. {
  5. //一个整型的Buffer静态变量
  6. static IntBuffer intBuffer = null;
  7. //省略了缓冲区的写入和读取等代码,具体查看前面小节的内容和随书源码
  8. public static void rewindTest() {
  9. //倒带
  10. intBuffer.rewind();
  11. //输出缓冲区属性
  12. Logger.info("------------after rewind ------------------");
  13. Logger.info("position=" + intBuffer.position());
  14. Logger.info("limit=" + intBuffer.limit());
  15. Logger.info("capacity=" + intBuffer.capacity());
  16. }
  17. //省略其他代码
  18. }
  19. rewindTest |> ------------after rewind ------------------
  20. rewindTest |> position=0
  21. rewindTest |> limit=5
  22. rewindTest |> capacity=20

从JDK中可以查阅Buffer.rewind()方法的源代码,具体如下:

  1. public final Buffer rewind() {
  2. position = 0; //重置为0,所以可以重读缓冲区中的所有数据
  3. mark = -1; //mark被清理,表示之前的临时位置不能再用了
  4. return this;
  5. }

重复读取的示例代码如下:

  1. package com.crazymakercircle.bufferDemo;
  2. //省略import
  3. public class UseBuffer
  4. {
  5. //一个整型的Buffer静态变量
  6. static IntBuffer intBuffer = null;
  7. //省略了缓冲区的写入和读取、倒带等代码,具体查看前面小节的内容和随书源码
  8. public static void reRead() {
  9. for (int i = 0; i< 5; i++) {
  10. if (i == 2) {
  11. //临时保存,标记一下第3个位置
  12. intBuffer.mark();
  13. }
  14. //读取元素
  15. int j = intBuffer.get();
  16. Logger.info("j = " + j);
  17. }
  18. //输出缓冲区的属性值
  19. Logger.info("------------after reRead------------------");
  20. Logger.info("position=" + intBuffer.position());
  21. Logger.info("limit=" + intBuffer.limit());
  22. Logger.info("capacity=" + intBuffer.capacity());
  23. }
  24. //省略其他代码
  25. }

3.3.6 mark()和reset()

mark()和reset()两个方法是配套使用的:Buffer.mark()方法将当前position的值保存起来放在mark属性中,让mark属性记住这个临时位置;然后可以调用Buffer.reset()方法将mark的值恢复到position中。

  1. package com.crazymakercircle.bufferDemo;
  2. //省略import
  3. public class UseBuffer
  4. {
  5. //一个整型的Buffer静态变量
  6. static IntBuffer intBuffer = null;
  7. //省略了缓冲区的倒带、重复读取等代码,具体查看前面小节的内容和随书源码
  8. //演示前提:
  9. //在前面的reRead()演示方法中,已经通过mark()方法暂存了position值
  10. public static void afterReset() {
  11. Logger.info("------------after reset------------------");
  12. //把前面保存在mark中的值恢复到position中
  13. intBuffer.reset();
  14. //输出缓冲区的属性值
  15. Logger.info("position=" + intBuffer.position());
  16. Logger.info("limit=" + intBuffer.limit());
  17. Logger.info("capacity=" + intBuffer.capacity());
  18. //读取并且输出元素
  19. for (int i =2; i< 5; i++) {
  20. int j = intBuffer.get();
  21. Logger.info("j = " + j);
  22. }
  23. }
  24. //省略其他代码
  25. }
  26. afterReset |> ------------after reset------------------
  27. afterReset |> position=2
  28. afterReset |> limit=5
  29. afterReset |> capacity=20
  30. afterReset |> j = 2
  31. afterReset |> j = 3
  32. afterReset |> j = 4

3.3.7 clear()

在读模式下,调用clear()方法将缓冲区切换为写模式。此方法的作用是:
(1)将position清零。
(2)limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。

  1. package com.crazymakercircle.bufferDemo;
  2. //省略import
  3. public class UseBuffer
  4. {
  5. //一个整型的Buffer静态变量
  6. static IntBuffer intBuffer = null;
  7. //省略了缓冲区的创建、写入、读取等代码,具体查看前面小节的内容和随书源码
  8. public static void clearDemo() {
  9. Logger.info("------------after clear------------------");
  10. //清空缓冲区,进入写模式
  11. intBuffer.clear();
  12. //这个示例程序运行之后,结果如下:
  13. main |>清空
  14. clearDemo |> ------------after clear------------------
  15. clearDemo |> position=0
  16. clearDemo |> limit=20
  17. clearDemo |> capacity=20

3.3.8 使用Buffer类的基本步骤

(1)使用创建子类实例对象的allocate()方法创建一个Buffer类的实例对象。
(2)调用put()方法将数据写入缓冲区中。
(3)写入完成后,在开始读取数据前调用Buffer.flip()方法,将缓冲区转换为读模式。
(4)调用get()方法,可以从缓冲区中读取数据。
(5)读取完成后,调用Buffer.clear()方法或Buffer.compact()方法,将缓冲区转换为写模式,可以继续写入。

3.4 详解NIO Channel类

前面提到,Java NIO中一个socket连接使用一个Channel来表示。从更广泛的层面来说,一个通道可以表示一个底层的文件描述符,例如硬件设备、文件、网络连接等。然而,远不止如此,Java NIO的通道可以更加细化。例如,不同的网络传输协议类型,在Java中都有不同的NIO Channel实现。
最为重要的四种Channel实现:FileChannelSocketChannelServerSocketChannelDatagramChannel
对于以上四种通道,说明如下:
(1)FileChannel:文件通道,用于文件的数据读写。
(2)SocketChannel:套接字通道,用于套接字TCP连接的数据读写。
(3)ServerSocketChannel:服务器套接字通道(或服务器监听通道),允许我们监听TCP连接请求,为每个监听到的请求创建一个SocketChannel通道。
(4)DatagramChannel:数据报通道,用于UDP的数据读写。

3.4.1 FileChannel

FileChannel(文件通道)是专门操作文件的通道。通过FileChannel,既可以从一个文件中读取数据,也可以将数据写入文件中。特别申明一下,FileChannel为阻塞模式,不能设置为非阻塞模式。

  1. 获取FileChannel

可以通过文件的输入流、输出流获取FileChannel

  1. //创建一个文件输入流
  2. FileInputStream fis = new FileInputStream(srcFile);
  3. //获取文件流的通道
  4. FileChannel inChannel = fis.getChannel();
  5. //创建一个文件输出流
  6. FileOutputStream fos = new FileOutputStream(destFile);
  7. //获取文件流的通道
  8. FileChannel outchannel = fos.getChannel();

也可以通过RandomAccessFile(文件随机访问)类来获取FileChannel实例

  1. //创建RandomAccessFile随机访问对象
  2. RandomAccessFile rFile = new RandomAccessFile("filename.txt""rw");
  3. //获取文件流的通道(可读可写)
  4. FileChannel channel = rFile.getChannel();
  1. 读取FileChannel

在大部分应用场景中,从通道读取数据都会调用通道的int read(ByteBuffer buf)方法,它把从通道读取的数据写入ByteBuffer缓冲区,并且返回读取的数据量。

  1. RandomAccessFile aFile = new RandomAccessFile(fileName, "rw");
  2. //获取通道(可读可写)
  3. FileChannel channel = aFile.getChannel();
  4. //获取一个字节缓冲区
  5. ByteBuffer buf = ByteBuffer.allocate(CAPACITY);
  6. int length = -1;
  7. //调用通道的read()方法,读取数据并写入字节类型的缓冲区
  8. while ((length = channel.read(buf)) != -1) {
  9. //省略buf中的数据处理
  10. }

以上代码中channel.read(buf)读取通道的数据时,对于通道来说是读模式,对于ByteBuffer缓冲区来说是写入数据,这时ByteBuffer缓冲区处于写模式。

  1. 写入FileChannel

把数据写入通道,在大部分应用场景中都会调用通道的write(ByteBuffer)方法,此方法的参数是一个ByteBuffer缓冲区实例,是待写数据的来源。
write(ByteBuffer)方法的作用是从ByteBuffer缓冲区中读取数据,然后写入通道自身,而返回值是写入成功的字节数。

  1. //如果buf处于写模式(如刚写完数据),需要翻转buf,使其变成读模式
  2. buf.flip();
  3. int outlength = 0;
  4. //调用write()方法,将buf的数据写入通道
  5. while ((outlength = outchannel.write(buf)) != 0) {
  6. System.out.println("写入的字节数:" + outlength);
  7. }
  1. 在以上的outchannel.write(buf)调用中,对于入参buf实例来说,需要从其中读取数据写入outchannel通道中,所以入参buf必须处于读模式,不能处于写模式。
  1. 关闭通道

通道使用完成后,必须将其关闭。关闭非常简单,调用close()方法即可。

  1. //关闭通道
  2. channel.close();
  1. 强制刷新到磁盘

在将缓冲区写入通道时,出于性能的原因,操作系统不可能每次都实时地将写入数据落地(或刷新)到磁盘,完成最终的数据保存。
在将缓冲区数据写入通道时,要保证数据能写入磁盘,可以在写入后调用一下FileChannel的force()方法。

  1. //强制刷新到磁盘
  2. channel.force(true);

3.4.2 使用FileChannel完成文件复制的实战案例

下面是一个简单的实战案例:使用FileChannel复制文件。具体的功能是使用FileChannel将原文件复制一份,把原文件中的数据都复制到目标文件中。

  1. package com.crazymakercircle.iodemo.fileDemos;
  2. //省略import,具体请参见源代码工程
  3. public class FileNIOCopyDemo {
  4. public static void main(String[] args) {
  5. //演示复制资源文件
  6. nioCopyResouceFile();
  7. }
  8. /**
  9. * 复制两个资源目录下的文件
  10. */
  11. public static void nioCopyResouceFile() {
  12. //源
  13. String sourcePath = NioDemoConfig.FILE_RESOURCE_SRC_PATH;
  14. String srcPath = IOUtil.getResourcePath(sourcePath);
  15. Logger.info("srcPath=" + srcPath);
  16. //目标
  17. String destPath = NioDemoConfig.FILE_RESOURCE_DEST_PATH;
  18. String destDecodePath = IOUtil.builderResourcePath(destPath);
  19. Logger.info("destDecodePath=" + destDecodePath);
  20. //复制文件
  21. nioCopyFile(srcDecodePath, destDecodePath);
  22. }
  23. /**
  24. * NIO方式复制文件
  25. * @param srcPath 源路径
  26. * @param destPath目标路径
  27. */
  28. public static void nioCopyFile(String srcPath, String destPath){
  29. File srcFile = new File(srcPath);
  30. File destFile = new File(destPath);
  31. try {
  32. //如果目标文件不存在,则新建
  33. if (!destFile.exists()) {
  34. destFile.createNewFile();
  35. }
  36. long startTime = System.currentTimeMillis();
  37. FileInputStream fis = null;
  38. FileOutputStream fos = null;
  39. FileChannel inChannel = null; //输入通道
  40. FileChannel outchannel = null; //输出通道
  41. try {
  42. fis = new FileInputStream(srcFile);
  43. fos = new FileOutputStream(destFile);
  44. inChannel = fis.getChannel();
  45. outchannel = fos.getChannel();
  46. int length = -1;
  47. //新建buf,处于写模式
  48. ByteBufferbuf = ByteBuffer.allocate(1024);
  49. //从输入通道读取到buf
  50. while ((length = inChannel.read(buf)) != -1) {
  51. //buf第一次模式切换:翻转buf,从写模式变成读模式
  52. buf.flip();
  53. int outlength = 0;
  54. //将buf写入输出的通道
  55. while ((outlength = outchannel.write(buf)) != 0) {
  56. System.out.println("写入的字节数:" + outlength);
  57. }
  58. //buf第二次模式切换:清除buf,变成写模式
  59. buf.clear();
  60. }
  61. //强制刷新到磁盘
  62. outchannel.force(true);
  63. } finally {
  64. //关闭所有的可关闭对象
  65. IOUtil.closeQuietly(outchannel);
  66. IOUtil.closeQuietly(fos);
  67. IOUtil.closeQuietly(inChannel);
  68. IOUtil.closeQuietly(fis);
  69. }
  70. long endTime = System.currentTimeMillis();
  71. Logger.info("base复制毫秒数:" + (endTime - startTime));
  72. } catch (IOException e) {
  73. e.printStackTrace();
  74. }
  75. }

3.4.3 SocketChannel

在NIO中,涉及网络连接的通道有两个:一个是SocketChannel,负责连接的数据传输;另一个是ServerSocketChannel,负责连接的监听。其中,NIO中的SocketChannel传输通道与OIO中的Socket类对应,NIO中的ServerSocketChannel监听通道对应于OIO中的ServerSocket类。
ServerSocketChannel仅应用于服务端,而SocketChannel同时处于服务端和客户端。所以,对于一个连接,两端都有一个负责传输的SocketChannel。
无论是ServerSocketChannel还是SocketChannel,都支持阻塞和非阻塞两种模式。如何进行模式的设置呢?调用configureBlocking()方法,具体如下:
(1)socketChannel.configureBlocking(false)设置为非阻塞模式。
(2)socketChannel.configureBlocking(true)设置为阻塞模式。
在非阻塞模式下,通道的操作是异步、高效的,这也是相对于传统OIO的优势所在。下面详细介绍在非阻塞模式下通道的获取、读写和关闭等操作。

  1. 获取SocketChannel传输通道

在客户端,先通过SocketChannel静态方法open()获得一个套接字传输通道,然后将socket设置为非阻塞模式,最后通过connect()实例方法对服务器的IP和端口发起连接。

  1. //获得一个套接字传输通道
  2. SocketChannel socketChannel = SocketChannel.open();
  3. //设置为非阻塞模式
  4. socketChannel.configureBlocking(false);
  5. //对服务器的IP和端口发起连接
  6. socketChannel.connect(new InetSocketAddress("127.0.0.1", 80));

在非阻塞情况下,与服务器的连接可能还没有真正建立,socketChannel.connect()方法就返回了,因此需要不断地自旋,检查当前是否连接到了主机:

  1. while(! socketChannel.finishConnect() ){
  2. //不断地自旋、等待,或者做一些其他的事情
  3. }

在连接建立的事件到来时,服务端的ServerSocketChannel能成功地查询出这个新连接事件,并且通过调用服务端ServerSocketChannel监听套接字的accept()方法来获取新连接的套接字通道:

  1. //新连接事件到来,首先通过事件获取服务器监听通道
  2. ServerSocketChannel server = (ServerSocketChannel) key.channel();
  3. //获取新连接的套接字通道
  4. SocketChannel socketChannel = server.accept();
  5. //设置为非阻塞模式
  6. socketChannel.configureBlocking(false);

NIO套接字通道主要用于非阻塞的传输场景。所以,基本上都需要调用通道的configureBlocking(false)方法,将通道从阻塞模式切换为非阻塞模式。

  1. 读取SocketChannel传输通道

调用read()方法,将数据读入缓冲区ByteBuffer。

  1. ByteBufferbuf = ByteBuffer.allocate(1024);
  2. int bytesRead = socketChannel.read(buf);
  1. 在读取时,因为是异步的,所以我们必须检查read()的返回值,以便判断当前是否读取到了数据。read()方法的返回值是读取的字节数,如果是-1,那么表示读取到对方的输出结束标志,即对方已经输出结束,准备关闭连接。实际上,通过read()方法读数据本身是很简单的,比较困难的是在非阻塞模式下如何知道通道何时是可读的。
  1. 写入SocketChannel传输通道

大部分应用场景都会调用通道的int write(ByteBufferbuf)方法。

  1. //写入前需要读取缓冲区,要求ByteBuffer是读模式
  2. buffer.flip();
  3. socketChannel.write(buffer);
  1. 关闭SocketChannel传输通道

在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用一次shutdownOutput()终止输出方法,向对方发送一个输出的结束标志(-1)。然后调用socketChannel.close()方法,关闭套接字连接。

  1. //调用终止输出方法,向对方发送一个输出的结束标志
  2. socketChannel.shutdownOutput();
  3. //关闭套接字连接
  4. IOUtil.closeQuietly(socketChannel);

3.4.4 使用SocketChannel发送文件的实战案例

下面的实战案例是使用FileChannel读取本地文件内容,然后在客户端使用SocketChannel把文件信息和文件内容发送到服务器。客户端的完整代码如下:

  1. package com.crazymakercircle.iodemo.socketDemos;
  2. //…
  3. public class NioSendClient {
  4. private Charset charset = Charset.forName("UTF-8");
  5. /**
  6. * 向服务端传输文件
  7. */
  8. public void sendFile()
  9. {
  10. try
  11. {
  12. String sourcePath = NioDemoConfig.SOCKET_SEND_FILE;
  13. String srcPath = IOUtil.getResourcePath(sourcePath);
  14. Logger.debug("srcPath=" + srcPath);
  15. String destFile = NioDemoConfig.SOCKET_RECEIVE_FILE;
  16. Logger.debug("destFile=" + destFile);
  17. File file = new File(srcPath);
  18. if (!file.exists())
  19. {
  20. Logger.debug("文件不存在");
  21. return;
  22. }
  23. FileChannel fileChannel = new FileInputStream(file).getChannel();
  24. SocketChannel socketChannel = SocketChannel.open();
  25. socketChannel.socket().connect(new InetSocketAddress("127.0.0.1",18899));
  26. socketChannel.configureBlocking(false);
  27. Logger.debug("Client 成功连接服务端");
  28. while (!socketChannel.finishConnect())
  29. {
  30. //不断地自旋、等待,或者做一些其他的事情
  31. }
  32. //发送文件名称和长度
  33. ByteBuffer buffer = sengFileNameAndLength(destFile, file, socketChannel);
  34. //发送文件内容
  35. int length = sendContent(file, fileChannel, socketChannel, buffer);
  36. if (length == -1)
  37. {
  38. IOUtil.closeQuietly(fileChannel);
  39. socketChannel.shutdownOutput();
  40. IOUtil.closeQuietly(socketChannel);
  41. }
  42. Logger.debug("======== 文件传输成功 ========");
  43. } catch (Exception e)
  44. {
  45. e.printStackTrace();
  46. }
  47. }
  48. //方法:发送文件内容
  49. public int sendContent(File file, FileChannel fileChannel,
  50. SocketChannel socketChannel,
  51. ByteBuffer buffer) throws IOException
  52. {
  53. //发送文件内容
  54. Logger.debug("开始传输文件");
  55. int length = 0;
  56. long progress = 0;
  57. while ((length = fileChannel.read(buffer)) > 0)
  58. {
  59. buffer.flip();
  60. socketChannel.write(buffer);
  61. buffer.clear();
  62. progress += length;
  63. Logger.debug("| " + (100 * progress / file.length()) + "% |");
  64. }
  65. return length;
  66. }
  67. //方法:发送文件名称和长度
  68. public ByteBuffer sengFileNameAndLength(String destFile,
  69. File file,
  70. SocketChannel socketChannel) throws IOException
  71. {
  72. //发送文件名称
  73. ByteBuffer fileNameByteBuffer = charset.encode(destFile);
  74. ByteBuffer buffer = ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);
  75. //发送文件名称长度
  76. int fileNameLen = fileNameByteBuffer.capacity();
  77. buffer.putInt(fileNameLen);
  78. buffer.flip();
  79. socketChannel.write(buffer);
  80. buffer.clear();
  81. Logger.info("Client 文件名称长度发送完成:", fileNameLen);
  82. //发送文件名称
  83. socketChannel.write(fileNameByteBuffer);
  84. Logger.info("Client 文件名称发送完成:", destFile);
  85. //发送文件长度
  86. buffer.putLong(file.length());
  87. buffer.flip();
  88. socketChannel.write(buffer);
  89. buffer.clear();
  90. Logger.info("Client 文件长度发送完成:", file.length());
  91. return buffer;
  92. }
  93. }

3.4.5 DatagramChannel

在Java NIO中,使用DatagramChannel来处理UDP的数据传输。

  1. 获取DatagramChannel

获取数据报通道的方式很简单,调用DatagramChannel类的open()静态方法即可。然后调用configureBlocking(false)方法,设置成非阻塞模式。

  1. //获取DatagramChannel
  2. DatagramChannel channel = DatagramChannel.open();
  3. //设置为非阻塞模式
  4. datagramChannel.configureBlocking(false);

如果需要接收数据,还需要调用bind()方法绑定一个数据报的监听端口,具体如下:

  1. //调用bind()方法绑定一个数据报的监听端口
  2. channel.socket().bind(new InetSocketAddress(18080));
  1. 从DatagramChannel读取数据

当DatagramChannel通道可读时,可以从DatagramChannel读取数据。和前面的SocketChannel读取方式不同,这里不调用read()方法,而是调用receive(ByteBufferbuf)方法将数据从DatagramChannel读入,再写入ByteBuffer缓冲区中。

  1. //创建缓冲区
  2. ByteBuffer buf = ByteBuffer.allocate(1024);
  3. //从DatagramChannel读入,再写入ByteBuffer缓冲区
  4. SocketAddress clientAddr= datagramChannel.receive(buf);

通道读取receive(ByteBufferbuf)方法虽然读取了数据到buf缓冲区,但是其返回值是SocketAddress类型,表示返回发送端的连接地址(包括IP和端口)。

  1. 写入DatagramChannel

向DatagramChannel发送数据,和向SocketChannel通道发送数据的方法是不同的。这里不是调用write()方法,而是调用send()方法。

  1. //把缓冲区翻转为读模式
  2. buffer.flip();
  3. //调用send()方法,把数据发送到目标IP+端口
  4. dChannel.send(buffer, new InetSocketAddress("127.0.0.1",18899));
  5. //清空缓冲区,切换到写模式
  6. buffer.clear();
  1. 关闭DatagramChannel
    1. //简单关闭即可
    2. dChannel.close();

    3.4.6 使用DatagramChannel发送数据的实战案例

    下面是一个使用DatagramChannel发送数据的客户端示例程序,功能是获取用户的输入数据,通过DatagramChannel将数据发送到远程的服务器。 ```java package com.crazymakercircle.iodemo.udpDemos; //… public class UDPClient { public void send() throws IOException {
    1. //获取DatagramChannel
    2. DatagramChannel dChannel = DatagramChannel.open();
    3. //设置为非阻塞
    4. dChannel.configureBlocking(false);
    5. ByteBuffer buffer = ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);
    6. Scanner scanner = new Scanner(System.in);
    7. Print.tcfo("UDP客户端启动成功!");
    8. Print.tcfo("请输入发送内容:");
    9. while (scanner.hasNext()) {
    10. String next = scanner.next();
    11. buffer.put((Dateutil.getNow() + " >>" + next).getBytes());
    12. buffer.flip();
    13. //通过DatagramChannel发送数据
    14. dChannel.send(buffer,new InetSocketAddress("127.0.0.1",18899));
    15. buffer.clear();
    16. }
    17. //关闭DatagramChannel
    18. dChannel.close();
    } public static void main(String[] args) throws IOException {
    1. new UDPClient().send();
    } }
  1. 接下来看看在服务端应该如何使用DatagramChannel接收数据。<br />服务端是通过DatagramChannel绑定一个服务器地址(IP+端口),接收客户端发送过来的UDP数据报。服务端的完整代码如下:
  2. ```java
  3. package com.crazymakercircle.iodemo.udpDemos;
  4. //…
  5. public class UDPServer {
  6. public void receive() throws IOException {
  7. //获取DatagramChannel
  8. DatagramChannel datagramChannel = DatagramChannel.open();
  9. //设置为非阻塞模式
  10. datagramChannel.configureBlocking(false);
  11. //绑定监听地址
  12. datagramChannel.bind(new InetSocketAddress("127.0.0.1",18899));
  13. Print.tcfo("UDP服务器启动成功!");
  14. //开启一个通道选择器
  15. Selector selector = Selector.open();
  16. //将通道注册到选择器
  17. datagramChannel.register(selector, SelectionKey.OP_READ);
  18. //通过选择器查询IO事件
  19. while (selector.select() > 0) {
  20. Iterator<SelectionKey> iterator =
  21. selector.selectedKeys().iterator();
  22. ByteBuffer buffer =
  23. ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);
  24. //迭代IO事件
  25. while (iterator.hasNext()) {
  26. SelectionKeyselectionKey = iterator.next();
  27. //可读事件,有数据到来
  28. if (selectionKey.isReadable()) {
  29. //读取DatagramChannel数据
  30. SocketAddress client = datagramChannel.receive(buffer);
  31. buffer.flip();
  32. Print.tcfo(new String(buffer.array(), 0, buffer.limit()));
  33. buffer.clear();
  34. }
  35. }
  36. iterator.remove();
  37. }
  38. //关闭选择器和通道
  39. selector.close();
  40. datagramChannel.close();
  41. }
  42. public static void main(String[] args) throws IOException {
  43. new UDPServer().receive();
  44. }
  45. }

3.5 详解NIO Selector

3.5.1 选择器与注册

简单地说,选择器的使命是完成IO的多路复用,其主要工作是通道的注册、监听、事件查询。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。选择器和通道的关系是监控和被监控的关系。
选择器提供了独特的API方法,能够选出(select)所监控的通道已经发生了哪些IO事件,包括读写就绪的IO操作事件。
在NIO编程中,一般是一个单线程处理一个选择器,一个选择器可以监控很多通道。所以,通过选择器,一个单线程可以处理数百、数千、数万甚至更多的通道。在极端情况下(数万个连接),只用一个线程就可以处理所有的通道,这样会大量地减少线程之间上下文切换的开销。
通道和选择器之间的关联通过register(注册)的方式完成。调用通道的Channel.register(Selector sel,int ops)方法,可以将通道实例注册到一个选择器中。register方法有两个参数:第一个参数指定通道注册到的选择器实例;第二个参数指定选择器要监控的IO事件类型。
可供选择器监控的通道IO事件类型包括以下四种:
(1)可读:SelectionKey.OP_READ。
(2)可写:SelectionKey.OP_WRITE。
(3)连接:SelectionKey.OP_CONNECT。
(4)接收:SelectionKey.OP_ACCEPT。

  1. //监控通道的多种事件,用“按位或”运算符来实现
  2. int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE ;

什么是IO事件?
这里的IO事件不是对通道的IO操作,而是通道处于某个IO操作的就绪状态,表示通道具备执行某个IO操作的条件。

3.5.2 SelectableChannel

并不是所有的通道都是可以被选择器监控或选择的。判断一个通道能否被选择器监控或选择有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道),如果是,就可以被选择,否则不能被选择。

3.5.3 SelectionKey

通道和选择器的监控关系注册成功后就可以选择就绪事件,具体的选择工作可调用Selector的select()方法来完成。通过select()方法,选择器可以不断地选择通道中所发生操作的就绪状态,返回注册过的那些感兴趣的IO事件。
通过SelectionKey不仅可以获得通道的IO事件类型,还可以获得发生IO事件所在的channel,另外,还可以获得selector

3.5.4 选择器使用流程

  1. 获取选择器实例。选择器实例是通过调用静态工厂方法open()来获取的

    1. //调用静态工厂方法open()来获取Selector实例
    2. Selector selector = Selector.open();

    Selector的类方法open()的内部是向选择器SPI发出请求,通过默认的SelectorProvider(选择器提供者)对象获取一个新的选择器实例。Java中的SPI(Service Provider Interface,服务提供者接口)是一种可以扩展的服务提供和发现机制。Java通过SPI的方式提供选择器的默认实现版本。也就是说,其他的服务提供者可以通过SPI的方式提供定制化版本的选择器的动态替换或者扩展。

  2. 将通道注册到选择器实例。要实现选择器管理通道,需要将通道注册到相应的选择器上 ```java //获取通道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //设置为非阻塞 serverSocketChannel.configureBlocking(false); //绑定连接 serverSocketChannel.bind(new InetSocketAddress(18899)); //将通道注册到选择器上,并指定监听事件为“接收连接” serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);

  1. FileChannel不能与选择器一起使用,因为FileChannel只有阻塞模式,不能切换到非阻塞模式;而socket相关的所有通道都可以<br />一个通道并不一定支持所有的四种IO事件。例如,服务器监听通道ServerSocketChannel仅支持Accept(接收到新连接)IO事件,而传输通道SocketChannel则不同,它不支持Accept类型的IO事件。<br />可以在注册之前通过通道的validOps()方法来获取该通道支持的所有IO事件集合。
  2. 3. 选出感兴趣的IO就绪事件(选择键集合)。
  3. ```java
  4. //轮询,选择感兴趣的IO就绪事件(选择键集合)
  5. while (selector.select() > 0) {
  6. Set selectedKeys = selector.selectedKeys();
  7. Iterator keyIterator = selectedKeys.iterator();
  8. while(keyIterator.hasNext()) {
  9. SelectionKey key = keyIterator.next();
  10. //根据具体的IO事件类型执行对应的业务操作
  11. if(key.isAcceptable()) {
  12. //IO事件:ServerSocketChannel服务器监听通道有新连接
  13. } else if (key.isConnectable()) {
  14. //IO事件:传输通道连接成功
  15. } else if (key.isReadable()) {
  16. //IO事件:传输通道可读
  17. } else if (key.isWritable()) {
  18. //IO事件:传输通道可写
  19. }
  20. //处理完成后,移除选择键
  21. keyIterator.remove();
  22. }
  23. }
  1. 处理完成后,需要将选择键从SelectionKey集合中移除,以防止下一次循环时被重复处理。SelectionKey集合不能添加元素,如果试图向SelectionKey中添加元素,则将抛出java.lang.UnsupportedOperationException异常。<br />用于选择就绪的IO事件的select()方法有多个重载的实现版本,具体如下:
  1. select():阻塞调用,直到至少有一个通道发生了注册的IO事件。
  2. select(long timeout):和select()一样,但最长阻塞时间为timeout指定的毫秒数。
  3. selectNow():非阻塞,不管有没有IO事件都会立刻返回。

    3.5.5 使用NIO实现Discard服务器的实战案例

    Discard服务器的功能很简单:仅读取客户端通道的输入数据,读取完成后直接关闭客户端通道,并且直接抛弃掉(Discard)读取到的数据。 ```java package com.crazymakercircle.iodemo.NioDiscard; //… public class NioDiscardServer { public static void startServer() throws IOException {

    1. //1.获取选择器
    2. Selector selector = Selector.open();
    3. //2.获取通道
    4. ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    5. //3.设置为非阻塞
    6. serverSocketChannel.configureBlocking(false);
    7. //4.绑定连接
    8. serverSocketChannel.bind(new InetSocketAddress(18899));
    9. Logger.info("服务器启动成功");
    10. //5.将通道注册的“接收新连接”IO事件注册到选择器上
    11. serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    12. //6.轮询感兴趣的IO就绪事件(选择键集合)
    13. while (selector.select() > 0) {
    14. //7.获取选择键集合
    15. Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();
    16. while (selectedKeys.hasNext()) {
    17. //8.获取单个的选择键,并处理
    18. SelectionKey selectedKey = selectedKeys.next();
    19. //9.判断key是具体的什么事件
    20. if (selectedKey.isAcceptable()) {
    21. //10.若选择键的IO事件是“连接就绪”,就获取客户端连接
    22. SocketChannel socketChannel = serverSocketChannel.accept();
    23. //11.将新连接切换为非阻塞模式
    24. socketChannel.configureBlocking(false);
    25. //12.将新连接的通道的可读事件注册到选择器上
    26. socketChannel.register(selector, SelectionKey.OP_READ);
    27. } else if (selectedKey.isReadable()) {
    28. //13.若选择键的IO事件是“可读”,则读取数据
    29. SocketChannel socketChannel = (SocketChannel) selectedKey.channel();
    30. //14.读取数据,然后丢弃
    31. ByteBufferbyteBuffer = ByteBuffer.allocate(1024);
    32. int length = 0;
    33. while ((length = socketChannel.read(byteBuffer)) >0)
    34. {
    35. byteBuffer.flip();
    36. Logger.info(new String(byteBuffer.array(), 0, length));
    37. byteBuffer.clear();
    38. }
    39. socketChannel.close();
    40. }
    41. //15.移除选择键
    42. selectedKeys.remove();
    43. }
    44. }
    45. //16.关闭连接
    46. serverSocketChannel.close();

    } public static void main(String[] args) throws IOException {

    1. startServer();

    } }

  1. 客户端首先建立到服务器的连接,发送一些简单的数据,然后直接关闭连接。客户端的DiscardClient代码更加简单
  2. ```java
  3. package com.crazymakercircle.iodemo.NioDiscard;
  4. //…
  5. public class NioDiscardClient {
  6. public static void startClient() throws IOException {
  7. InetSocketAddress address =new InetSocketAddress("127.0.0.1",18899);
  8. //1.获取通道
  9. SocketChannel socketChannel = SocketChannel.open(address);
  10. //2.切换成非阻塞模式
  11. socketChannel.configureBlocking(false);
  12. //不断地自旋、等待连接完成,或者做一些其他的事情
  13. while (!socketChannel.finishConnect()) {
  14. }
  15. Logger.info("客户端连接成功");
  16. //3.分配指定大小的缓冲区
  17. ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
  18. byteBuffer.put("hello world".getBytes());
  19. byteBuffer.flip();
  20. //发送到服务器
  21. socketChannel.write(byteBuffer);
  22. socketChannel.shutdownOutput();
  23. socketChannel.close();
  24. }
  25. public static void main(String[] args) throws IOException {
  26. startClient();
  27. }
  28. }