Java基础知识总结(二)——NIO

关于NIO这部分,除了《Java编程思想》中的介绍还有两份资料我觉得很好:一是《深入Java Web技术内幕》第2章的部分,二是并发编程网上Jakob JenkovNIO系列教程翻译,读完之后受益匪浅。

1. NIO是什么:

java.nio是JDK1.4之后加入的,它新颖的特点在于: (1)面向缓存;(2)非阻塞;(3)直接内存
首先来看看它的整体结构:一个完整的NIO程序体系应该包括 Selector,Channel,Buffer,它们是NIO最核心的3个关键部分;


非阻塞IO是NIO的一大特点,那它是怎么实现的呢,Selector和它的名字所反映的一样起到选择和调度的作用,它所调度的就是Channel(通道),Channel包括:FileChannel,SocketChannel,ServerSocketChannel,DatagramChannel,它们负责对指定的资源进行访问读写,其中除了FileChannel都具有非阻塞的功能,ServerSocketChannel相当于一个服务器程序,它有accept方法监听和接受客户端的请求,而SocketChannel则对应于一个具体的Socket连接,它可以通过ServerSocketChannel的accept方法来得到,我们可以通过read和write对该连接信道进行读写,DatagramChannel是UDP数据报通信方式。在传统的IO中,accept,read,write方法都是阻塞的方式进行的,也就是说accept方法负责接受一个客户端请求,在未接受到一个请求之前线程就会阻塞在此处不能进行,因此要同时打开多个ServerSocketChannel必须要有多个线程支持,在非阻塞模式下,accept,read,write可以在直接返回,让其他任务可以执行,这样我们可以在一个线程中同时处理多个Channel,那你可能会问,accept直接返回了,请求来的时候如何在去调用accept接受呢,这就需要Selector来调度了,Selector的select()方法是阻塞的,它可以同时监听对Channel的操作请求,将请求转发到对应的channel中,从总体上看,把每个Channel中阻塞等待的行为统一移到了Selector,从而我们可以在单线程中同时处理多个信道的读写任务。Buffer缓存则是我们对Channel进行读写的工具,它还提供了Char,Int等多种不同的视图让我们可以以不同的方式读写数据,也提供了Heap和Direct直接内存两种缓存存储方式。

下面我们对这3个关键的“部件”进行详细的分析,当然我们应当明白不同的技术有不同的使用场景,这里为了突出NIO的特点我们集中于单线程(或少量线程)非阻塞的方式,它使用与高并发数据量处理少而简单的场景。


2. Selector:

结合上面的论述,可以看到Selector起到了代替多个Channel监听感兴趣事件发生的作用,这让我很容易想起一个设计模式——观察者模式,在这里Selector是Obserable,Channel是Observer,Channel要向Selector注册自己对哪些事情感兴趣,当事件发生时,Selector通知对应的Channel。

这里注册有个两个部分:哪个channel和指定的事件,SelectionKey包含了注册的要素:

2.1 SelectionKey(注册感兴趣的事,监听返回准备好的事,它关联一个Selector和Channel,我为什么忍不住想到迪米特法则,中毒太深...):

操作事件:

SelectionKey.OP_CONNECT
SelectionKey.OP_ACCEPT
SelectionKey.OP_READ
SelectionKey.OP_WRITE

因为SelectionKey包含5个部分:

(1)interest集合和ready集合一样包含有一些方便判断的方法,可以看api或源码;

(2)ready集合;

(3)channel引用;

(4)Selector引用;

(5)attach附加对象(可选);

2.2 Selector的重要方法:

(1)select方法:包括select(),selectNow(非阻塞),select(long timeout)返回int,有多个个ready的Channel;

(2)selectedKeys方法:返回ready的Channel的selectionKey集合,遍历它们,根据readyOps集合处理对应事件;

(3)wakeUp方法:从select阻塞中唤醒;

(4)close方法:是所用selectionKey无效,也就释放了对Channel们的引用不影响垃圾回收啦;

2.3 Selector使用示例:

public class SelectorSample {
    private List<SelectableChannel> channels;
    private boolean isListening = true;

    public SelectorSample(List<SelectableChannel> channels) {
        this.channels = channels;
    }

    public void doHandle() {
        try(Selector selector = Selector.open()) {
            for(SelectableChannel channel : channels) {
                channel.configureBlocking(false); //非阻塞
                channel.register(selector, SelectionKey.OP_ACCEPT | SelectionKey.OP_CONNECT
                        | SelectionKey.OP_READ | SelectionKey.OP_WRITE);
            }
            while(isListening) {
                int ready = selector.select();
                if(ready == 0) continue;
                Set<SelectionKey> selectionKeys = selector.keys();
                for(Iterator<SelectionKey> iterator = selectionKeys.iterator(); iterator.hasNext();) {
                    SelectionKey key = iterator.next();
                    if(key.isAcceptable()) {
                        System.out.println("doSomething when be acceptable");
                    } else if(key.isConnectable()) {
                        System.out.println("doSomething when be able to connect");
                    } else if(key.isReadable()) {
                        System.out.println("doSomething when be readable");
                    } else if(key.isWritable()) {
                        System.out.println("doSomething when be writable");
                    }
                    iterator.remove(); //注意要从就绪集合中删除,下次就绪有selector添加
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

2. Channel :

Channel的体系中有:SelectableChannel和InterruptiableChannel,前者继承自后者,之前说过FileChannel是不可以非阻塞的它属于InterruptiableChannel,而其他3种进一步属于SelectableChannel。

2.1 如何打开通道:

ServerSocketChannel,SocketChannel,DatagramChannel,Pipe都有open方法,可以用来打开通道,它们都属于网络编程,底层是要依赖操作系统层对应网络模块的实现,在Java中这3个通道和管道都是通过SelectorProvider来创建的,该Provider在不同平台上有不同的实现,JVM有一个“system-wide”的默认实现,它是单例的。
而FileChannel是对应于文件系统的,基于Java I/O,我们可以从FileInputStream,FileOutputStream,RandomAccessFile来获取一个对应于特定文件的通道。

我们进一步看看这四个具体的Channel实现那些接口,来分析它们各自有什么功能:

2.2 ServerSocketChannel:

线程安全;

public abstract class ServerSocketChannel
    extends AbstractSelectableChannel
    implements NetworkChannel
它的主要功能就是监听的某个地址和端口上的套接字请求,并打开SocketChannel;

2.3 SocketChannel:

线程安全,read/write都进行了同步控制;

public abstract class SocketChannel
    extends AbstractSelectableChannel
    implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel
ByteChannel实现了WritableChannel和ReadableChannel,因此它是可读写的;

Scatter/Gatther分别实现了将一个channel的内容读到多个buffer(一个Buffer满了才能读到下一个)和多个Buffer写到一个Channel的功能;

NetworkChannel:绑定到地址/端口的能力;

我们可以通过它来进行一个端到端的,有连接的套接字通信;


2.4 DatagramChannel:

线程安全的;

public abstract class DatagramChannel
    extends AbstractSelectableChannel
    implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, MulticastChannel
与SocketChannel接口上的不同在与MulticastChannel,它是NetworkChannel子类;增加了多播的功能,使得我们可以使用基于UDP套接字的多播功能;


2.5 Pipe:

管道一般可以在两个线程中进行单向的数据传输,它有两个嵌套类:SinkChannel和SourceChannel,分别负责在一个线程(发送者)写入和在另一个线程中读取:

public static abstract class SinkChannel
        extends AbstractSelectableChannel
        implements WritableByteChannel, GatheringByteChannel

public static abstract class SourceChannel
        extends AbstractSelectableChannel
        implements ReadableByteChannel, ScatteringByteChannel
基于上面的论述,相信已经可以很清除的明白它们各自有什么功能了:都可以非阻塞,一个负责写,并且可以将多个Buffer一起写入,一个负责读,可以从Channel中将数据读入多个Buffer;

2.6 FileChannel:

这个Channel是阻塞的,是操作磁盘文件一种方式,它和之前几个Channel大不相同,所以我要将它和下面要介绍的DirectByteBuffer(MappedByteBuffer)一起讨论。

3. Buffer:

总的来说,具体工作中使用Buffer的次数要远远多与Selector和Channel,我们通过它对Channel进行具体的读写操作。

之前说过NIO的特点之一就是面向缓存,我们在使用Buffer时都是基于一块分配指定的大小的固定内存进行操作的,只有两种分配方式:Heap和Direct,它们的区别下面会详细说明。无论我们进行视图转换(CharBuffer/IntBuffer等等),还是compact压缩,还是duplicate复制、slice切片,都是最初的allocate分配那一块内存。

3.1 Buffer的基本属性和重要方法:

capacity:容量;

limit:可操作的限制位置;

position:下一个操作位置;

mark:标记;

address:使用direct内存时的内存地址;

capacity,limit,position这3个属性是我们进行操作最关键和常用的,结合操作方法我们来看看它们的使用细节:

flip:limit=position,position=0;这个方法通常在从通道中读取数据后使用,这样我们可以再从Buffer中读取数据;或者在Buffer写入数据后调用,让通道写入;

rewind:position=0,mark=-1;

clear:position=0,limit=capacity,mark=-1;通常在重新从

remaining:limit-position;用于检查是否还有数据;

mark和reset:mark标记,mark=position;reset复位,position=mark(mark>0时),注意它并不会修改mark值;

compact:将原来(limit-position)未处理完的数据复制到开头,再将position移到数据的下一个位置,limit=capacity,这个方法是进行压缩,去掉已处理过的数据,主要   为了接下来将数据写入Buffer,注意,它只是在原数组上进行复制的,没有新分配空间;

另一个你可能需要注意的是equals方法和compareTo方法,它们比较的是limit-position之间的大小;

3.2 Buffer的体系结构:

首先,重要的事先说说: Buffer并不是线程安全的

来看看Buffer的体系:

Channel都是基于字节的,我们一般也从ByteBuffer开始;

       ByteBuffer具体实现(分配):

ByteBuffer有两个分配方法(它们返回HeapByteBuffer和DirectByteBuffer都是default包权限,我们无法直接使用它们):

allocate:HeapByteBuffer,从JVM堆中分配,收到JVM垃圾回收处理机制管理,实际上就是为了一个固定的byte[];

allocateDirect:DirectByteBuffer,使用JNI在native内存中分配,那怎么回收直接内存呢,DirectBuffer(DirectByteBuffer的接口),可以返回Cleaner,通过它我们可以释放直接内存,否则你就只能等待Full GC的发生来释放它了;

Buffer的视图:

在类图中我们可以看到有CharBuffer,IntBuffer,另外还有FloatBuffer,DoubleBuffer,ShortBuffer,LongBuffer以及MappedBuffer(特别的,内存映射);

它们实际上都是由ByteBuffer而产生,操作同一块内存,只是读取的方式不一样,以HeapByteBuffer和CharBuffer为例我们来看看它们是怎么完成“视图”的使命的。

转换方法

ByteBuffer.asCharBuffer();

HeapByteBuffer中是这样实现的:

public CharBuffer asCharBuffer() {
        int size = this.remaining() >> 1;
        int off = offset + position();
        return (bigEndian
                ? (CharBuffer)(new ByteBufferAsCharBufferB(this,
                                                               -1,
                                                               0,
                                                               size,
                                                               size,
                                                               off))
                : (CharBuffer)(new ByteBufferAsCharBufferL(this,
                                                               -1,
                                                               0,
                                                               size,
                                                               size,
                                                               off)));
    }
考虑到字顺这里有两个实现,新建一个ByteBufferAsCharBufferB还是操作那一块内存,只是我们换了一组capacity,limit,mark,position来操作;

而在具体的get/put方法中:

public char get() {
        return Bits.getCharB(bb, ix(nextGetIndex()));
    }
是通过Bits这个工具类来进行不同基本类型的读取和操作。 整个事情就是这样,基于字节ByteBuffer,考虑字顺用不同的方式去读写同一块内存

PS:对于CharBuffer和ByteBuffer之间的转换,涉及到编解码,Charset有ByteBuffer = encode(CharBuffer)和CharBuffer = decode(ByteBuffer);


4. FileChannel

同样它也是线程安全的。

4.1 打开FileChannel:

之前我们已经提到了它可通过FileInputStream、FileOutputStream和RandomAccessFile获得,但是它们具有不同的读写权限:
RandomAccessFile:rw
public final FileChannel getChannel() {
        synchronized (this) {
            if (channel == null) {
                channel = FileChannelImpl.open(fd, path, true, rw, this);
            }
            return channel;
        }
    }
FileOutputStream:
public FileChannel getChannel() {
        synchronized (this) {
            if (channel == null) {
                channel = FileChannelImpl.open(fd, path, false, true, append, this);
            }
            return channel;
        }
    }
FileInputStream:
public FileChannel getChannel() {
        synchronized (this) {
            if (channel == null) {
                channel = FileChannelImpl.open(fd, path, true, false, this);
            }
            return channel;
        }
    }
正如RandomAccessFile(可以seek方式前后读写文件),FileInputStream和FileOutputStream本身的差异一样,它们也具有不同的特点;

4.2 FileChannel的独特的方法:

写,读,关闭和其他Channel使用方式是一样的,我们来看看它对文件还有什么其他操作:
FileChannel.position(long):移到文件的特定位置,接下来你可以从这个位置写/读,你可以在文件结束符之后写入,这和我们在Unix中一样,它也可能会造成文件空洞。
FileChannel.truncate(long):截取前多少字节的数据;
FileChannel.size():返回关联文件大小;
FileChannel.force():将缓存内(操作系统为了提高性能)的数据强制写入磁盘;

4.3 transfer:

如果你需要在WritableChannel之间传递数据,使用transferTo和transferFrom是一个非常好的选择;
因为这种方式不需要将磁盘数据从内核空间复制到应用用户空间在传到另一个内核空间,而是直接在内核空间中通过“通道传输”;

4.4 FileChannel中的内存映射:

为了显示内存映射到底是怎么存储,可以看看创建MapByteBuffer的源码:
static MappedByteBuffer newMappedByteBuffer(int var0, long var1, FileDescriptor var3, Runnable var4) {
        if(directByteBufferConstructor == null) {
            initDBBConstructor();
        }

        try {
            MappedByteBuffer var5 = (MappedByteBuffer)directByteBufferConstructor.newInstance(new Object[]{new Integer(var0), new Long(var1), var3, var4});
            return var5;
        } catch (IllegalAccessException | InvocationTargetException | InstantiationException var7) {
            throw new InternalError(var7);
        }
    }
这里可以看到显然是通过直接内存的方式,但是这段代码是在FileChannel的map方法中调用的,它和allocateDirect是有区别的;要理解直接内存和内存映射中的原理需要一些重要的基础知识,才能真正弄清楚HeapByteBuffer,DirectByteBuffer,和map之间的区别。
(1)为什么FileInputStream和HeapByteBuffer相对要慢很多呢
Java中读取文件要依赖于内核的系统调用,将文件数据从磁盘读取到内核IO缓存区(内存中),再转到JVM堆中(上面已经看到了HeapByteBuffer中new byte[])。首先要明白为什么要建立内核IO缓冲区,就是为了减少磁盘的访问次数,因为磁盘IO效率很低,读到内核IO缓存区转到JVM堆又需要调用native方法,依赖系统调用,系统调用的开销有比较大,因为我们可以BufferedInputStream和HeapByteBuffer来建立缓冲区减少系统调用。尽管我们通过内核IO缓冲区和Java应用缓冲区两层缓存分别减少了耗时的磁盘IO和系统调用,但是仍然不能避免内核态切换到用户态,内核空间复制到用户空间这样的耗时操作。

(2)为什么allocateDirect要比传统方式快
如果你看了DirectByteBuffer的源码你就会发现其中并没有new byte[]这样在JVM Heap中分配的行为,用的是address(Buffer中定义,long,64位)。实际上DirectMemory使用的是native堆,因此避免了向JVM堆复制的开销,但是需要注意的一点是,allocateDirect方法本身的开销比allocate方法大,因为它依赖系统调用,因此我们使用时应该避免频繁调用它分配小块内存。
设置:JVM参数中,-XX:MaxDirectMemorySize可以控制它的大小,默认值为-Xmx的大小;
回收:FULL CG和我们手动通过Cleaner去释放;

PS:2015/10/24 补充
allocateDirect存在于内核空间,如果所有的操作都存在与内核空间中,可以减少内核空间向用户空间复制,因此很快,但是如果频繁的分配小的直接内存,系统调用的开销会抵消减少复制的好处;另外如果我们用很小的直接内存读取磁盘数据到内存(也就是从ByteBuffer中get出来使用)还是会产生复制,性能并不会有太大改善。而

(3)为什么内存映射更快呢
简单的说,内存映射并没有上面的分配行为,既不需要native堆也不需要JVM堆,它是以Java进程直接将需要读写的文件映射为虚拟内存,以内存的方式进行读写,这样读写自然要比它们要快的多。同时它也是线程间进行通信的一种方式,两个进程映射同一块虚拟内存,共享内存的方式进行通信。
通过FileChannel.map可以对指定文件或其部分进行内存映射,有三种模式:READ_ONLY, READ_WRITE, PRIVATE。

4.5 不同读取方式的对比:

public class DirectMemoryTest {
    private static final String TEST_FILE = "/home/yjh/test.file";

    public static void testNormal(String TEST_FILE) {
        System.out.print("Normal ");
        try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
            FileChannel fileChannel = inputStream.getChannel()) {
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            while(fileChannel.read(buffer) != -1) {
                buffer.flip();
                buffer.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void testDirect(String TEST_FILE) {
        System.out.print("Direct ");
        try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
            FileChannel fileChannel = inputStream.getChannel()) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
            while(fileChannel.read(buffer) != -1) {
                buffer.flip();
                buffer.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void testMapped(String TEST_FILE) {
        System.out.print("Mapped ");
        try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
            FileChannel fileChannel = inputStream.getChannel()) {
            MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
            byte[] b = new byte[1024];
            while(buffer.get(b).position() < buffer.limit()) {

            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void test(Consumer<String> consumer) {
        long startTime = System.currentTimeMillis();

        consumer.accept(TEST_FILE);

        long endTime = System.currentTimeMillis();

        System.out.println("Time consume: " + (endTime - startTime));
    }

    public static void main(String[] args) {
        test(DirectMemoryTest::testNormal);
        test(DirectMemoryTest::testDirect);
        test(DirectMemoryTest::testMapped);
    }
}
我对三种方式分别读取整个test文件的内容,该文件大小为500M,运行结果(单位:ms):
Normal Time consume: 930
Direct Time consume: 800
Mapped Time consume: 222

在这个例子中,allocateDirect的直接内存同样读取了很多次(500MB / 1024B)磁盘数据,调用了很多次系统调用,因此和传统方式的效率并不有太大提升。




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值