Java NIO(NIO socket网络编程)

1,799 阅读12分钟

Java I/O

image.png

  • Java IO核心就是流。流只能是单向的,要么输入要么输出。只能选其一。
  • java.io中最为核心的一个概念是流(stream),面向流的编程。Java中,一个流要么是输入流,要么是输出流,不可能同时既是输入流又是输出流。

java NIO

NIO主要内容

  • java.nio中拥有3个核心概念:SelectorChannelBuffer。在java.nio中,我们是面向块(block)或是缓冲区(buffer)编程的。Buffer本身就是一块内存,底层实现上,它实际上是个数组。数据的读、写都是通过Buffer来实现的。
  • 除了数组之外,Buffer还提供了对于数据的结构化访问方式,并且可以追踪到系统的读写过程。
  • Java中的7种原生数据类型都有各自对应的Buffer类型,如IntBuffer,LongBuffer,ByteBuffer及CharBuffer等等。没有BooleanBuffer。
  • Channel指的是可以向其写入数据或是从中读取数据的对象,它类似于java.io中的stream,所有数据的读写都是通过Buffer来进行的永远不会出现直接向channel写入数据的情况,或是直接从channel读取数据的情况。
  • 与stream不同的是,Channel是双向的,一个流只可能是InputStream或是OutputStream,Channel打开后则可以进行读取、写入或是读写。
  • 由于Channel是双向的,即全双工通信,因此它能更好地反映出底层操作系统的真实情况;在Linux系统中,底层操作系统的通道就是双向的。
  • 在Java NIO(New Input/Output)中,Channel(通道)是一个抽象概念,代表一个与I/O源或目标之间的连接。它可以是文件、套接字、管道等。Channel提供了一种高效的、非阻塞的I/O操作方式,与传统的Java I/O(也称为IO流)相比,它更适用于处理大量的并发连接。
    • 可以把Channel类比成网线,然后程序可以从网线里面拿数据,也可以往网线里面传数据。

代码样例

  • 输入
public class NIOInTest {
    public static void main(String[] args) throws Exception {
        File file = new File("C:/Users/25852/Desktop/HTTP请求.jmx");
        FileInputStream inputStream = new FileInputStream(file);
        FileChannel fileChannel = inputStream.getChannel();// 流变channel
        ByteBuffer byteBuffer = ByteBuffer.allocate((int)file.length());
        fileChannel.read(byteBuffer);//从channel中读数据给buffer
        byteBuffer.flip();//翻转 从写变成读模式
        while(byteBuffer.hasRemaining()){//buffer 中是否还有元素
             byte b = byteBuffer.get();
             System.out.print((char) b);
        }
        inputStream.close();
    }
}
  • 输出
public class NIOOutTest {
    public static void main(String[] args) throws Exception {
        File file = new File("test.txt");
        FileOutputStream outputStream = new FileOutputStream(file);
        FileChannel fileChannel = outputStream.getChannel();// 流变 channel
        byte[] message = "hello world ,It's me".getBytes();
        ByteBuffer byteBuffer = ByteBuffer.allocate(message.length);
        byteBuffer.put(message);// 数据都要装入buffer
        byteBuffer.flip();  //Buffer 默认是读模式,需要反转
        fileChannel.write(byteBuffer);// 将buffer写入channel
        System.out.println("输出成功");
        outputStream.close();
    }
}
  • 可以发现不管输入还是输出都用的是FileChannel,不像流还要指定In还是Out,我Channel都可以。所以说是双向的。
  • 读要将数据从channel读入buffer,再将数据从buffer中读出来。
  • 写要将数据写入buffer,再将buffer中的数据写入channel。

关于NIO Buffer 三个属性: position、limit、capacity。

直接读Buffer的JDK源码就可知其含义

  • 英文版

image.png

  • 译文

  • 特定原始类型数据的容器。除了Boolean
  • 缓冲区是特定原始类型元素的线性有限序列。 除了内容之外,缓冲区的基本属性是它的capacity(容量), limit(限制), and position(位置)。
    • 缓冲区的capacity是它包含的元素数。 缓冲区的capacity永远不会为负且永远不会改变
    • 缓冲区的limit不应读取或写入的第一个元素的索引。 缓冲区的limit永远不会为负,也永远不会大于其capacity。
    • 缓冲区的position是要读取或写入的下一个元素的索引。 缓冲区的位置永远不会为负,也永远不会大于其limit。
  • 每个非布尔基本类型都有这个类的一个子类。
  • 传输数据
    • 该类的每个子类都定义了两类get和put操作:
      • 相对操作从当前位置开始读取或写入一个或多个元素,然后将position增加传输的元素数量。 如果请求的传输超过限制,则相对获取操作会抛出BufferUnderflowException而相对放置操作会抛出BufferOverflowException ; 在任何一种情况下,都不会传输数据。
      • 绝对操作采用显式元素索引并且不影响position。 如果 index 参数超出限制,则绝对get和put操作会抛出IndexOutOfBoundsException 。
    • 当然,数据也可以通过适当channel的 I/O 操作传入或传出缓冲区,这些操作总是相对于当前position。
  • 标记和重置
    • 缓冲区的mark是在调用reset方法时将其position重置到的索引。 标记并不总是被定义,但当它被定义时,它永远不会是负数,也永远不会大于position。 如果定义了标记,则在将位置或限制调整为小于标记的值时将丢弃该标记。 如果未定义标记,则调用reset方法会导致抛出InvalidMarkException 。

    mark 方法要和reset方法要搭配使用。可以使position回到mark的地方。

  • 不变量
    • 以下不变量适用于mark、position、limit和capacity值:
      • 0 <=mark<=position<=limit<=capacity
    • 新创建的缓冲区始终具有零位置和未定义的标记。 初始限制可能为零,也可能是某个其他值,具体取决于缓冲区的类型及其构造方式。 新分配的缓冲区的每个元素都初始化为零。
  • 清除、翻转和倒带
    • 除了用于访问position、limit和capacity值以及用于标记和重置的方法之外,该类还定义了以下对缓冲区的操作:
      • clear 使缓冲区为新的通道读取或相对放置操作序列做好准备:它将limit置为capacity和position设置为零。
      • flip 使缓冲区为新的通道写入或相对获取操作序列做好准备:它将limit设置为当前position,然后将position设置为零。
      • rewind 使缓冲区准备好重新读取它已经包含的数据:它保持limit不变并将position设置为零。
  • 只读缓冲区
    • 每个缓冲区都是可读的,但并非每个缓冲区都是可写的。 每个缓冲区类的变异方法被指定为可选操作,当在只读缓冲区上调用时将抛出ReadOnlyBufferException 。 只读缓冲区不允许更改其内容,但其标记、位置和限制值是可变的。 缓冲区是否为只读可以通过调用它的isReadOnly方法来确定
  • 线程安全
    • 多个并发线程使用缓冲区是不安全的。 如果一个缓冲区被多个线程使用,那么对缓冲区的访问应该有适当的同步控制。
  • 链式调用
    • 此类中没有返回值的方法被指定为返回调用它们的缓冲区。 这允许链接方法调用; 例如,语句序列
      b.flip();
      b.position(23);
      b.limit(42);
      
    • 可以用单一的、更紧凑的语句代替
      b.flip().position(23).limit(42);
      

调用clear并没有清除数据,只是写的时候会覆盖原来的数据,让三变量回到刚创建buffer时的状态。

  • 复制文件的代码
public class NIOCopyTest {
    public static void main(String[] args) throws Exception{
        File file = new File("test.txt");
        FileInputStream fileInputStream = new FileInputStream(file);//输流
        FileOutputStream fileOutputStream = new FileOutputStream("out.txt");//目的地

        FileChannel channel = fileInputStream.getChannel();//获取输入channel

        ByteBuffer buffer = ByteBuffer.allocate(10);
        while (channel.read(buffer)!=-1){//会继续读输入channel中的内容
            //channel.read(buffer);
            buffer.flip();
            channel = fileOutputStream.getChannel();//输出channel
            System.out.println("before write position :"+buffer.position());
            for (int i = 0; i < buffer.limit(); i++) {//输出一下buffer的内容
                System.out.print((char) buffer.array()[i]);
            }
            System.out.println();
            channel.write(buffer);
            System.out.println("after write position :"+buffer.position());//write 之后position也会变化
            buffer.clear();
            channel = fileInputStream.getChannel();//输入channel
        }
        fileInputStream.close();
        fileOutputStream.close();
        channel.close();
//        channel.read(buffer);
//        buffer.flip();
//        channel = fileOutputStream.getChannel();
//        channel.write(buffer);
    }
}

绝对方法与相对方法的含义:

  • 相对方法:limit值与position值会在操作时被考虑到。
    • 就是会操作limit和position,比如clear(),flip()
  • 绝对方法:完全忽略掉limit值与position值。
    • 不改变limit和position,比如get()、put(),由jdk底层实现,考虑是IOUtil,没有开源

其它的方法

  • slice()分割的buffer和原buffer共享的。
  • buffer还有只读模式。我们可以随时将一个普通Buffer调用asReadOnlyBuffer方法返回一个只读Buffer,但不能将一个只读Buffer转换为读写Buffer,只读buffer的put方法直接抛异常。

DirectBuffer 和 HeapBuffer

  • DirectBuffer通过Unsafe类中的方法,创建内存区域,native方法,c语言的malloc(int c)。
  • Buffer中的long address;存直接内存地址。堆中引用指向堆外内存。

HeapBuffer为什么要从Heap拷贝一份buffer到直接内存,操作系统直接访问不行吗?

  • IO都要和硬件关联,操作系统可以直接访问堆中的内容,但是,IO较慢,在堆中buffer会出现变故,比如,GC,有一个标记-整理算法,会压缩空间,就有可能导致buffer内存位置改变,就导致IO设备错乱,导致数据不准确。
  • 让buffer对象固定不动,也不现实,不GC肯定也不行。
  • 还是把堆中的buffer拷贝一份到直接内,稳妥,内存地址不容易变,在内存中拷贝也不慢(相对IO操作来说简直小巫见大巫),而且也不会被JVM GC。

    但是JVM 在拷贝buffer时,JVM已经支持不GC了,稳的很。

  • long address会随着DirectBuffer对象的销毁而销毁,操作系统发现没有引用了就会回收直接内存中的buffer,即不会发生内存泄漏。稳的很。
    unsafe.freeMemory(address);
    address = 0;
    

要点

  • Java的Buffer有两种类型:Heap Buffer和Direct Buffer。
  • Heap Buffer是在JVM堆上分配的字节数组,Direct Buffer是在JVM外部分配的直接内存。如果将Heap Buffer中的数据写入磁盘或者网卡,JVM会先在JVM外部申请一个内存,将要写入的数据拷贝到JVM外部申请的内存中,然后再写入到磁盘或者网卡中。这样就会有一次额外的内存拷贝,会降低性能
  • 而Direct Buffer可以避免这种内存拷贝,因为它本身就是在JVM外部分配的直接内存。
  • Heap Buffer和Direct Buffer的优缺点: | 类型 | 优点 | 缺点 | 适用场景 | | --- | --- | --- | --- | | Heap Buffer | 分配和回收速度快,方便操作,受JVM管理 | 进行I/O操作时需要额外的数据拷贝,影响性能 | 主要用于存储和处理数据,不涉及I/O操作 | | Direct Buffer | 进行I/O操作时无需额外的数据拷贝,提高效率,避免内存碎片 | 分配和回收速度慢,不方便操作,不受JVM管理 | 主要用于网络或文件的I/O操作,数据量大或频繁 |
    • 如果你的缓冲区既要用于存储和处理数据,又要用于网络或文件的I/O操作,那么你可以根据实际情况进行权衡,比如可以使用Heap Buffer来存储和处理数据,然后在需要进行I/O操作时,使用wrap或者duplicate方法来创建一个对应的Direct Buffer,这样可以减少Direct Buffer的创建和销毁开销,也可以减少数据拷贝开销。
  • Direct Buffer的缺点有以下几点:
    1. Direct Buffer的创建和销毁比Heap Buffer要耗费更多的资源,因为它需要调用操作系统的malloc和free函数,而不是由JVM管理。
    2. Direct Buffer不受JVM的垃圾回收机制的管辖,它需要依靠一个Cleaner对象来释放内存,这个过程可能会延迟或者失败。
    3. Direct Buffer的大小受到操作系统的限制,如果申请的内存超过了操作系统允许的最大值,就会抛出OutOfMemoryError异常。

其他一些样例

  • 修改文件内容,文件映射,将文件映射到内存,改了内存中的也就改了硬盘中的
public class FileMapTest {//修改文件特定位置的内容
   public static void main(String[] args) throws Exception{
       File file = new File("test.txt");
       RandomAccessFile randomAccessFile = new RandomAccessFile(file,"rw");//有文件指针
       FileChannel fileChannel = randomAccessFile.getChannel();

       FileChannel.MapMode mode;
       MappedByteBuffer mappedByteBuffer = fileChannel.map(MapMode.READ_WRITE, 0, file.length());
       mappedByteBuffer.put(0,"H".getBytes()[0]);//修该某文件对应索引的位置。
       mappedByteBuffer.put(2,"H".getBytes()[0]);
       mappedByteBuffer.put(3,"H".getBytes()[0]);
       mappedByteBuffer.put(4,"H".getBytes()[0]);
       System.out.println("修改成功");
       fileChannel.close();
   }
}
  • 文件锁,可以锁住文件的部分内容,还可以设置是否为共享锁
public class FileLockTest {
    public static void main(String[] args) throws Exception{
        RandomAccessFile randomAccessFile = new RandomAccessFile("test.txt", "rw");
        FileChannel fileChannel = randomAccessFile.getChannel();
        FileLock fileLock = fileChannel.lock(2,9,true);//锁住索引2 到 9的内容,且是共享锁

        System.out.println("valid :" +fileLock.isValid());
        System.out.println("isShared :"+fileLock.isShared());
        fileLock.release();//释放锁
        fileChannel.close();

    }
}
  • 内存映射和普通的文件读写操作有什么不同?
    • 回答:内存映射的方式和普通的文件读写操作的区别在于,内存映射可以将部分或全部文件映射到进程的虚拟地址空间中,使得进程可以直接通过访问虚拟地址来访问文件内容,而不是通过系统调用进行文件读写操作。这种方式可以提高文件读写的效率,并且减少了应用程序和操作系统之间的数据传输次数,从而提高了系统的整体性能。此外,内存映射还可以实现多个进程之间的共享文件,从而方便协同工作和数据共享。
  • 那它还需要将硬盘中的文件加载进内存吗?
    • 回答:是的,内存映射仍然需要将硬盘中的文件加载进内存。在内存映射的过程中,进程首先需要打开文件,并且通过系统调用将文件内容加入到操作系统的内核缓存中。然后,操作系统会将文件内容加载到进程的虚拟地址空间中,使得进程可以直接访问文件内容。因此,内存映射仍然需要将文件加载到内存中来进行访问,但是与普通的文件读写操作相比,内存映射可以通过减少数据传输次数来提高文件读写的效率。
  • 普通的文件读写进程每次访问都会产生系统调用,而内存映射不需要频繁的系统调用。

关于Buffer的Scattering(散开)与Gathering(聚集)

Scattering

  • 读的时候,将一个channel中的内容放置到不同buffer中,要将每个buffer填满,再填下一个buffer。是按顺序的。一个channel对应多个buffer。

Gathering

  • 写的时候,按buffer的循序,填入channel。多个buffer对应一个channel。

一般有buffer数组

使用场景

  • 在定义网络协议时,就可以将协议的不同组成Scattering成不同的buffer:
    • 因为协议的各部分内容大小是固定的。
    • 将协议名称放一个buffer,请求头放一个buffer,请求体放一个buffer。
    • 解析时就很方便。

样例

  • 样例
  • win10 可以用Telnet当客户端, Telnet 127.0.0.1 8899
public class BufferArrayTest {//win10 可以用Telnet Telnet 127.0.0.1 8899
    public static void main(String[] args) throws Exception{
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.socket().bind(new InetSocketAddress(8899));
        int messageLength = 2 + 3 + 4;
        ByteBuffer[] byteBuffers = new ByteBuffer[3];

        byteBuffers[0] = ByteBuffer.allocate(2);
        byteBuffers[1] = ByteBuffer.allocate(3);
        byteBuffers[2] = ByteBuffer.allocate(4);

        SocketChannel socketChannel = serverChannel.accept();//阻塞

        while (true){
            int bytesRead = 0;
            while (bytesRead < messageLength){//接收
                long read = socketChannel.read(byteBuffers);//Scattering
                bytesRead +=read;
                Arrays.asList(byteBuffers).forEach(System.out::println);
            }
            Arrays.asList(byteBuffers).forEach(Buffer::flip);
            long bytesWritten = 0;
            while (bytesWritten < messageLength){//回写
                long write = socketChannel.write(byteBuffers);//Gathering
                bytesWritten +=write;
            }
            Arrays.asList(byteBuffers).forEach(Buffer::clear);
            System.out.println("bytesRead "+bytesRead+",bytesWritten "+bytesWritten+" ,messageLength "+messageLength);
        }
    }
}

Socket网络编程

socket绑定的端口号并不是正在双方交互的端口号

  • 而是选一个空闲的端口号,进行交互。代码中写的端口号只有标记作用。

每连接一个socket就会创建线程。

  • 如果连接太多,线程就爆满。

NIO网络编程

聊天小程序

  • server告诉所有client哪个client发送的消息。
  • server
public class NioServer {
    private static Map<String, SocketChannel> clientMap = new HashMap<>();//记录客户端信息,方便内容分发

    public static void main(String[] args) throws Exception {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.bind(new InetSocketAddress(8899));
        Selector selector = Selector.open();
        /*
        当accept触发时,就可以触发对应的事件逻辑,
        是将channel绑定到selector ,注册会有 SelectionKey生成
         */
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);//一般以连接事件为起源
        while (true){
            selector.select();//阻塞,等待事件发生
            Set<SelectionKey>  selectionKeys = selector.selectedKeys();//返回已发生的注册事件
            selectionKeys.forEach(key ->{//判断事件类型,进行相应操作
                final SocketChannel client;
                try {
                    if (key.isAcceptable()){//根据key获得channel
                        //之所以转换ServerSocketChannel,因为前面注册的就是这个类
                        ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
                        client = serverChannel.accept();//新的channel 和客户端建立了通道
                        client.configureBlocking(false);//非阻塞
                        client.register(selector,SelectionKey.OP_READ);//将新的channel和selector,绑定
                        String clientKey = "【"+ UUID.randomUUID() +"】";//用UUID,标识客户端client
                        clientMap.put(clientKey,client);
                        //完成客户端注册
                    }else if (key.isReadable()){//是否有数据可读
                        client = (SocketChannel) key.channel();
                        ByteBuffer readBuffer =  ByteBuffer.allocate(1024);
                        int count = client.read(readBuffer);
                        if (count>0){
                            readBuffer.flip();
                            Charset charset = StandardCharsets.UTF_8;
                            String receiveMassage = String.valueOf(charset.decode(readBuffer).array());
                            System.out.println(client +": "+receiveMassage);//显示哪个client发消息
                            String senderKey = null;
                            for (Map.Entry<String, SocketChannel> entry : clientMap.entrySet()){
                                if (client == entry.getValue()){
                                    senderKey = entry.getKey();//确定哪个client发送的消息
                                    break;
                                }
                            }
                            for (Map.Entry<String, SocketChannel> entry : clientMap.entrySet()){
                                SocketChannel channel = entry.getValue();
                                ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
                                writeBuffer.put((senderKey + ": " + receiveMassage).getBytes());//告诉所有client ,谁发了消息,发了什么
                                writeBuffer.flip();
                                channel.write(writeBuffer);
                            }
                        }
                    }
                    //selectionKeys.clear();//处理完事件一定要移除
                }catch (Exception e){
                    e.printStackTrace();
                }finally {
                    selectionKeys.clear();//处理完事件一定要移除
                }
            });
        }
    }
}
  • client
public class NioClient {
    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        Selector selector = Selector.open();
        socketChannel.register(selector, SelectionKey.OP_CONNECT);
        socketChannel.connect(new InetSocketAddress("127.0.0.1",8899));
        while (true){
            selector.select();//阻塞 等待事件发生
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            selectionKeys.forEach(key ->{
                try {
                    if (key.isConnectable()){
                        SocketChannel channel = (SocketChannel) key.channel();
                        if (channel.isConnectionPending()){//是否正在连接
                            channel.finishConnect(); //结束正在连接
                            ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
                            writeBuffer.put((LocalDateTime.now() + " 连接成功").getBytes());
                            writeBuffer.flip();
                            channel.write(writeBuffer);//将buffer写入channel
                            ExecutorService service = Executors.newSingleThreadExecutor(Executors.defaultThreadFactory());
                            service.submit(()->{//线程,从键盘读入数据
                               try {
                                   while (true){
                                       writeBuffer.clear();//清空buffer
                                       InputStreamReader input = new InputStreamReader(System.in);
                                       BufferedReader bufferedReader = new BufferedReader(input);
                                       String senderMessage = bufferedReader.readLine();
                                       writeBuffer.put(senderMessage.getBytes());
                                       writeBuffer.flip();
                                       channel.write(writeBuffer);
                                   }
                               }catch (Exception e){
                                   e.printStackTrace();
                               }
                            });
                        }
                        channel.register(selector,SelectionKey.OP_READ);//注册事件
                    }else if (key.isReadable()){//channel 有信息的输入
                        SocketChannel channel = (SocketChannel) key.channel();//哪个channel 触发了 read
                        ByteBuffer readBuffer = ByteBuffer.allocate(1024);
                        int count = channel.read(readBuffer);//server发来的
                        if (count > 0){
                            String receiveMessage = new String(readBuffer.array(),0,count);
                            System.out.println(receiveMessage);
                        }
                    }
                }catch (Exception e){
                    e.printStackTrace();
                }finally {
                    selectionKeys.clear();//移除已经发生的事件
                }
            });
        }
    }
}

NIO 编程要点

  • 服务端先open ServerChannel.设置非阻塞,channel.socket获取socket。
  • 绑定端口号
  • Selecto.open
  • channel注册进selector
  • 之后的channel从 SelectionKey.channel()获取,获取的是触发事件的channel。
  • 最后要移除处理过的SelectionKey。
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(new InetSocketAddress(8899));
Selector selector = Selector.open();
/*
当accept触发时,就可以触发对应的事件逻辑,
是将channel绑定到selector ,注册会有 SelectionKey生成
 */
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);//一般以连接事件为起源

Set<SelectionKey>  selectionKeys = selector.selectedKeys();//返回已发生的注册事件

ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();

selectionKeys.clear();//处理完事件一定要移除

关于socket和TCP/IP

我们平时说的最多的socket是什么呢,实际上socket是对TCP/IP协议的封装,Socket本身并不是协议,而是一个调用接口。

(API),通过Socket,我们才能使用TCP/IP协议。 实际上,Socket跟TCP/IP协议没有必然的联系。Socket编程接。

在设计的时候,就希望也能适应其他的网络协议。所以说,Socket的出现只是使得程序员更方便地使用TCP/IP协议栈而已,是对TCP/IP协议的抽象,从而形成了我们知道的一些最基本的函数接口,比如create、listen、connect、accept、send、read和write等等。网络有一段关于socket和TCP/IP协议关系的说法比较容易理解:

“TCP/IP只是一个协议栈,就像操作系统的运行机制一样,必须要具体实现,同时还要提供对外的操作接口。这个就像操作系统会提供标准的编程接口,比如win32编程接口一样,TCP/IP也要提供可供程序员做网络开发所用的接口,这就是Socket编程接口。”