Heim  >  Artikel  >  Java  >  Vertiefte Kenntnisse der Java NIO-Kernkomponenten

Vertiefte Kenntnisse der Java NIO-Kernkomponenten

不言
不言nach vorne
2018-11-16 17:42:222538Durchsuche

Dieser Artikel vermittelt Ihnen ein tiefgreifendes Verständnis der Kernkomponenten von Java NIO. Ich hoffe, dass er für Freunde in Not hilfreich ist.

Synchronisation, asynchron, blockierend, nicht blockierend

Zuallererst sind diese Konzepte sehr leicht zu verwechseln, aber sie sind auch an NIO beteiligt, Fassen wir also zusammen [1].

  • Synchronisation: Wenn der API-Aufruf zurückkehrt, kennt der Aufrufer das Ergebnis der Operation (wie viele Bytes tatsächlich gelesen/geschrieben wurden).

  • Asynchron: Im Vergleich zur Synchronisation kennt der Aufrufer das Ergebnis des Vorgangs nicht, wenn der API-Aufruf zurückkehrt, und das Ergebnis wird später per Rückruf benachrichtigt.

  • Blockierung: Wenn keine Daten gelesen werden können oder nicht alle Daten geschrieben werden können, wird der aktuelle Thread angehalten und wartet.

  • Nicht blockierend: Lesen Sie beim Lesen so viele Daten wie möglich und kehren Sie dann zurück. Schreiben Sie beim Schreiben so viele Daten wie möglich und kehren Sie dann zurück.

Für E/A-Vorgänge lautet der Klassifizierungsstandard für synchron und asynchron laut der Dokumentation auf der offiziellen Website von Oracle „ob der Aufrufer warten muss, bis der E/A-Vorgang abgeschlossen ist.“ ". Dieses „Warten auf den E/A-Vorgang“ „Abgeschlossen“ bedeutet nicht, dass die Daten gelesen werden müssen oder dass alle Daten geschrieben werden, sondern bezieht sich auf den tatsächlichen E/A-Vorgang, z. B. den Zeitraum, in dem Daten vorliegen Die zwischen dem TCP/IP-Protokollstapelpuffer und dem JVM-Puffer übertragene Zeit gibt an, ob der Anrufer warten möchte.

Unsere häufig verwendeten Methoden read() und write() sind synchrone E/A und werden in einen blockierenden und einen nicht blockierenden Modus unterteilt Wenn es lesbar ist, wird es direkt zurückgegeben, ohne dass tatsächlich E/A-Vorgänge ausgeführt werden.

Zusammenfassend lässt sich sagen, dass es in Java tatsächlich nur drei Mechanismen gibt: synchrone blockierende E/A, synchrone nicht blockierende E/A und asynchrone E/A. Worüber wir im Folgenden sprechen werden, sind die ersten beiden wurden in JDK 1.7 eingeführt. Asynchrone E/A heißt NIO.2.

Traditionelles IO

Wir wissen, dass das Aufkommen einer neuen Technologie immer mit Verbesserungen und Verbesserungen einhergeht, und das Gleiche gilt für das Aufkommen von Java NIO.

Herkömmliche E/A blockiert E/A und das Hauptproblem ist die Verschwendung von Systemressourcen. Um beispielsweise die Daten einer TCP-Verbindung zu lesen, rufen wir die read()-Methode von InputStream auf. Dadurch wird der aktuelle Thread angehalten, bis Daten eintreffen. Dann belegt der Thread während der Zeit, in der die Daten eintreffen . Die Ressource (Speicher-Thread-Stapel) tut nichts, was heißt, sie belegt die Grube und nimmt keinen Scheiß. Um die Daten anderer Verbindungen zu lesen, müssen wir einen anderen Thread starten. Dies stellt möglicherweise kein Problem dar, wenn die Anzahl gleichzeitiger Verbindungen gering ist. Wenn die Anzahl der Verbindungen jedoch eine bestimmte Größenordnung erreicht, werden Speicherressourcen von einer großen Anzahl von Threads verbraucht. Andererseits erfordert der Thread-Wechsel eine Änderung des Status des Prozessors, z. B. des Programmzählers und der Registerwerte, sodass ein sehr häufiger Wechsel zwischen einer großen Anzahl von Threads ebenfalls eine Verschwendung von Ressourcen darstellt.

Mit der Entwicklung der Technologie bieten moderne Betriebssysteme neue I/O-Mechanismen, um diese Ressourcenverschwendung zu vermeiden. Auf dieser Grundlage wurde Java NIO geboren. Das repräsentative Merkmal von NIO ist die nicht blockierende E/A. Dann haben wir herausgefunden, dass die einfache Verwendung von nicht blockierendem E/A das Problem nicht löst, da die Methode read() sofort zurückkehrt, wenn keine Daten gelesen werden. Wir wissen nicht, wann die Daten bei Ihnen eintreffen Sie können die read()-Methode nur weiterhin aufrufen, um es erneut zu versuchen, was offensichtlich eine Verschwendung von CPU-Ressourcen darstellt. Wie Sie unten sehen können, wurde die Selector-Komponente entwickelt, um dieses Problem zu lösen.

Java NIO-Kernkomponenten

1.Kanal

Konzept

Alle E/A-Vorgänge in Java NIO basieren auf Kanalobjekten, genau wie Stream-Vorgänge Sie basieren alle auf demselben Stream-Objekt, daher ist es notwendig, zunächst zu verstehen, was Channel ist. Der folgende Inhalt stammt aus der Dokumentation von JDK 1.8

Ein Kanal stellt eine offene Verbindung zu einer Entität dar, beispielsweise einem
Hardwaregerät, einer Datei, einem Netzwerk-Socket oder einer Programmkomponente, die
ist in der Lage, eine oder mehrere verschiedene E/A-Operationen auszuführen, zum Beispiel Lesen oder Schreiben.

Wie aus dem obigen Inhalt ersichtlich ist, stellt ein Kanal eine Verbindung zu einer bestimmten Entität dar. Diese Entität kann sei eine Datei, ein Netzwerk-Socket, ein Empfang von Wörtern usw. Mit anderen Worten: Der Kanal ist eine von Java NIO bereitgestellte Brücke, über die unser Programm mit den zugrunde liegenden E/A-Diensten des Betriebssystems interagieren kann.

Channel ist eine sehr einfache und abstrakte Beschreibung, die mit verschiedenen E/A-Diensten interagiert, unterschiedliche E/A-Vorgänge ausführt und unterschiedliche Implementierungen hat. Zu den spezifischen gehören daher FileChannel, SocketChannel usw.

Kanäle ähneln Streams. Sie können Daten in einen Puffer einlesen und Daten in einen Puffer in einen Kanal schreiben.

Vertiefte Kenntnisse der Java NIO-Kernkomponenten

Natürlich gibt es Unterschiede, die sich hauptsächlich in den folgenden zwei Punkten widerspiegeln:

  • Ein Kanal kann entweder Lesen und Schreiben möglich sein, und ein Stream ist unidirektional (also ist er in InputStream und OutputStream unterteilt)

  • Der Kanal verfügt über einen nicht blockierenden I/O-Modus

Implementierung

Die am häufigsten verwendeten Kanalimplementierungen in Java NIO sind wie folgt. Es ist ersichtlich, dass sie eins zu eins den traditionellen E/A-Operationsklassen entsprechen.

  • FileChannel: Dateien lesen und schreiben

  • DatagramChannel: UDP-Protokoll-Netzwerkkommunikation

  • SocketChannel: TCP-Protokoll-Netzwerkkommunikation

  • ServerSocketChannel: Überwachung von TCP-Verbindungen

2.Buffer

Der in NIO verwendete Puffer ist kein einfaches Byte-Array, sondern eine gekapselte Pufferklasse. Über die bereitgestellte API können wir Daten flexibel manipulieren, wie unten beschrieben.

Entsprechend den Java-Basistypen bietet NIO eine Vielzahl von Puffertypen wie ByteBuffer, CharBuffer, IntBuffer usw. Der Unterschied besteht darin, dass die Einheitenlänge beim Lesen und Schreiben von Puffern unterschiedlich ist (in Variableneinheiten von). der entsprechenden Art) Lesen und Schreiben).

Es gibt drei sehr wichtige Variablen in Buffer, die der Schlüssel zum Verständnis des Arbeitsmechanismus von Buffer sind:

  • Kapazität (Gesamtkapazität)

  • Position (aktuelle Position des Zeigers)

  • Limit (Lese-/Schreibgrenzposition)

Puffer funktioniert Ähnlich wie bei C sind Zeichenarrays in der Sprache sehr ähnlich: Kapazität ist die Gesamtlänge des Arrays, Position ist die Indexvariable für das Lesen/Schreiben von Zeichen und Grenze ist die Position des Endzeichens. Die Ausgangssituation der drei Variablen im Puffer ist wie folgt:

Vertiefte Kenntnisse der Java NIO-Kernkomponenten

Während des Lese-/Schreibvorgangs im Puffer verschiebt sich die Position nach hinten , und die Grenze Es ist die Grenze der Positionsbewegung. Es ist nicht schwer, sich vorzustellen, dass beim Schreiben in den Puffer die Grenze auf die Größe der Kapazität und beim Lesen des Puffers auf die tatsächliche Endposition der Daten festgelegt werden sollte. (Hinweis: Das Schreiben von Pufferdaten in den Kanal ist eine Pufferleseoperation, das Lesen von Daten vom Kanal in den Puffer ist eine Pufferschreiboperation)

Vor dem Lesen/Schreiben des Puffers können wir die Pufferklasse aufrufen, um sie bereitzustellen Es gibt einige Hilfsmethoden, um die Werte von Position und Limit korrekt festzulegen, darunter hauptsächlich die folgende

  • flip(): Limit auf den Wert von Position setzen und dann Position auf setzen 0. Wird vor dem Lesen des Puffers aufgerufen.

  • rewind(): Position
    einfach auf 0 setzen. Es wird im Allgemeinen vor dem erneuten Lesen der Pufferdaten aufgerufen. Beispielsweise wird es verwendet, wenn Daten aus demselben Puffer gelesen und in mehrere Kanäle geschrieben werden.

  • clear(): Rückkehr zum Ausgangszustand, d. h. der Grenzwert entspricht der Kapazität und die Position wird auf 0 gesetzt. Wird aufgerufen, bevor erneut in den Puffer geschrieben wird.

  • compact(): Verschieben Sie die ungelesenen Daten (Daten zwischen Position und Grenze) an den Anfang des Puffers und setzen Sie position
    an das Ende dieser Daten an der nächsten Position. Tatsächlich entspricht dies dem erneuten Schreiben eines solchen Datenelements in den Puffer.

Sehen Sie sich dann ein Beispiel für die Verwendung von FileChannel zum Lesen und Schreiben von Textdateien an. Verwenden Sie dieses Beispiel, um die lesbaren und beschreibbaren Eigenschaften des Kanals und die grundlegende Verwendung von Buffer zu überprüfen (beachten Sie, dass FileChannel kann nicht auf den nicht blockierenden Modus eingestellt werden.

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();

In diesem Beispiel werden zwei Puffer verwendet, von denen byteBuffer als Datenpuffer zum Lesen und Schreiben von Kanälen und charBuffer zum Speichern dekodierter Zeichen verwendet wird. Die Verwendung von „clear()“ und „flip()“ ist wie oben erwähnt. Zu beachten ist die letzte Methode „compact()“. Auch wenn die Größe von „charBuffer“ völlig ausreicht, um die dekodierten Daten von „byteBuffer“ aufzunehmen, ist dies „compact()“. Dies ist wichtig. Da die UTF-8-Kodierung häufig verwendeter chinesischer Zeichen 3 Bytes einnimmt, besteht eine hohe Wahrscheinlichkeit einer Kürzung in der Mitte. Bitte sehen Sie sich das Bild unten an:

Vertiefte Kenntnisse der Java NIO-Kernkomponenten

Wenn der Decoder 0xe4 am Ende des Puffers liest, kann er nicht einem Unicode zugeordnet werden. Die Funktion des dritten Parameters false der decode()-Methode besteht darin, den Decoder die nicht zugeordneten Bytes verarbeiten zu lassen die Daten dahinter. Für zusätzliche Daten stoppt die Methode decode() hier und die Position fällt auf die Position 0xe4 zurück. Dadurch verbleibt das erste Byte der „mittleren“ Zeichenkodierung im Puffer, der nach vorne komprimiert werden muss, um mit den richtigen und nachfolgenden Daten gespleißt zu werden.

Übrigens ist der CharsetDecoder im Beispiel auch eine neue Funktion von Java NIO, Sie sollten also entdeckt haben, dass NIO-Operationen pufferorientiert sind (herkömmliche E/A ist streamorientiert).

Bisher verstehen wir die grundlegende Verwendung von Kanal und Puffer. Als nächstes muss über die wichtige Komponente gesprochen werden, die darin besteht, einen Thread mehrere Kanäle verwalten zu lassen.

3.Selector

Was ist Selector?

Selector (Selektor) ist eine spezielle Komponente, die zum Erfassen des Status (oder Ereignisses) jedes Kanals verwendet wird. Wir registrieren zunächst den Kanal beim Selektor und legen die Ereignisse fest, die uns wichtig sind. Anschließend können wir in aller Ruhe auf das Eintreten des Ereignisses warten, indem wir die Methode select() aufrufen.

Der Kanal hat die folgenden 4 Ereignisse, die wir überwachen müssen:

  • Akzeptieren: Es besteht eine akzeptable Verbindung

  • Verbinden : Erfolgreiche Verbindung

  • Lesen: Es sind Daten zum Lesen vorhanden

  • Schreiben: Daten können geschrieben werden

为什么要用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复制代码

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

Vertiefte Kenntnisse der Java NIO-Kernkomponenten

可以发现,它们值为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,提供异步调用接口,开发高性能服务器的一个很好的选择,之前在项目中使用过,但没有深入学习,打算下一步好好学学它,到时候再写一篇笔记。

Das Ziel des Java-NIO-Designs besteht darin, Programmierern APIs zur Verfügung zu stellen, mit denen sie die neuesten E/A-Mechanismen moderner Betriebssysteme nutzen können. Zusätzlich zu den in diesem Artikel erwähnten Komponenten und Funktionen gibt es viele andere, wie z. B. Pipe.), Pfad (Pfad), Dateien (Dateien) usw. Einige sind neue Komponenten zur Verbesserung der E/A-Leistung, andere sind Tools zur Vereinfachung von E/A-Vorgängen den Link unter „Referenzen“ am Ende.


Das obige ist der detaillierte Inhalt vonVertiefte Kenntnisse der Java NIO-Kernkomponenten. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Stellungnahme:
Dieser Artikel ist reproduziert unter:segmentfault.com. Bei Verstößen wenden Sie sich bitte an admin@php.cn löschen