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

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

想写这个系列很久了,对自己也是个总结与提高。原来在学JAVA时,那些JAVA入门书籍会告诉你一些规律还有法则,但是用的时候我们一般很难想起来,因为我们用的少并且不知道为什么。知其所以然方能印象深刻并学以致用。


本篇文章针对堆外内存与DirectBuffer进行深入分析,了解Java对于堆外内存处理的机制,为下一篇文件IO做好准备


Java堆栈内存与堆外内存



1. 堆栈内存

堆栈内存指的是堆内存和栈内存:堆内存是GC管理的内存,栈内存是线程内存。

堆内存结构:


image.png


还有一个更细致的结构图(包括MetaSpace还有code cache):


image.png


我们看下面一段代码来简单理解下堆栈的关系:

public static void main(String[] args) {
    Object o = new Object();
}

其中new Object()是在堆上面分配,而Object o这个变量,是在main这个线程栈上面。

  • 应用程序所有的部分都使用堆内存,然后栈内存通过一个线程运行来使用。
  • 不论对象什么时候创建,他都会存储在堆内存中,栈内存包含它的引用。栈内存只包含原始值变量好和堆中对象变量的引用。
  • 存储在堆中的对象是全局可以被访问的,然而栈内存不能被其他线程所访问。
  • 通过JVM参数-Xmx我们可以指定最大堆内存大小,通过-Xss我们可以指定每个线程线程栈占用内存大小


2. 堆外内存


2.1. 广义的堆外内存

除了堆栈内存,剩下的就都是堆外内存了,包括了jvm本身在运行过程中分配的内存,codecache,jni里分配的内存,DirectByteBuffer分配的内存等等


2.2. 狭义的堆外内存 - DirectByteBuffer

而作为java开发者,我们常说的堆外内存溢出了,其实是狭义的堆外内存,这个主要是指java.nio.DirectByteBuffer在创建的时候分配内存,我们这篇文章里也主要是讲狭义的堆外内存,因为它和我们平时碰到的问题比较密切


为啥要使用堆外内存。通常因为:

  • 在进程间可以共享,减少虚拟机间的复制
  • 对垃圾回收停顿的改善:如果应用某些长期存活并大量存在的对象,经常会出发YGC或者FullGC,可以考虑把这些对象放到堆外。过大的堆会影响Java应用的性能。如果使用堆外内存的话,堆外内存是直接受操作系统管理( 而不是虚拟机 )。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。
  • 在某些场景下可以提升程序I/O操纵的性能。少去了将数据从堆内内存拷贝到堆外内存的步骤。


3. JNI调用与内核态及用户态


  • 内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。
  • 用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。


  • 系统调用:为了使上层应用能够访问到这些资源,内核为上层应用提供访问的接口


Java调用原生方法即JNI就是系统调用的一种。



我们举个例子,文件读取;Java本身并不能读取文件,因为用户态没有权限访问外围设备。需要通过系统调用切换内核态进行读取。


目前,JAVA的IO方式有基于流的传统IO还有基于块的NIO方式(虽然文件读取其实不是严格意义上的NIO,哈哈)。面向流意味着从流中一次可以读取一个或多个字节,拿到读取的这些做什么你说了算,这里没有任何缓存(这里指的是使用流没有任何缓存,接收或者发送的数据是缓存到操作系统中的,流就像一根水管从操作系统的缓存中读取数据)而且只能顺序从流中读取数据,如果需要跳过一些字节或者再读取已经读过的字节,你必须将从流中读取的数据先缓存起来。面向块的处理方式有些不同,数据是先被 读/写到buffer中的,根据需要你可以控制读取什么位置的数据。这在处理的过程中给用户多了一些灵活性,然而,你需要额外做的工作是检查你需要的数据是否已经全部到了buffer中,你还需要保证当有更多的数据进入buffer中时,buffer中未处理的数据不会被覆盖。

我们这里只分析基于块的NIO方式,在JAVA中这个块就是ByteBuffer。


4. Linux下零拷贝原理


大部分web服务器都要处理大量的静态内容,而其中大部分都是从磁盘文件中读取数据然后写到socket中。我们以这个过程为例子,来看下不同模式下Linux工作流程


4.1. 普通Read/Write模式

涉及的代码抽象:

//从文件中读取,存入tmp_buf
read(file, tmp_buf, len);
//将tmp_buf写入socket
write(socket, tmp_buf, len);

看上去很简单的步骤但是经过了很多复制:



  1. 当调用 read 系统调用时,通过 DMA(Direct Memory Access)将数据 copy 到内核模式
  2. 然后由 CPU 控制将内核模式数据 copy 到用户模式下的 buffer 中
  3. read 调用完成后,write 调用首先将用户模式下 buffer 中的数据 copy 到内核模式下的 socket buffer 中
  4. 最后通过 DMA copy 将内核模式下的 socket buffer 中的数据 copy 到网卡设备中传送。

从上面的过程可以看出,数据白白从内核模式到用户模式走了一圈,浪费了两次 copy(第一次,从kernel模式拷贝到user模式;第二次从user模式再拷贝回kernel模式,即上面4次过程的第2和3步骤。),而这两次 copy 都是 CPU copy,即占用CPU资源


4.2. sendfile模式


微信图片_20220624190030.jpg


通过 sendfile 传送文件只需要一次系统调用,当调用 sendfile 时:

  1. 首先通过 DMA copy 将数据从磁盘读取到 kernel buffer 中
  2. 然后通过 CPU copy 将数据从 kernel buffer copy 到 sokcet buffer 中
  3. 最终通过 DMA copy 将 socket buffer 中数据 copy 到网卡 buffer 中发送 sendfile 与 read/write 方式相比,少了 一次模式切换一次 CPU copy。但是从上述过程中也可以发现从 kernel buffer 中将数据 copy 到socket buffer 是没必要的。


4.3. sendfile模式改进

Linux2.4 内核对sendFile模式进行了改进:


微信图片_20220624190132.jpg


改进后的处理过程如下:

  1. DMA copy 将磁盘数据 copy 到 kernel buffer 中 2.向 socket buffer 中追加当前要发送的数据在 kernel buffer 中的位置和偏移量
  2. DMA gather copy 根据 socket buffer 中的位置和偏移量直接将 kernel buffer 中的数据 copy 到网卡上。

经过上述过程,数据只经过了 2 次 copy 就从磁盘传送出去了。(事实上这个 Zero copy 是针对内核来讲的,数据在内核模式下是 Zero-copy 的)。

当前许多高性能 http server 都引入了 sendfile 机制,如 nginx,lighttpd 等。


5. Java零拷贝实现的变化


Zero-Copy技术省去了将操作系统的read buffer拷贝到程序的buffer,以及从程序buffer拷贝到socket buffer的步骤,直接将read buffer拷贝到socket buffer. Java NIO中的FileChannal.transferTo()方法就是这样的实现

public void transferTo(long position,long count,WritableByteChannel target);

transferTo()方法将数据从一个channel传输到另一个可写的channel上,其内部实现依赖于操作系统对zero copy技术的支持。在unix操作系统和各种linux的发型版本中,这种功能最终是通过sendfile()系统调用实现。下边就是这个方法的定义:

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);


5.1. Linux 2.4之前的底层实现

和之前所述一样,我们用下面两幅图更清楚的展示一下发生的复制以及内核态用户态切换:


微信图片_20220624190153.jpg


微信图片_20220624190157.jpg


内核、用户态切换的次数只有两次,将数据的复制次只有三次(只有一次用到cpu资源) 在Linux2.4之后,我们可以将这仅有的一次cpu复制也去掉

相关文章
|
26天前
|
存储 缓存 安全
Java内存模型深度解析:从理论到实践####
【10月更文挑战第21天】 本文深入探讨了Java内存模型(JMM)的核心概念与底层机制,通过剖析其设计原理、内存可见性问题及其解决方案,结合具体代码示例,帮助读者构建对JMM的全面理解。不同于传统的摘要概述,我们将直接以故事化手法引入,让读者在轻松的情境中领略JMM的精髓。 ####
33 6
|
17天前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
21 0
|
1月前
|
存储 Java 编译器
Java内存模型(JMM)深度解析####
本文深入探讨了Java内存模型(JMM)的工作原理,旨在帮助开发者理解多线程环境下并发编程的挑战与解决方案。通过剖析JVM如何管理线程间的数据可见性、原子性和有序性问题,本文将揭示synchronized关键字背后的机制,并介绍volatile关键字和final关键字在保证变量同步与不可变性方面的作用。同时,文章还将讨论现代Java并发工具类如java.util.concurrent包中的核心组件,以及它们如何简化高效并发程序的设计。无论你是初学者还是有经验的开发者,本文都将为你提供宝贵的见解,助你在Java并发编程领域更进一步。 ####
|
27天前
|
存储 算法 Java
Java内存管理深度剖析与优化策略####
本文深入探讨了Java虚拟机(JVM)的内存管理机制,重点分析了堆内存的分配策略、垃圾回收算法以及如何通过调优提升应用性能。通过案例驱动的方式,揭示了常见内存泄漏的根源与解决策略,旨在为开发者提供实用的内存管理技巧,确保应用程序既高效又稳定地运行。 ####
|
6天前
|
存储 监控 Java
Java的NIO体系
通过本文的介绍,希望您能够深入理解Java NIO体系的核心组件、工作原理及其在高性能应用中的实际应用,并能够在实际开发中灵活运用这些知识,构建高效的Java应用程序。
24 5
|
19天前
|
存储 监控 算法
Java内存管理深度剖析:从垃圾收集到内存泄漏的全面指南####
本文深入探讨了Java虚拟机(JVM)中的内存管理机制,特别是垃圾收集(GC)的工作原理及其调优策略。不同于传统的摘要概述,本文将通过实际案例分析,揭示内存泄漏的根源与预防措施,为开发者提供实战中的优化建议,旨在帮助读者构建高效、稳定的Java应用。 ####
31 8
|
17天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
21天前
|
存储 算法 Java
Java 内存管理与优化:掌控堆与栈,雕琢高效代码
Java内存管理与优化是提升程序性能的关键。掌握堆与栈的运作机制,学习如何有效管理内存资源,雕琢出更加高效的代码,是每个Java开发者必备的技能。
46 5
|
19天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
19天前
|
安全 Java 程序员
Java内存模型的深入理解与实践
本文旨在深入探讨Java内存模型(JMM)的核心概念,包括原子性、可见性和有序性,并通过实例代码分析这些特性在实际编程中的应用。我们将从理论到实践,逐步揭示JMM在多线程编程中的重要性和复杂性,帮助读者构建更加健壮的并发程序。
下一篇
DataWorks