Cet article vous apporte une compréhension approfondie des composants principaux de Java NIO. Il a une certaine valeur de référence. Les amis dans le besoin peuvent s'y référer.
Synchronisation, asynchrone, bloquant, non bloquant
Tout d'abord, ces concepts sont très faciles à confondre, mais ils interviennent aussi dans NIO, alors résumons [1].
Synchronisation : lorsque l'appel API revient, l'appelant connaîtra le résultat de l'opération (combien d'octets ont été réellement lus/écrits).
Asynchrone : Par rapport à la synchronisation, l'appelant ne connaît pas le résultat de l'opération au retour de l'appel API, et le résultat sera notifié par rappel ultérieurement.
Blocage : lorsqu'aucune donnée ne peut être lue ou que toutes les données ne peuvent pas être écrites, le fil de discussion en cours est suspendu et attend.
Non bloquant : lors de la lecture, lisez autant de données que possible, puis revenez. Lors de l'écriture, écrivez autant de données que possible, puis revenez.
Pour les opérations d'E/S, selon la documentation sur le site officiel d'Oracle, la norme de classification pour synchrone et asynchrone est « si l'appelant doit attendre que l'opération d'E/S soit terminée. ". Cette "attente de l'opération d'E/S" "Complète" ne signifie pas que les données doivent être lues ou que toutes les données sont écrites, mais elle fait référence à l'opération d'E/S réelle, comme la période pendant laquelle les données sont transféré entre le tampon de pile du protocole TCP/IP et le tampon JVM Time, si l'appelant souhaite attendre.
Ainsi, nos méthodes read() et write() couramment utilisées sont des E/S synchrones. Les E/S synchrones sont divisées en modes bloquant et non bloquant. S'il s'agit d'un mode non bloquant, aucune donnée n'est disponible. détecté Lorsqu'il est lisible, il est renvoyé directement sans effectuer réellement d'opérations d'E/S.
Le résumé est qu'il n'y a en fait que trois mécanismes en Java : les E/S bloquantes synchrones, les E/S synchrones non bloquantes et les E/S asynchrones. Ce dont nous parlerons ci-dessous sont les deux premiers. ont été introduits dans le JDK 1.7. Les E/S asynchrones sont appelées NIO.2.
Nous savons que l'émergence d'une nouvelle technologie s'accompagne toujours d'améliorations et d'améliorations, et il en va de même pour l'émergence de Java NIO.
Les E/S traditionnelles bloquent les E/S, et le principal problème est le gaspillage des ressources système. Par exemple, afin de lire les données d'une connexion TCP, nous appelons la méthode read() de InputStream. Cela entraînera la suspension du thread actuel jusqu'à l'arrivée des données. Ensuite, le thread occupera de la mémoire pendant le temps où les données arrivent. . La ressource (pile de threads de stockage) ne fait rien, ce qui revient, comme on dit, à occuper la fosse et à ne pas chier. Pour lire les données des autres connexions, nous devons démarrer un autre thread. Cela peut ne pas poser de problème lorsque le nombre de connexions simultanées est faible. Toutefois, lorsque le nombre de connexions atteint une certaine échelle, les ressources mémoire seront consommées par un grand nombre de threads. D'un autre côté, le changement de thread nécessite de modifier l'état du processeur, tel que les valeurs du compteur de programme et des registres, donc basculer très fréquemment entre un grand nombre de threads est également un gaspillage de ressources.
Avec le développement de la technologie, les systèmes d'exploitation modernes fournissent de nouveaux mécanismes d'E/S pour éviter ce gaspillage de ressources. Sur cette base, Java NIO est né. La caractéristique représentative de NIO est les E/S non bloquantes. Ensuite, nous avons découvert que le simple fait d'utiliser des E/S non bloquantes ne résout pas le problème, car en mode non bloquant, la méthode read() retournera immédiatement lorsqu'aucune donnée n'est lue. Nous ne savons pas quand les données vous arriveront. ne peut que continuer à appeler la méthode read() pour réessayer, ce qui est évidemment un gaspillage de ressources CPU. Comme vous pouvez le voir ci-dessous, le composant Selector est né pour résoudre ce problème.
Toutes les opérations d'E/S dans Java NIO sont basées sur des objets Channel, tout comme les opérations de flux Ils sont tous basés sur le même objet Stream, il faut donc d'abord comprendre ce qu'est Channel. Le contenu suivant est tiré de la documentation du JDK 1.8
Un canal représente une connexion ouverte à une entité telle qu'un
périphérique matériel, un fichier, une socket réseau ou un composant de programme qui
est capable d'effectuer une ou plusieurs opérations d'E/S distinctes, par
par exemple la lecture ou l'écriture.
Comme le montre le contenu ci-dessus, un canal représente une connexion à une certaine entité. être un fichier, une prise réseau Recevoir des mots, etc. En d'autres termes, le canal est un pont fourni par Java NIO pour que notre programme puisse interagir avec les services d'E/S sous-jacents du système d'exploitation.
Channel est une description très basique et abstraite, qui interagit avec différents services d'E/S, effectue différentes opérations d'E/S et a différentes implémentations, les plus spécifiques incluent FileChannel, SocketChannel, etc.
Les canaux sont similaires aux flux. Vous pouvez lire des données dans un tampon et écrire des données dans un tampon sur un canal.
Bien sûr, il existe des différences, qui se reflètent principalement dans les deux points suivants :
Une chaîne peut soit La lecture et l'écriture sont possibles, et un Stream est unidirectionnel (il est donc divisé en InputStream et OutputStream)
Le canal a un mode E/S non bloquant
Les implémentations de canaux les plus couramment utilisées dans Java NIO sont les suivantes. On peut voir qu'elles correspondent aux classes d'opérations d'E/S traditionnelles un à un.
FileChannel : lire et écrire des fichiers
DatagramChannel : communication réseau selon le protocole UDP
SocketChannel : communication réseau selon le protocole TCP
ServerSocketChannel : surveillance des connexions TCP
Le tampon utilisé dans NIO n'est pas un simple tableau d'octets, mais une classe Buffer encapsulée. Grâce à l'API qu'il fournit, nous pouvons manipuler les données de manière flexible, comme détaillé ci-dessous.
Correspondant aux types de base Java, NIO fournit une variété de types de tampons, tels que ByteBuffer, CharBuffer, IntBuffer, etc. La différence est que la longueur de l'unité lors de la lecture et de l'écriture des tampons est différente (en unités de variables de le type correspondant) lecture et écriture).
Il y a trois variables très importantes dans Buffer, qui sont la clé pour comprendre le mécanisme de fonctionnement de Buffer, à savoir
capacité (capacité totale)
position (position actuelle du pointeur)
limite (position limite de lecture/écriture)
Le tampon fonctionne comme Les tableaux de caractères C dans le langage sont très similaires. Par analogie, la capacité est la longueur totale du tableau, la position est la variable d'indice permettant de lire/écrire des caractères et la limite est la position du caractère de fin. La situation initiale des trois variables dans le Buffer est la suivante
Pendant le processus de lecture/écriture du Buffer, la position reculera , et la limite C'est la limite du mouvement de position. Il n'est pas difficile d'imaginer que lors de l'écriture dans le tampon, la limite doit être définie sur la taille de la capacité, et lors de la lecture du tampon, la limite doit être définie sur la position finale réelle des données. (Remarque : l'écriture de données du Buffer sur le canal est une opération de lecture du Buffer, la lecture des données du canal vers le Buffer est une opération d'écriture du Buffer)
Avant de lire/écrire le Buffer, nous pouvons appeler la classe Buffer pour fournir Il existe quelques méthodes auxiliaires pour définir correctement les valeurs de position et de limite, notamment les suivantes
flip() : définissez la limite sur la valeur de position, puis définissez la position sur 0. Appelé avant de lire le Buffer.
rewind() : définissez simplement la position
sur 0. Il est généralement appelé avant de relire les données du Buffer. Par exemple, il est utilisé lors de la lecture de données du même Buffer et de leur écriture sur plusieurs canaux.
clear() : retour à l'état initial, c'est-à-dire que la limite est égale à la capacité et la position est définie sur 0. Appelé avant d'écrire à nouveau au Buffer.
compact() : Déplacez les données non lues (données entre la position et la limite) au début du tampon, et définissez la position
à la fin de ces données, la position suivante. En fait, cela équivaut à réécrire une telle donnée dans le tampon.
Ensuite, regardez un exemple d'utilisation de FileChannel pour lire et écrire des fichiers texte. À travers cet exemple, vérifiez les caractéristiques de lecture et d'écriture du canal et l'utilisation de base de Buffer (notez que. FileChannel ne peut pas être réglé en mode non bloquant).
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();
Cet exemple utilise deux tampons, dont byteBuffer est utilisé comme tampon de données pour la lecture et l'écriture du canal, et charBuffer est utilisé pour stocker les caractères décodés. L'utilisation de clear() et flip() est celle mentionnée ci-dessus. Ce qu'il faut noter, c'est la dernière méthode compact(). Même si la taille de charBuffer est tout à fait suffisante pour accueillir les données décodées de byteBuffer, cette compact() est. essentiel. Parce que l'encodage UTF-8 des caractères chinois couramment utilisés occupe 3 octets, il y a une forte probabilité de troncature au milieu. Veuillez voir l'image ci-dessous :
Lorsque le Decoder lit 0xe4 à la fin du tampon, il ne peut pas être mappé sur un Unicode. La fonction du troisième paramètre false de la méthode decode() est de laisser le Decoder traiter les octets non mappés et. les données derrière eux. Pour des données supplémentaires, la méthode decode() s'arrêtera ici et la position reviendra à la position 0xe4. En conséquence, le premier octet du codage de caractères « moyen » est laissé dans le tampon, qui doit être compacté vers l'avant pour être épissé avec les données correctes et ultérieures. BTW, le CharsetDecoder dans l'exemple est également une nouvelle fonctionnalité de Java NIO, vous auriez donc dû découvrir que les opérations NIO sont orientées tampon (les E/S traditionnelles sont orientées flux). Jusqu'à présent, nous comprenons l'utilisation de base de Channel et Buffer. La prochaine chose à aborder est l'élément important consistant à laisser un thread gérer plusieurs canaux. 3.SelectorQu'est-ce que SelectorSelector (sélecteur) est un composant spécial utilisé pour collecter le statut (ou l'événement) de chaque chaîne. Nous enregistrons d'abord le canal dans le sélecteur et définissons les événements qui nous intéressent, puis nous pouvons attendre tranquillement que l'événement se produise en appelant la méthode select(). La chaîne dispose des 4 événements suivants que nous pouvons surveiller :
前文说了,如果用阻塞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复制代码
这种写法一点都不陌生,支持位运算的编程语言里都这么玩,用一个整型变量可以标识多种状态,它是怎么做到的呢,其实很简单,举个例子,首先预定义一些常量,它们的值(二进制)如下
可以发现,它们值为1的位都是错开的,因此对它们进行按位或运算之后得出的值就没有二义性,可以反推出是由哪些变量运算而来。怎么判断呢,没错,就是“按位与”运算。比如,现在有一个状态集合变量值为 0011,我们只需要判断 “0011 & OP_READ” 的值是 1 还是 0 就能确定集合是否包含 OP_READ 状态。
然后,注意 register() 方法返回了一个SelectionKey的对象,这个对象包含了本次注册的信息,我们也可以通过它修改注册信息。从下面完整的例子中可以看到,select()之后,我们也是通过获取一个 SelectionKey 的集合来获取到那些状态就绪了的通道。
概念和理论的东西阐述完了(其实写到这里,我发现没写出多少东西,好尴尬(⊙ˍ⊙)),看一个完整的例子吧。
这个例子使用Java NIO实现了一个单线程的服务端,功能很简单,监听客户端连接,当连接建立后,读取客户端的消息,并向客户端响应一条消息。
需要注意的是,我用字符 ‘0′(一个值为0的字节) 来标识消息结束。
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,统计服务端的响应时间,而且连接建立后不要立刻发送数据,这样才能发挥出服务端非阻塞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之后我们都会有这样一个疑问:到底什么时候该用NIO,什么时候该用传统的I/O呢?
其实了解他们的特性后,答案还是比较明确的,NIO擅长1个线程管理多条连接,节约系统资源,但是如果每条连接要传输的数据量很大的话,因为是同步I/O,会导致整体的响应速度很慢;而传统I/O为每一条连接创建一个线程,能充分利用处理器并行处理的能力,但是如果连接数量太多,内存资源会很紧张。
总结就是:连接数多数据量小用NIO,连接数少用I/O(写起来也简单- -)。
经过NIO核心组件的学习,了解了非阻塞服务端实现的基本方法。然而,细心的你们肯定也发现了,上面那个完整的例子,实际上就隐藏了很多问题。比如,例子中只是简单的将读取到的每个字节输出,实际环境中肯定是要读取到完整的消息后才能进行下一步处理,由于NIO的非阻塞特性,一次可能只读取到消息的一部分,这已经很糟糕了,如果同一条连接会连续发来多条消息,那不仅要对消息进行拼接,还需要切割,同理,例子中给客户端响应的时候,用了个while()循环,保证数据全部write完成再做其它工作,实际应用中为了性能,肯定不会这么写。另外,为了充分利用现代处理器多核心并行处理的能力,应该用一个线程组来管理这些连接的事件。
要解决这些问题,需要一个严谨而繁琐的设计,不过幸运的是,我们有开源的框架可用,那就是优雅而强大的Netty,Netty基于Java NIO,提供异步调用接口,开发高性能服务器的一个很好的选择,之前在项目中使用过,但没有深入学习,打算下一步好好学学它,到时候再写一篇笔记。
L'objectif de la conception Java NIO est de fournir aux programmeurs des API pour profiter des derniers mécanismes d'E/S des systèmes d'exploitation modernes, il a donc une large couverture En plus des composants et fonctionnalités mentionnés dans cet article, il en existe de nombreux. d'autres, tels que Pipe. ), Path (chemin), Files (fichiers), etc. Certains sont de nouveaux composants utilisés pour améliorer les performances d'E/S, et certains sont des outils pour simplifier les opérations d'E/S. Pour une utilisation spécifique, veuillez vous référer à. le lien dans les références à la fin.
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!