ITPub博客

首页 > 应用开发 > Java > 底层实现和优化——深入思考Java IO和NIO(一)

底层实现和优化——深入思考Java IO和NIO(一)

Java 作者:喝水会长肉 时间:2021-12-28 15:08:25 0 删除 编辑

我之前经常这么问自己,我真的懂JAVA IO和NIO吗?看着很简单,也经常使用,可是用过就忘了。

JAVA IO是指Java程序处理输入输出,现在通常是指文件和网络IO。NIO是JDK 1.4之后提供的新的IO方面的API。

Java IO和NIO虽然看起来很简单,但要说点什么出来,却似乎有什么如鲠在喉,无从说起。

本文从底层源码、理论图解和模型思考几个方面尝试 讲透JAVA IO和NIO。

Java IO

JDK 1.4之前提供了Java io包。

Java IO API的设计特点

在java.io包下提供了很多的API,包括Stream和Reader、Writer等。按照给编程者交付的信息不同,这些分为两类:

  • 字节流包括InputStream和 OutputStream。

    • InputStream是可读的,提供read函数,具体的实现有FileInputStream,SocketInputStream等

    • OutputStream可写的,提供write函数,具体的实现有FileInputStream,SocketInputStream等

  • 字符流包括Reader和Writer。

    • Reader是可读的字符流,提供read函数,比如从shell终端读取输入,具体的实现有FileReader等

    • Writer是可写的字符流,提供write函数,具体的实现有Printer和FileWriter等

Java IO API的底层原理

下面的这个例子将数据源读到的内容写入目的数组。

FileInputStream fis 
= 
new 
FileInputStream
(source
)
;

FileOutputStream fos = new FileOutputStream (des ) ;
byte [ ] bytes = new byte [ 1024 * 1024 ] ;
int len ;
while ( (len = fis . read (bytes ) ) != - 1 ) {
   fos . write (bytes , 0 , len ) ;
}
  • fis是文件输入流

  • fos是文件输出流

  • 文件流读完时, fis.read 返回-1

追踪代码,发现FileInputStream的read函数底层调用的是:

private native int readBytes(byte b[], int off, int len) throws IOException; 复制代码

这是native函数了,从Java代码没法看到具体的实现。通过查看openjdk的 c语言源码,可以看到readBytes的实现:

jint 
readBytes
(
JNIEnv 
*env
, jobject 
this
, jbyteArray bytes
,  jint off
, jint len
, jfieldID fid
)

{
   jint nread ;
   char stackBuf [ BUF_SIZE ] ;
   char *buf = NULL ;
    FD fd ;
//java学习交流:603835449  进入可领取学习资源及对十年开发经验大佬提问,免费解答!

    if ( IS_NULL (bytes ) ) {
        JNU_ThrowNullPointerException (env , NULL ) ;
        return - 1 ;
    }
    if ( outOfBounds (env , off , len , bytes ) ) {
        JNU_ThrowByName (env , "java/lang/IndexOutOfBoundsException" , NULL ) ;
        return - 1 ;
    }
    if (len == 0 ) {
        return 0 ;
    } else if (len > BUF_SIZE ) {
       buf = malloc (len ) ;
        if (buf == NULL ) {
            JNU_ThrowOutOfMemoryError (env , NULL ) ;
            return 0 ;
        }
    } else {
       buf = stackBuf ;
    }
   fd = getFD (env , this , fid ) ;
    if (fd == - 1 ) {
        JNU_ThrowIOException (env , "Stream Closed" ) ;
       nread = - 1 ;
    } else {
       nread = IO_Read (fd , buf , len ) ;
        if (nread > 0 ) {
            ( *env ) - > SetByteArrayRegion (env , bytes , off , nread , (jbyte * )buf ) ;
        } else if (nread == - 1 ) {
            JNU_ThrowIOExceptionWithLastError (env , "Read error" ) ;
        } else { /* EOF */
           nread = - 1 ;
        }
    }
    if (buf != stackBuf ) {
        free (buf ) ;
    }
    return nread ;
}

C语言的函数参数比native函数多了 JNIEnv *env, jobject this

可以看出,底层C语言代码的流程是:

  • malloc一个len( 要read的长度)长度的buf数组:  buf = malloc(len)

  • 系统调用read操作: IO_Read(fd, buf, len)

  • 将buf数据 复制到JVM的 bytes数组:  (*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf)

FileOutputStream的write函数实现也是用到了malloc,系统调用write。那么, 为什么要malloc一块新的内存出来做系统调用,而不是直接使用bytes数组来做系统调用呢?

这是 因为Linux提供的pread或者read等系统调用,只能操作一块固定的内存区域。这就意味着只能对direct memory进行操作,而heap memory中的对象在经历gc后 内存地址会发生改变,也就是 JVM heap区的内存对于虚拟机外的操作来说 不是一块固定的区域。

如下图:

  • buf:非JVM管理的内存,是 固定的

  • bytes:JVM中的heap对应的实际物理内存,因为垃圾收集的原因可能会移动

  • read等系统调用:经过 两次上下文切换,会将fd的内容先拷贝到内核态的缓冲区,再从内核态缓冲区拷贝到用户态的buf

  • 最终buf的数据要拷贝到我们为FileInputStream读操作在heap堆申请的bytes数组里面去,完成Java程序读文件的功能。

Java IO是阻塞的

我们知道系统调用read/write等是阻塞的。 阻塞在文件读写例子的时候还不明显,网络读写会出现明显的阻塞,因为网络连接的缓冲区里面不一定有数据,有数据的时候则read阻塞结束返回数据。

对于网络IO是这样的:

Java IO 的总结

上面讲到的Java IO,优点是api设计和用法简单,但也存在两个缺点:

  • 提供的api是阻塞的,对应的底层逻辑是系统调用read/write等

  • 比系统调用的开销还多一次内存分配和拷贝的过程

JAVA NIO

java IO的两个缺点开销很大,不能应对日益增长的高并发需求。 Java NIO的出现主要就是为了解决java IO的两个缺点,并满足日益增长的高并发需求。

本章会先简单描述nio api的设计特点,然后一步步阐述是怎么解决java IO的缺点的。以及满足高并发需求需要的设计,将在下一章进一步展开。

NIO API的设计特点

NIO的数据结构主要是 Buffer和 Channel,可以先简单学习下,有个大概印象。(如果已经了解则可以跳过这个章节)

  • Buffer接口,提供get/put等操作。实现该接口的类有ByteBuffer、LongBuffer和CharBuffer等,ByteBuffer的使用最广。

    • Buffer是一个存放数据的缓冲区,具体实现是提供position和limit等指针来对数据进行读写操作。

Channel接口是给Java程序提供read/write的接口api,具体的实现有:

    • 文件的 FileChannel

    • 网络的 SocketChannel、DatagramChannel

    • 管道:PipelineChannel

在NIO中,我们就是将 Buffer和 Channel两种数据结构组合在一起使用

具体表现在:

  • 将 准备好的ByteBuffer数据 写入channel

  • 将channel里 可用的数据读入ByteBuffer

熟练使用Buffer

Buffer是一个 可读可写有很多指针的数据结构,下图展示了对它的一系列操作:

  • 分配一个ByteBuffer ByteBuffer.allocate(12)后:position=0,cap=limit=12

    • 此时buffer为空,表示可以往里面写入数据

    • 当然要读也可以,只是读到的字节全是0,而且会改变position的值

  • 写入数据 byteBuffer.put("hello".getBytes())后: position=5,cap=limit=12

  • flip操作 byteBuffer.flip()之后:pos=0,cap=12,limit=5

    • 此时相当于改为可读的模式,position和limit之间的数据 [position,limit)是可读的。

    • 通道写完要读的时候,记得flip

  • 从通道读数据 byteBuffer.get(bytes)将buffer数据读到一个字节数组(长度为2)里面,之后position指向2的位置

  • 清理buffer byteBuffer.clear();之后,position等指针回到初始值,但是实际没有对buffer里面的变量进行任何改变

    • 准备往通道里面写数据的时候,一般记得先clear

深入理解Buffer

要理解NIO的Buffer,需要对Buffer的具体分配有清晰的理解。对于ByteBuffer,又有三种具体的实现:

  • HeapByteBuffer

  • DirectByteBuffer

  • MappedByteBuffer

其实这几种的区别就是他们在内存的分配不同,如下图: 

  • HeapByteBuffer是分配在JVM 堆内存上

  • DirectByteBuffer是使用的堆外内存

  • MappedByteBuffer是针对IO的文件描述符而言,通过mmap系统调用将外设的地址映射到内存,

mmap

进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset); 

在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址。mmap可以绕过读写文件的时候,内核态和用户态数据拷贝的过程

NIO read实现的底层原理

下面举个具体的例子,还是用于文件的拷贝:

FileChannel readChannel 
= fis
.
getChannel
(
)
;

FileChannel writeChannel = fos . getChannel ( ) ;
ByteBuffer byteBuffer = ByteBuffer . allocate ( 1024 * 1024 ) ; //1M缓冲区
while (readChannel . read (byteBuffer ) != - 1 ) {
   byteBuffer . flip ( ) ;
   writeChannel . write (byteBuffer ) ;
   byteBuffer . clear ( ) ;
}
  • FileInputStream可以通过 getChannel()得到 FileChannel

  • ByteBuffer.allocate(1024 * 1024)分配的是 HeapByteBuffer

那么,readChannel.read(byteBuffer)底层是怎么做的呢?

//java学习交流:603835449  进入可领取学习资源及对十年开发经验大佬提问,免费解答!

ByteBuffer bb = Util . getTemporaryDirectBuffer (dst . remaining ( ) ) ;
int n = readIntoNativeBuffer (fd , bb , position , nd ) ;

首先通过 getTemporaryDirectBuffer分配了一个临时的DirectBuffer,最终调用了native方法 pread0


static native int 
pread0
(FileDescriptor fd
, long address
, int len
,

                        long position ) throws IOException ;

C语言代码跟进去,发现,


JNIEXPORT jint 
JNICALL

Java_sun_nio_ch_FileDispatcherImpl_pread0 ( JNIEnv *env , jclass clazz , jobject fdo ,
                           jlong address , jint len , jlong offset
)
{
   jint fd = fdval (env , fdo ) ;
    void *buf = ( void * ) jlong_to_ptr (address ) ;

    return convertReturnVal (env , pread64 (fd , buf , len , offset ) , JNI_TRUE ) ;
}

对于Linux系统,这里实际也是调用了pread或pread64等系统调用。 这里为何需要先分配一个 DirectByteBuffer呢?其实这里和malloc是差不多的,而是由JVM直接管理这块内存。

这么看起来,和JAVA IO read的做法很相似。只是 原来的buf换为这里的 tmp DirectByteBuffer。 但假设这里的代码这么写,

FileChannel readChannel 
= fis
.
getChannel
(
)
;

FileChannel writeChannel = fos . getChannel ( ) ;
ByteBuffer byteBuffer = ByteBuffer . allocateDirect ( 1024 * 1024 ) ; //1M缓冲区
while (readChannel . read (byteBuffer ) != - 1 ) {
   byteBuffer . flip ( ) ;
   writeChannel . write (byteBuffer ) ;
   byteBuffer . clear ( ) ;
}

使用  ByteBuffer.allocateDirect(1024 * 1024)分配一个DirectByteBuffer,减少了一次用户态到用户态的数据拷贝过程。 

MappedByteBuffer

效率能否更高呢?MappedByteBuffer类就是为了进一步提升效率设计的。

@Test

public void test ( ) throws IOException {
   FileChannel inChannel = FileChannel . open (Paths . get ( "nio.dmg" ) , StandardOpenOption . READ ) ;
   FileChannel outChannel = FileChannel . open (Paths . get ( "nio2.dmg" ) , StandardOpenOption . WRITE ,StandardOpenOption . READ ,StandardOpenOption . CREATE ) ;
   System .out . println ( "outChannel = " + outChannel ) ;

   long size = inChannel . size ( ) ;
   System .out . println ( "size = " + size ) ;
   MappedByteBuffer inMappedBuffer = inChannel . map (FileChannel .MapMode . READ_ONLY , 0 , size ) ;


   byte [ ] bytes = new byte [inMappedBuffer . limit ( ) ] ;
   inMappedBuffer . get (bytes ) ;
    //可读的channel才可以建立mappedByteBuffer...
   MappedByteBuffer outMappedBuffer = outChannel . map (FileChannel .MapMode . READ_WRITE , 0 , size ) ;
   outMappedBuffer . put (bytes ) ;
}
  • 使用 MappedByteBuffer inMappedBuffer =inChannel.map(FileChannel.MapMode.READ_ONLY, 0, size)从inChannel获得一个MappedByteBuffer

  • 使用  MappedByteBuffer outMappedBuffer = outChannel.map(FileChannel.MapMode.READ_WRITE, 0, size);也获得另外一个MappedByteBuffer

  • 可以通过读取inMappedBuffer的数据,再put到outMappedBuffer里去

我们看看FileChannel的map函数:

MappedByteBuffer 
map
(
MapMode mode
, long position
, long size
)
{

   addr = map0 (imode , mapPosition , mapSize ) ;  
   FileDescriptor mfd ;
    try {
       mfd = nd . duplicateForMapping (fd ) ;
    } catch (IOException ioe ) {
        unmap0 (addr , mapSize ) ;
        throw ioe ;
    }
   int isize = (int )size ;
   Unmapper um = new Unmapper (addr , mapSize , isize , mfd ) ;
    if ( ( !writable ) || (imode == MAP_RO ) ) {
        return Util . newMappedByteBufferR (isize ,
                                        addr + pagePosition ,
                                        mfd ,
                                        um ) ;
    } else {
        return Util . newMappedByteBuffer (isize ,
                                       addr + pagePosition ,
                                       mfd ,
                                       um ) ;
    }
}


最终调用的是 private native long map0(int prot, long position, long length)函数, 查看Open JDK C语言的Linux系统的实现,发现其实就是调用了 mmap64。


JNIEXPORT jlong 
JNICALL

Java_sun_nio_ch_FileChannelImpl_map0 ( JNIEnv *env , jobject this ,
                                    jint prot , jlong off , jlong len , jboolean map_sync
) {
    void *mapAddress = 0 ;
   jobject fdo = ( *env ) - > GetObjectField (env , this , chan_fd ) ;
   jint fd = fdval (env , fdo ) ;
   int protections = 0 ;
   int flags = 0 ;
   mapAddress = mmap64 (
        0 ,                     /* Let OS decide location */
       len ,                   /* Number of bytes to map */
       protections ,           /* File permissions */
       flags ,                 /* Changes are shared */
       fd ,                   /* File descriptor of mapped file */
       off ) ;                 /* Offset into file */
    return ( (jlong ) (unsigned long ) mapAddress ) ;
}


使用 Channel.map分配的MappedByteBuffer,不仅没有用户态到用户态的数据拷贝过程,还减少了用户态到内核态的数据拷贝过程。

transferTo等API

@Test

public void test2 ( ) throws IOException {
   FileChannel inChannel = FileChannel . open (Paths . get ( "nio.dmg" ) , StandardOpenOption . READ ) ;
   FileChannel outChannel = FileChannel . open (Paths . get ( "nio2.dmg" ) , StandardOpenOption . WRITE ,StandardOpenOption . READ ,StandardOpenOption . CREATE ) ;
   inChannel . transferTo ( 0 ,inChannel . size ( ) ,outChannel ) ;
}


  • 底层是直接调用的 sendfile(srcFD, dstFD, position, &numBytes, NULL, 0)来实现的

  • 这个API相当于前面的那个例子,通过读取 src的MappedBuffer的数据,再put到 target的MappedBuffer里去

  • transferTo可用于 文件到文件的传输

  • transferTo也可用于 文件到tcp socket的传输

  • fileChannel的transferFrom也可以从 socket Channel或者file Channel读数据

  • kafka使用了Java NIO的 transferTo/trasferFrom零拷贝用于网络和文件的拷贝

NIO初总结

本节讲了NIO的一些基本用法和主要数据结构,包括:

  • Buffer的各种操作

  • FileChannel的read和write基本流程和实现原理;以及HeapByteBuf和DirectByteBuf的区别。

  • FileChannel的MappedByteBuf的用法和实现原理

  • FileChannel的transferTo的用法和原理

跟JAVA IO比起来,NIO通过Buffer的各种 增强设计,已经达到减少数据拷贝次数的目的。

下文将详述NIO在网络通信中是怎么解决 阻塞的API这个问题的。



来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70010294/viewspace-2849727/ ,如需转载,请注明出处,否则将追究法律责任。

请登录后发表评论 登录
全部评论

注册时间:2021-11-19

  • 博文量
    199
  • 访问量
    86383