Java 堆外内存、零拷贝、直接内存以及针对于NIO中的FileChannel的思考(中)

简介: Java 堆外内存、零拷贝、直接内存以及针对于NIO中的FileChannel的思考(中)

5.2. Linux 2.4之后的底层实现


微信图片_20220624190247.jpg


在内核为2.4或者以上版本的linux系统上,socket缓冲区描述符将被用来满足这个需求。这个方式不仅减少了内核用户态间的切换,而且也省去了那次需要cpu参与的复制过程。 从用户角度来看依旧是调用transferTo()方法,但是其本质发生了变化:

  1. 调用transferTo方法后数据被DMA从文件复制到了内核的一个缓冲区中。
  2. 数据不再被复制到socket关联的缓冲区中了,仅仅是将一个描述符(包含了数据的位置和长度等信息)追加到socket关联的缓冲区中。DMA直接将内核中的缓冲区中的数据传输给协议引擎,消除了仅剩的一次需要cpu周期的数据复制。


5.3 对于JAVA普通字节流IO与NIOFileChannel实现的零拷贝性能:

直接上源码:

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
public class FileCopyTest {
    /**
     * 通过字节流的方式复制文件
     * @param fromFile 源文件
     * @param toFile   目标文件
     * @throws FileNotFoundException 未找到文件异常
     */
    public static void fileCopyNormal(File fromFile, File toFile) throws FileNotFoundException {
        InputStream inputStream = null;
        OutputStream outputStream = null;
        try {
            inputStream = new BufferedInputStream(new FileInputStream(fromFile));
            outputStream = new BufferedOutputStream(new FileOutputStream(toFile));
            //用户态缓冲有1kB这么大,不算小了
            byte[] bytes = new byte[1024];
            int i;
            //读取到输入流数据,然后写入到输出流中去,实现复制
            while ((i = inputStream.read(bytes)) != -1) {
                outputStream.write(bytes, 0, i);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (inputStream != null) {
                    inputStream.close();
                }
                if (outputStream != null) {
                    outputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    /**
     * 用filechannel进行文件复制
     *
     * @param fromFile 源文件
     * @param toFile   目标文件
     */
    public static void fileCopyWithFileChannel(File fromFile, File toFile) {
        FileInputStream fileInputStream = null;
        FileOutputStream fileOutputStream = null;
        FileChannel fileChannelInput = null;
        FileChannel fileChannelOutput = null;
        try {
            fileInputStream = new FileInputStream(fromFile);
            fileOutputStream = new FileOutputStream(toFile);
            //得到fileInputStream的文件通道
            fileChannelInput = fileInputStream.getChannel();
            //得到fileOutputStream的文件通道
            fileChannelOutput = fileOutputStream.getChannel();
            //将fileChannelInput通道的数据,写入到fileChannelOutput通道
            fileChannelInput.transferTo(0, fileChannelInput.size(), fileChannelOutput);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (fileInputStream != null) {
                    fileInputStream.close();
                }
                if (fileChannelInput != null) {
                    fileChannelInput.close();
                }
                if (fileOutputStream != null) {
                    fileOutputStream.close();
                }
                if (fileChannelOutput != null) {
                    fileChannelOutput.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String[] args) throws IOException {
        File fromFile = new File("D:/readFile.txt");
        File toFile = new File("D:/outputFile.txt");
        //预热
        fileCopyNormal(fromFile, toFile);
        fileCopyWithFileChannel(fromFile, toFile);
        //计时
        long start = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            fileCopyNormal(fromFile, toFile);
        }
        System.out.println("fileCopyNormal time: " + (System.currentTimeMillis() - start));
        start = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            fileCopyWithFileChannel(fromFile, toFile);
        }
        System.out.println("fileCopyWithFileChannel time: " + (System.currentTimeMillis() - start));
    }
}

测试结果:

fileCopyNormal time: 14271
fileCopyWithFileChannel time: 6632

差了一倍多的时间(文件大小大概8MB),如果文件更大这个差距应该更加明显。


6. DirectBuffer分配


Java中NIO的核心缓冲就是ByteBuffer,所有的IO操作都是通过这个ByteBuffer进行的;Bytebuffer有两种: 分配HeapByteBuffer

ByteBuffer buffer = ByteBuffer.allocate(int capacity);


分配DirectByteBuffer

ByteBuffer buffer = ByteBuffer.allocateDirect(int capacity);

两者的区别:


微信图片_20220624190304.jpg


6.1. 为何HeapByteBuffer会多一次拷贝?

6.1.1. FileChannel的force api说明

FileChannel的force方法: FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入到FileChannel里的数据一定会即时写到磁盘上。要保证这一点,需要调用force()方法。 force()方法有一个boolean类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上。

6.1.3. 为何一定要复制到DirectByteBuffer来读写(系统调用)

首先,先说一点,执行native方法的线程,被认为是处于SafePoint,所以,会发生 NIO 如果不复制到 DirectByteBuffer,就会有 GC 发生重排列对象内存的情况(可以参考我的另一篇文章: https://blog.csdn.net/zhxdick/article/details/107450858)。

传统 BIO 是面向 Stream 的,底层实现可以理解为写入的是 byte 数组,调用 native 方法写入 IO,传的参数是这个数组,就算GC改变了内存地址,但是拿这个数组的引用照样能找到最新的地址,,对应的方法时是:FileOutputStream.write

private native void writeBytes(byte b[], int off, int len, boolean append)
        throws IOException;

但是NIO,为了提升效率,传的是内存地址,省去了一次间接应用,但是就必须用 DirectByteBuffer 防止内存地址改变,对应的是 NativeDispatcher.write

abstract int write(FileDescriptor fd, long address, int len)
        throws IOException;

那为何内存地址会改变呢?GC会回收无用对象,同时还会进行碎片整理,移动对象在内存中的位置,来减少内存碎片。DirectByteBuffer不受GC控制。如果不用DirectByteBuffer而是用HeapByteBuffer,如果在调用系统调用时,发生了GC,导致HeapByteBuffer内存位置发生了变化,但是内核态并不能感知到这个变化导致系统调用读取或者写入错误的数据。所以一定要通过不受GC影响的DirectByteBuffer(之前有笔误,

@郭LUA

感谢指正)来进行IO系统调用。

假设我们要从网络中读入一段数据,再把这段数据发送出去的话,采用Non-direct ByteBuffer的流程是这样的:

网络 –> 临时的DirectByteBuffer –> 应用 Non-direct ByteBuffer –> 临时的Direct ByteBuffer –> 网络

这种方式是直接在堆外分配一个内存(即,native memory)来存储数据, 程序通过JNI直接将数据读/写到堆外内存中。因为数据直接写入到了堆外内存中,所以这种方式就不会再在JVM管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。

采用Direct ByteBuffer的流程是这样的:

网络 –> 应用 Direct ByteBuffer –> 网络

可以看到,除开构造和析构临时Direct ByteBuffer的时间外,起码还能节约两次内存拷贝的时间。那么是否在任何情况下都采用Direct Buffer呢?

不是。对于大部分应用而言,两次内存拷贝的时间几乎可以忽略不计,而构造和析构DirectBuffer的时间却相对较长。在JVM的实现当中,某些方法会缓存一部分临时Direct ByteBuffer,意味着如果采用Direct ByteBuffer仅仅能节约掉两次内存拷贝的时间, 而无法节约构造和析构的时间。就用Sun的实现来说,write(ByteBuffer)和read(ByteBuffer)方法都会缓存临时Direct ByteBuffer,而write(ByteBuffer[])和read(ByteBuffer[])每次都生成新的临时Direct ByteBuffer。


6.1.2. FileChannel和SocketChannel依赖的IOUtil源码解析

无论是FileChannel还是SocketChannel,他们的读写方法都依赖IOUtil的相同方法,我们这里来看下: IOUtil.java

static int write(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
    //如果是DirectBuffer,直接写
    if (var1 instanceof DirectBuffer) {
        return writeFromNativeBuffer(var0, var1, var2, var4);
    } else {
        //非DirectBuffer
        //获取已经读取到的位置
        int var5 = var1.position();
        //获取可以读到的位置
        int var6 = var1.limit();
        assert var5 <= var6;
        //申请一个源buffer可读大小的DirectByteBuffer
        int var7 = var5 <= var6 ? var6 - var5 : 0;
        ByteBuffer var8 = Util.getTemporaryDirectBuffer(var7);
        int var10;
        try {
            var8.put(var1);
            var8.flip();
            var1.position(var5);
            //通过DirectBuffer写
            int var9 = writeFromNativeBuffer(var0, var8, var2, var4);
            if (var9 > 0) {
                var1.position(var5 + var9);
            }
            var10 = var9;
        } finally {
            //回收分配的DirectByteBuffer
            Util.offerFirstTemporaryDirectBuffer(var8);
        }
        return var10;
    }
}
//读的方法和写类似,这里省略


6.1.3. 为何一定要复制到DirectByteBuffer来读写(系统调用)

首先,先说一点,执行native方法的线程,被认为是处于SafePoint,所以,会发生 NIO 如果不复制到 DirectByteBuffer,就会有 GC 发生重排列对象内存的情况(可以参考我的另一篇文章: https://blog.csdn.net/zhxdick/article/details/107450858)。

传统 BIO 是面向 Stream 的,底层实现可以理解为写入的是 byte 数组,调用 native 方法写入 IO,传的参数是这个数组,就算GC改变了内存地址,但是拿这个数组的引用照样能找到最新的地址,,对应的方法时是:FileOutputStream.write

private native void writeBytes(byte b[], int off, int len, boolean append)
        throws IOException;

但是NIO,为了提升效率,传的是内存地址,省去了一次间接应用,但是就必须用 DirectByteBuffer 防止内存地址改变,对应的是 NativeDispatcher.write

abstract int write(FileDescriptor fd, long address, int len)
        throws IOException;

那为何内存地址会改变呢?GC会回收无用对象,同时还会进行碎片整理,移动对象在内存中的位置,来减少内存碎片。DirectByteBuffer不受GC控制。如果不用DirectByteBuffer而是用HeapByteBuffer,如果在调用系统调用时,发生了GC,导致HeapByteBuffer内存位置发生了变化,但是内核态并不能感知到这个变化导致系统调用读取或者写入错误的数据。所以一定要通过不受GC影响的DirectByteBuffer(之前有笔误,

@郭LUA

感谢指正)来进行IO系统调用。

假设我们要从网络中读入一段数据,再把这段数据发送出去的话,采用Non-direct ByteBuffer的流程是这样的:

网络 –> 临时的DirectByteBuffer –> 应用 Non-direct ByteBuffer –> 临时的Direct ByteBuffer –> 网络

这种方式是直接在堆外分配一个内存(即,native memory)来存储数据, 程序通过JNI直接将数据读/写到堆外内存中。因为数据直接写入到了堆外内存中,所以这种方式就不会再在JVM管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。


采用Direct ByteBuffer的流程是这样的:


网络 –> 应用 Direct ByteBuffer –> 网络

可以看到,除开构造和析构临时Direct ByteBuffer的时间外,起码还能节约两次内存拷贝的时间。那么是否在任何情况下都采用Direct Buffer呢?


不是。对于大部分应用而言,两次内存拷贝的时间几乎可以忽略不计,而构造和析构DirectBuffer的时间却相对较长。在JVM的实现当中,某些方法会缓存一部分临时Direct ByteBuffer,意味着如果采用Direct ByteBuffer仅仅能节约掉两次内存拷贝的时间, 而无法节约构造和析构的时间。就用Sun的实现来说,write(ByteBuffer)和read(ByteBuffer)方法都会缓存临时Direct ByteBuffer,而write(ByteBuffer[])和read(ByteBuffer[])每次都生成新的临时Direct ByteBuffer。

相关文章
|
1月前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
34 0
|
18天前
|
监控 Java API
探索Java NIO:究竟在哪些领域能大显身手?揭秘原理、应用场景与官方示例代码
Java NIO(New IO)自Java SE 1.4引入,提供比传统IO更高效、灵活的操作,支持非阻塞IO和选择器特性,适用于高并发、高吞吐量场景。NIO的核心概念包括通道(Channel)、缓冲区(Buffer)和选择器(Selector),能实现多路复用和异步操作。其应用场景涵盖网络通信、文件操作、进程间通信及数据库操作等。NIO的优势在于提高并发性和性能,简化编程;但学习成本较高,且与传统IO存在不兼容性。尽管如此,NIO在构建高性能框架如Netty、Mina和Jetty中仍广泛应用。
28 3
|
25天前
|
存储 监控 Java
Java的NIO体系
通过本文的介绍,希望您能够深入理解Java NIO体系的核心组件、工作原理及其在高性能应用中的实际应用,并能够在实际开发中灵活运用这些知识,构建高效的Java应用程序。
32 5
|
1月前
|
存储 监控 算法
Java内存管理深度剖析:从垃圾收集到内存泄漏的全面指南####
本文深入探讨了Java虚拟机(JVM)中的内存管理机制,特别是垃圾收集(GC)的工作原理及其调优策略。不同于传统的摘要概述,本文将通过实际案例分析,揭示内存泄漏的根源与预防措施,为开发者提供实战中的优化建议,旨在帮助读者构建高效、稳定的Java应用。 ####
42 8
|
1月前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
1月前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
1月前
|
安全 Java 程序员
Java内存模型的深入理解与实践
本文旨在深入探讨Java内存模型(JMM)的核心概念,包括原子性、可见性和有序性,并通过实例代码分析这些特性在实际编程中的应用。我们将从理论到实践,逐步揭示JMM在多线程编程中的重要性和复杂性,帮助读者构建更加健壮的并发程序。
|
1月前
|
存储 监控 算法
Java内存管理的艺术:深入理解垃圾回收机制####
本文将引领读者探索Java虚拟机(JVM)中垃圾回收的奥秘,解析其背后的算法原理,通过实例揭示调优策略,旨在提升Java开发者对内存管理能力的认知,优化应用程序性能。 ####
48 0
|
2月前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
427 1
|
2月前
|
Java
JVM内存参数
-Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80