Java NIO(上)

241 阅读35分钟

Java NIO(上)

一、Java NIO概述

Java NIO(New IO或者Non Blocking IO)是从Java1.4开始引入的一个新的IO API,可以替代标准的Java IO API。NIO支持面向缓冲区的、基于通道的IO操作。NIO将以更加高效的方式进行文件的读写操作。

1.1 阻塞IO

通常在进行同步I/O操作时,如果读取数据,代码会阻塞直至有可供读取的数据。同样,写入调用将会阻塞直至数据能够写入。传统的Server/Client模式会基于 TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。这种模式带来的一个问题就是线程数量的剧增,大量的线程会增大服务器的开销。大多数的实现为了避免这个问题,都采用了线程池模型,并设置线程池线程的最大数量,这也带来了新的问题,如果线程池中有100个线程,而有100个用户都在进行大文件下载,会导致第101个用户的请求无法及时处理,即便第101个用户只想请求一个几KB大小的页面。传统的Server/Client模式如下图所示:

image-20231223120247406

1.2 非阻塞IO(NIO)

NIO中非阻塞I/O采用了基于Reactor模式的工作方式,I/O调用不会被阻塞,相反是注册感兴趣的特定I/O事件,如可读数据到达,新的套接字连接等等,在发生特定事件时,系统再通知我们。NIO中实现非阻塞I/O的核心对象就是Selector,Selector就是注册各种I/O事件地方,而且当我们感兴趣的事件发生时,就是这个对象告诉我们所发生的事件,如下图所示:

image-20231223121501416

从图中可以看出,当有读或写等任何注册的事件发生时,可以从Selector中获得相应的SelectionKey,同时从SelectionKey中可以找到发生的事件和该事件所发生的具体的SelectableChannel,以获得客户端发送过来的数据

非阻塞指的是IO事件本身不阻塞,但是获取IO事件的select()方法是需要阻塞等待的。区别是阻塞的IO会阻塞在IO操作上,NIO阻塞在事件获取上,没有事件就没有IO,从高层次看IO就不阻塞了。也就是说只有IO已经发生那么我们才评估IO是否阻塞,但是select()阻塞的时候IO还没有发生,何谈IO的阻塞呢?

NIO的本质是延迟IO操作到真正发生IO的时候,而不是以前的只要IO流打开了就一直等待IO操作

IONIO
面向流(Stream Oriented)面向缓冲区(Buffer Oriented)
阻塞IO(Blocking UI)非阻塞IO(Non Blocking IO)
选择器(Selectors)

1.3 NIO概述

Java NIO由以下几个核心部分组成:

  • Channels
  • Buffers
  • Selectors

虽然 Java NIO中除此之外还有很多类和组件,但Channel,Buffer和Selector构成了核心的API。其它组件,如Pipe和FileLock,只不过是与三个核心组件共同使用的工具类

1)Channel

首先说一下Channel,可以翻译成"通道"。Channel和IO中的Stream(流)是差不多一个等级的。只不过Stream是单向的,譬如:InputStream,OutputStream;而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作

NIO 中的Channel的主要实现有:

  1. FileChannel
  2. DatagramChannel
  3. SocketChannel
  4. ServerSocketChannel

这里看名字就可以猜出个所以然来:分别可以对应文件IO、UDP和TCP(Server和Client)

2)Buffer

NIO 中的关键Buffer实现有:ByteBuffer,CharBuffer,DoubleBuffer,FloatBuffer,IntBuffer,LongBuffer,ShortBuffer,分别对应基本数据类型: byte,char,double,float,int,long,short

3)Selector

Selector运行单线程处理多个Channel,如果你的应用打开了多个通道,但每个连接的流量都很低,使用Selector就会很方便。例如在一个聊天服务器中。要使用Selector, 得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新的连接进来、数据接收等

4)三者关系

  • 每个Channel对应一个Buffer
  • Selector对应一个线程
  • 程序切换到哪个Channel是由事件决定的
  • Selector根据不同的事件,在各个通道上转换
  • Buffer就是一个内存块,底层是一个数组
  • NIO的数据的读写通过双向的Buffer,BIO的数据读写是靠单向的流,NIO切换方向靠的是flip方法
  • Channel是双向的,操作系统的通道也是双向的,Channel反映了操作系统的情况

二、Java NIO(Channel)

1. Channel概述

Channel是一个通道,可以通过它读取和写入数据,它就像水管一样,网络数据通过Channel读取和写入

通道与流的不同之处在于通道是双向的,流只是在一个方向上移动(一个流必须是InputStream或者OutputStream的子类),而且通道可以用于读、写或者同时用于读写

因为Channel是全双工的,所以它可以比流更好地映射底层操作系统的API。NIO中通过Channel封装了对数据源的操作,通过Channel我们可以操作数据源,但又不必关心数据源的具体物理结构。这个数据源可能是多种的。比如,可以是文件,也可以是网络socket。在大多数应用中,channel与文件描述符或者socket是一一对应的,Channel用于在字节缓冲区和位于通道另一侧的实体(通常是一个文件或套接字)之间有效地传输数据

Channel接口有两个方法:isOpen()和close()

public interface Channel extends Closeable {

    /**
     * Tells whether or not this channel is open.
     *
     * @return <tt>true</tt> if, and only if, this channel is open
     */
    public boolean isOpen();

    /**
     * Closes this channel.
     *
     * <p> After a channel is closed, any further attempt to invoke I/O
     * operations upon it will cause a {@link ClosedChannelException} to be
     * thrown.
     *
     * <p> If this channel is already closed then invoking this method has no
     * effect.
     *
     * <p> This method may be invoked at any time.  If some other thread has
     * already invoked it, however, then another invocation will block until
     * the first invocation is complete, after which it will return without
     * effect. </p>
     *
     * @throws  IOException  If an I/O error occurs
     */
    public void close() throws IOException;

}

与缓冲区不同,通道API主要由接口指定。不同的操作系统上通道实现(ChannelImplementation)会有根本性的差异,所以通道API仅仅描述了可以做什么。因此很自然地,通道实现经常使用操作系统的本地代码。通道接口允许您以一种受控且可移植的方式来访问底层的I/O服务。

Channel是一个对象,可以通过它读取和写入数据。拿NIO与原来的I/O做个比较,通道就像是流。所有数据都通过Buffer对象来处理。您永远不会将字节直接写入通道中,相反,您是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。

为什么一定要使用缓冲区呢?因为NIO使用Buffer高效的处理数据

  1. 内存管理:Buffer提供了一种更有效的内存管理方式。它可以在堆内存或直接内存中创建一个固定大小的内存区域,用于暂存数据。这样可以避免频繁的内存分配和释放操作,提高性能
  2. 数据交换:Buffer用作数据在通道之间的交换介质。通过将数据从通道读取到Buffer,然后从Buffer写入到另一个通道,可以实现数据的传输和复制。Buffer可以作为中间缓冲区,在数据传输过程中进行数据转换和缓冲,提供高效的数据处理能力
  3. 快速访问:Buffer提供了一组方便的方法来读取和写入数据。它可以根据特定的数据类型进行读写操作,如整型、字符型等。此外,Buffer还提供了位置、标记和限制等属性,可以方便地管理数据的位置和范围。通过这些方法和属性,可以快速有效地访问和操作数据
  4. 非阻塞IO支持:Buffer是NIO中非阻塞IO的关键组件之一。在非阻塞IO模式下,可以将数据从Buffer读取到通道或从通道写入到Buffer,实现异步的数据传输操作。Buffer的使用可以帮助管理和处理非阻塞IO中的数据

综上所述,Buffer在Java的NIO中具有重要作用。它提供了高效的内存管理、数据交换、快速访问和非阻塞IO支持。通过使用Buffer,可以更好地控制和管理数据,提高程序的性能和效率

Java NIO的通道类似流,但又有些不同:

  • 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。
  • 通道可以异步地读写。
  • 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。

正如上面所说,从通道读取数据到缓冲区,从缓冲区写入数据到通道。如下图所示:

image-20231223135414380

2. Channel的实现

下面是 Java NIO中最重要的Channel的实现:

  1. FileChannel:从文件中读写数据
  2. DatagramChannel:能通过UDP读写网络中的数据
  3. SocketChannel:能通过TCP读写网络中的数据
  4. ServerSocketChannel:可以监听新进来的TCP连接,像Web服务器那样,对每一个新进来的连接都会创建一个SocketChannel

正如你所看到的,这些通道涵盖了UDP和TCP网络IO,以及文件IO

3. FileChannel介绍和示例

FileChannel类可以实现常用的read,write以及scatter/gather操作,同时它也提供了很多专用于文件的新方法。这些方法中的许多都是我们所熟悉的文件操作

方法描述
int read(ByteBuffer dst)从Channel中读取数据到ByteBuffer
long read(ByteBuffer[] dsts)将Channel中的数据“分散”到ByteBuffer[]
int write(ByteBuffer src)将ByteBuffer中的数据写入到Channel
long write(ByteBuffer[] srcs)将ByteBuffer[]中的数据“聚集”到Channel
long position()返回此通道的文件位置
FileChannel position(long p)设置此通道的文件位置
long size()返回此通道的文件当前大小
FileChannel truncate(long s)将此通道的文件截取为指定大小
void force(boolean metaData)强制将所有对此通道的文件更新写入到存储设备中

示例需要先介绍Buffer通常的操作:

  1. 将数据写入缓冲区:调用buffer.flip()反转读写模式
  2. 从缓冲区读取数据:调用buffer.clear()或buffer.compact()清除缓冲区内容
  3. Channel可以向Buffer传入数据,Buffer也可以向Channel传入数据,而flip则是切换Buffer读写模式的方法,Buffer有读模式和写模式,读模式可以读取缓冲区Buffer的数据,写模式是向Buffer中写入数据

FileChannel+ByteBuffer的示例

public class FileChannelDemo1 {
    //FileChannel读取数据到buffer中
    public static void main(String[] args) throws IOException {
        //创建FileChannel
        //r表示读,w表示写
        RandomAccessFile aFile = new RandomAccessFile("D:\\计算机笔记\\aFileTest.txt","rw");
        FileChannel channel = aFile.getChannel();

        //创建Buffer
        ByteBuffer buf = ByteBuffer.allocate(1024);

        //通过FileChannel读取数据到Buffer中
        int bytesRead = channel.read(buf);
        while (bytesRead != -1){
            System.out.println("读取了" + bytesRead);
            buf.flip();
            while (buf.hasRemaining()){
                System.out.println((char)buf.get());
            }
            buf.clear();
            bytesRead = channel.read(buf);
        }
        aFile.close();
        System.out.println("操作结束了");
    }
}

4. FileChannel操作详解

1)打开FileChannel

在使用FileChannel之前,必须先打开它。但是,我们无法直接打开一个FileChannel,需要通过使用一个InputStream、OutputStream或RandomAccessFile来获取一个FileChannel实例。下面是通过RandomAccessFile

RandomAccessFile raf = new RandomAccessFile("D:\\计算机笔记\\aFileTest.txt","rw");
FileChannel channel = raf.getChannel();

2)从FileChannel读取数据

调用多个read()方法之一从FileChannel中读取数据。如:

//创建Buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
//读取数据到Buffer中
int readNum = channel.read(buffer);

首先,分配一个Buffer。从FileChannel中读取的数据将被读到Buffer中。然后,调用FileChannel.read()方法。该方法将数据从FileChannel读取到Buffer中。read()方法返回的int值表示了有多少字节被读到了Buffer 中。如果返回-1,表示到了文件末尾

3)向FileChannel中写数据

使用FileChannel.write()方法向FileChannel写数据,该方法的参数是一个Buffer。如:

public class FileChannelDemo2 {
    //写入到FileChannel当中
    public static void main(String[] args) throws IOException {
        //打开FileChannel
        RandomAccessFile aFile = new RandomAccessFile("D:\\计算机笔记\\aFileTest01.txt","rw");
        FileChannel channel = aFile.getChannel();

        //创建Buffer对象
        ByteBuffer buf = ByteBuffer.allocate(1024);

        //将buf中的数据写入channel
        String newData = "data atguigu";
        //先清理buf
        buf.clear();
        buf.put(newData.getBytes());
        //buf读写转换
        buf.flip();

        //FileChannel完成最终的实现
        while (buf.hasRemaining()){
            channel.write(buf);
        }

        //关闭channel
        channel.close();
    }
}

注意FileChannel.write()是在while循环中调用的。因为无法保证write()方法一次能向FileChannel写入多少字节,因此需要重复调用write()方法,直到Buffer中已经没有尚未写入通道的字节

4)关闭FileChannel

用完FileChannel后必须将其关闭。如:

channel.close();

5)FileChannel的position方法

有时可能需要在FileChannel的某个特定位置进行数据的读/写操作。可以通过调用position()方法获取FileChannel的当前位置。也可以通过调用position(long pos)方法设置FileChannel的当前位置

//获取当前位置
long pos = channel.position(); 

//设置当前位置
channel.position(pos +123);

如果将位置设置在文件结束符之后,然后试图从文件通道中读取数据,读方法将返回-1(文件结束标志)

如果将位置设置在文件结束符之后,然后向通道中写数据,文件将撑大到当前位置并写入数据。这可能导致“文件空洞”,磁盘上物理文件中写入的数据间有空隙

6)FileChannel的size方法

FileChannel实例的size()方法将返回该实例所关联文件的大小。如:

long fileSize = channel.size();

7)FileChannel的truncate方法

可以使用 FileChannel.truncate()方法截取一个文件。截取文件时,文件将中指定长度后面的部分将被删除。如:

channel.truncate(1024);
//这个例子截取文件的前1024个字节

8)FileChannel的force方法

FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入到 FileChannel里的数据一定会即时写到磁盘上。要保证这一点,需要调用force()方法

force()方法有一个boolean类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上

9)FileChannel的transferTo和transferFrom方法

通道之间的数据传输:

如果两个通道中有一个是FileChannel,那你可以直接将数据从一个channel传输到另外一个 channel。

(1)transferFrom()方法

FileChannel的transferFrom()方法可以将数据从源通道传输到FileChannel中(译者注:这个方法在JDK文档中的解释为将字节从给定的可读取字节通道传输到此通道的文件中)。下面是一个FileChannel完成文件间的复制的例子:

public class FileChannelDemo3 {
    public static void main(String[] args) throws IOException {
        //创建两个channel
        RandomAccessFile aFile = new RandomAccessFile("D:\\计算机笔记\\aFileTest01.txt","rw");
        FileChannel fromChannel = aFile.getChannel();
        RandomAccessFile bFile = new RandomAccessFile("D:\\计算机笔记\\002.txt","rw");
        FileChannel toChannel = bFile.getChannel();

        //将fromChannel中的数据传入toChannel中
        long position = 0;
        long size = fromChannel.size();
        toChannel.transferFrom(fromChannel,position,size);

        aFile.close();
        bFile.close();
        System.out.println("结束了!");
    }
}

方法的输入参数position表示从position处开始向目标文件写入数据,count表示最多传输的字节数

如果源通道的剩余空间小于count个字节,则所传输的字节数要小于请求的字节数

此外要注意,在SoketChannel的实现中,SocketChannel只会传输此刻准备好的数据(可能不足count字节)

因此,SocketChannel可能不会将请求的所有数据(count 个字节)全部传输到FileChannel中

(1)transferTo()方法

transferTo()方法将数据从FileChannel传输到其他的channel中。

下面是一个 transferTo()方法的例子

public class FileChannelDemo4 {
    public static void main(String[] args) throws IOException {
        //创建两个channel
        RandomAccessFile aFile = new RandomAccessFile("D:\\计算机笔记\\aFileTest01.txt","rw");
        FileChannel fromChannel = aFile.getChannel();
        RandomAccessFile bFile = new RandomAccessFile("D:\\计算机笔记\\003.txt","rw");
        FileChannel toChannel = bFile.getChannel();

        //将fromChannel中的数据传入toChannel中
        long position = 0;
        long size = fromChannel.size();
        fromChannel.transferTo(position,size,toChannel);

        aFile.close();
        bFile.close();
        System.out.println("结束了!");
    }
}

5. Socket通道

  • 新的Socket通道类可以运行非阻塞模式并且是可选择的,可以激活大程序(如网络服务器和中间件组件)巨大的可伸缩性和灵活性。本节中我们会看到,再也没有为每个Socket连接使用一个线程的必要了,也避免了管理大量线程所需的上下文交换开销。借助新的NIO类,一个或几个线程就可以管理成百上千的活动 Socket连接了并且只有很少甚至可能没有性能损失。所有的Socket通道类(DatagramChannel、SocketChannel和ServerSocketChannel)都继承了位于java.nio.channels.spi包的AbstractSelectableChannel。这意味着我们可以用一个Selector对象来执行Socket通道的就绪选择(readiness selection)
  • 请注意DatagramChannel和SocketChannel实现定义读和写功能的接口而ServerSocketChannel不实现。ServerSocketChannel负责监听传入的连接和创建新的SocketChannel对象它本身从不传输数据
  • 在我们具体讨论每一种Socket通道前,您应该了解Socket和Socket通道之间的关系,通道是一个连接I/O服务导管并提供与该服务交互的方法。就某个Socket而言,它不会再次实现与之对应的Socket通道类中的Socket协议API,而java.net中已经存在的Socket通道都可以被大多数协议操作重复使用。全部 Socket通道类(DatagramChannel、SocketChannel和ServerSocketChannel)在被实例化时都会创建一个对等Socket对象。Socket不能被重复使用,Channel可以被重复使用。这些是我们所熟悉的来自java.net的类(Socket、ServerSocket和DatagramSocket),它们已经被更新以识别通道。对等 Socket可以通过调用socket()方法从一个通道上获取。此外,这三个java.net类现在都有getChannel()方法
  • 要把一个Socket通道置于非阻塞模式,我们要依靠所有Socket通道类的公有超级类:SelectableChannel。就绪选择(readiness selection)是一种可以用来查询通道的机制,该查询可以判断通道是否准备好执行一个目标操作,如读或写。非阻塞I/O和可选择性是紧密相连的,那也正是管理阻塞模式的API代码要在SelectableChannel超级类中定义的原因。设置或重新设置一个通道的阻塞模式是很简单的,只要调用configureBlocking方法即可,传递参数值为true则设为阻塞模式,参数值为false值设为非阻塞模式。可以通过调用isBlocking()方法来判断某个Socket通道当前处于哪种模式

AbstractSelectableChannel.java中实现的configureBlocking()方法如下:

可以通过configureBlocking()来设置Socket是阻塞/非阻塞

public final SelectableChannel configureBlocking(boolean block)
    throws IOException
{
    synchronized (regLock) {
        if (!isOpen())
            throw new ClosedChannelException();
        if (blocking == block)
            return this;
        if (block && haveValidKeys())
            throw new IllegalBlockingModeException();
        implConfigureBlocking(block);
        blocking = block;
    }
    return this;
}

非阻塞Socket通常被认为是服务端使用的,因为它们使同时管理很多Socket通道变得更容易。但是,在客户端使用一个或几个非阻塞模式的 socket 通道也是有益处的,例如,借助非阻塞Socket通道,GUI程序可以专注于用户请求并且同时维护与一个或多个服务器的会话。在很多程序上,非阻塞模式都是有用的。

偶尔地,我们也会需要防止Socket通道的阻塞模式被更改。API中有一个blockingLock()方法,该方法会返回一个非透明的对象引用。返回的对象是通道实现修改阻塞模式时内部使用的。只有拥有此对象的锁的线程才能更改通道的阻塞模式。

1)ServerSocketChannel

ServerSocketChannel是一个基于通道的Socket监听器(本身不传数据,而是一个监听器)

它同我们所熟悉的java.net.ServerSocket执行相同的任务,不过它增加了通道语义,因此能够在非阻塞模式下运行

由于ServerSocketChannel没有 bind()方法(在Java1.7以后有了bind方法),因此有必要取出对等的socket并使用它来绑定到一个端口以开始监听连接。我们也是使用对等ServerSocket的API来根据需要设置其他的Socket选项

同java.net.ServerSocket一样,ServerSocketChannel也有accept()方法。一旦创建了一个ServerSocketChannel并用对等Socket绑定了它,然后您就可以在其中一个上调用accept()。如果您选择在ServerSocket上调用accept()方法,那么它会同任何其他的ServerSocket表现一样的行为:总是阻塞并返回一个 java.net.Socket对象

如果您选择在ServerSocketChannel上调用accept()方法则会返回SocketChannel类型的对象,返回的对象能够在非阻塞模式下运行

换句话说:

ServerSocketChannel的accept()方法会返回SocketChannel类型对象,SocketChannel可以在非阻塞模式下运行。其它Socket的accept()方法会阻塞返回一个 Socket对象。如果ServerSocketChannel以非阻塞模式被调用,当没有传入连接在等待时,ServerSocketChannel.accept()会立即返回null。正是这种检查连接而不阻塞的能力实现了可伸缩性并降低了复杂性。可选择性也因此得到实现。我们可以使用一个选择器实例来注册ServerSocketChannel对象以实现新连接到达时自动通知的功能

public class ServerSocketChannelDemo {
    public static void main(String[] args) throws IOException, InterruptedException {
        //端口号设置
        int port = 8888;

        //创建buffer
        ByteBuffer buffer = ByteBuffer.wrap("hello atguigu".getBytes());

        //创建ServerSocketChannel对象
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //绑定
        ssc.socket().bind(new InetSocketAddress(port));

        //设置非阻塞模式
        ssc.configureBlocking(false);

        //监听是否有新的监听传入
        while (true){
            System.out.println("waiting for connections");
            SocketChannel sc = ssc.accept();
            if (sc == null){    //没有连接传入
                System.out.println("null");
                Thread.sleep(2000);
            }else {
                System.out.println("Incomming connection from:" + sc.socket().getRemoteSocketAddress());
                buffer.rewind();    //指针指向0
                sc.write(buffer);
                sc.close();
            }
        }
    }
}

当找个浏览器执行127.0.0.1:8888时,会有如下结果被记录:

image-20231223155238176

  • 打开 ServerSocketChannel

通过调用ServerSocketChannel.open()方法来打开ServerSocketChannel

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
  • 关闭 ServerSocketChannel

通过调用ServerSocketChannel.close()方法来关闭ServerSocketChannel

serverSocketChannel.close();
  • 监听新的连接

通过ServerSocketChannel.accept()方法监听新进的连接。当accept()方法返回时候,它返回一个包含新进来的连接的SocketChannel。因此,accept()方法会一直阻塞到有新连接到达

通常不会仅仅只监听一个连接,在while循环中调用accept()方法。如下面的例子:

while (true){
    System.out.println("waiting for connections");
    SocketChannel sc = ssc.accept();
  • 阻塞模式

设置:ssc.configureBlocking(false);

image-20231223155806679

会在 SocketChannel sc = ssc.accept();这里阻塞住进程

  • 非阻塞模式

ServerSocketChannel可以设置成非阻塞模式。在非阻塞模式下,accept()方法会立刻返回,如果还没有新进来的连接,返回的将是null。 因此,需要检查返回SocketChannel是否是null。如:

while (true){
    System.out.println("waiting for connections");
    SocketChannel sc = ssc.accept();
    if (sc == null){    //没有连接传入

2)SocketChannel

SocketChannel介绍

SocketChannel是一个连接到TCP网络套接字的通道。操作面向Buffer缓冲区

A selectable channel for stream-oriented connecting sockets.

以上是Java docs中对于SocketChannel的描述:SocketChannel是一种面向流连接sockets套接字的可选择通道。从这里可以看出:

  • SocketChannel是用来连接Socket套接字
  • SocketChannel主要用途用来处理网络I/O的通道
  • SocketChannel是基于TCP 连接传输
  • SocketChannel实现了可选择通道,可以被多路复用的
  • 用于TCP的网络I/O套接字的通道
SocketChannel特征
  1. 对于已经存在的Socket不能创建SocketChannel
  2. SocketChannel中提供的open接口创建的Channel并没有进行网络级联,需要使用connect接口连接到指定地址
  3. 未进行连接的SocketChannle执行I/O操作时,会抛出NotYetConnectedException
  4. SocketChannel支持两种 I/O 模式:阻塞式和非阻塞式
  5. SocketChannel支持异步关闭。如果SocketChannel在一个线程上read阻塞,另一个线程对该SocketChannel调用shutdownInput,则读阻塞的线程将返回-1表示没有读取任何数据;如果SocketChannel在一个线程上write阻塞,另一个线程对该SocketChannel调用shutdownWrite,则写阻塞的线程将抛出AsynchronousCloseException
  6. SocketChannel支持设定参数

SO_SNDBUF 套接字发送缓冲区大小

SO_RCVBUF 套接字接收缓冲区大小

SO_KEEPALIVE 保活连接

O_REUSEADDR 复用地址

SO_LINGER 有数据传输时延缓关闭 Channel (只有在非阻塞模式下有用)

TCP_NODELAY 禁用 Nagle 算法

SocketChannel的使用
  • 创建SocketChannel
//方式1
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("www.baidu.com",80));

//方式2
SocketChannel socketChannel1 = SocketChannel.open();
socketChannel1.connect(new InetSocketAddress("www.baidu.com",80));

直接使用有参open api或者使用无参open api,但是在无参open只是创建了一个SocketChannel对象,并没有进行实质的tcp连接

  • 连接校验
//测试SocketChannel是否为open状态
socketChannel.isOpen();

//测试SocketChannel是否已经连接
socketChannel.isConnected();

//测试SocketChannel是否正在进行连接
socketChannel.isConnectionPending();

//校验正在进行套接字连接的SocketChannel是否已经完成连接
socketChannel.finishConnect();
  • 设置阻塞和非阻塞模式

前面提到SocketChannel支持阻塞非阻塞两种模式

socketChannel.configureBlocking(false);//非阻塞
  • 读写
SocketChannel socketChannel = SocketChannel.open (new InetSocketAddress("www.baidu.com", 80));
ByteBuffer byteBuffer = ByteBuffer.allocate (16);
socketChannel.read(byteBuffer);
socketChannel.close();
System.out.println("read over");

以上为阻塞式读,当执行到read处,线程将阻塞,控制台将无法打印read over

SocketChannel socketChannel = SocketChannel. open (new InetSocketAddress("www.baidu.com", 80));
socketChannel.configureBlocking(false);
ByteBuffer byteBuffer = ByteBuffer.allocate (16);
socketChannel.read(byteBuffer);
socketChannel.close();
System.out.println("read over");

以上为非阻塞读,控制台将打印read over

读写都是面向缓冲区,这个读写方式与前文中的FileChannel相同

  • 设置和获取参数
socketChannel.setOption(StandardSocketOptions.SO_KEEPALIVE,Boolean.TRUE).setOption(StandardSocketOptions.TCP_NODELAY,Boolean.TRUE);

通过setOptions方法可以设置socket套接字的相关参数

socketChannel.getOption(StandardSocketOptions.SO_KEEPALIVE);
socketChannel.getOption(StandardSocketOptions.SO_RCVBUF);

可以通过getOption获取相关参数的值。如默认的接收缓冲区大小是8192byte

SocketChannel还支持多路复用,但是多路复用在后续内容中会介绍到

3)DatagramChannel

向地址ip端口号发送内容;接收ip端口号发送来的内容

正如SocketChannel对应Socket,ServerSocketChannel对应ServerSocket,每一个DatagramChannel对象也有一个关联的DatagramSocket对象。

正如SocketChannel模拟连接导向的流协议(如TCP/IP),DatagramChannel则模拟包导向的无连接协议(如UDP/IP)。DatagramChannel是无连接的,每个数据报(datagram)都是一个自包含的实体,拥有它自己的目的地址及不依赖其他数据报的数据负载。与面向流的的Socket不同DatagramChannel可以发送单独的数据报给不同的目的地址。

同样,DatagramChannel对象也可以接收来自任意地址的数据包。每个到达的数据报都含有关于它来自何处的信息(源地址)

  • 打开DatagramChannel
DatagramChannel server = DatagramChannel.open();
server.socket().bind(new InetSocketAddress(10086));

此例子是打开10086端口接收UDP数据包

  • 接收数据

通过send()发送UDP包

DatagramChannel server = DatagramChannel.open();
ByteBuffer sendBuffer = ByteBuffer. wrap ("client send".getBytes());//发送的内容
server.send(sendBuffer, new InetSocketAddress("127.0.0.1",10086));
  • 发送数据

通过send()发送UDP包

DatagramChannel server = DatagramChannel.open();
ByteBuffer sendBuffer = ByteBuffer. wrap ("client send".getBytes());//发送的内容
server.send(sendBuffer, new InetSocketAddress("127.0.0.1",10086));
  • 连接

UDP 不存在真正意义上的连接,这里的连接是向特定服务地址用 read 和 write 接收发送数据包。

client.connect(new InetSocketAddress("127.0.0.1",10086));
int readSize= client.read(sendBuffer);
server.write(sendBuffer);

read()和write()只有在connect()后才能使用,不然会抛NotYetConnectedException异常。

用read()接收时,如果没有接收到包,会抛PortUnreachableException异常

  • 示例

客户端发送,服务端接收的例子

public class DatagramChannelDemo {
    //发送的实现
    @Test
    public void sendDatagram() throws IOException, InterruptedException {
        //打开DatagramChannel
        DatagramChannel sendChannel = DatagramChannel.open();

        InetSocketAddress sendAddress = new InetSocketAddress("127.0.0.1", 9999);
        while (true) {
            ByteBuffer buffer = ByteBuffer.wrap("发送atguigu".getBytes("UTF-8"));
            sendChannel.send(buffer, sendAddress);
            System.out.println("发送完成");
            Thread.sleep(1000);
        }
    }

    //接收的实现
    @Test
    public void receiveDatagram() throws IOException {
        //打开DatagramChannel
        DatagramChannel receiveChannel = DatagramChannel.open();
        InetSocketAddress receiveAddress = new InetSocketAddress(9999);
        //绑定
        receiveChannel.bind(receiveAddress);
        
        //创建buffer
        ByteBuffer receiveBuffer = ByteBuffer.allocate(1024);
        while (true) {
            receiveBuffer.clear();
            SocketAddress socketAddress = receiveChannel.receive(receiveBuffer);
            receiveBuffer.flip();
            System.out.println("socketAddress-->" + socketAddress.toString());
            System.out.println(Charset.forName("UTF-8").decode(receiveBuffer));
        }
    }
}

按顺序启动send和receive的Test,就可以看到,一边发送,一边接收

发送的方法:

image-20231224142011001

接收的方法:

image-20231224142031364

演示read和write的逻辑

@Test
public void testConnection() throws IOException {
    //打开DatagramChannel
    DatagramChannel connChannel = DatagramChannel.open();
    //绑定9999端口
    connChannel.bind(new InetSocketAddress(9999));

    //建立连接
    connChannel.connect(new InetSocketAddress("127.0.0.1",9999));

    //write方法
    ByteBuffer buffer = ByteBuffer.wrap("发送atguigu".getBytes("UTF-8"));
    connChannel.write(buffer);

    //创建buffer
    ByteBuffer readBuffer = ByteBuffer.allocate(1024);
    while (true){
        readBuffer.clear();
        connChannel.read(readBuffer);
        readBuffer.flip();
        System.out.println(Charset.forName("UTF-8").decode(readBuffer));
    }
}

6、Scanner/Gather

Java NIO开始支持scatter/gather,scatter/gather用于描述从Channel中读取或者写入到Channel的操作。

分散(scatter)

从Channel中读取是指在读操作时将读取的数据写入多个Buffer中。因此,Channel将从Channel中读取的数据”分散“(scatter)到多个Buffer中

聚集(gather)

写入Channel是指在写操作时将多个Buffer的数据写入同一个Channel,因此,Channel将多个Buffer 中的数据“聚集“(gather)后发送到Channel

scatter/gather经常用于需要将传输的数据分开处理的场合,例如传输一个由消息头和消息体组成的消息,你可能会将消息体和消息头分散到不同的 buffer中,这样你可以方便的处理消息头和消息体

1)Scattering Reads

Scattering Reads是指数据从一个channel读取到多个buffer中。如下图描述:

image-20231224143445895

ByteBuffer header = ByteBuffer.allocate (128);
ByteBuffer body = ByteBuffer.allocate (1024);

ByteBuffer[] bufferArray = {header,body};

channel.read(bufferArray);

注意Buffer首先被插入到数组,然后再将数组作为channel.read()的输入参数

read()方法按照Buffer在数组中的顺序将从Channel中读取的数据写入到Buffer,当一个Buffer被写满后,Channel紧接着向另一个Buffer中写

Scattering Reads在移动下一个Buffer前,必须填满当前的Buffer,这也意味着它不适用于动态消息,消息大小不固定

换句话说,如果存在消息头和消息体,消息头必须完成填充(例如128byte),Scattering Reads才能正常工作

2)Gathering Writes

Gathering Writes是指数据从多个buffer写入到同一个channel。如下图描述:

image-20231224143928102

ByteBuffer header = ByteBuffer.allocate (128);
ByteBuffer body = ByteBuffer.allocate (1024);

//write data into buffers
ByteBuffer[] bufferArray = {header,body};

channel.write(bufferArray);

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

三、Java NIO(Buffer)

1. Buffer简介

Java NIO中的Buffer用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的

image-20231224144828511

缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存

缓冲区实际上是一个容器对象,更直接的说,其实就是一个数组,在NIO库中,所有数据都是用缓冲区处理的

在读取数据时,它是直接读到缓冲区中的; 在写入数据时,它也是写入到缓冲区中的;任何时候访问NIO中的数据,都是将它放到缓冲区中。而在面向流I/O系统中,所有数据都是直接写入或者直接将数据读取到Stream对象中

在NIO中,所有的缓冲区类型都继承于抽象类Buffer,最常用的就是ByteBuffer,对于Java中的基本类型,基本都有一个具体Buffer类型与之相对应,它们之间的继承关系如下图所示:

image-20231224145426028

2. Buffer的基本使用

1.使用 Buffer 读写数据,一般遵循以下四个步骤:

  • 写入数据到Buffer
  • 调用flip()方法
  • 从Buffer中读取数据
  • 调用clear()方法或者compact()方法

当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式,在读模式下,可以读取之前写入到buffer的所有数据。一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法,clear()方法会清空整个缓冲区,compact()方法只会清除已经读过的数据,任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面

2.使用ByteBuffer的例子:

public class BufferDemo1 {
    @Test
    public void buffer01() throws IOException {
        //打开Channel
        RandomAccessFile raf = new RandomAccessFile("D:\\全部笔记\\其他相关\\a01.txt", "rw");
        FileChannel channel = raf.getChannel();

        //创建Buffer
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        //读取
        int byteRead = channel.read(buffer);
        while (byteRead != -1) {
            //转换成read模式
            buffer.flip();
            while (buffer.hasRemaining()){
                System.out.println((char) buffer.get());
            }
            buffer.clear();
            byteRead = channel.read(buffer);
        }

        raf.close();
    }
}

3.使用IntBuffer的例子:

@Test
public void buffer02() {
    //创建Buffer
    IntBuffer buffer = IntBuffer.allocate(8);

    //buffer里放数据
    for (int i = 0; i < buffer.capacity(); i++) {
        int j = 2 * (i+1);
        buffer.put(j);
    }

    //重置缓冲区
    buffer.flip();
    //从buffer中取数据
    while (buffer.hasRemaining()){
        int value = buffer.get();
        System.out.println("value = " + value);
    }
}

输出如下:

image-20231224151114295

3. Buffer的capacity、position和limit

为了理解Buffer的工作原理,需要熟悉它的三个属性:

  • capacity
  • position
  • limit

position和limit的含义取决于Buffer处在读模式还是写模式。不管Buffer处在什么模式,capacity的含义总是一样的

关于capacity,position和limit在读写模式中的说明

image-20231224151610055

1)capacity

作为一个内存块,Buffer有一个固定的大小值,也"capacity"

你只能往里写capacity个byte、long,char等类型

一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据

2)position

写数据到Buffer中时,position表示写入数据的当前位置,position的初始值为0

当一个byte、long等数据写到Buffer后, position会向下移动到下一个可插入数据的Buffer单元。position最大可为capacity–1(因为 position 的初始值为0)

读数据到Buffer中时,position表示读入数据的当前位置,如position=2时表示已开始读入了3个byte,或从第3个byte开始读取

通过ByteBuffer.flip()切换到读模式时position会被重置为0,当Buffer从position读入数据后,position会下移到下一个可读入的数据Buffer单元

3)limit

写数据时,limit表示可对Buffer最多写入多少个数据。写模式下,limit等于Buffer的capacity 读数据时,limit表示Buffer里有多少可读数据(not null的数据),因此能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)剩余未读的数据

4. Buffer分配和写数据

1)Buffer分配

要想获得一个Buffer对象首先要进行分配。 每一个Buffer类都有一个allocate方法

下面是一个分配 48 字节 capacity 的 ByteBuffer 的例子:

ByteBuffer buf = ByteBuffer.allocate(48);

这是分配一个可存储1024个字符的CharBuffer:

CharBuffer buf = CharBuffer.allocate(1024);

2)向Buffer中写数据

写数据到Buffer有两种方式:

  • 从Channel写到Buffer
int bytesRead = inChannel.read(buf); //read into buffer.
  • 通过Buffer的put()方法写到Buffer里
buf.put(127);

put方法有很多版本,允许你以不同的方式把数据写入到Buffer中。例如,写到一个指定的位置,或者把一个字节数组写入到Buffer

3)flip方法

flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值

换句话说,position现在用于标记读的位置,limit表示之前写进了多少个byte、char等(现在能读取多少个byte、char等)

5. 从Buffer中读数据

从 Buffer 中读取数据有两种方式:

  • 从Buffer读取数据到Channel
//read from buffer into channel.
int bytesWritten = inChannel.write(buf);
  • 使用get()方法从Buffer中读取数据
byte aByte = buf.get();

get方法有很多版本,允许你以不同的方式从Buffer中读取数据。例如,从指定position读取,或者从Buffer中读取数据到字节数组

6. Buffer的几个方法

1)rewind()方法

Buffer.rewind()将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)

2)clear()与compact()方法

一旦读完Buffer中的数据,需要让Buffer准备好再次被写入。可以通过clear()或compact()方法来完成

如果调用的是clear()方法,position将被设回0,limit被设置成capacity的值。换句话说,Buffer被清空了。Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据

如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法

compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。清除已读过的数据

3)mark()与reset()方法

事务操作

通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position

之后可以通过调用Buffer.reset()方法恢复到这个position。例如:

buffer.mark();
//call buffer.get() a couple of times, e.g. during parsing.
buffer.reset(); //set position back to mark.

7. 缓冲区操作

1)缓冲区分片

在NIO中,除了可以分配或者包装一个缓冲区对象外,还可以根据现有的缓冲区对象来创建一个子缓冲区,即在现有缓冲区上切出一片来作为一个新的缓冲区,但现有的缓冲区与创建的子缓冲区在底层数组层面上是数据共享的,也就是说,子缓冲区相当于是现有缓冲区的一个视图窗口。调用slice()方法可以创建一个子缓冲区

@Test
public void testConect3() throws IOException {
    ByteBuffer buffer = ByteBuffer.allocate (10);
    // 缓冲区中的数据 0-9
    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 *= 10;
        slice.put(i, b);//指定索引值,修改内容
    }
    
    //重新指定之前的位置
    buffer.position(0);
    buffer.limit(buffer.capacity());
    
    while (buffer.remaining() > 0) {
        System. out .println(buffer.get());
    }
}

2)只读缓冲区

只读缓冲区非常简单,可以读取它们,但是不能向它们写入数据

可以通过调用缓冲区的asReadOnlyBuffer()方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区,并与原缓冲区共享数据,只不过它是只读的

如果原缓冲区的内容发生了变化,只读缓冲区的内容也随之发生变化:

@Test
public void testConect4() throws IOException {
    ByteBuffer buffer = ByteBuffer. allocate (10);
    // 缓冲区中的数据 0-9
    for (int i = 0; i < buffer.capacity(); ++i) {
        buffer.put((byte) i);
    }
    
    // 创建只读缓冲区
    ByteBuffer readonly = buffer.asReadOnlyBuffer();
    
    // 改变原缓冲区的内容
    for (int i = 0; i < buffer.capacity(); ++i) {
        byte b = buffer.get(i);
        b *= 10;
        buffer.put(i, b);
    }
    
    readonly.position(0);
    readonly.limit(buffer.capacity());
    
    // 读取只读缓冲区的内容也随之改变
    while (readonly.remaining() > 0) {
        System. out .println(readonly.get());
    }
}

如果尝试修改只读缓冲区的内容,则会报ReadOnlyBufferException异常。

只读缓冲区对于保护数据很有用。在将缓冲区传递给某个对象的方法时,无法知道这个方法是否会修改缓冲区中的数据。创建一个只读的缓冲区可以保证该缓冲区不会被修改

只可以把常规缓冲区转换为只读缓冲区,而不能将只读的缓冲区转换为可写的缓冲区

3)直接缓冲区

直接缓冲区是为加快I/O速度,使用一种特殊方式为其分配内存的缓冲区,JDK文档中的描述为:给定一个直接字节缓冲区,Java虚拟机将尽最大努力直接对它执行本机I/O操作

也就是说,它会在每一次调用底层操作系统的本机I/O操作之前(或之后),尝试避免将缓冲区的内容拷贝到一个中间缓冲区中 或者从一个中间缓冲区中拷贝数据。要分配直接缓冲区,需要调用allocateDirect()方法,而不是allocate()方法,使用方式与普通缓冲区并无区别

拷贝文件示例:

@Test
public void testConect5() throws IOException {
    //读取
    String infile = "d:\\achang\\01.txt";
    FileInputStream fin = new FileInputStream(infile);
    FileChannel fcin = fin.getChannel();
    
    //输出
    String outfile = "d:\\achang\\02.txt";
    FileOutputStream fout = new FileOutputStream(outfile);
    FileChannel fcout = fout.getChannel();
    
    // 使用 allocateDirect,而不是 allocate
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    while (true) {
        buffer.clear();
        int r = fcin.read(buffer);
        if (r == -1) {
            break;
        }
        buffer.flip();//转为写模式
        fcout.write(buffer);
    }
}

4)内存映射文件I/O

内存映射文件I/O是一种读和写文件数据的方法,它可以比常规的基于流或者基于通道的I/O快的多

内存映射文件I/O是通过使文件中的数据出现为 内存数组的内容来完成的,这其初听起来似乎不过就是将整个文件读到内存中,但是事实上并不是这样。一般来说,只有文件中实际读取或者写入的部分才会映射到内存中

示例代码:MappedByteBuffer

static private final int start = 0;
static private final int limit = 1024;

static public void main(String args[]) throws Exception {
    RandomAccessFile raf = new RandomAccessFile("d:\\achang\\01.txt","rw");
    FileChannel fc = raf.getChannel();
    
    MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE,start,limit);
    
    mbb.put(0,(byte) 97);
    mbb.put(1023, (byte) 122);
    
    raf.close();
}