ホームページ  >  記事  >  Java  >  Java NIO コアコンポーネントの深い理解

Java NIO コアコンポーネントの深い理解

不言
不言転載
2018-11-16 17:42:222538ブラウズ

この記事では、Java NIO のコア コンポーネントについて詳しく説明します。必要な方は参考にしていただければ幸いです。

同期、非同期、ブロッキング、ノンブロッキング

まず第一に、これらの概念は非常に混同しやすいですが、NIO にも関係しています。それでは[1]を要約しましょう。

  • 同期: API 呼び出しが返されると、呼び出し元は操作の結果 (実際に読み書きされたバイト数) を知ることができます。

  • 非同期: 同期と比較すると、API 呼び出しが返されたときに呼び出し元は操作の結果を知りません。結果は後でコールバックによって通知されます。

  • ブロッキング: データを読み取ることができない場合、またはすべてのデータを書き込むことができない場合、現在のスレッドは一時停止されて待機します。

  • ノンブロッキング: 読み取り時には、できるだけ多くのデータを読み取ってから戻ります。書き込み時には、できるだけ多くのデータを書き込んでから戻ります。

I/O 操作の場合、Oracle の公式 Web サイトのドキュメントによると、同期と非同期の分類基準は、「呼び出し元が I/O 操作が完了するまで待つ必要があるかどうか」です。この「I/O 操作を待つ」「完了」とは、データを読み取る必要があることや、すべてのデータが書き込まれることを意味するのではなく、データが読み込まれている期間など、実際の I/O 操作を指します。 TCP/IP プロトコル スタック バッファと JVM バッファの間で転送される時間 (呼び出し側が待機するかどうか)。

したがって、一般的に使用される read() メソッドと write() メソッドは同期 I/O であり、同期 I/O はブロッキング モードと非ブロッキング モードに分かれています。非ブロッキング モードの場合、データは存在しません。読み取り可能な場合は、実際には I/O 操作を実行せずに直接返されます。

要約すると、Java には実際には同期ブロッキング I/O、同期非ブロッキング I/O、および非同期 I/O の 3 つのメカニズムしかありません。以下で説明するのは最初の 2 つです。 JDK 1.7 で導入された非同期 I/O は NIO.2 と呼ばれます。

従来の IO

新しいテクノロジーの出現には常に改善と改良が伴うことを私たちは知っています。これは Java NIO の出現にも当てはまります。

従来の I/O は I/O をブロックしており、主な問題はシステム リソースの無駄です。たとえば、TCP 接続のデータを読み取るために、InputStream の read() メソッドを呼び出します。これにより、データが到着するまで現在のスレッドが一時停止され、データが到着するまでの間、スレッドがメモリを占有します。リソース (ストレージ スレッド スタック) は何もしません。これは、よく言われるように、ピットを占有し、他の接続のデータを読み取るためには、別のスレッドを開始する必要があります。同時接続数が少ない場合には問題にならない場合もありますが、接続数がある程度の規模に達すると、多数のスレッドによってメモリ リソースが消費されます。一方、スレッドの切り替えでは、プログラム カウンタやレジスタの値などのプロセッサのステータスを変更する必要があるため、多数のスレッドを頻繁に切り替えることもリソースの無駄になります。

テクノロジーの発展に伴い、最新のオペレーティング システムは、このリソースの無駄を回避するための新しい I/O メカニズムを提供します。これをもとにJava NIOが誕生しました。NIOの代表的な機能はノンブロッキングI/Oです。その後、ノンブロッキング I/O を使用するだけでは問題は解決しないことがわかりました。ノンブロッキング モードでは、データが読み取られないと read() メソッドがすぐに返されるため、いつデータが到着するかがわかりません。再試行するには read() メソッドを呼び出し続けることしかできませんが、これは明らかに CPU リソースの無駄です。以下に示すように、Selector コンポーネントはこの問題を解決するために生まれました。

Java NIO コア コンポーネント

1.Channel

コンセプト

Java NIO のすべての I/O 操作は、ストリーム操作と同様に、Channel オブジェクトに基づいています。これらはすべて同じ Stream オブジェクトに基づいているため、最初に Channel が何であるかを理解する必要があります。次の内容は、JDK 1.8 のドキュメントからの抜粋です。

チャネルは、
ハードウェア デバイス、ファイル、ネットワーク ソケット、またはプログラム コンポーネントなどのエンティティへのオープン接続を表します。

例: 読み取りまたは書き込みなど、1 つ以上の個別の I/O 操作を実行できます。

上記の内容からわかるように、チャネルは特定のエンティティへの接続を表します。ファイル、ネットワークソケット Word など。言い換えれば、チャネルは、プログラムがオペレーティング システムの基礎となる I/O サービスと対話するために Java NIO によって提供されるブリッジです。

チャネルは非常に基本的で抽象的な説明であり、さまざまな I/O サービスと対話し、さまざまな I/O 操作を実行し、さまざまな実装を行うため、具体的なものには FileChannel、SocketChannel などが含まれます。

チャネルはストリームに似ており、バッファにデータを読み取ったり、バッファ内のデータをチャネルに書き込んだりできます。

Java NIO コアコンポーネントの深い理解

もちろん、違いはありますが、主に次の 2 つの点に反映されます。

  • チャネル読み取りと書き込みが可能で、ストリームは一方向です (つまり、InputStream と OutputStream に分割されます)

  • チャネルにはノンブロッキング I/O モードがあります

実装

Java NIO で最も一般的に使用されるチャネル実装は、従来の I/O 操作クラスに 1 対 1 で対応していることがわかります。 。

  • FileChannel: ファイルの読み取りと書き込み

  • DatagramChannel: UDP プロトコル ネットワーク通信

  • SocketChannel: TCP プロトコル ネットワーク通信

  • ServerSocketChannel: TCP 接続の監視

2.Buffer

NIO で使用されるバッファは単純なバイト配列ではなく、カプセル化された Buffer クラスであり、以下で詳しく説明するように、NIO が提供する API を通じてデータを柔軟に操作できます。

Java の基本型に対応して、NIO は ByteBuffer、CharBuffer、IntBuffer などのさまざまな Buffer 型を提供します。違いは、バッファーを読み書きするときの単位長が異なることです (バッファーの単位)。対応する型の変数 (読み取りおよび書き込み)。

Buffer には 3 つの非常に重要な変数があります。これらは、

  • #capacity (総容量)

    # です。

  • ##position (ポインタの現在位置)
  • limit (読み取り/書き込み境界位置)
  • バッファは動作しますC 言語の文字配列と同様に、capacity は配列の全長、position は文字の読み取り/書き込みのための添字変数、limit は終了文字の位置です。バッファ内の 3 つの変数の初期状態は次のとおりです。

Java NIO コアコンポーネントの深い理解バッファの読み取り/書き込みの過程で、位置が後ろに移動します。 、および限界 位置移動の境界です。 Buffer への書き込み時には容量の大きさに制限を設定し、Buffer の読み取り時には実際のデータの終了位置に制限を設定する必要があることは想像に難くありません。 (注: バッファ データをチャネルに書き込むことはバッファ読み取り操作であり、データをチャネルからバッファに読み取ることはバッファ書き込み操作です)

バッファを読み書きする前に、次のように Buffer クラスを呼び出すことができます。位置と制限の値を正しく設定するための補助メソッドがいくつかあります。主に次の

    flip() が含まれます。位置の値に制限を設定し、位置を設定します。 0にします。バッファを読み取る前に呼び出されます。
  • rewind():position
  • を 0 に設定するだけです。これは通常、バッファ データを再読み取る前に呼び出されます。たとえば、同じバッファからデータを読み取って複数のチャネルに書き込む場合に使用されます。


  • clear(): 初期状態に戻ります。つまり、制限が容量に等しく、位置が 0 に設定されます。バッファに再度書き込む前に呼び出されます。
  • compact(): 未読のデータ (position とlimit の間のデータ) をバッファーの先頭に移動し、position
  • をこのデータの次の位置の末尾に設定します。実際、これはそのようなデータを再度バッファに書き込むことと同じです。


  • 次に、FileChannel を使用してテキスト ファイルの読み取りと書き込みを行う例を見て、チャネルの読み取り可能および書き込み可能特性と Buffer の基本的な使用法を確認します。 FileChannel を非ブロッキング モードに設定することはできません)。
FileChannel channel = new RandomAccessFile("test.txt", "rw").getChannel();
channel.position(channel.size());  // 移动文件指针到末尾(追加写入)

ByteBuffer byteBuffer = ByteBuffer.allocate(20);

// 数据写入Buffer
byteBuffer.put("你好,世界!\n".getBytes(StandardCharsets.UTF_8));

// Buffer -> Channel
byteBuffer.flip();
while (byteBuffer.hasRemaining()) {
    channel.write(byteBuffer);
}

channel.position(0); // 移动文件指针到开头(从头读取)
CharBuffer charBuffer = CharBuffer.allocate(10);
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();

// 读出所有数据
byteBuffer.clear();
while (channel.read(byteBuffer) != -1 || byteBuffer.position() > 0) {
    byteBuffer.flip();

    // 使用UTF-8解码器解码
    charBuffer.clear();
    decoder.decode(byteBuffer, charBuffer, false);
    System.out.print(charBuffer.flip().toString());

    byteBuffer.compact(); // 数据可能有剩余
}

channel.close();

この例では 2 つのバッファを使用します。そのうちの byteBuffer はチャネルの読み取りおよび書き込み用のデータ バッファとして使用され、charBuffer はデコードされた文字の格納に使用されます。 clear() と flip() の使用方法は前述したとおりです。注意する必要があるのは、charBuffer のサイズが byteBuffer のデコードされたデータに完全に対応するのに十分である場合でも、この Compact() メソッドです。これは必須です。一般的に使用される漢字の UTF-8 エンコードは 3 バイトを占めるため、途中で切り捨てられる可能性が高くなります。

## を参照してください。 Java NIO コアコンポーネントの深い理解#デコーダがバッファの最後に 0xe4 を読み取る場合、それを Unicode にマッピングすることはできません。decode() メソッドの 3 番目のパラメータ false の機能は、デコーダにマッピングされていないバイトを処理させることです。追加のデータの場合、decode() メソッドはここで停止し、位置は 0xe4 の位置に戻ります。その結果、「中」文字エンコーディングの最初のバイトがバッファーに残り、正しいデータと後続のデータを結合するには、このバイトを前方に圧縮する必要があります。

ところで、この例の CharsetDecoder も Java NIO の新機能であるため、NIO 操作はバッファー指向であることがわかったはずです (従来の I/O はストリーム指向です)。

ここまでで、Channel と Buffer の基本的な使い方を理解しました。次に説明するのは、1 つのスレッドで複数のチャネルを管理できるようにするという重要なコンポーネントです。

3.Selector

Selector とは

Selector (セレクター) は、各チャネルのステータス (イベント) を収集するために使用される特別なコンポーネントです。まずチャネルをセレクターに登録し、必要なイベントを設定します。その後、select() メソッドを呼び出してイベントが発生するのを静かに待ちます。

チャネルには、監視する次の 4 つのイベントがあります:

Accept: 受け入れ可能な接続があります
  • Connect : 接続成功
  • Read: 読み取るデータがあります
  • Write: データを書き込むことができます

为什么要用Selector

前文说了,如果用阻塞I/O,需要多线程(浪费内存),如果用非阻塞I/O,需要不断重试(耗费CPU)。Selector的出现解决了这尴尬的问题,非阻塞模式下,通过Selector,我们的线程只为已就绪的通道工作,不用盲目的重试了。比如,当所有通道都没有数据到达时,也就没有Read事件发生,我们的线程会在select()方法处被挂起,从而让出了CPU资源。

使用方法

如下所示,创建一个Selector,并注册一个Channel。

注意:要将 Channel 注册到 Selector,首先需要将 Channel 设置为非阻塞模式,否则会抛异常。

Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

register()方法的第二个参数名叫“interest set”,也就是你所关心的事件集合。如果你关心多个事件,用一个“按位或运算符”分隔,比如

SelectionKey.OP_READ | SelectionKey.OP_WRITE复制代码

这种写法一点都不陌生,支持位运算的编程语言里都这么玩,用一个整型变量可以标识多种状态,它是怎么做到的呢,其实很简单,举个例子,首先预定义一些常量,它们的值(二进制)如下

Java NIO コアコンポーネントの深い理解

可以发现,它们值为1的位都是错开的,因此对它们进行按位或运算之后得出的值就没有二义性,可以反推出是由哪些变量运算而来。怎么判断呢,没错,就是“按位与”运算。比如,现在有一个状态集合变量值为 0011,我们只需要判断 “0011 & OP_READ” 的值是 1 还是 0 就能确定集合是否包含 OP_READ 状态。

然后,注意 register() 方法返回了一个SelectionKey的对象,这个对象包含了本次注册的信息,我们也可以通过它修改注册信息。从下面完整的例子中可以看到,select()之后,我们也是通过获取一个 SelectionKey 的集合来获取到那些状态就绪了的通道。

一个完整实例

概念和理论的东西阐述完了(其实写到这里,我发现没写出多少东西,好尴尬(⊙ˍ⊙)),看一个完整的例子吧。

这个例子使用Java NIO实现了一个单线程的服务端,功能很简单,监听客户端连接,当连接建立后,读取客户端的消息,并向客户端响应一条消息。

需要注意的是,我用字符 ‘0′(一个值为0的字节) 来标识消息结束。

单线程Server

public class NioServer {

public static void main(String[] args) throws IOException {
    // 创建一个selector
    Selector selector = Selector.open();

    // 初始化TCP连接监听通道
    ServerSocketChannel listenChannel = ServerSocketChannel.open();
    listenChannel.bind(new InetSocketAddress(9999));
    listenChannel.configureBlocking(false);
    // 注册到selector(监听其ACCEPT事件)
    listenChannel.register(selector, SelectionKey.OP_ACCEPT);

    // 创建一个缓冲区
    ByteBuffer buffer = ByteBuffer.allocate(100);

    while (true) {
        selector.select(); //阻塞,直到有监听的事件发生
        Iterator<selectionkey> keyIter = selector.selectedKeys().iterator();

        // 通过迭代器依次访问select出来的Channel事件
        while (keyIter.hasNext()) {
            SelectionKey key = keyIter.next();

            if (key.isAcceptable()) { // 有连接可以接受
                SocketChannel channel = ((ServerSocketChannel) key.channel()).accept();
                channel.configureBlocking(false);
                channel.register(selector, SelectionKey.OP_READ);

                System.out.println("与【" + channel.getRemoteAddress() + "】建立了连接!");

            } else if (key.isReadable()) { // 有数据可以读取
                buffer.clear();

                // 读取到流末尾说明TCP连接已断开,
                // 因此需要关闭通道或者取消监听READ事件
                // 否则会无限循环
                if (((SocketChannel) key.channel()).read(buffer) == -1) {
                    key.channel().close();
                    continue;
                } 

                // 按字节遍历数据
                buffer.flip();
                while (buffer.hasRemaining()) {
                    byte b = buffer.get();

                    if (b == 0) { // 客户端消息末尾的\0
                        System.out.println();

                        // 响应客户端
                        buffer.clear();
                        buffer.put("Hello, Client!\0".getBytes());
                        buffer.flip();
                        while (buffer.hasRemaining()) {
                            ((SocketChannel) key.channel()).write(buffer);
                        }
                    } else {
                        System.out.print((char) b);
                    }
                }
            }

            // 已经处理的事件一定要手动移除
            keyIter.remove();
        }
    }
}
}</selectionkey>

Client

这个客户端纯粹测试用,为了看起来不那么费劲,就用传统的写法了,代码很简短。

要严谨一点测试的话,应该并发运行大量Client,统计服务端的响应时间,而且连接建立后不要立刻发送数据,这样才能发挥出服务端非阻塞I/O的优势。

public class Client {

public static void main(String[] args) throws Exception {
    Socket socket = new Socket("localhost", 9999);
    InputStream is = socket.getInputStream();
    OutputStream os = socket.getOutputStream();

    // 先向服务端发送数据
    os.write("Hello, Server!\0".getBytes());

    // 读取服务端发来的数据
    int b;
    while ((b = is.read()) != 0) {
        System.out.print((char) b);
    }
    System.out.println();

    socket.close();
}
}

NIO vs IO

学习了NIO之后我们都会有这样一个疑问:到底什么时候该用NIO,什么时候该用传统的I/O呢?

其实了解他们的特性后,答案还是比较明确的,NIO擅长1个线程管理多条连接,节约系统资源,但是如果每条连接要传输的数据量很大的话,因为是同步I/O,会导致整体的响应速度很慢;而传统I/O为每一条连接创建一个线程,能充分利用处理器并行处理的能力,但是如果连接数量太多,内存资源会很紧张。

总结就是:连接数多数据量小用NIO,连接数少用I/O(写起来也简单- -)。

Next

经过NIO核心组件的学习,了解了非阻塞服务端实现的基本方法。然而,细心的你们肯定也发现了,上面那个完整的例子,实际上就隐藏了很多问题。比如,例子中只是简单的将读取到的每个字节输出,实际环境中肯定是要读取到完整的消息后才能进行下一步处理,由于NIO的非阻塞特性,一次可能只读取到消息的一部分,这已经很糟糕了,如果同一条连接会连续发来多条消息,那不仅要对消息进行拼接,还需要切割,同理,例子中给客户端响应的时候,用了个while()循环,保证数据全部write完成再做其它工作,实际应用中为了性能,肯定不会这么写。另外,为了充分利用现代处理器多核心并行处理的能力,应该用一个线程组来管理这些连接的事件。

要解决这些问题,需要一个严谨而繁琐的设计,不过幸运的是,我们有开源的框架可用,那就是优雅而强大的Netty,Netty基于Java NIO,提供异步调用接口,开发高性能服务器的一个很好的选择,之前在项目中使用过,但没有深入学习,打算下一步好好学学它,到时候再写一篇笔记。

Java NIO 設計の目標は、最新のオペレーティング システムの最新の I/O メカニズムを活用するための API をプログラマに提供することであるため、この記事に含まれるコンポーネントと機能に加えて、広範囲にわたる内容が含まれます。その他、Pipe )、Path (パス)、Files (ファイル) などです。一部は I/O パフォーマンスを向上させるために使用される新しいコンポーネントであり、一部は I/O 操作を簡素化するためのツールです。具体的な使用方法については、を参照してください。最後の参考文献のリンク。


以上がJava NIO コアコンポーネントの深い理解の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はsegmentfault.comで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。