1.深入分析堆外内存 DirectByteBuffer & MappedByteBuffer
2.linux内核调试之 crash分析dump文件
3.Linux内核源码解析---万字解析从设计模式推演per-cpu实现原理
4.ART 深入浅出 - 为何 Thread.getStackTrace() 会崩溃?
深入分析堆外内存 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,这个属性表示分配堆外内存的地址,是云鲸源码为了在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做了一个对比,apache源码测试这里就不对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();就无效了。
到了这段代码我们就知道了,jsp源码设置我们有足够的空间来创建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。
linux内核调试之 crash分析dump文件
Linux 下有多个内存转储分析工具,如 lcrash、Alicia、Crash。Crash 是一个由 Dave Anderson 开发并维护的内存转储分析工具,当前版本为5.0.0。在没有统一标准的asp出题源码内存转储文件格式的情况下,Crash 支持多种格式。
Crash 的命令格式如下:crash [OPTION]... NAMELIST MEMORY-IMAGE[@ADDRESS]其中,namelist 是用于调试版本内核的名称列表,通常需要自定义编译,或者从发行版网站下载包含内核的/usr/lib/debug/lib/modules/内核版本/vmlinux软件包。而memory-image是转存的某种格式的dump文件。
为了使用 Crash,需要安装相应的kernel-debuginfo和debug-info-common软件包,如 CentOS 8 下,可以从debuginfo.centos.org/8/...下载安装包。
使用 Crash 的命令提示符执行相关操作。Crash 内置命令用于查看寄存器值、调用堆栈等信息,这些命令与 gdb 相似。
例如,bt命令用于打印内核堆栈,可以列出所有内核堆栈或指定进程的堆栈。使用 bt + pid列出特定进程的堆栈,bt -f列出所有堆栈详细信息,bt -p仅打印崩溃线程的内核栈。
dmesg命令用于查看崩溃时的内核日志信息。
dis命令用于反汇编地址或函数,显示该地址对应的源码。例如,dis -l显示特定行号的源码。
rd命令用于读取内存内容。
mod命令用于查看、加载模块的符号调试信息。需要加载包含符号信息的模块。
x/FMT命令用于查看内存内容,FMT参数包括大小、格式和长度。
sym命令用于将虚拟地址转换为符号。
ps命令用于打印内核崩溃时的进程信息。
file命令用于打印指定进程的文件打开列表。
Crash 还支持如 vm [pid]查看进程的虚拟地址空间,task [pid]查看进程的task_struct和thread_info信息,以及kmem -I查看内存使用情况。
Crash 可以用于实际测试,如主动触发崩溃情况分析和分析空指针产生的 core dump 文件。在实验中,内核版本为 4..0-..1.el8_2.x_,Crash 版本为 7.2.7-3.el8,且使用了 kexec-tool。
以上是 Crash 工具的主要功能和使用方法,通过这些命令,开发者可以深入分析内存转储文件,定位并解决潜在的内存错误。
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中,使用汇编获取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中的方法执行后,元数据分配如下图所示。
接着,我们分析pcpu_alloc_alloc_info的方法,完成元数据分配。
在pcpu_setup_first_chunk方法中,我们看到分配的smap和dmap在后期将通过slab再次分配。
在main.c的mm_init中,我们关注重点区域,完成map数组的slab分配。
至此,我们探讨了Linux内核中per-cpu实现的原理,从设计到源码分析,全面展现了这一关键机制在现代服务器架构中的作用。
ART 深入浅出 - 为何 Thread.getStackTrace() 会崩溃?
前言
Thread 类的 getStackTrace() 方法是日常开发中常用的工具,特别是用于卡顿检测方案,如周期性调用 Thread.getStackTrace() 或 Thread.getAllStackTraces 获取主线程调用栈。然而,在频繁调用时,有时会引发崩溃现象。崩溃栈显示关键调用链路涉及 VMStack_getThreadStackTrace()、ThreadList::SuspendThreadByPeer()、ThreadSuspendByPeerWarning()、~LogMessage() 和 Runtime::Abort() 等。接下来,我们将逐步分析这一过程及其原因。
Thread.getStackTrace 源码分析
在 ART 源码版本 Android 中,核心调用在于 VMStack.cc 文件的 GetThreadStack 方法。关键步骤已用注释标记。GetThreadStack() 内部逻辑包括挂起线程、调用回调函数生成调用栈以及恢复线程。挂起线程的主要方法是 SuspendThreadByPeer(),该函数包含多步骤,但主要涉及初始化变量、循环检查目标线程状态、设置挂起标志位以及循环判断目标线程是否挂起,直至超时。
关键点之一在于,当超时时调用 ThreadSuspendByPeerWarning() 函数,其内部 LOG 调用会在严重级别为 FATAL 时直接触发 Abort。这就是文章开头提到的崩溃栈的原因。通常,为避免此崩溃,可以使用 ThreadList::SuspendThreadByThreadId() 函数,该函数在超时时仅产生 WARNING 级别的 LOG,并不会终止运行。
超时时间由 thread_suspend_timeout_ns_ 变量决定,此变量在 Runtime 初始化时传入 ThreadList,若未指定,则默认值在 thread_list.h 文件中。默认值为 秒,即时间单位为纳秒。因此, 秒的默认超时时间是导致问题的原因之一。
另一个关键点涉及 ART 如何实际挂起线程。关键代码是 suspended_thread->ModifySuspendCount(),它设置挂起标志位。该函数的原理已通过注释解释。此外,从检查点的角度出发,Java 中的 Check Point 概念在解释执行和机器码执行过程中起到暂停当前指令执行的作用,从而挂起当前线程。检查点存在于 Java 指令执行过程中的特定位置,如 switch/case 语句。
总结
通过深入分析,我们知道 Java 层的 Thread.getStackTrace() 方法本质上是将目标线程设置为请求挂起的状态,然后循环判断线程是否挂起。这一过程依赖于各个检查点的执行,从而在调用栈生成过程中引发超时。因此,目标线程迟迟未能执行到检查点是 Thread.getStackTrace() 方法超时的根本原因。