美文网首页Java基础随笔-生活工作点滴
内存问题分析(二)-内存管理基础(下)

内存问题分析(二)-内存管理基础(下)

作者: Stan_Z | 来源:发表于2019-07-06 21:25 被阅读72次

    接上篇继续总结内存管理基础。

    五、内存回收

    无论计算机上有多少内存都是不够的,因而linux kernel需要通过内存回收策略来保证系统持续有内存使用。

    5.1 基本概念

    1. 页分类(按有无文件背景页面主要分两种)
    文件页(file-backed page):有文件背景页面。可以直接和硬盘对应的文件进行交换。
    匿名页(anonymous page):无文件背景页面。如进程堆、栈、数据段使用的页等,无法直接跟磁盘交换,但是可以跟swap区进行交换。

    2. 缓存
    对于有文件背景的页面,程序去读文件时,可以通过read也可以通过mmap去读。通过任何一种方式从磁盘读文件时,内核都会给你申请一个page cache,来缓存硬盘上的内容。这样,读过一遍的数据下次再读的时候就直接从page cache里去拿,提升了性能和访问速度。

    而这里需要补充两对概念:
    1)对比两种文件操作的方式:read/write 和 mmap

    read/write: 常规文件操作为了提高读写效率和保护磁盘,使用了page cache机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的buffer在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。但是读过的数据下次再读就直接从page cache里去拿了, 这时效率也是很高的。

    mmap:创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。对文件的读取操作跨过了页缓存,减少了数据的拷贝次数,用内存读写取代I/O读写,提高了文件读取效率。

    2)cache 与 buffer
    通过文件系统来访问文件产生的缓存记录为cache。
    直接操作磁盘产生的缓存记录为buffer。

    这里cache提升了文件读写的性能和速度,但是也占用了物理内存空间,并且在程序运行结束后,cache memory也不会自动释放,而需要通过内存回收策略来释放。

    5.2 哪些内存可以回收

    属于内核的大部分页框是不能够进行回收的,比如内核栈、内核代码段、内核数据段以及大部分内核使用的页框。
    进程使用的页框可以进行回收的,比如进程代码段、进程数据段、进程堆栈、进程访问文件时映射的文件页、进程间共享内存使用的页。

    5.3 页回收方式

    页回写:如果一个很少使用的页的后备存储器是一个块设备(例如文件映射),则可以将内存直接同步到块设备,腾出的页面可以被重用。
    页交换:如果页面没有后备存储器,则可以交换到特定swap分区,再次被访问时再交换回内存。
    页丢弃:如果页面的后备存储器是一个文件,但文件内容在内存不能被修改(例如可执行文件),那么在当前不需要的情况下可直接丢弃。

    5.4 页回收算法-LRU

    当Linux系统内存有盈余时,内核会尽量多地使用内存作为page cache,提高系统性能,page cache会被加入到文件类型的LRU链表中,当系统内存紧张时,会按一定的算法来回收内存,下面简单了解下:

    LRU链表按zone来配置,每个zone中都有一整套LRU链表。

    而一个lru链表描述符中总共有5个双向链表头,它们分别描述五中不同类型的链表:

    • LRU_INACTIVE_ANON:称为非活动匿名页lru链表(swap)
    • LRU_ACTIVE_ANON:称为活动匿名页lru链表(swap)
    • LRU_INACTIVE_FILE:称为非活动文件页lru链表(磁盘)
    • LRU_ACTIVE_FILE:称为活动文件页lru链表(磁盘)
    • LRU_UNEVICTABLE:此链表中保存的是此zone中所有禁止换出的页的描述符。

    那么lru链表进行的操作主要有以下几种:

    • 将不处于lru链表的新页放入到lru链表中
    • 将非活动lru链表中的页移动到非活动lru链表尾部
    • 将处于活动lru链表的页移动到非活动lru链表
    • 将处于非活动lru链表的页移动到活动lru链表
    • 将页从lru链表中移除

    LRU老化规则:页面通过lru批处理,转来转去,从活动链表转到非活动链表,从非活动链表靠前转到链尾,在内存回收时,非活动链表链尾的页被回收掉。

    当内存紧张时,优先换出无脏数据的page cache(文件页包含page cache),直接丢弃。其次才是匿名页和有脏数据的文件页的回收。遵循URL老化规则。通过Swappiness来确定更倾向于回收哪种更多一点,swappiness越大,越倾向于回收匿名页,反之越倾向于回收文件页。将swapness=0则意味着不再交换匿名页,swapness=100, 尽量交换匿名页,Swappiness默认值为60。

    5.4 页回收时机
    周期性回收(被动触发):这是由后台运行的守护进程 kswapd 完成的,回收的时机由水位控制。
    直接页面回收(主动触发):“内存严重不足”事件的触发。

    如果操作系统在进行了内存回收操作之后仍然无法回收到足够多的页面以满足上述内存要求,那么操作系统只有最后一个选择,那就是使用 OOM( out of memory )killer,它从系统中挑选一个最合适的进程杀死它,并释放该进程所占用的所有页面。

    5.4.1 水位控制

    名称 描述
    high 内存回收到该值时停止回收。
    low 内存到该值时触发kswapd线程的内存回收。
    min 如果剩余内存减少到触及这个水位,可认为内存严重不足,当前进程就会被堵住,kernel会直接在这个进程的进程上下文里面做直接页面回收。

    注:由于每个ZONE是分别管理各自内存的,因此每个ZONE都有这三个水位。

    5.4.2 回收代码调用路径

    页面回收代码调用路径

    直接页面回收

    系统会调用函数 try_to_free_pages() 去检查当前内存区域中的页面,回收那些最不常用的页面。该函数会反复调用 shrink_zones() 以及 shrink_slab() 释放一定数目的页面,默认值是 32 个页面。如果在特定的循环次数内没有能够成功释放 32 个页面,那么页面回收会调用 OOM killer 选择并杀死一个进程,然后释放它占用的所有页面。

    注:OOM_killer是Linux自我保护的方式,当内存不足时不至于出现太严重问题,有点壮士断腕的意味。在kernel 2.6,内存不足将唤醒oom_killer,挑出/proc/<pid>/oom_score最大者并将之kill掉。

    定期(周期性)回收

    kswapd进程以水线为触发点,按LRU链表来进行回收。系统会调用函数balance_pgdat(),它主要调用的函数是 shrink_zone() 和 shrink_slab()。

    5.4.3 函数介绍

    shrink_zone()
    该函数主要做了两件事情:
    1)将某些页面从 active 链表移到 inactive 链表,这是由函数 shrink_active_list() 实现的。
    2)从 inactive 链表中选定一定数目的页面,将其放到一个临时链表中,这由函数 shrink_inactive_list() 完成。该函数最终会调用 shrink_page_list() 去回收这些页面。

    shrink_slab()
    该函数用来回收磁盘缓存所占用的页面的。Linux 操作系统并不清楚这类页面是如何使用的,所以如果希望操作系统回收磁盘缓存所占用的页面,那么必须要向操作系统内核注册 shrinker 函数,shrinker 函数会在内存较少的时候主动释放一些该磁盘缓存占用的空间。函数 shrink_slab() 会遍历 shrinker 链表,从而对所有注册了 shrinker 函数的磁盘缓存进行处理。Android内核的lowmemorykiller机制就是注册了shrinker,内存过低时选择性杀死进程来回收内存。

    shrink_page_list()
    逻辑流程图:

    shrink_page_list()执行逻辑
    六、用户空间内存管理

    6.1 Android用户空间进程划分

    • Native进程:不包含虚拟机实例的linux进程。
    • Java进程:包含了虚拟机实例的linux进程。

    6.2 内存管理
    6.2.1 Natvie进程
    1)内存区域划分:
    Native进程与Linux进程一样,虚拟内存区域分为:代码区、只读常量区、全局区、BSS段、堆区、栈区


    native进程内存区域划分

    代码区:存放函数体的二进制代码。
    只读常量区:存放字符串常量,以及const修饰的全局变量 。
    全局区/数据区:存放已经初始化的全局变量和已经初始化用static修饰的局部变量。
    BSS段:存放没有初始化的全局变量和未初始化静态局部变量,该区域会在main函数执行前进行自动清零。
    堆区:一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
    栈区:由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。

    注意:栈区和堆区之间并没有严格分割线,可以进行微调,并且堆区分配一般从低地址到高地址分配,而栈区分配一般从高地址到低地址分配。

    以上是标准划分,但是对于一个进程的内存空间,逻辑上可以分为以下三部分:
    程序区: 程序的二进制文件。
    静态存储区:(只读常量区、全局区、BSS段)全局变量和静态变量。
    动态存储区:(堆区、栈区)本地变量。

    注:
    局部变量:在一个有限的范围内的变量,作用域是有限的,对于程序来说,在一个函数体内部声明的普通变量都是局部变量,局部变量会在栈上申请空间,函数结束后,申请的空间会自动释放。
    全局变量:在函数体外申请的,会被存放在全局(静态区)上,知道程序结束后才会被结束,这样它的作用域就是整个程序。
    静态变量:和全局变量的存储方式相同,在函数体内声明为static就可以使此变量像全局变量一样使用,不用担心函数结束而被释放。

    2)内存分配与回收
    内存的静态分配和动态分配的区别主要是两个:

    • 时间上:静态分配发生在程序编译和连接时,动态分配则发生在程序调入和执行时。
    • 空间上:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由函数malloc进行分配。不过栈的动态分配和堆不同,他的动态分配是由编译器进行释放,无需我们手工实现。

    动态内存分配与回收
    所谓动态内存分配,就是指在程序执行的过程中动态地分配或者回收存储空间的分配内存的方法。动态内存分配不象数组等静态内存分配方法那样需要预先分配存储空间,而是由系统根据程序的需要即时分配,且分配的大小就是程序要求的大小。

    分配和回收操作函数介绍:
    malloc:动态内存分配,用于在堆上申请一块连续的指定大小的内存区域,但是并没有初始化。
    calloc:则将初始化这部分的内存,设置为0. calloc = malloc + memset(初始化工作)。
    alloca:是向栈申请内存,因此无需释放。
    realloc:则对malloc申请的内存进行大小的调整。
    (注:这四个函数都是由free来释放内存。)

    new :new 基于 malloc,却又高于malloc,是它的一个提升版本。首先new不是库函数,它是一个关键字,通过new操作符申请的内存都在自由存储区,且不需要指定内存块大小。内存分配失败时,会抛出bac_alloc异常,而不是返回一个NULL等等。new是通过delete来释放内存,它同样也是一个关键字。

    6.2.2 Java进程
    从之前写的系统启动流程中我们了解了,zygote是java进程的鼻祖,它通过了Runtime启动了虚拟机,并通过fork,把虚拟机作为环境带给了每一个应用进程。虚拟机的设计除了提供跨平台能力之外,也提供了对象生命周期的管理,内存管理,线程管理,安全和异常的管理等统一的处理方案。

    1)内存区域划分
    这部分之前文章有总结:,如下是JVM内存划分模型,其实Dalvik和ART都一样,就是Heap的space结构会有区别:

    JVM内存划分模型

    程序计数器:是一块较小的线程私有的内存空间,用来记录正在执行的虚拟机字节码指令,以此来记录当前线程的运行状态;它是一个指针,指向执行引擎正在执行的指令的地址。
    虚拟机栈:栈是一块连续的内存区域,大小是由操作系统预定好的(2M左右),它是先进后出的队列,进出一一对应,不产生碎片,运行效率稳定高。局部变量的基本数据类型和引用存储于栈中,因为它们属于方法中的变量,生命周期随方法而结束。
    本地方法栈:针对Native方法的,功能与虚拟机栈一致。
    静态存储区(方法区):内存在程序编译的时候就已经分配好,这块内存在程序整个运行期间都存在。它主要存放静态数据、全局static数据和包含常量池。
    :堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的),堆大小受限于计算机系统中有效的虚拟内存(32bit系统理论上是4G),所以堆的空间比较灵活,比较大。对于堆,频繁的分配和回收内存会造成大量内存碎片,使程序效率降低。堆内存用于存放引用的对象实体、成员变量全部存储于堆中(包括基本数据类型,引用和引用的对象实体,因为它们属于类,类对象终究是要被new出来使用的)。

    Java内存玩的就是虚拟机划分的一亩三分地,而大小是由系统设置的,内存的分配与回收都是虚拟机负责的。申请的内存超过了一亩三分地就会oom,内存不足会触发gc,而gc又分串行gc与并行gc,art虚拟机优化了gc环节,大大缩短了全线程block的时长,但是如果明显的内存抖动还是会造成卡顿问题。

    好了,虚拟机内存管理暂时不分析了,之后有机会再单独开系列来分析,内存管理基础暂时就写这么多,歇了。

    参考:
    https://blog.csdn.net/jasonchen_gbd/article/details/79462014
    http://www.wowotech.net/memory_management/233.html
    https://www.ibm.com/developerworks/cn/linux/l-cn-pagerecycle/
    https://www.cnblogs.com/fah936861121/p/6878699.html
    https://blog.csdn.net/Luoshengyang/article/details/42492621
    https://blog.csdn.net/Luoshengyang/article/details/42555483

    相关文章

      网友评论

        本文标题:内存问题分析(二)-内存管理基础(下)

        本文链接:https://www.haomeiwen.com/subject/vlvmhctx.html