开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情
IO&网络
本地IO
1.FileDescriptor
-
文件描述符 (File descriptor) 是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
-
Java FileDescriptor 类用来表示开放文件、开放套接字等。当 FileDescriptor 表示文件时,可以通俗的将 FileDescriptor 看成是该文件,但是不能直接通过 FileDescriptor 对该文件进行操作,若要通过 FileDescriptor 对该文件进行操作,则需要新创建 FileDescriptor 对应的 FileOutputStream,然后再对文件进行操作。源码如下:
public final class FileDescriptor { /** * 在形式上是一个非负整数。 * 实质是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。 * 当程序打开一个现有文件或者创建一个新文件时内核向进程返回一个文件描述符。 */ private int fd; private long handle; private Closeable parent; private List<Closeable> otherParents; private boolean closed; public /**/ FileDescriptor() { fd = -1; handle = -1; } static { initIDs(); } // Set up JavaIOFileDescriptorAccess in SharedSecrets static { sun.misc.SharedSecrets.setJavaIOFileDescriptorAccess( new sun.misc.JavaIOFileDescriptorAccess() { public void set(FileDescriptor obj, int fd) { obj.fd = fd; } public int get(FileDescriptor obj) { return obj.fd; } public void setHandle(FileDescriptor obj, long handle) { obj.handle = handle; } public long getHandle(FileDescriptor obj) { return obj.handle; } } ); } /** * 标准输入流 * @see java.lang.System#in */ public static final FileDescriptor in = standardStream(0); /** * 标准输出流 * @see java.lang.System#out */ public static final FileDescriptor out = standardStream(1); /** * 标准错误输出流 * @see java.lang.System#err */ public static final FileDescriptor err = standardStream(2); /** * Tests if this file descriptor object is valid. * * @return {@code true} if the file descriptor object represents a * valid, open file, socket, or other active I/O connection; * {@code false} otherwise. */ public boolean valid() { return ((handle != -1) || (fd != -1)); } /** * Force all system buffers to synchronize with the underlying * device. This method returns after all modified data and * attributes of this FileDescriptor have been written to the * relevant device(s). In particular, if this FileDescriptor * refers to a physical storage medium, such as a file in a file * system, sync will not return until all in-memory modified copies * of buffers associated with this FileDesecriptor have been * written to the physical medium. * * sync is meant to be used by code that requires physical * storage (such as a file) to be in a known state For * example, a class that provided a simple transaction facility * might use sync to ensure that all changes to a file caused * by a given transaction were recorded on a storage medium. * * sync only affects buffers downstream of this FileDescriptor. If * any in-memory buffering is being done by the application (for * example, by a BufferedOutputStream object), those buffers must * be flushed into the FileDescriptor (for example, by invoking * OutputStream.flush) before that data will be affected by sync. * * @exception SyncFailedException * Thrown when the buffers cannot be flushed, * or because the system cannot guarantee that all the * buffers have been synchronized with physical media. * @since JDK1.1 */ public native void sync() throws SyncFailedException; /* This routine initializes JNI field offsets for the class */ private static native void initIDs(); private static native long set(int d); private static FileDescriptor standardStream(int fd) { FileDescriptor desc = new FileDescriptor(); desc.handle = set(fd); return desc; } /* * Package private methods to track referents. * If multiple streams point to the same FileDescriptor, we cycle * through the list of all referents and call close() */ /** * Attach a Closeable to this FD for tracking. * parent reference is added to otherParents when * needed to make closeAll simpler. */ synchronized void attach(Closeable c) { if (parent == null) { // first caller gets to do this parent = c; } else if (otherParents == null) { otherParents = new ArrayList<>(); otherParents.add(parent); otherParents.add(c); } else { otherParents.add(c); } } /** * Cycle through all Closeables sharing this FD and call * close() on each one. * * The caller closeable gets to call close0(). */ @SuppressWarnings("try") synchronized void closeAll(Closeable releaser) throws IOException { if (!closed) { closed = true; IOException ioe = null; try (Closeable c = releaser) { if (otherParents != null) { for (Closeable referent : otherParents) { try { referent.close(); } catch(IOException x) { if (ioe == null) { ioe = x; } else { ioe.addSuppressed(x); } } } } } catch(IOException ex) { /* * If releaser close() throws IOException * add other exceptions as suppressed. */ if (ioe != null) ex.addSuppressed(ioe); ioe = ex; } finally { if (ioe != null) throw ioe; } } } }
2.Java IO
2.1.操作系统
2.1.1.用户空间与内核空间
- 目前操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间 (虚拟存储空间) 为 4G (2的32次方)。为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为用户空间 (User space) 和内核空间 (Kernel space) 。运行的应用程序都是运行在用户空间,且用户空间的程序不能直接访问内核空间。只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力,内核独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。针对 Linux 操作系统而言,将最高的 1G 字节 (从虚拟地址 0xC0000000 到 0xFFFFFFFF) ,供内核使用,称为内核空间,而将较低的 3G 字节 (从虚拟地址 0x00000000 到 0xBFFFFFFF ),供各个进程使用,称为用户空间。
2.1.2.进程切换
-
当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。因此,用户进程想要执行 IO 操作的话,必须通过系统调用来间接访问内核空间。为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,这个过程比较耗费资源。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
- 保存处理机上下文,包括程序计数器和其他寄存器。
- 更新PCB信息。
- 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
- 选择另一个进程执行,并更新其PCB。
- 更新内存管理的数据结构。
- 恢复处理机上下文。
2.1.3.进程的阻塞
- 正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程 (获得CPU) ,才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用 CPU 资源的。
2.1.4.缓存IO
- 缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存 (page cache) 中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
- 缓存 IO 的缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
2.1.5.IO交互流程
- 通常用户进程中的一次完整I/O交互流程分为两阶段,首先是经过内核空间,也就是由操作系统处理;紧接着就是到用户空间,也就是交由应用程序
- 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,两者不能简单地使用指针传递数据。因为Linux使用的虚拟内存机制,必须通过系统调用请求 Kernel 来协助完成 I/O 操作,内核会为每个 I/O 设备维护一个缓冲区,用户空间的数据可能被换出,所以当内核空间使用用户空间的指针时,对应的数据可能不在内存中。
2.2.BIO/NIO/AIO
2.2.1.BIO
-
BIO (Blocking IO):同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。
Java BIO 是面向流的,面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。Java NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程的灵活性。但是,还需要检查该缓冲区是否包含所有需要处理的数据。而且,要确保当更多的数据读入缓冲区时,不能覆盖缓冲区里尚未处理的数据。
-
Java BIO 的各种流是阻塞的。这意味着,当一个线程调用 read() 或 write() 时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情。
2.2.2.NIO
-
NIO (New IO):同步非阻塞的 I/O 模型,在Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer等抽象。
Java NIO 是面向缓冲区的,Java NIO 的非阻塞模式,是一个线程从某通道 (Channel) 发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用,就什么都不会获取,而不是保持线程阻塞,所以直到数据变成可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此。一个线程请求写入某通道一些数据,但不需要等待它完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞 I/O 的空闲时间用于在其他通道上执行 I/O 操作,所以一个单独的线程现在可以管理多个 I/O 通道。
-
Java NIO 的选择器 (Selector) 允许一个单独的线程监视多个输入通道,可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制使一个单独的线程很容易管理多个通道。而 BIO 没有选择器。
2.2.3.AIO
-
AIO (Asynchronous I/O):JDK 1.7(NIO2) 才实现真正的异步 IO,把 I/O 读写操作完全交给操作系统,学习了 Linux Epoll 模式。
AIO 基本原理:Java AIO 处理 API 中,重要的三个类分别是:AsynchronousServerSocketChannel (服务端) 、AsynchronousSocketChannel (客户端) 及CompletionHandler (用户处理器) 。CompletionHandler 接口实现应用程序向操作系统发起 I/O 请求,当完成后处理具体逻辑,否则做自己该做的事情,真正的异步 I/O 需要操作系统更强的支持。
-
在多路复用 I/O 模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步 I/O 模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在 I/O 完成后通知用户线程直接使用即可。异步 I/O 模型使用 Proactor 设计模式实现这一机制。
2.3.NIO的使用
- 在NIO中有三个核心对象:缓冲区 (Buffer) 、选择器 (Selector) 和通道 (Channel)。
2.3.1.缓冲区
-
缓冲区实际上是一个容器对象,更直接地说,其实就是一个数组,在 NIO 库中,所有数据都是用缓冲区处理的在读取数据时,它是直接读到缓冲区中的;在写入数据时,它也是写入缓冲区的;任何时候访问 NIO 中的数据,都是将它放到缓冲区中。在 NIO 中,所有的缓冲区类型都继承于抽象类 Buffer ,最常用的就是 ByteBuffer。
-
基本原理:在缓冲区中,最重要的属性有下面三个,它们一起合作完成对缓冲区内部状态的变化跟踪
- position:指定下一个将要被写入或者读取的元素索引,它的值由 get()、put() 方法自动更新,在新创建一个 Buffer 对象时,position 被初始化为 0 。
- limit:指定还有多少数据需要取出 (在从缓冲区写入通道时) ,或者还有多少空间可以放入数据(在从通道读入缓冲区时)。
- capacity:指定了可以存储在缓冲区中的最大数据容量。
缓冲区写入:写入时 position 右移。
缓冲区读取:一是把 limit 设置为当前的 position 值。二是把 position 设置为 0。通过 position 下标从 0 开始读取,终点为 limit 。
缓冲区清空:恢复初始状态。
-
缓冲区的分配:在创建一个缓冲区对象时,会调用静态方法 allocate() 来指定缓冲区的容量,其实调用 allocate() 方法相当于创建了一个指定大小的数组,并把它包装为缓冲区对象。
-
缓冲区分片:在 NIO 中,除了可以分配或者包装一个缓冲区对象,还可以根据现有的缓冲区对象创建一个子缓冲区,即在现有缓冲区上切出一片作为一个新的缓冲区,现有的缓冲区与创建的子缓冲区在底层数组层面上是数据共享的,子缓冲区相当于现有缓冲区的一个视图窗口。调用 slice() 方法可以创建一个子缓冲区。
-
缓冲区的实现:
- 只读缓冲区:只读缓冲区非常简单,可以读取它们,但是不能向它们写入数据。可以通过调用缓冲区的 asReadOnlyBuffer() 方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区,并与原缓冲区共享数据,只不过它是只读的。如果原缓冲区的内容发生了变化,只读缓冲区的内容也随之发生变化
- 直接缓冲区:直接缓冲区是为加快 I/O 速度,使用一种特殊方式为其分配内存的缓冲区。
- 内存映射:内存映射是一种读和写文件数据的方法,可以比常规的基于流或者基于通道的 I/O 快得多
2.3.2.通道
-
通道是一个对象,通过它可以读取和写入数据,当然所有数据都通过Buffer对象来处理。我们永远不会将字节直接写入通道,而是将数据写入包含一个或者多个字节的缓冲区。多个 Channel 以事件的方式可以注册到同一个 Selector,从而达到用一个线程处理多个请求成为可能。
从通道读取到缓冲区的三个步骤
- 从 FileInputStream 获取 Channel 。
- 创建 Buffer。
- 将数据从 Channel 读取到 Buffer 中。
-
对比流通道的优点:
- 通道可以同时进行读写,而流只能读或者只能写。
- 通道可以实现异步读写数据。
- 通道可以从缓冲读数据,也可以写数据到缓冲。
2.3.3.选择器(网络IO)
-
传统的 Client/Server 模式会基于 TPR (Thread per Request) ,服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。这种模式带来的一个问题就是线程数量的剧增,大量的线程会增大服务器的开销。大多数的实现为了避免这个问题,都采用了线程池模型。
使用 NIO 中非阻塞 I/O 编写服务器处理程序,大体上可以分为下面三个步骤:
- 向 Selector 对象注册感兴趣的事件。
- 从 Selector 中获取感兴趣的事件。
- 根据不同的事件进行相应的处理。
-
NIO 选择器部分源码解读
public abstract class Selector implements Closeable { protected Selector() {} //打开选择器(创建一个选择器) public static Selector open() throws IOException { return SelectorProvider.provider().openSelector(); } //检查选择器是否打开 public abstract boolean isOpen(); //返回创建此channel的provider public abstract SelectorProvider provider(); //返回所有 Channel 对应的 SelectionKey 的集合,通过 SelectionKey 可以找到对应的 Channel public abstract Set<SelectionKey> keys(); //返回所有发生事件的 Channel 对应的 SelectionKey 的集合,通过 SelectionKey 可以找到对应的 Channel public abstract Set<SelectionKey> selectedKeys(); /** * Selects a set of keys whose corresponding channels are ready for I/O operations. * 立即返回的 select 过程 */ public abstract int selectNow() throws IOException; /** * Selects a set of keys whose corresponding channels are ready for I/O operations. * 监控所有注册的 Channel,当其中的 Channel 有 IO 操作可以进行时, * 将这些 Channel 对应的 SelectionKey 找到。参数用于设置超时时间。 */ public abstract int select(long timeout) throws IOException; /** * Selects a set of keys whose corresponding channels are ready for I/O operations. * 无超时时间的 select 过程,一直等待,直到发现有 Channel 可以进行 IO 操作 */ public abstract int select() throws IOException; /** * Causes the first selection operation that has not yet returned to return immediately. * 唤醒 Selector,对无超时时间的 select 过程起作用,终止其等待 */ public abstract Selector wakeup(); /** * Closes this selector. */ public abstract void close() throws IOException; }当某个 Channel 调用 register 方法的时候,会将其注册到 Selector 上,注册方法返回一个 SelectionKey,该 SelectionKey 会被放入 Selector 内部的 SelectionKey 集合中。该 SelectionKey 和 Selector 关联(即通过 SelectionKey 可以找到对应的 Selector,也和该 Channel 关联 (即通过 SelectionKey 可以找到对应的 Channel) 。
public abstract class SelectionKey { /** * Constructs an instance of this class. */ protected SelectionKey() { } // -- Channel and selector operations -- //返回与该 SelectionKey 关联的 channel public abstract SelectableChannel channel(); //返回与该 SelectionKey 关联的 selector public abstract Selector selector(); /** * Tells whether or not this key is valid. */ public abstract boolean isValid(); /** * Requests that the registration of this key's channel with its selector * be cancelled. Upon return the key will be invalid and will have been * added to its selector's cancelled-key set. The key will be removed from * all of the selector's key sets during the next selection operation. */ public abstract void cancel(); // -- Operation-set accessors -- /** * Retrieves this key's interest set. */ public abstract int interestOps(); /** * Sets this key's interest set to the given value. */ public abstract SelectionKey interestOps(int ops); /** * Retrieves this key's ready-operation set. */ public abstract int readyOps(); // -- Operation bits and bit-testing convenience methods -- //事件 public static final int OP_READ = 1 << 0; public static final int OP_WRITE = 1 << 2; public static final int OP_CONNECT = 1 << 3; public static final int OP_ACCEPT = 1 << 4; // SelectionKey 对应的 channel 是否可读 public final boolean isReadable() { return (readyOps() & OP_READ) != 0; } // SelectionKey 对应的 channel 是否可写 public final boolean isWritable() { return (readyOps() & OP_WRITE) != 0; } // SelectionKey 对应的 channel 是否已经建立起 socket 连接 public final boolean isConnectable() { return (readyOps() & OP_CONNECT) != 0; } // SelectionKey 对应的 channel 是否准备好建立连接 public final boolean isAcceptable() { return (readyOps() & OP_ACCEPT) != 0; } // -- Attachments -- private volatile Object attachment = null; private static final AtomicReferenceFieldUpdater<SelectionKey,Object> attachmentUpdater = AtomicReferenceFieldUpdater.newUpdater( SelectionKey.class, Object.class, "attachment" ); /** * Attaches the given object to this key. */ public final Object attach(Object ob) { return attachmentUpdater.getAndSet(this, ob); } /** * Retrieves the current attachment. */ public final Object attachment() { return attachment; } }
2.3.4.NIO的实现
-
在标准 IO API 中,可以操作字节流和字符流,但在 NIO 中,可以操作通道和缓冲,数据总是从通道被读取到缓冲中或者从缓冲写入到通道中。
-
读写步骤:
- 写数据到缓冲区;
- 调用 buffer.flip() 方法:将 Buffer 从写模式切换到读模式,将 position 值重置为0,limit 的值设置为之前 position 的值
- 从缓冲区中读取数据;
- 调用 buffer.clear() 或 buffer.compat() 方法;
-
使用NIO操作本地文件IO读写示例程序如下:
public static void copyFileUseNIO(String src,String dst) throws IOException{ //声明源文件和目标文件 FileInputstream fi = new FileInputstream(new File(src) ); FileOutputstream fo = new FileOutputstream(new File(dst) ) ;//获得传输通道channel Filechannel inChannel = fi.getchannel(); Filechannel outchannel = fo.getchannel() ;//获得缓冲区buffer ByteBuffer buffer = ByteBuffer.allocate(1024); while(int eof = inchannel.read(buffer) != -1){ //重设一下buffer的position=0,limit=position buffer.flip() ; //开始写 outChannel.write(buffer); //写完要重置buffer,重设position=0,limit=capacity buffer.clear( ); } inchannel.close(); outChannel.close(); fi.close(); fo.close(); }
2.3.5.原生 NIO 程序的问题
- NIO 的类库和 API 繁杂,使用起来非常麻烦,需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
- 需要具备其他的额外技能做铺垫:例如熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,开发者必须对多线程和网路编程非常熟悉,才能编写出高质量的 NIO 程序。
- 可靠性能力补齐,开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等。NIO 编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大。
- JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。官方声称在 JDK 1.6 版本的 update 18 修复了该问题,但是直到 JDK 1.7 版本该问题仍旧存在,只不过该 Bug 发生概率降低了一些而已,它并没有被根本解决。