淮安做网站建设的网络公司,网站没有在工信部备案,网页设计模板图片中文,安庆网站建设推广3. 内存管理
为什么要有虚拟内存#xff1f;
我们想要同时在内存中运行多个程序#xff0c;就需要把进程所使用的地址隔离#xff0c;所以使用了虚拟内存。简单来说#xff0c;虚拟内存地址是程序使用的内存地址。物理内存地址是实际存在硬件里面的地址。
操作系统为每个…3. 内存管理
为什么要有虚拟内存
我们想要同时在内存中运行多个程序就需要把进程所使用的地址隔离所以使用了虚拟内存。简单来说虚拟内存地址是程序使用的内存地址。物理内存地址是实际存在硬件里面的地址。
操作系统为每个进程都分配了一套虚拟内存地址进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元MMU的映射关系来转换变成物理地址然后再通过物理地址访问内存。
操作系统使用内存分段和内存分页的方式管理虚拟地址与物理地址之间的关系。
内存分段 程序是由若干个逻辑分段组成的如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的所以就用分段Segmentation的形式把这些段分离出来。 分段机制下的虚拟地址由段选择因子和段内偏移量两部分组成 段选择因子保存在段寄存器内。段选择子里面最重要的是段号用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。 段内偏移量位于 0 和段界限之间如果段内偏移量是合法的就将段基地址加上段内偏移量得到物理内存地址。 虚拟地址是通过段表与物理地址进行映射的。 分段机制会把程序的虚拟地址分成多个段每个段在段表中有一个项在这一项找到段的基地址再加上偏移量于是就能找到物理内存中的地址。 分段也存在不足之处存在内存碎片问题和内存交换的效率低的问题。 内存碎片主要分为内部内存碎片和外部内存碎片。内存分段管理可以做到1段根据实际需求分配内存有多少需求就分配多大的段所以不会出现内部内存碎片。但是由于每个段的长度不固定所以多个段未必能恰好使用所有的内存空间会产生了多个不连续的小物理内存导致新的程序无法被装载所以会出现外部内存碎片的问题。 解决「外部内存碎片」的问题就是内存交换。可以将音乐程序占用的那 256MB 内存写到硬盘上然后再从硬盘上读回来到内存里。不过再读回的时候我们不能装载回原来的位置而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间于是新的 200MB 程序就可以装载进来。 为什么分段会导致内存交换效率低 产生了外部内存碎片那就不得不重新 Swap 内存区域这个过程会产生性能瓶颈。因为硬盘的访问速度要比内存慢太多了每一次内存交换我们都需要把一大段连续的内存数据写到硬盘上。 所以**如果内存交换的时候交换的是一个占内存空间很大的程序这样整个机器都会显得卡顿。**为了解决内存分段的「外部内存碎片和内存交换效率低」的问题就出现了内存分页。
当需要进行内存交换的时候让需要交换写入或者从磁盘装载的数据更少一点这样就可以解决问题了。这个办法也就是内存分页Paging。分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间我们叫页Page。
内存分页中虚拟地址与物理地址之间通过页表来映射。页表是存储在内存里的内存管理单元 MMU就做将虚拟内存地址转换成物理地址的工作。当进程访问的虚拟地址在页表中查不到时系统会产生一个缺页异常进入系统内核空间分配物理内存、更新进程页表最后再返回用户空间恢复进程的运行。
分页是怎么解决分段【外部内存碎片和内存交换效率低】的问题
内存分页由于内存空间都是预先划分好的也就不会像内存分段一样在段与段之间会产生间隙非常小的内存这正是分段会产生外部内存碎片的原因。而采用了分页页与页之间是紧密排列的所以不会有外部碎片。 但是因为内存分页机制分配内存的最小单位是一页即使程序不足一页大小我们最少只能分配一个页所以页内会出现内存浪费所以针对内存分页机制会有内部内存碎片的现象。 如果内存空间不够操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉也就是暂时写在硬盘上称为换出Swap Out。一旦需要的时候再加载进来称为换入Swap In。所以一次性写入磁盘的也只有少数的一个页或者几个页不会花太多时间内存交换的效率就相对比较高。在加载程序时不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后并不真的把页加载到物理内存里而是只有在程序运行中需要用到对应虚拟内存页里面的指令和数据时再加载到物理内存里面去。
分页机制下虚拟地址和物理地址是如何映射的 在分页机制下虚拟地址分为两部分页号和页内偏移。页号作为页表的索引基地址页表中与页内偏移的组合就形成了物理内存地址。 对于虚拟地址和物理地址相互转换就三个步骤 虚拟内存分为页号和页内偏移量。根据页号从页表里面查询对应的物理页号。直接拿物理页号加上前面的偏移量就得到了物理内存地址。
单页表的实现方式在 32 位和页大小 4KB 的环境下一个进程的页表需要装下 100 多万个「页表项」并且每个页表项是占用 4 字节大小的于是相当于每个页表需占用 4MB 大小的空间。存在空间上的缺陷。
我们把这个 100 多万个「页表项」的单级页表再分页将页表一级页表分为 1024 个页表二级页表每个表二级页表中包含 1024 个「页表项」形成二级分页。
为什么多级页表比单页表省空间 使用二级分页如果某个一级页表的页表项没有被用到也就不需要创建这个页表项对应的二级页表了即可以在需要时才创建二级页表。 从页表的性质来看保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间不分级的页表就需要有 100 多万个页表项来映射而二级分页则只需要 1024 个页表项。 我们把二级分页再推广到多级页表就会发现页表占用的内存空间更少了这一切都要归功于对局部性原理的充分应用。
对于 64 位的系统两级分页肯定不够了就变成了四级目录分别是 全局页目录项 PGDPage Global Directory 上层页目录项 PUDPage Upper Directory 中间页目录项 PMDPage Middle Directory 页表项 PTEPage Table Entry 局部性原理讲的是在一段时间内整个程序的执行仅限于程序的某一部分相应地程序访问的存储空间也局限于某个内存区域。主要分为两类 时间局部性如果程序中的某条指令一旦执行则不久之后该指令可能再次被执行如果某数据被访问则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元则不久之后其附近的存储单元也将被访问。 TLB
多级页表虽然解决了空间上的问题但是虚拟地址到物理地址的转换就多了几道转换的工序这显然就降低了这俩地址转换的速度也就是带来了时间上的开销。
程序是有局部性的即在一段时间内整个程序的执行仅限于程序中的某一部分。相应地执行所访问的存储空间也局限于某个内存区域。我们就可以利用这一特性把最常访问的几个页表项存储到访问速度更快的硬件于是计算机科学家们就在 CPU 芯片中加入了一个专门存放程序最常访问的页表项的 Cache这个 Cache 就是 TLBTranslation Lookaside Buffer 通常称为页表缓存、转址旁路缓存、快表等。
在 CPU 芯片里面封装了内存管理单元Memory Management Unit芯片它用来完成地址转换和 TLB 的访问与交互。有了 TLB 后那么 CPU 在寻址时会先查 TLB如果没找到才会继续查常规的页表。TLB 的命中率其实是很高的因为程序最常访问的页就那么几个。
内存分段和内存分页并不是对立的它们是可以组合起来在同一个系统中使用的那么组合起来后通常称为段页式内存管理。
段页式内存管理实现的方式 先将程序划分为多个有逻辑意义的段也就是前面提到的分段机制 接着再把每个段划分为多个页也就是对分段划分出来的连续空间再划分固定大小的页
虚拟内存地址结构由段号、段内页号和页内位移三部分组成。
用于段页式地址变换的数据结构是每一个程序一张段表每个段又建立一张页表段表中的地址是页表的起始地址而页表中的地址则为某页的物理页号。
段页式地址变换中要得到物理地址须经过三次内存访问
第一次访问段表得到页表起始地址第二次访问页表得到物理页号第三次将物理页号与页内位移组合得到物理地址。
虚拟内存的作用
第一进程获取的虚拟内存可以远超物理内存大小。因为局部性原理CPU访问的内存大概率都是重复的。只有当我们访问了这块虚拟内存才会分配对应的物理内存。第二虚拟内存解决了多进程之间地址冲突的问题由于每个进程都有自己的页表所以每个进程的虚拟内存空间就是相互独立的。这些页表是私有的进程也没有办法访问其他进程的页表。第三虚拟内存通过段表和页表进行映射页表里的页表项中除了物理地址之外还有一些标记属性的比特比如控制一个页的读写权限标记该页是否存在等所以在内存访问方面安全性更高。
malloc是如何分配内存的
在 Linux 操作系统中虚拟地址空间的内部又被分为内核空间和用户空间两部分不同位数的系统地址空间的范围也不同。比如最常见的 32 位和 64 位系统
32位系统最大只能申请4G的内存空间其中内核空间1G、用户空间3G。64位系统可以分配的空间更多其中内核空间128T、用户空间128T。
内核空间与用户空间的区别
进程在用户态时只能访问用户空间内存而只有进入内核态后才可以访问内核空间的内存
虽然每个进程都各自有独立的虚拟内存但是每个虚拟内存中的内核地址其实关联的都是相同的物理内存。这样进程切换到内核态后就可以很方便地访问内核空间内存。
用户空间内存从低到高分别是 6 种不同的内存段 代码段包括二进制可执行代码 数据段包括已初始化的静态常量和全局变量 BSS 段包括未初始化的静态变量和全局变量 堆段包括动态分配的内存从低地址开始向上增长 文件映射段包括动态库、共享内存等从低地址开始向上增长。 栈段包括局部变量和函数调用的上下文等。栈的大小是固定的一般是 8 MB。当然系统也提供了参数以便我们自定义大小
在这 6 个内存段中堆和文件映射段的内存是动态分配的。比如说使用 C 标准库的 malloc() 或者 mmap() 就可以分别在堆和文件映射段动态分配内存。 malloc是如何分配内存的 malloc 申请内存的时候会有两种方式向操作系统申请堆内存。 方式一通过 brk() 系统调用从堆分配内存 该方式实现方式简单就是通过 brk() 函数将「堆顶」指针向高地址移动获得新的内存空间。 方式二通过 mmap() 系统调用在文件映射区域分配内存 通过调用【私有匿名映射】的方式在文件映射区分配一块内存。 什么场景下 malloc() 会通过 brk() 分配内存又是什么场景下通过 mmap() 分配内存 malloc() 源码里默认定义了一个阈值如果用户分配的内存小于 128 KB则通过 brk() 申请内存如果用户分配的内存大于 128 KB则通过 mmap() 申请内存不同glibc版本不同阈值不同。 glibc是LInux最底层的api几乎其他任何运行库都会依赖于glibc。 malloc()分配的是物理内存吗 malloc()分配的是虚拟内存。如果分配后的虚拟内存没有被访问虚拟内存不会映射到物理内存发生缺页这样就不会占用物理内存了。 只有在访问已分配的虚拟地址空间的时候操作系统通过查找页表发现虚拟内存对应的页没有在物理内存中就会触发缺页中断然后操作系统会建立虚拟内存和物理内存之间的映射关系。 malloc(1)会分配多大的虚拟内存 malloc() 在分配内存的时候并不是老老实实按用户预期申请的字节数来分配内存空间大小而是会预分配更大的空间作为内存池。 如果分配内存小于128kb,最终以malloc 默认的内存管理器Ptmalloc2为例malloc(1) 实际上预分配 132K 字节的内存。多16KB free释放内存会归还给操作系统吗 针对 malloc 通过 brk() 方式申请的内存的情况通过 free 释放内存后堆内存还是存在的并没有归还给操作系统而是缓存着放进 malloc 的内存池里。 等下次在申请内存的时候就直接从内存池取出对应的内存块就行了而且可能这个内存块的虚拟地址与物理地址的映射关系还存在这样不仅减少了系统调用的次数也减少了缺页中断的次数这将大大降低 CPU 的消耗。 如果 malloc 通过 mmap 方式申请的内存free 释放内存后就会归还给操作系统内存得到真正释放。 为什么不全部使用mmap来分配内存 频繁通过 mmap 分配内存不仅每次都会发生运行态的切换还会发生缺页中断在第一次访问虚拟地址后这样会导致 CPU 消耗较大。 为了改进这两个问题malloc 通过 brk() 系统调用在堆空间申请内存的时候由于堆空间是连续的所以直接预分配更大的内存来作为内存池当内存释放的时候就缓存在内存池中。 为什么不全部使用brk来分配内存 如果我们连续申请了 10k20k30k 这三片内存如果 10k 和 20k 这两片释放了变为了空闲内存空间但是如果下次申请的内存大于 30k没有可用的空闲内存空间必须向 OS 申请实际使用内存继续增大。随着系统频繁地 malloc 和 free 尤其对于小块内存堆内将产生越来越多不可用的碎片导致“内存泄露”。 所以malloc 实现中充分考虑了 brk 和 mmap 行为上的差异及优缺点默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。 free()函数只传入一个内存地址为什么能知道要释放多大的内存 当执行 free() 函数时free 会对传入进来的内存地址向左偏移 16 字节保存内存块的信息比如大小然后从这个 16 字节的分析出当前的内存块的大小自然就知道要释放多大的内存了。
内存满了会发生什么
CPU 访问虚拟内存时 如果发现这个虚拟内存没有映射到物理内存 CPU 就会产生缺页中断进程会从用户态切换到内核态并将缺页中断交给内核的 Page Fault Handler 缺页中断函数处理。 缺页中断处理函数会看是否有空闲的物理内存如果有就直接分配物理内存并建立虚拟内存与物理内存之间的映射关系。 如果没有空闲的物理内存那么内核就会开始进行回收内存的工作回收的方式主要是两种直接内存回收和后台内存回收。 后台内存回收kswapd在物理内存紧张的时候会唤醒 kswapd 内核线程来回收内存这个回收内存的过程异步的不会阻塞进程的执行。 直接内存回收direct reclaim如果后台异步回收跟不上进程内存申请的速度就会开始直接回收这个回收内存的过程是同步的会阻塞进程的执行。
如果直接内存回收后空闲的物理内存仍然无法满足此次物理内存的申请那么内核就会放最后的大招了 ——触发 OOM Out of Memory机制。
OOM Killer 机制会根据算法选择一个占用物理内存较高的进程然后将其杀死以便释放内存资源如果物理内存依然不足OOM Killer 会继续杀死占用物理内存较高的进程直到释放足够的内存位置。 哪些内存可以被回收 主要有两类内存可以被回收而且它们的回收方式也不同。 文件页File-backed Page内核缓存的磁盘数据Buffer和内核缓存的文件数据Cache都叫作文件页。回收干净页的方式是直接释放内存回收脏页的方式是先写回磁盘后再释放内存。 匿名页Anonymous Page这部分内存没有实际载体不像文件缓存有硬盘文件这样一个载体比如堆、栈数据等。这部分内存很可能还要再次被访问所以不能直接释放内存它们回收的方式是通过 Linux 的 Swap 机制Swap 会把不常访问的内存先写到磁盘中然后释放这些内存给其他更需要的进程使用。再次访问这些内存时重新从磁盘读入内存就可以了。 文件页和匿名页的回收都是基于 LRU 算法也就是优先回收不常访问的内存。LRU 回收算法实际上维护着 active 和 inactive 两个双向链表其中 active_list 活跃内存页链表这里存放的是最近被访问过活跃的内存页 inactive_list 不活跃内存页链表这里存放的是很少被访问非活跃的内存页 回收内存带来的性能影响 回收内存的操作基本都会发生磁盘 I/O 的如果回收内存的操作很频繁意味着磁盘 I/O 次数会很多会影响操作系统性能。 针对回收内存导致的性能影响常见的解决方式。 设置 /proc/sys/vm/swappiness调整文件页和匿名页的回收倾向尽量倾向于回收文件页 swappiness 的范围是 0-100数值越大越积极使用 Swap也就是更倾向于回收匿名页数值越小越消极使用 Swap也就是更倾向于回收文件页。一般建议 swappiness 设置为 0默认值是 60这样在回收内存的时候会更倾向于文件页的回收但是并不代表不会回收匿名页。 设置 /proc/sys/vm/min_free_kbytes调整 kswapd 内核线程异步回收内存的时机设置 /proc/sys/vm/zone_reclaim_mode调整 NUMA 架构下内存回收策略建议设置为 0这样在回收本地内存之前会在其他 Node 寻找空闲内存从而避免在系统还有很多空闲内存的情况下因本地 Node 的本地内存不足发生频繁直接内存回收导致性能下降的问题 尽早触发kswapd内核线程异步回收内存 内核定义了三个内存阈值watermark也称为水位用来衡量当前剩余内存pages_free是否充裕或者紧张分别是 页高阈值pages_high 说明内存有一定压力但还可以满足应用程序申请内存的请求在这个区间说明内存充足 页低阈值pages_low 如果剩余内存pages_free在页低阈值pages_low和页最小阈值pages_min之间说明内存压力比较大剩余内存不多了。这时 kswapd0 会执行内存回收直到剩余内存大于高阈值pages_high为止。虽然会触发内存回收但是不会阻塞应用程序因为两者关系是异步的。 页最小阈值pages_min 如果剩余内存pages_free小于页最小阈值pages_min说明用户可用内存都耗尽了此时就会触发直接内存回收这时应用程序就会被阻塞因为两者关系是同步的。 kswapd 的活动空间只有 pages_low 与 pages_min 之间的这段区域如果剩余内存低于了 pages_min 会触发直接内存回收高于了 pages_high 又不会唤醒 kswapd。 增大 min_free_kbytes 配置设置的是页最小阈值这会使得系统预留过多的空闲内存从而在一定程度上降低了应用程序可使用的内存量这在一定程度上浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。 所以在调整 min_free_kbytes 之前需要先思考一下应用程序更加关注什么如果关注延迟那就适当地增大 min_free_kbytes尽量少的触发直接内存回收如果关注内存的使用量那就适当地调小 min_free_kbytes。 NUMA架构下的内存回收策略 SMP 指的是一种多个 CPU 处理器共享资源的电脑硬件架构也就是说每个 CPU 地位平等它们共享相同的物理资源包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的因此这种系统也被称为一致存储访问结构UMAUniform Memory Access。 随着 CPU 处理器核数的增多多个 CPU 都通过一个总线访问内存这样总线的带宽压力会越来越大同时每个 CPU 可用带宽会减少这也就是 SMP 架构的问题。 为了解决 SMP 架构的问题就研制出了 NUMA 结构即非一致存储访问结构Non-uniform memory access。 NUMA 架构将每个 CPU 进行了分组每一组 CPU 用 Node 来表示一个 Node 可能包含多个 CPU 。每个 Node 有自己独立的资源包括内存、IO 等每个 Node 之间可以通过互联模块总线QPI进行通信所以也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是访问远端 Node 的内存比访问本地内存要耗时很多。 虽然说访问远端 Node 的内存比访问本地内存要耗时很多但是相比内存回收的危害而言访问远端 Node 的内存带来的性能影响还是比较小的。因此zone_reclaim_mode 一般建议设置为 0即在回收本地内存之前在其他 Node 寻找空闲内存 如何保护一个进程不被OOM杀掉呢 在经历完直接内存回收后空闲的物理内存大小依然不够那么就会触发 OOM 机制Linux 内核里有一个 oom_badness() 函数它会把系统中可以被杀掉的进程扫描一遍并对每个进程打分得分最高的进程就会被首先杀掉。进程得分的结果受下面这两个方面影响 第一进程已经使用的物理内存页面数。 第二每个进程的 OOM 校准值 oom_score_adj。它是可以通过 /proc/[pid]/oom_score_adj 来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值调整进程被 OOM Kill 的几率。
在4GB物理内存的机器上申请8G内存会怎么样 32位系统和64位系统所占用的内核空间不同 32 位系统的内核空间占用 1G位于最高处剩下的 3G 是用户空间64 位系统的内核空间和用户空间都是 128T分别占据整个内存空间的最高和最低处剩下的中间部分是未定义的。 32位系统的场景 因为 32 位操作系统进程最多只能申请 3 GB 大小的虚拟内存空间所以进程申请 8GB 内存的话在申请虚拟内存阶段就会失败 64位系统的场景 64 位操作系统进程可以使用 128 TB 大小的虚拟内存空间所以进程申请 8GB 内存是没问题的因为进程申请内存是申请虚拟内存只要不读写这个虚拟内存操作系统就不会分配物理内存。 但是申请虚拟内存的过程中还是用到了物理内存比如内核保存虚拟内存的数据结构如果主机只有2GB的物理内存的话大概率会触发OOM。没有开始Swap机制的情况下 内存溢出(Out Of Memory简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了系统会提示内存溢出。 程序申请的虚拟内存如果没有被使用它是不会占用物理空间的。当访问这块虚拟内存后操作系统才会进行物理内存分配。如果申请物理内存大小超过了空闲物理内存大小就要看操作系统有没有开启 Swap 机制 如果没有开启 Swap 机制程序就会直接 OOM 如果有开启 Swap 机制程序可以正常运行。 Swap机制 当系统的物理内存不够用的时候就需要将物理内存中的一部分空间释放出来以供当前运行的程序使用。将内存数据换出磁盘又从磁盘中恢复数据到内存的过程就是 Swap 机制负责的。Swap 就是把一块磁盘空间或者本地文件当成内存来使用它包含换出和换入两个过程 换出Swap Out 是把进程暂时不用的内存数据存储到磁盘中并释放这些数据占用的内存 换入Swap In是在进程再次访问这些内存的时候把它们从磁盘读到内存中来 使用 Swap 机制优点是应用程序实际可以使用的内存空间将远远超过系统的物理内存。当然频繁地读写硬盘会显著降低操作系统的运行速率这也是 Swap 的弊端。 在Linux中Swap机制会在内存不足和内存闲置的场景下触发 内存不足当系统需要的内存超过了可用的物理内存时内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存保证正在执行的进程的可用性这个内存回收的过程是强制的直接内存回收Direct Page Reclaim。内存闲置应用程序在启动阶段使用的大量内存在启动后往往都不会使用通过后台运行的守护进程kSwapd我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换Page replacement的守护进程它也是负责交换闲置内存的主要进程它会在空闲内存低于一定水位 时回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程所以回收内存的过程是异步的不会阻塞当前申请内存的进程。 Swap换入换出的是什么类型的内存 换出文件页内核缓存的文件数据因为都有对应的磁盘文件所以在回收文件数据的时候 直接写回到对应的文件就可以了。 换出匿名页但是像进程的堆、栈数据等它们是没有实际载体这部分内存被称为匿名页。而且这部分内存很可能还要再次被访问所以不能直接释放内存于是就需要有一个能保存匿名页的磁盘载体这个载体就是 Swap 分区。
如何避免预读失效和缓存污染问题
传统的 LRU 算法存在这两个问题 「预读失效」导致缓存命中率下降 「缓存污染」导致缓存命中率下降
Redis 的缓存淘汰算法则是通过实现 LFU 算法来避免「缓存污染」而导致缓存命中率下降的问题Redis 没有预读机制。
MySQL 和 Linux 操作系统是通过改进 LRU 算法来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。 Linux的缓存在Page Cache 中 Linux 操作系统是会对读取的文件数据进行缓存的会缓存在文件系统中的 Page Cache。Page Cache 属于内存空间里的数据由于内存访问比磁盘访问快很多在下一次访问相同的数据就不需要通过磁盘 I/O 了命中缓存就直接返回数据即可。 因此Page Cache 起到了加速访问数据的作用。 MySQL的缓存在Buffer Pool中 MySQL 的数据是存储在磁盘里的为了提升数据库的读写性能Innodb 存储引擎设计了一个缓冲池Buffer PoolBuffer Pool 属于内存空间里的数据。
传统的 LRU 算法的实现思路是这样的 当访问的页在内存里就直接把该页对应的 LRU 链表节点移动到链表的头部。 当访问的页不在内存里除了要把该页放入到 LRU 链表的头部还要淘汰 LRU 链表末尾的页。
预读失效带来的问题
应用程序利用 read 系统调动读取 4KB 数据实际上内核使用预读机制ReadaHead 机制完成了 16KB 数据的读取。预读机制带来的好处就是减少了 磁盘 I/O 次数提高系统磁盘 I/O 吞吐量。如果这些被提前加载进来的页并没有被访问相当于这个预读工作是白做了这个就是预读失效。
在传统的LRU算法中如果预读页一直不会被访问到不会被访问的预读页却占用了 LRU 链表前排的位置而末尾淘汰的页可能是热点数据这样就大大降低了缓存命中率 。
避免预读失效的影响
要避免预读失效带来影响最好就是让预读页停留在内存里的时间要尽可能的短让真正被访问的页才移动到 LRU 链表的头部从而保证真正被读取的热数据留在内存里的时间尽可能长。
Linux 操作系统实现两个了 LRU 链表活跃 LRU 链表active_list和非活跃 LRU 链表inactive_list 有了这两个 LRU 链表后预读页就只需要加入到 inactive list 区域的头部当页被真正访问的时候才将页插入 active list 的头部。如果预读的页一直没有被访问就会从 inactive list 移除这样就不会影响 active list 中的热点数据。 MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域young 区域 和 old 区域。 划分这两个区域后预读的页就只需要加入到 old 区域的头部当页被真正访问的时候才将页插入 young 区域的头部。如果预读的页一直没有被访问就会从 old 区域移除这样就不会影响 young 区域中的热点数据。
缓存污染带来的问题
如果还是使用「只要数据被访问一次就将数据加入到活跃 LRU 链表头部或者 young 区域」这种方式的话那么还存在缓存污染的问题。
当我们在批量读取数据的时候由于数据被访问了一次这些大量数据都会被加入到「活跃 LRU 链表」里然后之前缓存在活跃 LRU 链表或者 young 区域里的热点数据全部都被淘汰了如果这些大量的数据在很长一段时间都不会被访问的话那么整个活跃 LRU 链表或者 young 区域就被污染了。
比如当某一个 SQL 语句扫描了大量的数据时在 Buffer Pool 空间比较有限的情况下可能会将 Buffer Pool 里的所有页都替换出去导致大量热数据被淘汰了等这些热数据又被再次访问的时候由于缓存未命中就会产生大量的磁盘 I/OMySQL 性能就会急剧下降。
避免缓存污染造成的影响
只要我们提高进入到活跃 LRU 链表或者 young 区域的门槛就能有效地保证活跃 LRU 链表或者 young 区域里的热点数据不会被轻易替换掉。 Linux 在内存页被访问第二次的时候才将页从 inactive list 升级到 active list 里。 MySQL Innodb在内存页被访问第二次的时候并不会马上将该页从 old 区域升级到 young 区域因为还要进行停留在 old 区域的时间判断 如果第二次的访问时间与第一次访问的时间在 1 秒内默认值那么该页就不会被从 old 区域升级到 young 区域 如果第二次的访问时间与第一次访问的时间超过 1 秒那么该页就会从 old 区域升级到 young 区域
通过提高了进入 active list 或者 young 区域的门槛后就很好了避免缓存污染带来的影响。
Java并发常见面试题下
ThreadLocal ThreadLocal有什么用 TheadLocal是本地线程也就是每个线程都有自己的本地变量如果创建了一个ThreadLocal变量那么每次访问这个变量都会生成一个新的变量的副本。 ThreadLocal原理了解吗 ThreadLocal中最终的变量放在当前线程的ThreadLocalMap中。并不是存储在ThreadLocal上。ThreadLocalMap可以存储以ThreadLocal为keyObject对象为value的键值对。当调用set()或get()方法时实际调用的是ThreadLocalMap类对应的get()、set()方法。 TheadLocal内存泄漏问题是怎么导致的 ThreadLocalMap中使用的key为ThreadLocal的弱引用而value是强引用。在没有使用外部强引用的情况下在垃圾回收的时候key会被清理掉value不会被清理掉。 如果不做任何措施value永远不会被GC回收可能产生内存泄漏。 ThreadLocalMap中已经考虑了这种情况所以在调用set()、get()、remove()方法的时候会清理掉key为null的值。在使用完ThreadLocal方法后最好手动调用remove()方法。 弱引用 弱引用与软引用的区别在于只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中一旦发现了只具有弱引用的对象不管当前内存空间足够与否都会回收它的内存。不过由于垃圾回收器是一个优先级很低的线程 因此不一定会很快发现那些只具有弱引用的对象。 弱引用可以和一个引用队列ReferenceQueue联合使用如果弱引用所引用的对象被垃圾回收Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。 线程池 什么是线程池 线程池可以管理一系列线程。当有任务要处理时直接从线程池中获取线程处理处理完之后线程并不会立即被销毁而是等待下一个任务。 为什么要用线程池 降低资源消耗。通过重复利用已经创建的线程降低线程创建的成本。提高响应速度。如果有线程可用就可以直接执行任务。提高线程的可管理性。线程池可以对线程进行统一的分配调优和监控。 如何创建线程池 推荐使用ThreadPoolExecutor构造函数来创建来自定义线程池。 像《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建而是通过 ThreadPoolExecutor 构造函数的方式这样的处理方式让写的同学更加明确线程池的运行规则规避资源耗尽的风险。 Executors返回线程池对象的弊端 FixedThreadPool 和 SingleThreadExecutor 使用的是无界的 LinkedBlockingQueue任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求从而导致 OOM。 CachedThreadPool 使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE 可能会创建大量线程从而导致 OOM。 ScheduledThreadPool 和 SingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求从而导致 OOM。 创建Executors对象所使用的内部数据结构所规定的最大线程数量为Integer.MAX_VALUE。 线程池常见参数 ThreadPoolExecutor 3 个最重要的参数 corePoolSize : 任务队列未达到队列容量时最大可以同时运行的线程数量。maximumPoolSize : 任务队列中存放的任务达到队列容量的时候当前可以同时运行的线程数量变为最大线程数。workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数如果达到的话新任务就会被存放在队列中。 ThreadPoolExecutor其他常见参数 : keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候如果这时没有新的任务提交核心线程外的线程不会立即销毁而是会等待直到等待的时间超过了 keepAliveTime才会被回收销毁unit : keepAliveTime 参数的时间单位。threadFactory :executor 创建新线程的时候会用到。handler :饱和策略。关于饱和策略下面单独介绍一下。 线程池的饱和策略 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时ThreadPoolTaskExecutor 定义一些策略: ThreadPoolExecutor.AbortPolicy 抛出 RejectedExecutionException来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy 调用执行自己的线程运行任务也就是直接在调用execute方法的线程中运行(run)被拒绝的任务如果执行程序已关闭则会丢弃该任务。因此这种策略会降低对于新任务提交速度影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy 不处理新任务直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy 此策略将丢弃最早的未处理的任务请求。 举个例子Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候当我们不指定 RejectedExecutionHandler 饱和策略来配置线程池的时候**默认使用的是 AbortPolicy。**在这种饱和策略下如果队列满了ThreadPoolExecutor 将抛出 RejectedExecutionException 异常来拒绝新来的任务 这代表你将丢失对这个任务的处理。如果不想丢弃任务的话可以使用CallerRunsPolicy。CallerRunsPolicy 和其他的几个策略不同它既不会抛弃任务也不会抛出异常而是将任务回退给调用者使用调用者的线程来执行任务。 线程池常用的阻塞队列 内置的4种线程池 容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue无界队列FixedThreadPool 和 SingleThreadExector 。由于队列永远不会被放满因此FixedThreadPool最多只能创建核心线程数的线程。 SynchronousQueue同步队列 CachedThreadPool 。SynchronousQueue 没有容量不存储元素目的是保证对于提交的任务如果有空闲线程则使用空闲线程来处理否则新建一个线程来处理任务。也就是说CachedThreadPool 的最大线程数是 Integer.MAX_VALUE 可以理解为线程数是可以无限扩展的可能会创建大量线程从而导致 OOM。 DelayedWorkQueue延迟阻塞队列ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序而是会按照延迟的时间长短对任务进行排序内部采用的是“堆”的数据结构可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2即永远不会阻塞最大扩容可达 Integer.MAX_VALUE所以最多只能创建核心线程数的线程。 线程池处理任务流程 如果当前运行的线程数小于核心线程数那么就会新建一个线程来执行任务。 如果当前运行的线程数等于或大于核心线程数但是小于最大线程数那么就把该任务放入到任务队列里等待执行。 如果向任务队列投放任务失败任务队列已经满了但是当前运行的线程数是小于最大线程数的就新建一个线程来执行任务。 如果当前运行的线程数已经等同于最大线程数了新建线程将会使当前运行的线程超出最大线程数那么当前任务会被拒绝饱和策略会调用RejectedExecutionHandler.rejectedExecution()方法。 如何给线程池命名 利用guava包的ThreadFactoryBuilder。 自己创建一个类实现ThreadFactory类。 import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/*** 线程工厂它设置线程名称有利于我们定位问题。*/
public final class NamingThreadFactory implements ThreadFactory {private final AtomicInteger threadNum new AtomicInteger();private final ThreadFactory delegate;private final String name;/*** 创建一个带名字的线程池生产工厂*/public NamingThreadFactory(ThreadFactory delegate, String name) {this.delegate delegate;this.name name; // TODO consider uniquifying this}Overridepublic Thread newThread(Runnable r) {Thread t delegate.newThread(r);t.setName(name [# threadNum.incrementAndGet() ]);return t;}}如何设定线程池的大小 如果我们设置的线程池数量太小的话如果同一时间有大量任务/请求需要处理可能会导致大量的请求/任务在任务队列中排队等待执行甚至会出现任务队列满了之后任务/请求无法处理的情况或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的CPU 根本没有得到充分利用。 如果我们设置线程数量太大大量线程可能会同时在争取 CPU 资源这样会导致大量的上下文切换从而增加线程的执行时间影响了整体执行效率。 CPU 密集型任务(N1) 这种任务消耗的主要是 CPU 资源可以将线程数设置为 NCPU 核心数1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断或者其它原因导致的任务暂停而带来的影响。一旦任务暂停CPU 就会处于空闲状态而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。 CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。 I/O 密集型任务(2N) 这种任务应用起来系统会用大部分的时间来处理 I/O 交互而线程在处理 I/O 的时间段内不会占用 CPU 来处理这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中我们可以多配置一些线程具体的计算方法是 2N。 但凡涉及到网络读取文件读取这类都是 IO 密集型这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少大部分时间都花在了等待 IO 操作完成上。 如何动态修改线程池的参数 美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是 corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。maximumPoolSize : 当队列中存放的任务达到队列容量的时候当前可以同时运行的线程数量变为最大线程数。workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数如果达到的话新任务就会被存放在队列中。 具体可以借助开源项目实现。
Future类 Future类有什么用 Future类是异步思想的典型运用当执行某一耗时任务时可以将耗时任务交给一个子线程去异步执行。等事情干完后再通过Future类获取到耗时任务的执行结果。这样可以提高程序运行的效率。 在 Java 中Future 类只是一个泛型接口位于 java.util.concurrent 包下其中定义了 5 个方法主要包括下面这 4 个功能 取消任务判断任务是否被取消;判断任务是否已经执行完成;获取任务执行结果。 Callable和Future有什么关系 我们可以通过 FutureTask 来理解 Callable 和 Future 之间的关系。 FutureTask 提供了 Future 接口的基本实现常用来封装 Callable 和 Runnable具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。 FutureTask 有两个构造函数可传入 Callable 或者 Runnable 对象。实际上传入 Runnable 对象也会在方法内部转换为Callable 对象。 public FutureTask(CallableV callable) {if (callable null)throw new NullPointerException();this.callable callable;this.state NEW;
}
public FutureTask(Runnable runnable, V result) {// 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象this.callable Executors.callable(runnable, result);this.state NEW;
}FutureTask相当于对Callable 进行了封装管理着任务执行的情况存储了 Callable 的 call 方法的任务执行结果。 CompletableFuture类有什么用 Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。 Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外还提供了函数式编程、异步任务编排组合可以将多个异步任务串联起来组成一个完整的链式调用等能力。
AQS AQS是什么 AQS 的全称为 AbstractQueuedSynchronizer 翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks 包下面是一个抽象类。 AQS 为构建锁和同步器提供了一些通用功能的实现因此使用 AQS 能简单且高效地构造出应用广泛的大量的同步器比如我们提到的 ReentrantLockSemaphore其他的诸如 ReentrantReadWriteLockSynchronousQueue等等皆是基于 AQS 的。 AQS的原理是什么 AQS 核心思想是如果被请求的共享资源空闲则将当前请求资源的线程设置为有效的工作线程并且将共享资源设置为锁定状态。如果被请求的共享资源被占用那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制这个机制 AQS 是用 CLH 队列锁 实现的即将暂时获取不到锁的线程加入到队列中。 CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列虚拟的双向队列即不存在队列实例仅存在结点之间的关联关系。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点Node来实现锁的分配。在 CLH 同步队列中一个节点表示一个线程它保存着线程的引用thread、 当前节点在队列中的状态waitStatus、前驱节点prev、后继节点next。 AQS的核心原理图 AQS 使用 int 成员变量 state 表示同步状态通过内置的 线程等待队列 来完成获取资源线程的排队工作。 state 变量由 volatile 修饰用于展示当前临界资源的获锁情况。 // 共享变量使用volatile修饰保证线程可见性
private volatile int state;另外状态信息 state 可以通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且这几个方法都是 final 修饰的在子类中无法被重写。 //返回同步状态的当前值
protected final int getState() {return state;
}// 设置同步状态的值
protected final void setState(int newState) {state newState;
}
//原子地CAS操作将同步状态值设置为给定值update如果当前同步状态的值等于expect期望值
protected final boolean compareAndSetState(int expect, int update) {return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}以 ReentrantLock 为例state 初始值为 0表示未锁定状态。A 线程 lock() 时会调用 tryAcquire() 独占该锁并将 state1 。此后其他线程再 tryAcquire() 时就会失败直到 A 线程 unlock() 到 state0即释放锁为止其它线程才有机会获取该锁。当然释放锁之前A 线程自己是可以重复获取此锁的state 会累加这就是可重入的概念。但要注意获取多少次就要释放多少次这样才能保证 state 是能回到零态的。 再以 CountDownLatch 以例任务分为 N 个子线程去执行state 也初始化为 N注意 N 要与线程个数一致。这 N 个子线程是并行执行的每个子线程执行完后countDown() 一次state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state0 )会 unpark() 主调用线程然后主调用线程就会从 await() 函数返回继续后余动作。 Semaphore有什么用 synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。 Semaphore 的使用简单我们这里假设有 N(N5) 个线程来获取 Semaphore 中的共享资源下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源其他线程都会阻塞只有获取到共享资源的线程才能执行。等到有线程释放了共享资源其他阻塞的线程才能获取到。 当初始的资源个数为 1 的时候Semaphore 退化为排他锁。 Semaphore 有两种模式。 公平模式 调用 acquire() 方法的顺序就是获取许可证的顺序遵循 FIFO非公平模式 抢占式的。 Semaphore 对应的两个构造方法如下 public Semaphore(int permits) {sync new NonfairSync(permits);
}public Semaphore(int permits, boolean fair) {sync fair ? new FairSync(permits) : new NonfairSync(permits);
} 这两个构造方法都必须提供许可的数量第二个构造方法可以指定是公平模式还是非公平模式默认非公平模式。 Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流仅限于单机模式实际项目中推荐使用 Redis Lua 来做限流。 Semaphore的原理是什么 Semaphore 是共享锁的一种实现它默认构造 AQS 的 state 值为 permits你可以将 permits 的值理解为许可证的数量只有拿到许可证的线程才能执行。 调用semaphore.acquire() 线程尝试获取许可证如果 state 0 的话则表示可以获取成功。如果获取成功的话使用 CAS 操作去修改 state 的值 statestate-1。如果 state0 的话则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列挂起当前线程。 调用semaphore.release(); 线程尝试释放许可证并使用 CAS 操作去修改 state 的值 statestate1。释放许可证成功之后同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 statestate-1 如果 state0 则获取令牌成功否则重新进入阻塞队列挂起线程。 CountDownLatch有什么用 CountDownLatch 允许 count 个线程阻塞在一个地方直至所有线程的任务都执行完毕。 CountDownLatch 是一次性的计数器的值只能在构造方法中初始化一次之后没有任何机制再次对其设置值当 CountDownLatch 使用完毕后它不能再次被使用。 CountDownLatch的原理是什么 CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候如果 state 不为 0那就证明任务还没有执行完毕await() 方法就会一直阻塞也就是说 await() 方法之后的语句不会被执行。然后CountDownLatch 会自旋 CAS 判断 state 0如果 state 0 的话就会释放所有等待的线程await() 方法之后的语句得到执行。 用过CountDownLatch么什么场景下用的 CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方直至所有线程的任务都执行完毕。之前在项目中有一个使用多线程读取多个文件处理的场景我用到了 CountDownLatch 。具体场景是下面这样的 我们要读取处理 6 个文件这 6 个任务都是没有执行顺序依赖的任务但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。 为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务每一个线程处理完之后就将 count-1调用CountDownLatch对象的 await()方法直到所有文件读取完之后才会接着执行后面的逻辑。 CyclicBarrier有什么用 CyclicBarrier 和 CountDownLatch 非常类似它也可以实现线程间的技术等待但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。 CountDownLatch 的实现是基于 AQS 的而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。 CyclicBarrier 的字面意思是可循环使用Cyclic的屏障Barrier。它要做的事情是让一组线程到达一个屏障也可以叫同步点时被阻塞直到最后一个线程到达屏障时屏障才会开门所有被屏障拦截的线程才会继续干活。 CyclicBarrier原理 CyclicBarrier 内部通过一个 count 变量作为计数器count 的初始值为 parties 属性的初始化值每当一个线程到了栅栏这里了那么就将计数器减 1。如果 count 值为 0 了表示这是这一代最后一个线程到达栅栏就尝试执行我们构造方法中输入的任务。