点击链接阅读更多:
https://zhuanlan.zhihu.com/p/393403828
目录
3.2.2 运行过程中链接动态链接库与编译过程中链接动态库的区别
四、通过指针获取到的地址是虚拟内存中的地址还是物理内存中的地址
这篇文章主要讲述:
1.虚拟内存与物理内存的概念及关系
2.C/C++虚拟内存分配模型
3.程序占用的内存是虚拟内存还是物理内存
4.通过指针获取到的地址是虚拟内存中的地址还是物理内存中的地址
一、虚拟内存与物理内存
1.1 虚拟内存
虚拟内存是一种实现在计算机软硬件之间的内存管理技术,它将程序使用到的内存地址(虚拟地址)映射到计算机内存中的物理地址,虚拟内存使得应用程序从繁琐的管理内存空间任务中解放出来,提高了内存隔离带来的安全性,虚拟内存地址通常是连续的地址空间,由操作系统的内存管理模块控制,在触发缺页中断时利用分页技术将实际的物理内存分配给虚拟内存,而且64位机器虚拟内存的空间大小远超出实际物理内存的大小,使得进程可以使用比物理内存大小更多的内存空间。
1.2 虚拟内存与物理内存
关于虚拟内存和物理内存的关系可以看看这篇文章。
https://blog.csdn.net/qq_41687938/article/details/119112003?spm=1001.2014.3001.5501
二、C/C++中虚拟内存分配模型
记住这几个关键点:
-
每个进程都有它自己的虚拟内存
-
虚拟内存的大小取决于系统的体系结构
-
不同操作管理有着不同的管理虚拟内存的方式,但大多数操作系统的虚拟内存结构如下图:
virtual_memory 虚拟内存结构图
按照地址从高到低:(注意堆、栈的地址走向)
2.1 C语言中内存分配模型
在C语言中,内存主要分为如下5个存储区:
- 栈(Stack):位于函数内的局部变量(包括函数实参),由编译器负责分配释放,函数结束,栈变量失效。(先进后出)
- 堆(Heap):由程序员用malloc/calloc/realloc分配,free释放。如果程序员忘记free了,则会造成内存泄露,程序结束时该片内存会由OS回收,但程序只要不结束,就有可能造成内存泄露。注意它与数据结构中堆是两回事,分配方式倒是类似于链表。
- 全局区/静态区(Global Static Area): 全局变量和静态变量存放区,程序一经编译好,该区域便存在。在C语言中初始化的全局变量和静态变量和未初始化的放在相邻的两个区域(在C++中,由于全局变量和静态变量编译器会给这些变量自动初始化赋值,所以没有区分了),程序结束后由系统释放。
- C风格字符串常量存储区: 专门存放字符串常量的地方,程序结束后由系统释放。
- 程序代码区:存放程序二进制代码的区域。
2.2 C++语言中内存分配模型
在C++语言中,与C类似,不过也有所不同,内存主要分为如下5个存储区:
- 栈(Stack):位于函数内的局部变量(包括函数实参),由编译器负责分配释放,函数结束,栈变量失效。(先进后出)
- 堆(Heap):这里与C不同的是,该堆是由new申请的内存,由delete或delete[]负责释放。
- 自由存储区(Free Storage):由程序员用malloc/calloc/realloc分配,free释放。如果程序员忘记free了,则会造成内存泄露,程序结束时该片内存会由OS回收。
- 全局区/静态区(Global Static Area): 全局变量和静态变量存放区,程序一经编译好,该区域便存在。在C++中,由于全局变量和静态变量编译器会给这些变量自动初始化赋值,所以没有区分了初始化变量和未初始化变量了(c中区分了),程序结束后由系统释放。
- 常量区: 这是一块比较特殊的存储区,专门存储不能修改的常量(一般是const修饰的变量,或是一些常量字符串),程序结束后由系统释放。
注:c++中代码还是存在代码区的。
所以我们平时所说的代码的运行,分配,操作等,都是指的虚拟内存!!!!!!!!
三、程序占用的内存是虚拟内存还是物理内存
要讨论这个问题,先看看一些基本的内存相关知识。
3.1 内存管理
3.1.1 内存管理概念
一提到内存管理,我们头脑中闪出的两个概念,就是虚拟内存与物理内存。这两个概念主要来自于linux内核的支持。
Linux在内存管理上份为两级,一是线性区,类似于00c73000-00c88000,对应于虚拟内存,它实际上不占用实际物理内存;二是具体的物理页面,它对应我们机器上的物理内存。
这里要提到一个很重要的概念,内存的延迟分配。Linux内核在用户申请内存的时候,只是给它分配了一个线性区(也就是虚存),并没有分配实际物理内存;只有当用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理内存。内核释放物理页面是通过释放线性区(也就是虚存),找到其所对应的物理页面,将其全部释放的过程。
char *p=malloc(2048) //这里只是分配了虚拟内存2048,并不占用实际内存。
strcpy(p,"123") //分配了物理页面,虽然只是使用了3个字节,但内存还是为它分配了2048字节的物理内存。
free(p) //通过虚拟地址,找到其所对应的物理页面,释放物理页面,释放虚拟内存(线性区)。
我们知道用户的进程和内核是运行在不同的级别,进程与内核之间的通讯是通过系统调用来完成的。进程在申请和释放内存,主要通过brk,sbrk,mmap,unmmap这几个系统调用,传递的参数主要是对应的虚拟内存。
注意一点,在进程只能访问虚拟内存,它实际上是看不到内核物理内存的使用,这对于进程是完全透明的。
也就是说,程序申请和操作的内存都是在虚拟内存上的,包括堆(heap)、栈(stack)等。
3.1.2 glibc内存管理器
那么我们每次调用malloc来分配一块内存,都进行相应的系统调用呢?
答案是否定的,这里我要引入一个新的概念,glibc的内存管理器。glibc是GNU发布的libc库,即c运行库。
我们知道malloc和free等函数都是包含在glibc库里面的库函数,我们试想一下,每做一次内存操作,都要调用系统调用的话,那么程序将多么的低效。
实际上glibc采用了一种批发和零售的方式来管理内存。glibc每次通过系统调用的方式申请一大块内存(虚拟内存),当进程申请内存时,glibc就从自己获得的内存中取出一块给进程。
3.1.3 内存管理器面临的困难
我们在写程序的时候,每次申请的内存块大小不规律,而且存在频繁的申请和释放,这样不可避免的就会产生内存碎块。而内存碎块,直接会导致大块内存申请无法满足,从而更多的占用系统资源;如果进行碎块整理的话,又会增加cpu的负荷,很多都是互相矛盾的指标,这里我就不细说了。
我们在写程序时,涉及内存时,有两个概念heap和stack。传统的说法stack的内存地址是向下增长的,heap的内存地址是向上增长的。
3.1.4 以堆为例讲解内存的申请与释放
函数malloc和free,主要是针对heap进行操作,由程序员自主控制内存的访问。
在这里heap的内存地址向上增长,这句话不完全正确。
heap堆的申请
glibc对于heap内存申请大于128k的内存申请,glibc采用mmap的方式向内核申请内存,这不能保证内存地址向上增长;小于128k的则采用brk,对于它来讲是正确的。128k的阀值,可以通过glibc的库函数进行设置。
对于大块内存申请,glibc直接使用mmap系统调用为其划分出另一块虚拟地址,供进程单独使用;在该块内存释放时,使用unmmap系统调用将这块内存释放,这个过程中间不会产生内存碎块等问题。
针对小块内存的申请,在程序启动之后,进程会获得一个heap底端的地址,进程每次进行内存申请时,glibc会将堆顶向上增长来扩展内存空间,也就是我们所说的堆地址向上增长。在对这些小块内存进行操作时,便会产生内存碎块的问题。实际上brk和sbrk系统调用,就是调整heap顶地址指针。
那么heap堆的内存是什么时候释放呢?
当glibc发现堆顶有连续的128k的空间是空闲的时候,它就会通过brk或sbrk系统调用,来调整heap顶的位置,将占用的内存返回给系统。这时,内核会通过删除相应的线性区,来释放占用的物理内存。
下面我要讲一个内存空洞的问题:
一个场景,堆顶有一块正在使用的内存,而下面有很大的连续内存已经被释放掉了,那么这块内存是否能够被释放?其对应的物理内存是否能够被释放?
很遗憾,不能。
这也就是说,只要堆顶的部分申请内存还在占用,我在下面释放的内存再多,都不会被返回到系统中,仍然占用着物理内存。为什么会这样呢?
这主要是与内核在处理堆的时候,过于简单,它只能通过调整堆顶指针的方式来调整调整程序占用的线性区(虚拟内存);而又只能通过调整线性区(虚拟内存)的方式,来释放内存。所以只要堆顶不减小,占用的内存就不会释放。
提一个问题:
char *p=malloc(2);
free(p)
为什么申请内存的时候,需要两个参数,一个是内存大小,一个是返回的指针;而释放内存的时候,却只要内存的指针呢?
这主要是和glibc的内存管理机制有关。glibc中,为每一块内存维护了一个chunk的结构。glibc在分配内存时,glibc先填写chunk结构中内存块的大小,然后是分配给进程的内存。
chunk ------size
p------------ content
在进程释放内存时,只要 指针减去4 便可以找到该块内存的大小,从而释放掉。
注:glibc在做内存申请时,最少分配16个字节,以便能够维护chunk结构。
3.2 代码占用的内存
3.2.1 代码启动过程的内存管理
数据部分占用内存,那么我们写的程序是不是也占用内存呢?
在linux中,程序的加载,涉及到两个工具,linker 和loader。Linker主要涉及动态链接库的使用,loader主要涉及软件的加载。
- exec执行一个程序
- elf为现在非常流行的可执行文件的格式,它为程序运行划分了两个段,一个段是可以执行的代码段,它是只读,可执行;另一个段是数据段,它是可读写,不能执行。
- loader会启动,通过mmap系统调用,将代码端和数据段映射到内存中,其实也就是为其分配了虚拟内存,注意这时候,还不占用物理内存;只有程序执行到了相应的地方,内核才会为其分配物理内存。
- loader会去查找该程序依赖的链接库,首先看该链接库是否被映射进内存中,如果没有使用mmap,将代码段与数据段映射到内存中,否则只是将其加入进程的地址空间。这样比如glibc等库的内存地址空间是完全一样。
因此一个2M的程序,执行时,并不意味着为其分配了2M的物理内存,这与其运行了的代码量,与其所依赖的动态链接库有关。
3.2.2 运行过程中链接动态链接库与编译过程中链接动态库的区别
我们调用动态链接库有两种方法:一种是编译的时候,指明所依赖的动态链接库,这样loader可以在程序启动的时候,将所有的动态链接映射到内存中;一种是在运行过程中,通过dlopen和dlfree的方式加载动态链接库,动态将动态链接库加载到内存中。
从编程角度来讲,第一种是最方便的,效率上影响也不大。
在内存使用上有些差别:
第一种方式,一个库的代码,只要运行过一次,便会占用物理内存,之后即使再也不使用,也会占用物理内存,直到进程的终止。
第二中方式,库代码占用的内存,可以通过dlfree的方式,释放掉,返回给物理内存。
对于那些寿命很长,但又会偶尔调用各种库的进程有关。如果是这类进程,建议采用第二种方式调用动态链接库。
3.3 总结
从前所述可知,每一个程序运行都会构建相应的进程,那也就是说,程序占用内存以及使用内存都是从虚拟内存的角度上进行分析。程序只能看到虚拟内存,具体使用的物理内存需要操作系统内核进行决定。
四、通过指针获取到的地址是虚拟内存中的地址还是物理内存中的地址
一般来说,指针是不是逻辑地址根本就不重要。在这些高级的编程语言中,所有的地址在写的时候都是逻辑地址,最终都会映射到物理地址,不然没法运行。
对应用程序来说,不需要关心这个。可以认为是虚拟内存的地址,程序加载/运行时操作系统/硬件会进行正确的转换。如果在没有MMU的系统中,通常虚拟内存地址和物理地址是一回事。所以具体情况得根据实际情况具体分析。
不过既然是探讨,那就具体说一说。
首先我们要知道,不同进程地址空间是相互隔离的,不同进程两个值相同的指针对应的真实内存是不同的。
指针变量存储的地址应该是指虚拟地址,也就是在程序中能通过那个地址访问变量的地址。而普通指针是指"物理地址"。
指向类成员的指针是 经过处理的"偏移量" (一般是实际偏移量-1,多继承就复杂鸟).
参考:
https://blog.csdn.net/qq_41687938/article/details/119112003?spm=1001.2014.3001.5501
https://blog.csdn.net/melody157398/article/details/118643264
https://blog.csdn.net/qq_33706673/article/details/84670007
https://bbs.csdn.net/topics/210080352?list=2387616
https://blog.csdn.net/temetnosce/article/details/70473890