一、背景
Linux kernel out_of_memory(简称OOM)从字面上看可以就可以看出是因为没有内存可供分配导致,OOM的产生和内存分配相关,分析此类问题需要对linux kernel的内存管理非常了解才好定位问题。产生OOM的原因大部分是因为内存的泄漏导致,但也不排除部分小内存的设备(512M或者更低)在使用大量耗内存的应用时,设备的内存回收机制来不及回收内存也会导致出现无内存分配。
1.1、涉及概念
1、硬件原理和分页管理
CPU寻址内存,虚拟地址、物理地址
MMU以及RWX权限、kernel和user模式权限
内存的zone: DMA、Normal和HIGHMEM
Linux内存管理Buddy算法
连续内存分配器(CMA)
2、内存的动态申请和释放
slab、kmalloc/kfree、/proc/slabinfo和slabtop
用户空间malloc/free与内核之间的关系
mallopt
vmalloc
内存耗尽(OOM)、oom_score和oom_adj
3、进程的内存消耗和泄漏
进程的VMA。
进程内存消耗的4个概念:vss、rss、pss和uss
page fault的几种可能性,major和minor
应用内存泄漏的界定方法
应用内存泄漏的检测方法:valgrind和addresssanitizer
4、内存与I/O的交换
page cache
free命令的详细解释
read、write和mmap
file-backed的页面和匿名页
swap以及zRAM
页面回收和LRU
5、其他工程问题以及调优
DMA和cache一致性
内存的cgroup
性能方面的调优:page in/out, swapin/out
Dirty ratio的一些设置
swappiness
1.2、Linux内存管理
1.2.1、进程和内存
1、虚拟和物理地址
从用户向内核看,所使用的内存表象形式会依次经历“逻辑地址”——“线性地址”——“物理地址”几种形式。
进程所能直接操作的地址都为虚拟地址。当进程需要内存时,从内核获得的仅仅是虚拟的内存区域,而不是实际的物理地址,进程并没有获得物理内存(物理页面——页的概念请大家参考硬件基础一章),获得的仅仅是对一个新的线性地址区间的使用权。实际的物理内存只有当进程真的去访问新获取的虚拟地址时,才会由“请求页机制”产生“缺页”异常,从而进入分配实际页面的例程。
该异常是虚拟内存机制赖以存在的基本保证——它会告诉内核去真正为进程分配物理页,并建立对应的页表,这之后虚拟地址才实实在在地映射到了系统的物理内存上。(当然,如果页被换出到磁盘,也会产生缺页异常,不过这时不用再建立页表了)
这种请求页机制把页面的分配推迟到不能再推迟为止,并不急于把所有的事情都一次做完(这种思想有点像设计模式中的代理模式(proxy))。之所以能这么做是利用了内存访问的“局部性原理”,请求页带来的好处是节约了空闲内存,提高了系统的吞吐率。
2、进程内存管理
Linux操作系统采用虚拟内存管理技术,使得每个进程都有各自互不干涉的进程地址空间。该空间是块大小为4G的线性虚拟空间,用户所看到和接触到的都是该虚拟地址,无法看到实际的物理内存地址。利用这种虚拟地址不但能起到保护操作系统的效果(用户不能直接访问物理内存),而且更重要的是,用户程序可使用比实际物理内存更大的地址空间。
如果你要查看某个进程占用的内存区域,可以使用命令
cat /proc/<pid>/maps
获得(pid是进程号,你可以运行上面我们给出的例子——./example &;pid便会打印到屏幕),你可以发现很多类似于下面的数字信息。
3、进程内存的分配与回收
操作系统创建进程fork()、程序载入execve()、映射文件mmap()、动态内存分配malloc()/brk()等进程相关操作都需要分配内存给进程。不过这时进程申请和获得的还不是实际内存,而是虚拟内存,准确的说是“内存区域”。进程对内存区域的分配最终都会归结到do_mmap()函数上来(brk调用被单独以系统调用实现,不用do_mmap()),内核使用do_mmap()函数创建一个新的线性地址区间。但是说该函数创建了一个新VMA并不非常准确,因为如果创建的地址区间和一个已经存在的地址区间相邻,并且它们具有相同的访问权限的话,那么两个区间将合并为一个。如果不能合并,那么就确实需要创建一个新的VMA了。但无论哪种情况, do_mmap()函数都会将一个地址区间加入到进程的地址空间中--无论是扩展已存在的内存区域还是创建一个新的区域。
同样,释放一个内存区域应使用函数do_ummap(),它会销毁对应的内存区域。
1.2.2、系统物理内存管理
1、虽然应用程序操作的对象是映射到物理内存之上的虚拟内存,但是处理器直接操作的却是物理内存。所以当应用程序访问一个虚拟地址时,首先必须将虚拟地址转化成物理地址,然后处理器才能解析地址访问请求。地址的转换工作需要通过查询页表才能完成,概括地讲,地址转换需要将虚拟地址分段,使每段虚地址都作为一个索引指向页表,而页表项则指向下一级别的页表或者指向最终的物理页面。
2、物理内存管理
Linux内核管理物理内存是通过分页机制实现的,它将整个内存划分成无数个
4k大小的页
,从而分配和回收内存的基本单位便是内存页了。利用分页管理有助于灵活分配内存地址,因为分配时不必要求必须有大块的连续内存,系统可以东一页、西一页的凑出所需要的内存供进程使用。虽然如此,但是实际上系统使用内存时还是倾向于分配连续的内存块,因为分配连续内存时,页表不需要更改,因此能降低TLB的刷新率(频繁刷新会在很大程度上降低访问速度)。
鉴于上述需求,内核分配物理页面时为了尽量减少不连续情况,采用了“伙伴”关系来管理空闲页面。Linux中空闲页面的组织和管理利用了伙伴关系,因此空闲页面分配时也需要遵循伙伴关系,最小单位只能是2的幂倍页面大小。内核中分配空闲页面的基本函数是get_free_page/get_free_pages,它们或是分配单页或是分配指定的页面(2、4、8…512页)。
注意:
get_free_page是在内核中分配内存,不同于malloc在用户空间中分配,malloc利用堆动态分配,实际上是调用brk()系统调用,该调用的作用是扩大或缩小进程堆空间(它会修改进程的brk域)。如果现有的内存区域不够容纳堆空间,则会以页面大小的倍数为单位,扩张或收缩对应的内存区域,但brk值并非以页面大小为倍数修改,而是按实际请求修改。因此Malloc在用户空间分配内存可以以字节为单位分配,但内核在内部仍然会是以页为单位分配的。
3、内核内存使用
为了满足内核对这种小内存块的需要,Linux系统采用了一种被称为slab分配器的技术。Slab分配器的实现相当复杂,但原理不难,其核心思想就是“存储池”的运用。内存片段(小块内存)被看作对象,当被使用完后,并不直接释放而是被缓存到“存储池”里,留做下次使用,这无疑避免了频繁创建与销毁对象所带来的额外负载。
Slab技术不但避免了内存内部分片(下文将解释)带来的不便(引入Slab分配器的主要目的是为了减少对伙伴系统分配算法的调用次数——频繁分配和回收必然会导致内存碎片——难以找到大块连续的可用内存),而且可以很好地利用硬件缓存提高访问速度。
Slab分配器不仅仅只用来存放内核专用的结构体,它还被用来处理内核对小块内存的请求。当然鉴于Slab分配器的特点,一般来说内核程序中对小于一页的小块内存的请求才通过Slab分配器提供的接口
Kmalloc
来完成(虽然它可分配32 到131072字节的内存)。从内核内存分配的角度来讲,kmalloc可被看成是get_free_page(s)的一个有效补充,内存分配粒度更灵活了。
有兴趣的话,可以到
/proc/slabinfo
中找到内核执行现场使用的各种slab信息统计,其中你会看到系统中所有slab的使用信息。从信息中可以看到系统中除了专用结构体使用的slab外,还存在大量为Kmalloc而准备的Slab(其中有些为dma准备的)。
内核提供
vmalloc
函数分配内核虚拟内存,该函数不同于kmalloc,它可以分配较Kmalloc大得多的内存空间(可远大于128K,但必须是页大小的倍数),但相比Kmalloc来说,Vmalloc需要对内核虚拟地址进行重映射,必须更新内核页表,因此分配效率上要低一些(用空间换时间)。与用户进程相似,内核也有一个名为init_mm的mm_strcut结构来描述内核地址空间,其中页表项pdg=swapper_pg_dir包含了系统内核空间(3G-4G)的映射关系。因此vmalloc分配内核虚拟地址必须更新内核页表,而kmalloc或get_free_page由于分配的连续内存,所以不需要更新内核页表。
1.2.3、知识点
1、吞吐vs.响应
任何操作系统的调度器设计只追求2个目标:吞吐率大和延迟低。因为吞吐率要大,势必要把更多的时间放在做
真实的有用功
,而不是把时间浪费在频繁的进程
上下文切换
;而延迟要低,势必要求优先级高的进程可以随时抢占进来,打断别人,强行插队。
2、
CPU消耗型 vs. I/O消耗型
在Linux运行的进程,分为2类,一类是CPU消耗型(狂算),一类是I/O消耗型(狂睡,等I/O),前者CPU利用率高,后者CPU利用率低。一般而言,I/O消耗型任务对延迟比较敏感,应该被优先调度。
3、
分配vs. 占据
在应用程序里面malloc()成功的一刻,也不要以为真的拿到了内存,这个时候你的vss(虚拟地址空间,
Virtual Set Size
)会增大,但是你的rss(驻留在内存条上的内存,Resident SetSize)内存会随着写到每一页而缓慢增大。所以,分配成功的一刻,顶多只是被忽悠了,和你实际占有还是不占有,暂时没有半毛钱关系。
代码段的内存、堆的内存、栈的内存都是这样懒惰地拿到,demanding page。
我们有一台1GB内存的32位Linux系统,我们关闭swap,同时透过修改
overcommit_memory
为1来允许申请不超过进程虚拟地址空间的内存。
4、
隔离vs. 共享
Linux进程究竟耗费了多少内存,是一个非常复杂的概念,除了上面的vss, rss外,还有pss和uss,这些都是Linux不同于RTOS的显著特点之一。Linux各个进程既要做到隔离,但是隔离中又要实现共享,比如1000个进程都用libc,libc的代码段显然在内存只应该有一份。
1.2.4、内存结构 node、zone、page、order
-
Node
:每个CPU下的本地内存节点就是一个Node,如果是UMA架构下,就只有一个Node0,在NUMA架构下,会有多个Node -
Zone
:每个Node会划分很多域Zone,大概有下面这些:- 1) ZONE_DMA:定义适合DMA的内存域,该区域的长度依赖于处理器类型。比如ARM所有地址都可以进行DMA,所以该值可以很大,或者干脆不定义DMA类型的内存域。而在IA-32的处理器上,一般定义为16M。
- 2) ZONE_DMA32:只在64位系统上有效,为一些32位外设DMA时分配内存。如果物理内存大于4G,该值为4G,否则与实际的物理内存大小相同。
- 3) ZONE_NORMAL:定义可直接映射到内核空间的普通内存域。在64位系统上,如果物理内存小于4G,该内存域为空。而在32位系统上,该值最大为896M。
- 4) ZONE_HIGHMEM:只在32位系统上有效,标记超过896M范围的内存。在64位系统上,由于地址空间巨大,超过4G的内存都分布在ZONE_NORMA内存域。
- 5) ZONE_MOVABLE:伪内存域,为了实现减小内存碎片的机制。
-
分配价值链
- 除了只能在某个区域分配的内存(比如ZONE_DMA),普通的内存分配会有一个“价值”的层次结构,按分配的“廉价度”依次为:ZONE_HIGHMEM > ZONE_NORMAL > ZONE_DMA。
- 即内核在进行内存分配时,优先从高端内存进行分配,其次是普通内存,最后才是DMA内存
-
Page
:zone下面就是真正的内存页了,每个页基础大小是4K,他们维护在一个叫free_area的数组结构中- order:数组的index,也叫order,实际对应的是page的大小,比如order为0,那么就是一堆1个空闲页(4K)组成的链表,order为1,就是一堆2个空闲页(8K)组成的链表,order为2,就是一堆4个空闲页(16K)组成的链表
1.2.3、参考网址
专题相关
https://www.cnblogs.com/ralap7/p/9184773.html
https://blog.csdn.net/21cnbao/article/details/77505330
系统学习相关
https://blog.csdn.net/gatieme/article/category/6393814
https://blog.csdn.net/vanbreaker/article/category/1132690
1.3、OOM
1.3.1、解释
Linux有一个特性:OOM Killer,一个保护机制,用于避免在内存不足的时候不至于出现严重问题,把一些无关的进程优先杀掉,即在内存严重不足时,系统为了继续运转,内核会挑选一个进程,将其杀掉,以释放内存,缓解内存不足情况,不过这种保护是有限的,不能完全的保护进程的运行。在很多情况下,经常会看到还有剩余内存时,oom-killer依旧把进程杀死了。
内核根据既定策略选择需要kill的process,基本策略为:通过进程的内存占用情况计算“点数”,点数最高者被选中;如果没有选出来可kill的进程,那么直接panic;kill掉被选中的进程,以释放内存。
1.3.2、日志说明
1、gfp_mask
物理内存申请我们在上一篇分析了,会到不同的Node不同的zone,那么这次申请的是哪一部分?这个可以从
gfp_mask=0x201da, order=0
分析出来,gfp_mask(get free page)是申请内存的时候,会传的一个标记位,里面包含三个信息:区域修饰符、行为修饰符、类型修饰符。
0X201da = 0x20000 | 0x100| 0x80 | 0x40 | 0x10 | 0x08 | 0x02
也就是下面几个值:
___GFP_HARDWAL | ___GFP_COLD | ___GFP_FS | ___GFP_IO | ___GFP_MOVABLE| ___GFP_HIGHMEM
同时设置了___GFP_MOVABLE和___GFP_HIGHMEM会扫描ZONE_MOVABLE,其实也就是会在ZONE_NORMAL
例如:
//GFP_KERNEL会从normal memory申请内存
GFP_KERNEL|__GFP_NOWARN|__GFP_COMP|__GFP_ZERO)
参考文档:
https://blog.csdn.net/farmwang/article/details/66975128
2、order
本次申请内存的大小。例如,
//order = 3说明申请2^3个page,也就是8*4kB
1.3.3、参考文档
https://blog.csdn.net/frank_zyp/article/details/83746253
二、内存分析
2.1、系统内存
2.1.1、/proc/meminfo
关心字段
MemTotal:系统总内存,由于 BIOS、内核等会占用一些内存,所以这里和配置声称的内存会有一些出入,比如我这里配置有 2G,但其实只有 1.95G 可用。
MemFree:系统空闲内存。
MemAvailable:应用程序可用内存。有人会比较奇怪和 MemFree 的区别,可以从两个层面来区分,MemFree 是系统层面的,而 MemAvailable 是应用程序层面的。系统中有些内存虽然被使用了但是有一部分是可以回收的,比如 Buffers、Cached 及 Slab 这些内存,这部分可以回收的内存加上 MemFree 才是 MemAvailable 的内存值,这是内核通过特定算法算出来的,是一个估算值。
Buffers:缓冲区内存
Cached:缓存
2.1.2、free
MemTotal = used + free + buff/cache
有个
shared
字段,这个是多进程的共享内存空间,不常用。 free 很小,buff/cache 却很大,这是 Linux 的内存设计决定的,Linux 的想法是内存闲着反正也是闲着,不如拿出来做系统缓存和缓冲区,提高数据读写的速率。但是当系统内存不足时,buff/cache 会让出部分来,非常灵活的操作。
要看比较直观的值,可以加 -h 参数。
2.1.3、vmstat
这个命令也是非常常用了。但对于内存,显示信息有限。它更多是用于进行系统全局分析和 CPU 分析。
2.2、进程内存
2.2.1、top
top 命令运行时默认是按照 CPU 利用率进行排序的,如果要按照内存排序,该怎么操作呢?两种方法,一种
直接按 “M”(相应的按 “P” 是 CPU),另外一种是在键入 top 之后,按下 “F”,然后选择要排序的字段,再按下 “s” 确认
即可。
可以看到,我按照 “%MEM” 排序的结果。这个结果对于查看系统占用内存较多的哪些进程是比较有用的。
然后这里我们会重点关注几个地方,上面横排区,和前面几个命令一样可以查看系统内存信息,中间标注的横条部分,和内存相关的有三个字段:VIRT、RES、SHR。
- VIRT:virtual memory usage,进程占用的虚拟内存大小。
- RES:resident memory usage,进程常驻内存大小,也就是实际内存占用情况,一般我们看进程占用了多少内存,就是看的这个值。
- SHR:shared memory,共享内存大小,不常用。
2.2.2、ps
ps 同样可以查看进程占用内存情况,一般常用来查看 Top n 进程占用内存情况,如:
ps aux --sort=rss | head -n
,表示按 rss 排序,取 Top n。
关注三个字段:
- %MEM:进程使用物理内存所占百分比。
- VSZ:进程使用虚拟内存大小。
- RSS:进程使用物理内存大小,我们会重点关注这个值。
2.2.3、pmap
这个命令用于查看进程的内存映像信息,能够查看进程在哪些地方用了多少内存。 常用
pmap -x pid
来查看。
可以看到该进程内存被哪些库、哪些文件所占用,据此我们定位程序对内存的使用。
几个字段介绍一下:
- Address:占用内存的文件的内存起始地址。
- Kbytes:占用内存的字节数。
- RSS:实际占用内存大小。
- Dirty:脏页大小。
- Mapping:占用内存的文件,[anon] 为已分配的内存,[stack] 为程序堆栈
最后的 total 为统计的总值。我们可以使用
pmap -x pid | tail -1
这样只显示最后一行,循环显示最后一行,达到监控该进程的目的。
2.2.4、/proc/pid/maps
查看进程的虚拟地址空间是如何使用的。
该文件有6列,分别为:
地址:库在进程里地址范围
权限:虚拟内存的权限,r=读,w=写,x=,s=共享,p=私有;
偏移量:库在进程里地址范围
设备:映像文件的主设备号和次设备号;
节点:映像文件的节点号;
路径: 映像文件的路径
每项都与一个vm_area_struct结构成员对应。
2.2.5、/proc/pid/status
stat所提供信息类似,但可读性较好。
在这里我们关注VmSize|VmRSS|VmData|VmStk|VmExe|VmLib 这个6个指标,下面有一些简单的解释。
VmSize(KB) :虚拟内存大小。整个进程使用虚拟内存大小,是VmLib, VmExe, VmData, 和 VmStk的总和。
VmRSS(KB):虚拟内存驻留集合大小。这是驻留在物理内存的一部分。它没有交换到硬盘。它包括代码,数据和栈。
VmData(KB): 程序数据段的大小(所占虚拟内存的大小),堆使用的虚拟内存。
VmStk(KB): 任务在用户态的栈的大小,栈使用的虚拟内存
VmExe(KB): 程序所拥有的可执行虚拟内存的大小,代码段,不包括任务使用的库
VmLib(KB) :被映像到任务的虚拟内存空间的库的大小
2.3、其他
memstat、nmon、vmstat
参考网址
https://www.jianshu.com/p/dfd4b8e1d75c
http://stor.51cto.com/art/201804/570236.htm
三、OOM崩溃原因分析
引起LinuxOOM的原因的表现主要是三个方面,一种是设备本身内存不足,消耗过多资源;一种是内存泄漏引起,如malloc未释放;一种是频繁申请小块内存,导致系统神申请不到大块内存导致的
3.1、内存不足
系统本身分配的内存有限,如果有太多业务功能开启,内存耗尽,必然会导致设备触发OOM。此时只能去规避或者优化内存使用。
当然对于对于内存紧张的设备,内存在缓存中没法及时回收也是会导致OOM了,此时是需要修改内存配置,也就是处理vm下面的参数,使得一些特殊场景的内存能够及时回收,不要一直驻留在缓存中占用了。
3.2、内存泄漏
3.2.1、应用代码泄漏
需要确认系统是否真的存在内存泄漏
需要确认是哪个进程出现了内存泄漏
需要确认是哪个线程出现了内存泄漏
走读该线程代码,看看哪里maloc没有释放导致内存一直使用。
3.2.2、内核数据结构slab泄漏
1、查看崩溃时slab的信息,slab占用内存的大小:slab_unreclaimable。
2、通过抓取 /proc/slabinfo和/proc/mtk_memcfg/slabtrace继续分析。查看slabtrace中数值比较大的分配内存的接口(大于10000),然后看每一时间段的此数值是否有增加或者减少,然后再对应的代码中去查找是否有泄漏的可能,是否存在申请的内存没有释放的地方,从而定位修改问题。
#Confidential
# adb push dump_slabtrace.sh sdcard
# sh dump_slabtrace.sh &
testlog="/sdcard/lognew.txt"
i=0
while true
do
echo "`date +%Y/%m/%d-%H:%M:%S` - the ${i}th test start" >> $testlog
echo "******************KB3 meminfo:******************" >> $testlog
meminfo=`cat /proc/meminfo`
echo "$meminfo" >> $testlog
echo "******************KB3 slabinfo:******************" >> $testlog
slabinfo=`cat /proc/slabinfo`
echo "$slabinfo" >> $testlog
echo "******************KB3 slabtrace:*********************" >> $testlog
slabtrace=`cat /proc/mtk_memcfg/slabtrace`
echo "$slabtrace" >> $testlog
i=$(($i+1))
sleep 120
done
参考:
https://blog.csdn.net/frank_zyp/article/details/83746253
3.2.3、其他泄漏
当然,有时候有些例外的情况发生,比如频繁的创建进程也会导致,或者是利用内存制作的文件系统,频繁向文件中写入文件也会导致设备内存泄漏,最终触发OOM。
3.3、内存碎片
3.4、其他情况
3.4.1、原因
内存碎片是会造成在内存剩余比较多的情况下依然会杀死得分最高进程的情况,还有一种是low memory耗尽,因为内核使用low memory来跟踪所有的内存分配。在32位CPU下寻址范围是有限的,Linux内核定义了下面三个区域。
# DMA: 0x00000000 - 0x00999999 (0 - 16 MB)
# LowMem: 0x01000000 - 0x037999999 (16 - 896 MB) - size: 880MB
# HighMem: 0x038000000 - <硬件特定>
LowMem区(也叫
NORMAL ZONE
)共880MB,并且是固定不能变的(除非使用hugemem内核),对于高负荷的系统,可能因为LowMem使用不好而触发了OOM Killer机制。因为内存分配是一个连续的区域,在此时,如果LowMem里存在很多碎片或者LowFree太少,此时无法分配到一块连续的内存区域,就触发了OOM Killer。
3.4.2、确认
查看当前LowFree值:
cat /proc/meminfo | grep LowFree
查看LowMem内存碎片:
cat /proc/buddyinfo
3.4.3、解决办法
可以尝试将/proc/sys/vm/lower_zone_protection的值设为250或更大,可使用如下命令查看和设置该值:
cat /proc/sys/vm/lower_zone_protection
echo 250 > /proc/sys/vm/lower_zone_protection
或者可以修改/etc/sysctl.conf文件,以便重启后生效,添加的内容如下:
vm.lower_zone_protection = 250
3.4.4、参考文档
https://cloud.tencent.com/developer/article/1403389
四、内存优化
4.1、min_free_kbytes
(/proc/sys/vm/min_free_kbytes)
(1). 代表系统所保留空闲内存的最低限。
(2).min_free_kbytes的主要用途是计算影响内存回收的三个参数 watermark[min/low/high]
watermark[min] = min_free_kbytes换算为page单位即可,假设为min_free_pages。(因为是每个zone各有一套watermark参数,实际计算效果是根据各个zone大小所占内存总大小的比例,而算出来的per zone min_free_pages)
watermark[low] = watermark[min] * 5 / 4
watermark[high] = watermark[min] * 3 / 2
可以通过
/proc/zoneinfo
查看每个zone的watermark
(3).min_free_kbytes大小的影响
min_free_kbytes设的越大,watermark的线越高,同时三个线之间的buffer量也相应会增加。这意味着会较早的启动kswapd进行回收,且会回收上来较多的内存(直至watermark[high]才会停止),这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量。极端情况下设置min_free_kbytes接近内存大小时,留给应用程序的内存就会太少而可能会频繁地导致OOM的发生。
min_free_kbytes设的过小,则会导致系统预留内存过小。kswapd回收的过程中也会有少量的内存分配行为(会设上PF_MEMALLOC)标志,这个标志会允许kswapd使用预留内存;另外一种情况是被OOM选中杀死的进程在退出过程中,如果需要申请内存也可以使用预留部分。这两种情况下让他们使用预留内存可以避免系统进入deadlock状态。
参考网址:
https://blog.csdn.net/hu_jinghui/article/details/81740575
4.3 、参考网址
自己的文章:
https://blog.csdn.net/zhang_yin_liang/article/details/89053377