Java NIO读书笔记

2022-07-13 15:50:21 浏览数 (1)

大家好,又见面了,我是全栈君,祝每个程序员都可以多学几门语言。

简单介绍

NIO的作用就是改进程序的性能。由于有时候程序的性能瓶颈不再是CPU,而是IO。这时候NIO就派上用场了。NIO的原理就是尽量利用系统底层的资源来提高效率,比方利用DMA硬件减小CPU负荷,利用操作系统的epoll机制避免线程频繁切换。通过底层资源提高系统的吞吐量。

缓冲区

缓冲区就是一个固定大小的一组数据。缓冲区有四个很重要的属性:容量,限制,位置,标记。容量就是一个缓冲区最大能容量的元素数量,限制就是对容量进行逻辑上的限制,位置用于跟踪get或者put方法的位置,标记用于reset函数返回上次固定的位置。

put()方法用于往缓冲区中存入数据,get()方法用于从缓冲区中读取数据。写入操作详细的API有put(byte)、put(index, byte)、put(byte[])、put(byte[], int start, int length),有单个元素的写入,也有批量的写入。读取操作也一样拥有这四种API。

flip()用于交换未写入的和写入的数据。也就是将limit设为position,将position设为0。一般先存入一组数据之后,经过翻转,再从中读取原本写入的数据。

compact()将已经读过的数据进行压缩,将未读过的数据拷贝到缓冲区索引號为0的位置。复制之后,原来的数据不会被擦除。

mark()方法用于标记,reset()方法用于返回上次标记的位置。rewind()、clear()、flip()都会重置标记,position()、limit()、看情况,假设小于标记时也会重置标记。

缓冲区之间能够比較。比較一定要同样的类型。比較的根据是缓冲区剩余的内容,与标记、位置、容量、限制等无关。

创建缓冲区能够有两种方法。一种是创建新的缓冲区,调用xxBuffer.allocate,第二是将现有的数组进行封装,缓冲区写入的数据都会写入到原来的数组中。

缓冲区是能够复制的。调用duplicate()。复制出来的缓冲区事实上是一个视图。复制出来的缓冲区和原来的缓冲区拥有同样的数据,可是每一个缓冲区都有各自的属性,限制、位置、标记都是独立的。复制的时候也能够取缓冲区的一部分,调用slice()。

缓冲区还分为big-endian和little-endian。java.nio.ByteOrder能够获取本机的字节顺序。

另一种缓冲区称之为直接缓冲区,能够通过xxBuffer.allocateDirect获取。直接缓冲区就是操作性能比普通的缓冲区要高。

ByteBuffer提供了asXXBuffer。比方asShortBuffer、asCharBuffer等。这些缓冲区称之为视图缓冲区。就是将字节缓冲区以第二种行为提供给其它程序。ByteBuffer还提供了getInt、getLong、getDouble等方法,这些方法称之为视图操作,好像就在操作第二种类型的缓冲区。写入操作也是一样,也有视图操作。视图缓冲区和视图操作和字节顺序有关,所以在操作之前先设置字节顺序,默认的是BigEndian。

Java不支持无符号的数据类型。可是总是有解决的方法的。以下就是一种解决的方法。

代码语言:javascript复制
package com.ronsoft.books.nio.buffers;


import java.nio.ByteBuffer;
/**
 * Utility class to get and put unsigned values to a ByteBuffer object.
 * All methods here are static and take a ByteBuffer argument.
 * Since java does not provide unsigned primitive types, each unsigned
 * value read from the buffer is promoted up to the next bigger primitive
 * data type. getUnsignedByte() returns a short, getUnsignedShort() returns
 * an int and getUnsignedInt() returns a long.
 There is no getUnsignedLong()
 * since there is no primitive type to hold the value returned. If needed,
 * methods returning BigInteger could be implemented.
 * Likewise, the put methods take a value larger than the type they will
 * be assigning. putUnsignedByte takes a short argument, etc.
 *
 * @author Ron Hitchens (ron@ronsoft.com)
 */
public class Unsigned
{
    public static short getUnsignedByte (ByteBuffer bb)
    {
        return ((short)(bb.get() & 0xff));
    }
    public static void putUnsignedByte (ByteBuffer bb, int value)
    {
        bb.put ((byte)(value & 0xff));
    }
    public static short getUnsignedByte (ByteBuffer bb, int position)
    {
        return ((short)(bb.get (position) & (short)0xff));
    }
    public static void putUnsignedByte (ByteBuffer bb, int position,
                                        int value)
    {
        bb.put (position, (byte)(value & 0xff));
    }
    // ---------------------------------------------------------------
    public static int getUnsignedShort (ByteBuffer bb)
    {
        return (bb.getShort() & 0xffff);
    }
    public static void putUnsignedShort (ByteBuffer bb, int value)
    {
        bb.putShort ((short)(value & 0xffff));
    }
    public static int getUnsignedShort (ByteBuffer bb, int position)
    {
        return (bb.getShort (position) & 0xffff);
    }
    public static void putUnsignedShort (ByteBuffer bb, int position,
                                         int value)
    {
        bb.putShort (position, (short)(value & 0xffff));
    }
    // ---------------------------------------------------------------
    public static long getUnsignedInt (ByteBuffer bb)
    {
        return ((long)bb.getInt() & 0xffffffffL);
    }
    public static void putUnsignedInt (ByteBuffer bb, long value)
    {
        bb.putInt ((int)(value & 0xffffffffL));
    }
    public static long getUnsignedInt (ByteBuffer bb, int position)
    {
        return ((long)bb.getInt (position) & 0xffffffffL);
    }
    public static void putUnsignedInt (ByteBuffer bb, int position,
                                       long value)
    {
        bb.putInt (position, (int)(value & 0xffffffffL));
    }
}

最后另一种映射缓冲区,这样的缓冲区一定是直接缓冲区,仅仅能由FileChannel创建。

通道

通道和缓冲区不同,每一个操作系统都有不同的实现方式,因此通道的代码一般都是接口或者抽象类。

通道分为堵塞通道和非堵塞通道。非堵塞通道不能在文件通道上使用。

通道类似于一种连接,所以通道是不能循环使用的。通道能够被关闭。关闭能够通过close方法和中断,对通道发送中断信号通道就会关闭。这样的设计初看认为非常别扭,可是这样设计是为了便于在不同的操作系统中实现。

通道还支持批量写入或读取多个缓冲区。一般的操作系统都从底层支持批量写入或读取缓冲区,因此Java会将批量操作翻译成系统底层的API调用,让操作系统来完毕批量操作,因此速度很快。

文件通道仅仅能是堵塞通道。比起FileStream,FileChannel还提供了很多其它的操作,比方指定在某个位置写入数据。文件通道的创建须要FileStream或者RandomAccessFile,文件通道的状态和创建时传入的參数状态是保持一致的,文件的位置是同步的。文件通道还提供了force操作,将文件的改动马上写入文件。文件通道提供了truncate操作,用于设置文件的大小。

文件系统中有个文件洞(File Hole)的概念,就是文件的大小比占用的空间少。比方在文件的1GB位置写入10K数据,那么文件实际善用的空间是10K,而不是1G。

文件锁一个常见的误区是,每一个文件仅仅能有一个文件锁,不是每一个文件通道对象有一个文件锁,也不是每一个线程有一个文件锁,而是每一个文件有一个文件锁。因此,在同一个JVM中,假设对一个文件创建了两个文件通道,在同一个地方都加上相互排斥锁,是不会堵塞的。也就是说,在JVM内部,文件锁是不起作用的。文件锁要记得释放,最好就是将释放的代码放在finally块中。

文件映射缓冲区。这样的缓冲区和普通的缓冲区一样,可是数据的内容是放在磁盘上的。映射缓冲区有三种模式,一种是仅仅读,一种是读写,一种是私有。私有模式下,文件的改动是不会写入到文件的,仅仅是保存到缓冲区中。私有模式下,文件的内容会与其它普通的文件通道同步。可是同步的单位是分页,也就是说,私有模式下是否同步跟操作系统的分页大小有关。假设在私有模式下改动文件,那么相应的分页将不再和其它文件通道同步。

通道之间还能够直接传输,相关的方法是transferTo和transferFrom。有些操作系统内核就支持通道之间的传输,因此性能很高。

文件映射的load()方法能够将整个文件载入到操作系统的文件缓存中,同一时候文件的内容和磁盘保持同步。

套接字通道和文件通道不同,支持非堵塞模式。每一个套接字通道相应了一个套接字。这样的通道不能从现有的套接字中创建。 blockingLock()方法会返回一个Object对象,能够用Java中的synchronizedkeyword对这个对象进行锁定,防止其它的线程对该对象进行改动。套接字通道分为SocketChannel和ServerSocketChannel。ServerSocketChannel仅仅是提供了非堵塞的accept方法。

数据报通道使用UDP协议进行通信。注意,在接收数据的时候,假设缓冲区的容量不够了,那么多出的数据会被textbf{丢弃},不会有不论什么现象。发送数据的时候,假设缓冲区太大,超过了系统的发送队列,那么不会有不论什么数据会被发送。数据报通道也有connect方法,该方法仅仅是指定发送对象,并非真正的连接。

管道通道(PipeChannel)和Unix中的管道通信不是同一个概念。NIO中的管道通道仅仅能在一个JVM内部进行通信,而不是进程间的通信。进程间通信能够通过套接字。管道通信在创建的时候通过Pipe.open()就可以创建一对通道,SinkChannel和SourceChannel。SinkChannel用于写入,SourceChannel用于读取。通过管道能够实现一个线程仅仅顾写入数据,另外一个线程仅仅顾读取数据,有点类似于Python中的generator对象。管道通道最大的用处就是封装。将一个文件通道或者套接字通道封装成管道通道,提高代码的复用程度。经过实验,发现管道内部存在缓冲,就算另外一边没有读取,写入的一边也能够写入大于1K的数据。

选择器

选择器的详细实现仅仅能是通过操作系统来完毕,因此性能比較高。

有关选择器部分的有三个类,Selector、SelectionKey、SelectableChannel。

Selector用于管理多个可选通道和一堆SelectionKey。select方法会堵塞,返回的不是已经就绪的通道数量,而是在这次调用中成为就绪状态的通道数量。selectedKeys()返回的事实上是一个Set,而Set不支持多线程,所以假设selectedKeys放在另外的线程迭代,那么在迭代的过程中可能会产生ConcurrentModificationException。

Selector中有三种集合:注冊集合、选择集合、取消集合。选择集合仅仅会添加�不会降低,降低须要通过迭代器手动删除,每处理一次请求就删除相应的SelectionKey。

选择模式有两种一种是select第二种是epoll。Select是POSIX标准,而Epoll是Linux特有的。Select最多仅仅能监听1024个通道,而Epoll则没有这样的限制。Select每次调用时会扫描全部的通道,因此通道越多性能越差,而Epoll中有一个可用队列,这个队列由操作系统内核来维护的,当一个通道可用时,操作系统就会往队列中添加�通道,因此性能不会随着通道数量的添加�而变差。

Epoll有两种工作模式,一种是Level Trigger水平触发,还有一种是Edge Trigger边缘触发。默认是水平触发,这样的模式当通道的数据还没有读取完时,下一次选择之后selectionKeys会立即返回没有读完的通道,而边缘触发则不会,边缘触发的性能更高可是程序出错的可能性更大。

SelectionKey就是通道和选择器的相应关系。提供了readyOps()方法,这种方法返回通道已经就绪的操作。也能够通过isWritable()、isReadable()等方法推断通道是否支持某个操作,这两种方法是等价的。选择键还能够带上一个附件,便于通道获取參数。须要注意的是,附件假设不再使用,应该立即清除掉,否则会造成内存泄露。

SelectableChannel就是可选通道,它能够在多个Selector中注冊,注冊的时候要提供须要监听的事件,比方OP_READ、OP_WRITE。validOps()方法返回这个通道能够监听的操作。JDK中定义了4种兴趣:读、写、连接、接受。SocketChannel是不能接受连接的,所以validOps不会返回接受动作。注冊通道能够反复注冊,可是第二次注冊时仅仅会改动兴趣集,并返回同一个SelectionKey。假设第二次注冊的时候已经调用了cancel()方法,然而Selector还没有来得及更新,就会发生CancelledKeyException。

关闭通道应该是一个很高速的操作,没有不论什么堵塞。这是JavaNIO的设计目标。这种设计称为异步关闭。

一般编写代码的时候模板例如以下:

代码语言:javascript复制
while(true) {
    selector.select();


    Iterator<SelectionKey> keys = selector.selectedKeys();
    while(keys.hasNext()) {
        SelectionKey key = keys.next();


        // 处理事件
        ...


        // 处理完成之后删除,这样就表示 这次事件已经处理过了
        keys.remove();
    }
}

对于多核的计算机而言,仅仅有一个线程在工作是很低效的,为了在多核计算机上提升性能,必须引入多核线程和多个选择器。每一个线程一个选择器,每次接受连接的时候随机分配给一个线程。这是一种方法,第二种方法是当中一个线程用于接受连接,其余的线程专门负责处理业务。

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/118369.html原文链接:https://javaforall.cn

0 人点赞