1.Linux内核源码解析---万字解析从设计模式推演per-cpu实现原理
2.Ariane处理器源码剖析(五)续:MMU
3.深入分析堆外内存 DirectByteBuffer & MappedByteBuffer
4.通过do_execve源码分析程序的物理物理执行(上)(基于linux0.11)
5.Spring Boot引起的“堆外内存泄漏”排查及经验总结
Linux内核源码解析---万字解析从设计模式推演per-cpu实现原理
引子
在如今的大型服务器中,NUMA架构扮演着关键角色。内存内存它允许系统拥有多个物理CPU,源码源码不同NUMA节点之间通过QPI通信。物理物理虽然硬件连接细节在此不作深入讨论,内存内存但需明白每个CPU优先访问本节点内存,源码源码提前预知指标源码当本地内存不足时,物理物理可向其他节点申请。内存内存从传统的源码源码SMP架构转向NUMA架构,主要是物理物理为了解决随着CPU数量增多而带来的总线压力问题。
分配物理内存时,内存内存numa_node_id() 方法用于查询当前CPU所在的源码源码NUMA节点。频繁的物理物理内存申请操作促使Linux内核采用per-cpu实现,将CPU访问的内存内存变量复制到每个CPU中,以减少缓存行竞争和False Sharing,源码源码类似于Java中的Thread Local。
分配物理页
尽管我们不必关注底层实现,buddy system负责分配物理页,关键在于使用了numa_node_id方法。接下来,我们将深入探索整个Linux内核的per-cpu体系。
numa_node_id源码分析获取数据
在topology.h中,我们发现使用了raw_cpu_read函数,传入了numa_node参数。接下来,我们来了解numa_node的定义。
在topology.h中定义了numa_node。我们继续跟踪DECLARE_PER_CPU_SECTION的定义,最终揭示numa_node是一个共享全局变量,类型为int,存储在.data..percpu段中。
在percpu-defs.h中,numa_node被放置在ELF文件的.data..percpu段中,这些段在运行阶段即为段。接下来,我们返回raw_cpu_read方法。
在percpu-defs.h中,我们继续跟进__pcpu_size_call_return方法,此方法根据per-cpu变量的大小生成回调函数。对于numa_node的int类型,最终拼接得到的是raw_cpu_read_4方法。
在percpu.h中,调用了一般的read方法。在percpu.h中,获取numa_node的绝对地址,并通过raw_cpu_ptr方法。
在percpu-defs.h中,我们略过验证指针的环节,追踪arch_raw_cpu_ptr方法。接下来,我们来看x架构的实现。
在percpu.h中,php会员分成系统源码使用汇编获取this_cpu_off的地址,代表此CPU内存副本到".data..percpu"的偏移量。加上numa_node相对于原始内存副本的偏移量,最终通过解引用获得真正内存地址内的值。
对于其他架构,实现方式相似,通过获取自己CPU的偏移量,最终通过相对偏移得到pcp变量的地址。
放入数据
讨论Linux内核启动过程时,我们不得不关注per-cpu的值是如何被放入的。
在main.c中,我们以x实现为例进行分析。通过setup_percpu.c文件中的代码,我们将node值赋给每个CPU的numa_node地址处。具体计算方法通过early_cpu_to_node实现,此处不作展开。
在percpu-defs.h中,我们来看看如何获取每个CPU的numa_node地址,最终还是通过简单的偏移获取。需要注意如何获取每个CPU的副本偏移地址。
在percpu.h中,我们发现一个关键数组__per_cpu_offset,其中保存了每个CPU副本的偏移值,通过CPU的索引来查找。
接下来,我们来设计PER CPU模块。
设计一个全面的PER CPU架构,它支持UMA或NUMA架构。我们设计了一个包含NUMA节点的结构体,内部管理所有CPU。为每个CPU创建副本,其中存储所有per-cpu变量。静态数据在编译时放入原始数据段,动态数据在运行时生成。
最后,我们回到setup_per_cpu_areas方法的分析。在setup_percpu.c中,我们详细探讨了关键方法pcpu_embed_first_chunk。此方法管理group、unit、静态、保留、动态区域。
通过percpu.c中的关键变量__per_cpu_load和vmlinux.lds.S的链接脚本,我们了解了per-cpu加载时的地址符号。PERCPU_INPUT宏定义了静态原始数据的起始和结束符号。
接下来,我们关注如何分配per-cpu元数据信息pcpu_alloc_info。percpu.c中的方法执行后,元数据分配如下图所示。
接着,瑞数5 爬虫源码我们分析pcpu_alloc_alloc_info的方法,完成元数据分配。
在pcpu_setup_first_chunk方法中,我们看到分配的smap和dmap在后期将通过slab再次分配。
在main.c的mm_init中,我们关注重点区域,完成map数组的slab分配。
至此,我们探讨了Linux内核中per-cpu实现的原理,从设计到源码分析,全面展现了这一关键机制在现代服务器架构中的作用。
Ariane处理器源码剖析(五)续:MMU
虚拟存储器概念
在没有使用虚拟地址的系统中,处理器输出的地址直接送到物理存储器。而使用虚拟地址时,处理器输出的地址为虚拟地址,不会直接送到物理存储器,需要先进行地址转换。负责转换的部件称为MMU。
使用虚拟存储器不仅可以减少物理存储器容量需求,还有保护和共享等好处。虚拟地址通过页表(PT)映射到物理地址。页表存储虚拟地址到物理地址(***到PFN)的对应关系,表格大小取决于系统可用内存。页表结构不同于Cache,直接使用***寻址,无需Tag。
访问虚拟地址时,可能需要两次物理内存访问:先访问页表获取物理地址,再使用物理地址访问内存。现实中,处理器使用TLB和Cache加速过程。多级页表减少页表占用空间,TLB负责快速查找。缺页(Page Fault)发生时,从下级存储取页并更新页表。
操作系统使用页表控制每个页的访问权限,实现程序权限管理。写通(Write Through)方式在某些Cache间使用,写回(Write Back)类型Cache中,指令执行时仅更新D-Cache,物理内存更新可能延迟。
TLB(Translation Lookaside Buffer)作为页表缓存,提高访问速度。现代处理器采用两级TLB,容量和替换策略影响性能。TLB缺失可能由软件或硬件触发,随机替换算法适用于TLB。TLB写入确保页不被替换。控制TLB和Cache需管理进程ID等信息。
虚拟Cache通过虚拟地址寻址,与物理Cache不同,网页源码替换工具仍需TLB加速访问。虚拟Cache引入同义和同名问题,通过进程ID解决。控制Cache包括写操作、寻址策略等。将TLB和Cache放入流水线优化性能,限制了Cache大小。使用Virtually-Indexed, Virtually-Tagged方式,虚拟Cache与物理Cache结合解决重名问题。
MMU模块、TLB、虚拟内存系统、PTW等组件实现虚拟存储器功能。通过不同策略优化访问速度和内存使用。
深入分析堆外内存 DirectByteBuffer & MappedByteBuffer
大家好,我是大明哥,一个专注于「死磕 Java」系列创作的硬核程序员。本文内容已收录在我的技术网站:。
ByteBuffer有两种特殊类:DirectByteBuffer和MappedByteBuffer,它们的原理都是基于内存文件映射的。
ByteBuffer分为直接和间接两种。
我们先了解几个基本概念。
操作系统为什么要区分真实内存(物理内存)和虚拟内存呢?这是因为如果我们只使用物理内存会有很多问题。
对于常用的Linux操作系统而言,虚拟内存一般是4G,其中1G为系统内存,3G为应用程序内存。
进程使用的是虚拟内存,但我们数据还是存储在物理内存上,那么虚拟内存是怎样和物理内存对应起来的呢?答案是页表,虚拟内存和物理内存建立对应关系采用的是页表页映射的方式。
页表记录了虚拟内存每个页和物理内存之间的对应关系,具体如下:
它有两个栏位:有效位和路径。
当CPU寻址时,它有三种状态:
CPU访问虚拟内存地址过程如下:
下面是Linux进程的虚拟内存结构:
注意其中一块区域“Memory mapped region for shared libraries”,这块区域就是内存映射文件时将某一段虚拟地址和文件对象的某一部分建立映射关系,此时并没有拷贝数据到内存中,而是当进程代码第一次引用这段代码内的虚拟地址时,触发了缺页异常,这时候OS根据映射关系直接将文件的相关部分数据拷贝到进程的用户私有空间中去,当有操作第N页数据的时候重复这样的OS页面调度程序操作。这样就减少了文件拷贝到内核空间,再拷贝到用户空间,效率比标准IO高。
接下来,我们分析MappedByteBuffer和DirectByteBuffer的类图:
MappedByteBuffer是一个抽象类,DirectByteBuffer则是它的子类。
MappedByteBuffer作为抽象类,其实它本身还是非常简单的。定义如下:
在父类Buffer中有一个非常重要的属性address,这个属性表示分配堆外内存的2023新大圣棋牌源码地址,是为了在JNI调用GetDirectBufferAddress时提升它调用的速率。这个属性我们在后面会经常用到,到时候再分析。
MappedByteBuffer作为ByteBuffer的子类,它同时也是一个抽象类,相比ByteBuffer,它新增了三个方法:
与传统IO性能对比:
相比传统IO,MappedByteBuffer只有一个字,快!!!它之所以快,在于它采用了direct buffer(内存映射)的方式来读取文件内容。这种方式是直接调动系统底层的缓存,没有JVM,少了内核空间和用户空间之间的复制操作,所以效率大大提高了。那么它相比传统IO快了多少呢?下面我们来做个小实验。
通过更改size的数字,我们可以生成k,1M,M,M,1G五个文件,我们就这两个文件来对比MappedByteBuffer和传统IO读取文件内容的性能。
大明哥电脑是GB的MacBook Pro,对k,1M,M,M,1G五个文件的测试结果如下:
绿色是传统IO读取文件的,蓝色是使用MappedByteBuffer来读取文件的,从图中我们可以看出,文件越大,两者读取速度差距越大,所以MappedByteBuffer一般适用于大文件的读取。
父类MappedByteBuffer做了基本的介绍,且与传统IO做了一个对比,这里就不对DirectByteBuffer做介绍了,咱们直接撸源码,撸了源码后我相信你对堆外内存会有更加深入的了解。
DirectByteBuffer是包访问级别,其定义如下:
DirectByteBuffer可以通过ByteBuffer.allocateDirect(int capacity)进行构造。
调用DirectByteBuffer构造函数:
这段代码中有三个方法非常重要:
下面就来逐个分析这三段代码。
这段代码有两个作用
maxMemory=VM.maxDirectMemory(),获取JVM允许申请的最大DirectByteBuffer的大小,该参数可通过XX:MaxDirectMemorySize来设置。这里需要注意的是-XX:MaxDirectMemorySize限制的是总cap,而不是真实的内存使用量,因为在页对齐的情况下,真实内存使用量和总cap是不同的。
tryReserveMemory()可以统计DirectByteBuffer占用总内存的大小,如果发现堆外内存无法再次分配DirectByteBuffer则会返回false,这个时候会调用jlra.tryHandlePendingReference()来进行会触发一次非堵塞的Reference#tryHandlePending(false),通过注释我们了解了该方法主要还是协助ReferenceHandler内部线程进行下一次pending的处理,内部主要是希望遇到Cleaner,然后调用Cleaner#clean()进行堆外内存的释放。
如果还不行的话那就只能调用System.gc();了,但是我们需要注意的是,调用System.gc();并不能马上就可以执行full gc,所以就有了下面的代码,下面代码的核心意思是,尝试9次,如果依然没有足够的堆外内存来进行分配的话,则会抛出异常OutOfMemoryError("Direct buffer memory")。每次尝试之前都会Thread.sleep(sleepTime),给系统足够的时间来进行full gc。
总体来说Bits.reserveMemory(size, cap)就是用来统计系统中DirectByteBuffer到底占用了多少,同时通过进行GC操作来保证有足够的内存空间来创建本次的DirectByteBuffer对象。所以对于堆外内存DirectByteBuffer我们依然可以不需要手动去释放内存,直接交给系统就可以了。还有一点需要注意的是,别设置-XX:+DisableExplicitGC,否则System.gc();就无效了。
到了这段代码我们就知道了,我们有足够的空间来创建DirectByteBuffer对象了.unsafe.allocateMemory(size)是一个native方法,它是在堆外内存(C_HEAP)中分配一块内存空间,并返回堆外内存的基地址。
这段代码其实就是创建一个Cleaner对象,该对象用于对DirectByteBuffer占用的堆外内存进行清理,调用create()来创建Cleaner对象,该对象接受两个参数:
调用Cleaner#clean()进行清理,该方法其实就是调用thunk#run(),也就是Deallocator#run():
方法很简单就是调用unsafe.freeMemory()释放指定堆外内存地址的内存空间,然后重新统计系统中DirectByteBuffer的大小情况。
Cleaner是PhantomReference的子类,PhantomReference是虚引用,熟悉JVM的小伙伴应该知道虚引用的作用是跟踪垃圾回收器收集对象的活动,当该对象被收集器回收时收到一个系统通知,所以Cleaner的作用就是能够保证JVM在回收DirectByteBuffer对象时,能够保证相对应的堆外内存也释放。
在创建DirectByteBuffer对象的时候,会new一个Cleaner对象,该对象是PhantomReference的子类,PhantomReference为虚引用,它的作用在于跟踪垃圾回收过程,并不会对对象的垃圾回收过程造成任何的影响。
当DirectByteBuffer对象从pending状态->enqueue状态,它会触发Cleaner#clean()。
在clean()方法中其实就是调用thunk.run(),该方法有DirectByteBuffer的内部类Deallocator来实现:
直接用unsafe.freeMemory()释放堆外内存了,这个address就是分配堆外内存的内存地址。
关于堆外内存DirectByteBuffer就介绍到这里,我相信小伙伴们一定有所收获。下面大明哥介绍堆内内存:HeapByteBuffer。
通过do_execve源码分析程序的执行(上)(基于linux0.)
execve函数是操作系统的关键功能,它允许程序转变为进程。本文通过剖析do_execve源码,揭示程序转变成进程的机制。do_execve被视为系统调用,其运行过程在前文已有详细解析,此处不再赘述。分析将从sys_execve函数开启。
在执行_do_execve前,先审视内核栈。接下来,我们将深入理解do_execve的实现。
在加载可执行文件时,存在两种情况:编译后的二进制文件与脚本文件。脚本文件需加载对应解释器,本文仅探讨编译后的二进制文件。解析流程如下:首先验证文件可执行性和当前进程权限,通过后,仅加载头部数据,具体代码在真正运行时通过缺页中断加载。然后,申请物理内存并存储环境变量和参数,该步骤在copy_string函数中实现。
完成上述步骤后,内核栈结构发生变化。接着,执行代码释放原进程页目录和页表项信息,解除物理地址映射,这些信息通过fork继承。随后,调用change_ldt函数设置代码段、数据段基地址和限长,其中数据段限长为MB,代码段限长根据执行文件头部信息确定。完成物理地址映射后,内存布局随之调整。
紧接着,通过create_tables函数分配执行环境变量和参数的数组。执行完毕后,内存布局进一步调整。最后,设置栈、堆位置,以及eip为执行文件头部指定值,esp为当前栈位置,至此,可执行文件加载阶段完成。下文将探讨执行第一条指令后的后续步骤。
Spring Boot引起的“堆外内存泄漏”排查及经验总结
为了更好地实现对项目的管理,我们将组内一个项目迁移到MDP框架(基于Spring Boot),随后我们就发现系统会频繁报出Swap区域使用量过高的异常。笔者被叫去帮忙查看原因,发现配置了4G堆内内存,但是实际使用的物理内存竟然高达7G,确实不正常。JVM参数配置是“-XX:MetaspaceSize=M -XX:MaxMetaspaceSize=M -XX:+AlwaysPreTouch -XX:ReservedCodeCacheSize=m -XX:InitialCodeCacheSize=m, -Xssk -Xmx4g -Xms4g,-XX:+UseG1GC -XX:G1HeapRegionSize=4M”,实际使用的物理内存如下图所示:
使用Java层面的工具定位内存区域(堆内内存、Code区域或者使用unsafe.allocateMemory和DirectByteBuffer申请的堆外内存)。
笔者在项目中添加-XX:NativeMemoryTracking=detailJVM参数重启项目,使用命令jcmd pid VM.native_memory detail查看到的内存分布如下:
发现命令显示的committed的内存小于物理内存,因为jcmd命令显示的内存包含堆内内存、Code区域、通过unsafe.allocateMemory和DirectByteBuffer申请的内存,但是不包含其他Native Code(C代码)申请的堆外内存。所以猜测是使用Native Code申请内存所导致的问题。
为了防止误判,笔者使用了pmap查看内存分布,发现大量的M的地址;而这些地址空间不在jcmd命令所给出的地址空间里面,基本上就断定就是这些M的内存所导致。
使用系统层面的工具定位堆外内存。
因为已经基本上确定是Native Code所引起,而Java层面的工具不便于排查此类问题,只能使用系统层面的工具去定位问题。
首先,使用了gperftools去定位问题。
从上图可以看出:使用malloc申请的的内存最高到3G之后就释放了,之后始终维持在M-M。笔者第一反应是:难道Native Code中没有使用malloc申请,直接使用mmap/brk申请的?(gperftools原理就使用动态链接的方式替换了操作系统默认的内存分配器(glibc)。)
然后,使用strace去追踪系统调用。
因为使用gperftools没有追踪到这些内存,于是直接使用命令“strace -f -e"brk,mmap,munmap" -p pid”追踪向OS申请内存请求,但是并没有发现有可疑内存申请。
接着,使用GDB去dump可疑内存。
因为使用strace没有追踪到可疑内存申请;于是想着看看内存中的情况。就是直接使用命令gdp -pid pid进入GDB之后,然后使用命令dump memory mem.bin startAddress endAddressdump内存,其中startAddress和endAddress可以从/proc/pid/smaps中查找。然后使用strings mem.bin查看dump的内容,如下:
从内容上来看,像是解压后的JAR包信息。读取JAR包信息应该是在项目启动的时候,那么在项目启动之后使用strace作用就不是很大了。所以应该在项目启动的时候使用strace,而不是启动完成之后。
再次,项目启动时使用strace去追踪系统调用。
项目启动使用strace追踪系统调用,发现确实申请了很多M的内存空间,截图如下:
使用该mmap申请的地址空间在pmap对应如下:
最后,使用jstack去查看对应的线程。
因为strace命令中已经显示申请内存的线程ID。直接使用命令jstack pid去查看线程栈,找到对应的线程栈(注意进制和进制转换)如下:
这里基本上就可以看出问题来了:MCC(美团统一配置中心)使用了Reflections进行扫包,底层使用了Spring Boot去加载JAR。因为解压JAR使用Inflater类,需要用到堆外内存,然后使用Btrace去追踪这个类,栈如下:
然后查看使用MCC的地方,发现没有配置扫包路径,默认是扫描所有的包。于是修改代码,配置扫包路径,发布上线后内存问题解决。
为什么堆外内存没有释放掉呢?
虽然问题已经解决了,但是有几个疑问。带着疑问,直接看了一下 Spring Boot Loader那一块的源码。发现Spring Boot对Java JDK的InflaterInputStream进行了包装并且使用了Inflater,而Inflater本身用于解压JAR包的需要用到堆外内存。而包装之后的类ZipInflaterInputStream没有释放Inflater持有的堆外内存。于是以为找到了原因,立马向Spring Boot社区反馈了这个bug。但是反馈之后,就发现Inflater这个对象本身实现了finalize方法,在这个方法中有调用释放堆外内存的逻辑。也就是说Spring Boot依赖于GC释放堆外内存。
使用jmap查看堆内对象时,发现已经基本上没有Inflater这个对象了。于是就怀疑GC的时候,没有调用finalize。带着这样的怀疑,把Inflater进行包装在Spring Boot Loader里面替换成自己包装的Inflater,在finalize进行打点监控,结果finalize方法确实被调用了。于是又去看了Inflater对应的C代码,发现初始化的使用了malloc申请内存,end的时候也调用了free去释放内存。
此时,怀疑free的时候没有真正释放内存,便把Spring Boot包装的InflaterInputStream替换成Java JDK自带的,发现替换之后,内存问题也得以解决了。
再次看gperftools的内存分布情况,发现使用Spring Boot时,内存使用一直在增加,突然某个点内存使用下降了好多(使用量直接由3G降为M左右)。这个点应该就是GC引起的,内存应该释放了,但是在操作系统层面并没有看到内存变化,那是不是没有释放到操作系统,被内存分配器持有了呢?
继续探究,发现系统默认的内存分配器(glibc 2.版本)和使用gperftools内存地址分布差别很明显,2.5G地址使用smaps发现它是属于Native Stack。内存地址分布如下:
到此,基本上可以确定是内存分配器在捣鬼;搜索了一下glibc M,发现glibc从2.开始对每个线程引入内存池(位机器大小就是M内存),原文如下:
按照文中所说去修改MALLOC_ARENA_MAX环境变量,发现没什么效果。查看tcmalloc(gperftools使用的内存分配器)也使用了内存池方式。
为了验证是内存池搞的鬼,就简单写个不带内存池的内存分配器。使用命令gcc zjbmalloc.c -fPIC -shared -o zjbmalloc.so生成动态库,然后使用export LD_PRELOAD=zjbmalloc.so替换掉glibc的内存分配器。其中代码Demo如下:
通过在自定义分配器当中埋点可以发现实际申请的堆外内存始终在M-M之间,gperftools监控显示内存使用量也是在M-M左右。但是从操作系统角度来看进程占用的内存差别很大(这里只是监控堆外内存)。
使用不同分配器进行不同程度的扫包,占用的内存如下:
为什么自定义的malloc申请M,最终占用的物理内存在1.7G呢?因为自定义内存分配器采用的是mmap分配内存,mmap分配内存按需向上取整到整数个页,所以存在着巨大的空间浪费。通过监控发现最终申请的页面数目在k个左右,那实际上向系统申请的内存等于k * 4k(pagesize) = 2G。
为什么这个数据大于1.7G呢?因为操作系统采取的是延迟分配的方式,通过mmap向系统申请内存的时候,系统仅仅返回内存地址并没有分配真实的物理内存。只有在真正使用的时候,系统产生一个缺页中断,然后再分配实际的物理Page。
整个内存分配的流程如上图所示。MCC扫包的默认配置是扫描所有的JAR包。在扫描包的时候,Spring Boot不会主动去释放堆外内存,导致在扫描阶段,堆外内存占用量一直持续飙升。当发生GC的时候,Spring Boot依赖于finalize机制去释放了堆外内存;但是glibc为了性能考虑,并没有真正把内存归返到操作系统,而是留下来放入内存池了,导致应用层以为发生了“内存泄漏”。所以修改MCC的配置路径为特定的JAR包,问题解决。在发表这篇文章时,发现Spring Boot的最新版本(2.0.5.RELEASE)已经做了修改,在ZipInflaterInputStream主动释放了堆外内存不再依赖GC;所以Spring Boot升级到最新版本,这个问题也可以得到解决。