MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java NIO与传统I/O的对比

2021-09-304.3k 阅读

Java传统I/O简介

在Java早期版本中,提供的I/O操作是基于流(Stream)的。流是一种顺序访问数据的方式,数据像水流一样按顺序从一个地方传输到另一个地方。传统I/O主要分为字节流和字符流。

字节流

字节流用于处理二进制数据,以字节(8位)为单位进行读写操作。主要的类有InputStreamOutputStreamInputStream是所有字节输入流的抽象基类,OutputStream是所有字节输出流的抽象基类。

例如,从文件中读取字节数据:

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

public class ByteStreamReadExample {
    public static void main(String[] args) {
        try (InputStream inputStream = new FileInputStream("example.txt")) {
            int data;
            while ((data = inputStream.read()) != -1) {
                System.out.print((char) data);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,FileInputStreamInputStream的一个具体实现类,用于从文件中读取字节数据。read()方法每次读取一个字节,返回值为读取到的字节数据,如果到达文件末尾则返回 -1。

将数据写入文件的示例:

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;

public class ByteStreamWriteExample {
    public static void main(String[] args) {
        String message = "Hello, World!";
        try (OutputStream outputStream = new FileOutputStream("output.txt")) {
            outputStream.write(message.getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这里FileOutputStreamOutputStream的具体实现类,write(byte[] b)方法将字节数组中的数据写入文件。

字符流

字符流用于处理文本数据,以字符(16位,对应Unicode字符)为单位进行读写操作。主要的类有ReaderWriterReader是所有字符输入流的抽象基类,Writer是所有字符输出流的抽象基类。

从文件中读取字符数据的示例:

import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;

public class CharacterStreamReadExample {
    public static void main(String[] args) {
        try (Reader reader = new FileReader("example.txt")) {
            int data;
            while ((data = reader.read()) != -1) {
                System.out.print((char) data);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

FileReaderReader的具体实现类,用于从文件中读取字符数据。read()方法每次读取一个字符,返回值为读取到的字符数据,如果到达文件末尾则返回 -1。

将字符数据写入文件的示例:

import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;

public class CharacterStreamWriteExample {
    public static void main(String[] args) {
        String message = "你好,世界!";
        try (Writer writer = new FileWriter("output.txt")) {
            writer.write(message);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

FileWriterWriter的具体实现类,write(String str)方法将字符串中的字符数据写入文件。

Java NIO简介

Java NIO(New I/O)是从Java 1.4版本开始引入的一套新的I/O API。NIO与传统I/O最大的区别在于它是基于缓冲区(Buffer)和通道(Channel)进行操作的,并且支持非阻塞I/O。

缓冲区(Buffer)

缓冲区是NIO中用于存储数据的容器。它本质上是一块内存区域,被包装成一个对象,提供了一系列方法来方便地访问和操作这块内存中的数据。常见的缓冲区类型有ByteBufferCharBufferIntBuffer等,分别用于存储字节、字符、整数等数据类型。

ByteBuffer为例,创建一个ByteBuffer并写入数据:

import java.nio.ByteBuffer;

public class ByteBufferExample {
    public static void main(String[] args) {
        // 创建一个容量为1024的ByteBuffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        String message = "Hello, NIO!";
        byte[] bytes = message.getBytes();
        byteBuffer.put(bytes);
        // 切换到读模式
        byteBuffer.flip();
        byte[] result = new byte[byteBuffer.remaining()];
        byteBuffer.get(result);
        System.out.println(new String(result));
    }
}

在上述代码中,首先通过ByteBuffer.allocate(int capacity)方法创建一个指定容量的ByteBuffer。然后使用put(byte[] src)方法将字节数组中的数据放入缓冲区。接着调用flip()方法切换缓冲区到读模式,此时缓冲区的位置(position)被重置为0,限制(limit)被设置为当前位置,这样就可以从缓冲区读取之前写入的数据。最后使用get(byte[] dst)方法将缓冲区中的数据读取到字节数组中。

通道(Channel)

通道是NIO中用于数据传输的对象,它类似于传统I/O中的流,但又有本质的区别。通道可以双向传输数据,而流一般是单向的(输入流或输出流)。通道与缓冲区配合使用,数据的读取和写入都要通过缓冲区。常见的通道类型有FileChannel(用于文件I/O)、SocketChannel(用于TCP套接字I/O)、ServerSocketChannel(用于监听TCP连接)等。

以下是使用FileChannel读取文件的示例:

import java.io.FileInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelReadExample {
    public static void main(String[] args) {
        try (FileInputStream fileInputStream = new FileInputStream("example.txt");
             FileChannel fileChannel = fileInputStream.getChannel()) {
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
            int bytesRead = fileChannel.read(byteBuffer);
            while (bytesRead != -1) {
                byteBuffer.flip();
                byte[] data = new byte[byteBuffer.remaining()];
                byteBuffer.get(data);
                System.out.print(new String(data));
                byteBuffer.clear();
                bytesRead = fileChannel.read(byteBuffer);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,通过FileInputStream获取FileChannel,然后创建ByteBuffer。使用fileChannel.read(ByteBuffer dst)方法将文件中的数据读取到ByteBuffer中。每次读取后,切换ByteBuffer到读模式,读取数据并打印,然后调用clear()方法重置缓冲区以便下一次读取。

使用FileChannel写入文件的示例:

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelWriteExample {
    public static void main(String[] args) {
        String message = "Hello, FileChannel!";
        try (FileOutputStream fileOutputStream = new FileOutputStream("output.txt");
             FileChannel fileChannel = fileOutputStream.getChannel()) {
            ByteBuffer byteBuffer = ByteBuffer.wrap(message.getBytes());
            fileChannel.write(byteBuffer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这里通过FileOutputStream获取FileChannel,然后使用ByteBuffer.wrap(byte[] array)方法将字节数组包装成ByteBuffer,最后使用fileChannel.write(ByteBuffer src)方法将ByteBuffer中的数据写入文件。

阻塞与非阻塞I/O

传统I/O的阻塞特性

传统I/O是阻塞式的。当执行read()write()操作时,线程会被阻塞,直到操作完成。例如,在使用InputStream读取数据时,如果数据还没有准备好,线程会一直等待,直到有数据可读或者到达流的末尾。同样,在使用OutputStream写入数据时,如果目标设备(如网络套接字或文件系统)暂时无法接收数据,线程也会被阻塞。

下面是一个简单的示例,展示传统I/O读取操作时的阻塞情况:

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class TraditionalIOBlockingExample {
    public static void main(String[] args) {
        try (ServerSocket serverSocket = new ServerSocket(8080);
             Socket socket = serverSocket.accept();
             InputStream inputStream = socket.getInputStream()) {
            System.out.println("等待客户端发送数据...");
            int data;
            while ((data = inputStream.read()) != -1) {
                System.out.print((char) data);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,serverSocket.accept()方法会阻塞线程,直到有客户端连接。当客户端连接后,inputStream.read()方法又会阻塞线程,直到客户端发送数据。

NIO的非阻塞特性

NIO支持非阻塞I/O。在非阻塞模式下,调用read()write()方法时,线程不会被阻塞,而是立即返回。如果没有数据可读或者目标设备暂时无法接收数据,方法会返回一个特定的值(如 -1 表示没有数据可读)。

SocketChannel为例,展示非阻塞I/O的使用:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class NIONonBlockingExample {
    public static void main(String[] args) {
        try (SocketChannel socketChannel = SocketChannel.open()) {
            socketChannel.configureBlocking(false);
            socketChannel.connect(new InetSocketAddress("localhost", 8080));
            while (!socketChannel.finishConnect()) {
                // 可以在此处执行其他任务
                System.out.println("正在连接...");
            }
            ByteBuffer byteBuffer = ByteBuffer.wrap("Hello, Server!".getBytes());
            socketChannel.write(byteBuffer);
            byteBuffer.clear();
            int bytesRead = socketChannel.read(byteBuffer);
            while (bytesRead != -1) {
                byteBuffer.flip();
                byte[] data = new byte[byteBuffer.remaining()];
                byteBuffer.get(data);
                System.out.println(new String(data));
                byteBuffer.clear();
                bytesRead = socketChannel.read(byteBuffer);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,首先通过socketChannel.configureBlocking(false)SocketChannel设置为非阻塞模式。connect()方法会立即返回,通过finishConnect()方法判断连接是否完成。在读取和写入操作时,方法也不会阻塞线程,如果没有数据可读,read()方法会返回 -1。

性能对比

传统I/O性能分析

传统I/O在处理简单的I/O操作时,性能表现良好。但在处理大量并发I/O操作时,由于其阻塞特性,会创建大量的线程,每个线程在等待I/O操作完成时会占用系统资源,导致系统资源耗尽,性能急剧下降。例如,在一个高并发的网络服务器中,如果使用传统I/O,每个客户端连接都需要一个独立的线程来处理I/O操作,当客户端数量过多时,线程数量会过多,线程上下文切换开销增大,从而影响整体性能。

NIO性能分析

NIO在处理大量并发I/O操作时具有明显的性能优势。其非阻塞特性使得一个线程可以处理多个通道的I/O操作,通过多路复用器(如Selector)可以高效地管理多个通道,减少线程数量,降低线程上下文切换开销。同时,NIO的缓冲区和通道设计也提高了数据传输的效率。例如,在一个高并发的网络服务器中,使用NIO可以用少量的线程处理大量的客户端连接,大大提高了系统的并发处理能力和性能。

适用场景对比

传统I/O适用场景

传统I/O适用于简单的、单线程的I/O操作场景,例如小型的文件读写、简单的网络通信等。在这些场景中,不需要处理大量的并发连接,传统I/O的阻塞特性不会带来太大的问题,而且其简单易用的特点使得开发成本较低。例如,一个小型的命令行工具,需要从文件中读取配置信息并进行简单处理,使用传统I/O就非常合适。

NIO适用场景

NIO适用于高并发、高性能的I/O操作场景,如网络服务器、大规模数据传输等。在这些场景中,需要处理大量的并发连接,并且对性能要求较高。NIO的非阻塞特性和多路复用机制能够有效地提高系统的并发处理能力和性能。例如,一个大型的即时通讯服务器,需要同时处理大量客户端的连接和消息收发,使用NIO可以更好地满足性能需求。

数据处理方式对比

传统I/O的数据处理方式

传统I/O是基于流的顺序处理方式,数据像水流一样按顺序从一个地方传输到另一个地方。在读取数据时,需要按顺序逐个字节或字符地读取,写入数据时也是按顺序逐个字节或字符地写入。这种方式对于简单的线性数据处理比较方便,但对于复杂的数据结构或需要随机访问数据的场景不太适用。

NIO的数据处理方式

NIO基于缓冲区和通道进行数据处理。缓冲区提供了一种更灵活的数据存储和访问方式,可以随机访问缓冲区中的数据,并且可以对缓冲区中的数据进行各种操作,如分片、复制等。通道则负责在缓冲区和数据源或数据目标之间传输数据。这种方式更适合处理复杂的数据结构和需要随机访问数据的场景。例如,在处理网络协议中的数据包时,NIO的缓冲区可以方便地对数据包进行解析和组装。

内存管理对比

传统I/O的内存管理

在传统I/O中,内存管理相对简单。当使用字节流或字符流进行读写操作时,系统会根据需要分配内存来存储数据。例如,在读取文件时,InputStream会根据每次读取的数据量动态分配内存,写入文件时,OutputStream也会类似地处理。但这种方式可能会导致频繁的内存分配和释放,尤其是在处理大量数据时,可能会影响性能。

NIO的内存管理

NIO的缓冲区在内存管理上更加灵活和高效。可以通过allocate()方法预先分配一块指定大小的内存作为缓冲区,减少频繁的内存分配。此外,NIO还提供了直接缓冲区(Direct Buffer),直接缓冲区是直接分配在堆外内存中的,它可以减少数据从用户空间到内核空间的拷贝次数,提高I/O性能,但直接缓冲区的分配和释放成本较高,所以适用于长期存在且数据量较大的缓冲区。

例如,创建直接ByteBuffer

import java.nio.ByteBuffer;

public class DirectByteBufferExample {
    public static void main(String[] args) {
        // 创建一个容量为1024的直接ByteBuffer
        ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(1024);
    }
}

通过ByteBuffer.allocateDirect(int capacity)方法可以创建直接ByteBuffer。在使用完直接缓冲区后,需要手动调用ByteBuffercleaner()方法来释放内存。

总结

Java传统I/O和NIO在设计理念、工作方式、性能、适用场景等方面都存在显著差异。传统I/O简单易用,适用于简单的I/O场景;而NIO则更适合高并发、高性能的复杂I/O场景。在实际开发中,需要根据具体的需求和场景来选择合适的I/O方式,以达到最佳的性能和开发效率。