写点什么

28 天面试突击:JVM+Redis

  • 2022 年 5 月 03 日
  • 本文字数:6305 字

    阅读完需:约 21 分钟

首先我们来先看看虚拟内存与物理内存,虚拟内存和物理内存的关系印证了一句名言,「操作系统中的任何问题都可以通过一个抽象的中间层来解决」,虚拟内存正是如此。



没有虚拟内存,进程直接就可能修改其它进程的内存数据,虚拟内存的出现对内存使用做好了隔离,每个进程拥有独立的、连续的、统一的虚拟地址空间(好一个错觉)。像极了一个恋爱中的男人,拥有了她,仿佛拥有了全世界。


应用程序看到的都是虚拟内存,通过 MMU 进行虚拟内存到物理内存的映射,我们知道 linux 内存是按 4k 对齐,4k = 2^12 ,虚拟地址中的低 12 位其实是一个偏移量。


现在我们把页表想象为一个一维数组,对于虚拟地址中的每一页,都分配数组的一个槽位,这个槽位指向物理地址中的真正地址。那么有这么一个虚拟内存地址 0x1234010,那 0x010 就是页内偏移量,0x1234 是虚拟页号,CPU 通过 MMU 找到 0x1234 映射的物理内存页地址,假定为 0x2b601000,然后加上页内偏移 0x010,就找到了真正的物理内存地址 0x2b601010。如下图所示。


Linux 四级页表

但是这种方式有一个很明显的问题,虚拟地址空间可能会非常大,就算拿 32 位的系统为例,虚拟地址空间为 4GB,用户空间内存大小为 3GB,每页大小为 4kB,数组的大小为 786432(1024 * 1024)。每个页表项用 4 个字节来存储,这样 4GB 的空间映射就需要 3MB 的内存来存储映射表。(备注:这里很多资料说的是 4M,也没有太大的问题,我这里的考虑是内核空间是共用的,不用太过于纠结。)


对于单个进程来说,占用 3M 看起来没有什么,但是页表是进程独占的,每个进程都需要自己的页表,如果有一百个进程,就会占用 300MB 的内存,这还仅仅是做地址映射所花的内存。如果考虑 64 位系统超大虚拟地址空间的情况,这种一维的数组实现的方式更加不切实际。


为了解决这个问题,人们使用了 level 的概念,页表的结构分为多级,页表项的大小只与虚拟内存空间中真正使用的多少有关。之前一维数组表示的方式页表项的多少与虚拟地址空间的大小成正比,这种多级结构的方式使得没有使用的内存不用分配页表项。


于是人们想出了多级页表的形式,这种方式非常适合,因为大部分区域的虚拟地址空间实际上是没有使用的,使用多级页表可以显著的减少页表本身的内存占用。在 64 位系统上,Linux 采用了四级页表,


  • PGD:Page Global Directory,页全局目录,是顶级页表。

  • PUD:Page 《一线大厂 Java 面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》无偿开源 威信搜索公众号【编程进阶路】 Upper Directory,页上级目录,是第二级页表

  • PMD:Page Middle Derectory,页中间目录,是第三级页表。

  • PTE:Page Table Entry,页面表,最后一级页表,指向物理页面。


如下图所示。



应用程序看到的只有虚拟内存,是看不到物理地址的。当然是有办法可以通过一些手段通过虚拟地址拿到物理地址。比如这个例子,我们 malloc 一个 1M 的空间,返回了一个虚拟地址 0x7ffff7eec010,怎么知道这个虚拟地址对应的物理内存地址呢?


#include <stdlib.h>


#include <stdio.h>


#include <unistd.h>


int main() {


char *p = NULL;


p = malloc(1024 * 1024);


*p = 0;


printf("ptr: %p, pid: %d\n", p, getpid());


getchar();


return 0;


}


ptr: 0x7ffff7eec010, pid: 2621


复制代码


在应用层来做这个事情没有办法,但是难不倒我们,我们来写一个内核扩展模块来实现这个功能。


写一个内核模块也非常简单,分为下面几个步骤:


  • 定义好两个回调钩子,module_init, module_exit

  • 通过传入的 pid 获取到这个进程的 task_struct 通过 task_struct 中的 mm 变量和传入的虚拟内存地址 va,就可以拿到 pgd

  • 通过 pgd 就可以拿到 pud,然后再拿到 pmd,最好获取到 pte,这个 pte 已经存储的是物理内存的页帧

  • 通过低 12 位的页内偏移就可以得到最终的物理内存的地址。


精简以后的代码如下所示。


#include <linux/module.h>


...


int my_module_init(void) {


unsigned long pa = 0;


pgd_t *pgd = NULL; pud_t *pud = NULL;


pmd_t *pmd = NULL; pte_t *pte = NULL;


struct pid *p = find_vpid(pid);


struct task_struct *task_struct = pid_task(p, PIDTYPE_PID);


pgd = pgd_offset(task_struct->mm, va); // 获取第一级 pgd


pud = pud_offset(pgd, va); // 获取第二级 pud


pmd = pmd_offset(pud, va); // 获取第三级 pmd


pte = pte_offset_kernel(pmd, va); // 获取第四级 pte


unsigned long page_addr = pte_val(*pte) & PAGE_MASK;


unsigned long page_addr &= 0x7fffffffffffffULL;


page_offset = va & ~PAGE_MASK;


pa = page_addr | page_offset; // 加上偏移量


printk("virtual address 0x%lx in RAM Page is 0x%lx\n", va, pa);


return 0;


}


void my_module_exit(void) {


printk("module exit!\n");


}


module_init(my_module_init); // 注册回调钩子


module_exit(my_module_exit); // 注册回调钩子


MODULE_LICENSE("GPL");


MODULE_AUTHOR("Arthur.Zhang");


MODULE_DESCRIPTION("A simple virtual memory inspect");


复制代码


我们编译这个内核模块会生成一个 .ko 文件,然后加载这个 .ko 文件,传入进程号、虚拟内存地址。


make -C /lib/modules/(PWD) modules


insmod my_mem.ko pid=2621 va=0x7ffff7eec010


复制代码


然后执行 dmesg -T 就可以看到真正的物理地址的值了。


[Sat Oct 10 05:11:12 2020] virtual address 0x7ffff7eec010 in RAM Page is 0x2358a4010


复制代码


可以看到在这个例子中,虚拟地址 0x7ffff7eec010 对应的物理地址是 0x2358a4010。


完整的代码见:[github.com/arthur-zhan…](()

进程的内存布局

前面提到了虚拟内核和物理内存的关系,我们知道 linux 上的可执行文件的格式是 elf,elf 是一个静态文件,这个静态文件由不同的分节组成,我们这里叫它 section,在运行时,部分跟运行时相关的 Section 会被映射到进程的虚拟地址空间中,比如图中的代码段和数据段。除了这部分静态的区域,进程启动以后还有大量动态内存消耗区,比如栈、堆、mmap 区。



下面这个图是我们线上 java 服务使用 pmap 输出的内存布局的一部分,如下图所示。



那怎么来看这些部分呢?这就需要我们深入去理解 Linux 中进程的内存是如何被瓜分的。


libc 内存管理原理探究




Linux 内存管理有三个层面,第一层是我们的用户管理层,比如我们自己程序的内存池,mysql 的 bufferpool,第二层是 C 的运行时库,这部分代码是对内核的一个包装,方便上层应用更方便的开发,再下一层就是我们的内核层了。



我们今天要重点介绍的就是中间那一层,这一层是由一个 libc 的库来实现的,接下来详细看看看 libc 内存管理是如何做的。


Linux 内存管理的核心思想就是分层管理、批发零售、隐藏内部细节。我们还需要铭记在心的是 libc 中堆的管理是针对小内存分配释放来设计的,为了编程接口上的统一,大内存也是支持的。


我们先来看内存申请释放的两个函数,malloc 和 free,这两个函数的定义如下。


#include <stdlib.h>


void *malloc(size_t size);


void free(void *ptr);


复制代码


这两个函数压根不是系统调用,它们只是对 brk、mmap、munmap 系统调用的封装,那为什么有了这些系统调用,还需要 libc 再封装一层呢?


一个主要原因是因为系统调用很昂贵,而内存的申请释放又特别频繁,所以 libc 采取的的方式就是批量申请,然后作为内存的黄牛二道贩子,慢慢零售给后面的应用程序。



第二个原因是为了编程上的统一,比如有些时候用 brk,有些时候用 mmap,不太友好,brk 在多线程下还需要进行加锁,用一个 malloc 就很香。

Linux 内存分配器

Linux 的内存分配器有很多种,一开始是 Doug Lea 大神开发的 dlmalloc,这个分配器对多线程支持不友好,多线程下会竞争全局锁,随后有人基于 dmalloc 开发了 ptmalloc,增加了多线程的支持,除了 linux 官方的 ptmalloc,各个大厂有开发不同的 malloc 算法,比如 facebook 出品的 jemalloc,google 出品的 tcmalloc。



这些内存分配器致力于解决两个问题:多线程下锁的粒度问题,是全局锁,还是局部锁还是无锁。第二个问题是小内存回收和内存碎片问题,比如 jemalloc 在内存碎片上有显著的优势。

ptmalloc 的核心概念

接下来我们来看 Linux 默认的内存分配器 ptmalloc,我总结了一下它有关的四个核心概念:Arena、Heap、Chunk、Bins。


Arena


先来看 Arena,Arena 的中文翻译的意思是主战场、舞台,对应在内存分配这里,指的是内存分配的主战场。



Arena 的出现首先用来解决多线程下全局锁的问题,它的思路是尽可能的让一个线程独占一个 Arena,同时一个线程会申请一个或多个堆,释放的内存又会进入回收站,Arena 就是用来管理这些堆和回收站的。


Arena 的数据结构长啥样?它是一个结构体,可以用下面的图来表示。



它是一个单向循环链表,使用 mutex 锁来处理多线程竞争,释放的小块内存会放在 bins 的结构中。


前面提到,Arena 会尽量让一个线程独占一个锁,那如果我有几千个线程,会生成几千个 Arena 吗?显然是不会的,所有跟线程有关的瓶颈问题,最后都会走到 CPU 核数的限制这里来,分配区的个数也是有上限的,64 位系统下,分配区的个数大小是 cpu 核数的八倍,多个 Arena 组成单向循环链表。



我们可以写个代码来打印 Arena 的信息。它的原理是对于一个确定的程序,main_arena 的地址是一个位于 glibc 库的确定的地址,我们在 gdb 调试工具中可以打印这个地址。也可以使用 ptype 命令来查看这个地址对应的结构信息,如下图所示。



有了这个基础,我们就可以写一个 do while 来遍历这个循环链表了。我们把 main_arena 的地址转为 malloc_state 的指针,然后 do while 遍历,直到遍历到链表头。


struct malloc_state {


int mutex;


int flags;


void *fastbinsY[NFASTBINS];


struct malloc_chunk *top;


struct malloc_chunk *last_remainder;


struct malloc_chunk *bins[NBINS * 2 - 2];


unsigned int binmap[4];


struct malloc_state *next;


struct malloc_state *next_free;


size_t system_mem;


size_t max_system_mem;


};


void print_arenas(struct malloc_state *main_arena) {


struct malloc_state *ar_ptr = main_arena;


int i = 0;


do {


printf("arena[%02d] %p\n", i++, ar_ptr);


ar_ptr = ar_ptr->next;


} while (ar_ptr != main_arena);


}


#define MAIN_ARENA_ADDR 0x7ffff7bb8760


int main() {


...


print_arenas((void*)MAIN_ARENA_ADDR);


return 0;


}


复制代码


输出结果如下。



那为什么还要区分一个主分配,一个非主分配区呢?


这有点像皇上和王爷的关系, 主分配区只有一个,它还有一个特权,可以使用靠近 DATA 段的 Heap 区,它通过调整 brk 指针来申请释放内存。


从某种意义上来讲,Heap 区不过是 DATA 段的扩展而已。



非主分配区呢?它更像是一个分封在外地,自主创业的王爷,它想要内存时就使用 mmap 批发大块内存(64M)作为子堆(Sub Heap),然后在慢慢零售给上层应用。


一个 64M 用完,再开辟一个新的,多个子堆之间也是使用链表相连,一个 Arena 可以有多个子堆。在接下的内容中,我们还会继续详细介绍。



Heap


接下来我们来看 ptmalloc2 的第二个核心概念 ,heap 用来表示大块连续的内存区域。


主分配区的 heap 没有什么好讲的,我们这里重点看「非主分配」的子堆(也称为模拟堆),前面提到过,非主分配批发大块内存进行切割零售的。


那如何理解切割零售这句话呢?它的实现也非常简单,先申请一块 64M 大小的不可读不可写不可执行(PROT_NONE)的内存区域,需要内存时使用 mprotect 把一块内存区域的权限改为可读可写(R+W)即可,这块内存区域就可以分配给上层应用了。



以我们前面 java 进程的内存布局为例。



这中间的两块内存区域是属于一个子堆,它们加起来的大小是 64M,然后其中有一块 1.3M 大小的内存区域就是使用 mprotrect 分配出去的,剩下的 63M 左右的区域,是不可读不可写不可执行的待分配区域。


知道这个有什么用呢?太有用了,你在 google 里所有 Java 堆外内存等问题,有很大可能性会搜到 Linux 神奇的 64M 内存问题。有了这里的知识,你就比较清楚到底这 64M 内存问题是什么了。



与前面的 Arena 一样,我们同样可以在代码中,遍历所有 Arena 的所有的 heap 列表,代码如下所示。


struct heap_info {


struct malloc_state *ar_ptr;


struct heap_info *prev;


size_t size;


size_t mprotect_size;


char pad[0];


};


void dump_non_main_subheaps(struct malloc_state *main_arena) {


struct malloc_state *ar_ptr = main_arena->next;


int i = 0;


while (ar_ptr != main_arena) {


printf("arena[%d]\n", ++i);


struct heap_info *heap = heap_for_ptr(ar_ptr->top);


do {


printf("arena:%p, heap: %p, size: %d\n", heap->ar_ptr, heap, heap->size);


heap = heap->prev;


} while (heap != NULL);


ar_ptr = ar_ptr->next;


}


}


#define MAIN_ARENA_ADDR 0x7ffff7bb8760


dump_non_main_subheaps((void*)MAIN_ARENA_ADDR);


复制代码


Chunk


接下来我们来看分配的基本单元 chunk,chunk 的字面意思是「厚块; 厚片」,chunk 是 glibc 中内存分配的基础单元。以一个简单的例子来开头。


#include <stdio.h>


#include <stdlib.h>


#include <unistd.h>


int main(void) {


void *p;


p = malloc(1024);


printf("%p\n", p);


p = malloc(1024);


printf("%p\n", p);


p = malloc(1024);


printf("%p\n", p);


getchar();


return (EXIT_SUCCESS);


}


复制代码


这段代码逻辑是连续调用三次 malloc,每次分配 1k 内存,然后我们来观察它的内存地址。


./malloc_test


0x602010


0x602420


0x602830


复制代码


可以看到内存地址之间差了 0x410,1024 是等于 0x400,那多出来的 0x10 字节是什么?我们先按下不表。


再来回看 malloc 和 free,那我们不禁问自己一个问题,free 函数的参数只有一个指针,它是怎么知道要释放多少内存的呢?


#include <stdlib.h>


void *malloc(size_t size);


void free(void *ptr);


复制代码


香港作家张小娴说过,「凡事皆有代价,快乐的代价便是痛苦」。为了存储 1k 的数据,实际上还需要一些数据来记录这块内存的元数据。这块额外的数据被称为 chunk header,长度为 16 字节。这就是我们前面看到的多出来 0x10 字节。



这种通过在实际数据前面添加 head 方式使用的非常普遍,比如 java 中 new Integer(1024),实际存储的数据大小远不止 4 字节,它有一个巨大无比的对象头,里面存储了对象的 hashcode,经过了几次 GC,有没有被当做锁同步。



害,说 java 臃肿并不是没有道理。


在我们继续来看这个 16 字节的 header 里面到底存储了什么,它的结构示意图如下所示。



它分为两部分,前 8 字节表示前一个 chunk 块的大小,接下来的 8 字节表示当前 chunk 块的大小,因为 chunk 块要按 16 字节对齐,所以低 4 字节都是没用的,其中三个被用来当做标记位来使用,这三个分别是 AMP,其中 A 表示是否是主分配区,M 表示是否是 mmap 分配的大 chunk 块,P 表示前一个 chunk 是否在使用中。


以前面的例子为例,我们可以用 gdb 来查看这部分的内存。



可以看到对应 size 的 8 个字节是 0x0411,这个值是怎么来的呢?其实是按 size + 8 对齐到 16B 再加上低三位的 B001。


0x0400 + 0x10 + 0x01 = 0x0411


复制代码


因为当一个 chunk 正在被使用时,它的下一个 chunk 的 prev_size 是没有意义的,这 8 个字节可以被这个当前 chunk 使用。别奇怪,就是这么抠。接下来我们来看看 chunk 中 prev_size 的复用。测试的代码如下。


#include <stdlib.h>


#include <string.h>


void main() {


char *p1, *p2;


p1 = (char *)malloc(sizeof(char) * 18); // 0x602010


p2 = (char *)malloc(sizeof(char) * 1); // 0x602030


memcpy(p1, "111111111111111111", 18);


}


复制代码


编译这个源文件,然后使用 gdb 调试单步运行,查看 p1、p2 的地址。

用户头像

还未添加个人签名 2022.04.13 加入

还未添加个人简介

评论

发布
暂无评论
28天面试突击:JVM+Redis_程序员_爱好编程进阶_InfoQ写作社区