前言

对于前端或客户端开发者来说,Web 渲染引擎是个黑盒子,其中内存开销是开发者最关心的问题之一 - 一个网页、一个页面程序运行在 Web 渲染引擎中产生了多少内存,占用的内存其分布又是如何?本文的目的是站在 Web 渲染引擎开发者的角度,向读者介绍渲染引擎在运行时如何分配内存,希望能帮助读者解决疑惑,开发出质量更高的页面。

Web 渲染引擎的内存占用在单进程下的说明,分为按页面的前端视角、按适配的容器端视角两个角度来说明。

多进程模式下,绝大部分内存会”转移“到子进程。例如,当引擎开启 Renderer 多进程时,页面运行内存、内核缓存、页面临时内存将绝大部分转移到了 Renderer 子进程。当引擎开启 GPU 多进程模式后,GPU 显示内存的大部分(页端以及内核渲染所需部分)也将转移到 GPU 子进程。因此,如果启用了 U4 内核的完整多进程模式,那么 U4 内核几乎不占用宿主(基于 U4 内核的客户端应用)的

进程内存。Web 渲染引擎内存占用说明

Web 渲染引擎运行时可能产生以下部分内存:

  • 页面运行内存
  • 内核缓存
  • 页面临时内存

以下是各部分内存的详细说明。

1. 页面运行内存



GPU共享 nvdocker_GPU共享 nvdocker

这部分内存占用是由于页面需要正常渲染、以及脚本需要正确运行的保证。在页面的存活期间(在一个 U4 的 WebView 里,页面被组织成一个双向链表,即所谓前进后退列表,其中有且仅有一个页面是当前页面,这些页面都属于活着的),页面的上下文环境都被强引用着并占用内存。

页面的上下文环境包括但不限于:内部 DOM 节点对象和数据(以及其使用的图片资源、CSS 资源、文本资源等),排版节点 Renderer 对象、Javascript 对象和数据(包括其持有的 WebGL 资源、Canvas 上下文资源等)。

页面运行内存可以通过主动或被动销毁(当前页面无法被动销毁)页面释放,也可通过清理页面元素或者清理 Javascript 存活对象进行部分释放。

1)例如,WebView 正在运行 A 页面,通过不断加载新的 B, C, D... 等页面,使得 A 页面进入缓存,并被新的页面缓存填满 CachePageNumber 所设置的项目,从而使得 A 页面从缓存被淘汰销毁,则 A 页面的内存此时才被释放。

2)例如,直接将 A 页面所在 WebView 的实例销毁(通过 WebView.detach(), WebView.destroy()),则包括 A 页面在内、以及与该 WebView 相关联的前进后退列表页面将全部释放。

3)例如,A 页面进入了缓存,当前进程内存紧张产生了内存事件(中级或者严重),事件响应中可能清理部分或者全部的缓存页面,当中 A 页面有可能从缓存被淘汰并销毁。

4)例如,U4 渲染引擎提供了设置参数,可以控制总缓存数量,从而配置被动销毁逻辑运行的可控性。

5)例如,A 页面为游戏,业务框架可通过检测,在不可见事件里将页面较重的节点删除,或者将 Javascript 中较大占用的对象引用置空,能释放该页面所引用的部分内存。

需要注意的是,多个存活页面共存可能导致内存占用的增大带来 OOM 风险。

1)例如,同时创建多个 Webview 打开页面,这些页面运行内存不会因前后台、内存紧张、亮灭屏等外部环境影响,从而造成累积。

2)例如,WebView 在连续加载新页面时,旧页面会进入缓存而不是被销毁从而可能累积。

2. 内核缓存



GPU共享 nvdocker_GPU共享 nvdocker_02

U4 渲染引擎为了提高渲染性能,对于某些需要耗时生成后续又可能复用的中间态内存暂时不进行释放,而是通过一个固定大小并使用一定淘汰策略的内存池进行管理,该类内存统称为内核缓存。

内核缓存包括但不限于:图片解码数据缓存、GL 资源缓存、Skia 库图形缓存、Command Buffer 队列缓存、媒体流缓存、网络连接缓存、前述页面缓存等等。

内核缓存通过各自的淘汰策略、或者受内存压力事件影响进行回收内存。

1)例如,图片解码数据缓存最大限制设置为 64MB 时,当某个页面图片很大很多,使用内存超过此额度时,该策略将淘汰最久未被使用的条目,如果因正在引用锁定的关系无法清理到限制额度,将启用每 1 秒的定时器不断尝试及至降到最大限制以下。

2)例如,当 vmsize 达到 3GB 或 3.7GB 时将下发中级或严重内存压力事件,图片解码数据缓存根据级别中级或者严重将释放部分或全部缓存。

3)例如,通过配置 U4 渲染引擎扩展的参数可控制缓存大小配置淘汰策略以回收内存。

需要注意的是,缓存大小限制需按照各应用对性能的实际要求而设置,设置不当对应用也是额外的风险。

1)例如,页面缓存数参数按照业务页面加载深度考虑。通常该参数不建议超过10。

2)例如,DiscardableLimitBytes 参数一般按照屏幕分辨率×4 Bytes 计算单屏内存占用,例如高清屏(1080*1920)一屏占 8MB,那么 n 屏就是 8×n MB 可以获得向后滑屏 n 屏渲染加速的性能(仅按全屏图片计算,实际屏数更多)。

3)例如,合成器资源参数控制了合成器所用资源缓存的大小,需求计算原理同2),不过该部分资源按页面排版大小计算,缓存缺失将需要重新准备资源(滑屏很快时来不及准备产生黑块)。

3. 页面临时内存

页面临时内存是指页面渲染或运行过程中产生的中间临时内存。该内存实时需要,用完就扔,但也可能受回收算法迟滞影响导致峰值。

页面临时内存举例:发出 Ajax 请求并接收处理数据、Worker 和主文档 postMessage 通讯、Canvas 不断更新绘制、Javascript 脚本运行的栈变量和临时对象、渐进式图片渲染帧、页面加载期间临时内存等等。

该部分内存回收一般由渲染引擎实现,受堆算法或者 GC 算法所限。用户在页面设计时采用主动 GC、缓存复用、或者小量多遍方式能一定程度的降低峰值。

1)例如,对于频繁多次 postMessage,可以采用复用同一份 transferable 来避免产生多个临时对象或产生对象序列化拷贝。

2)例如,使用旧对象回收和重新初始化对象避免 new 新的实例以避免 GC 不及时。

3)例如,尽量采用小数据。采用 vue 来渲染页面,避免一次性通过 Ajax 返回整个页面导致峰值,可设计成分段多次返回,降低峰值。

4)例如,对于短时间内大量的数据可以采用 TCP 协议类似的发送窗口机制限制并发量。

以下设计是不建议的。

1)例如,并行发出大量 Ajax。某个业务在离线期间累积了大量的日志,待网络恢复后一次性全部 Ajax 发出,由于离线时间较长,累积的日志内容内存较大,Ajax 是一异步操作立刻返回,因此并发发出大量 Ajax 导致内存峰值明显。

2)例如,频繁发送 postMessage。某个业务中使用 worker 和主文档协同工作,worker 负责接收直播数据并 postMessage 到主文档,而主文档因故暂停(例如弹出是否允许定位权限框),使得临时数据累积得不到处理而崩溃。

3)例如,使用大量数据。某个业务使用 JS AI 组件进行运算,一次性从网络加载了好几百 MB 的数据进行训练,结果导致内存分配失败。


Web 渲染引擎内部内存分配说明

GPU共享 nvdocker_GPU共享 nvdocker_03

上图是我们输出的 U4 渲染引擎在某个时刻内存占用情况。我们可以看到,在 U4Core 分组下,总共有 BlinkGC、PartitionAlloc、SharedMemory、UCMalloc、V8几个分类,这其实就是渲染引擎内部分配和管理内存的几大模块。

  • V8 顾名思义就是 V8 Javascript 引擎所管理,一般用于分配 Javascript 对象和数据。
  • BlinkGC 是标记式垃圾回收算法堆,一般用于管理页面运行上下文对象。
  • PartitionAlloc 堆为分桶式内存分配算法,用于解析、排版、页面运行上下文以及临时内存。
  • UCMalloc 为类 libc 堆实现的仅用于 U4 渲染引擎分配算法,缓存、页面运行上下文、临时内存都有用到。
  • SharedMemory 为多进程共享内存,其中 DiscardableSharedMem 一般为图片解码缓存和 GL资源缓存等所用。

GPU 内存、线程内存、文件内存、总线内存


GPU共享 nvdocker_mssql 内存占用不释放_04

U4 渲染引擎在渲染页面时,可能会产生一些公共部分的内存,这部分内存属于渲染引擎级别(与页面不强相关),但也并非全部由内核所产生(应用框架或其它模块也相关)。

1、GPU 内存

产生 GPU 内存的有三大来源:应用(非 U4 渲染引擎)本身、U4 core、页面 WebGL。

其中页面 WebGL 的 API 调用产生的 GPU内存,由 Javascript 对象持有,随页面销毁而释放,属于页面运行上下文。

Android 的应用其可以通过设置选项(一般都打开硬件加速合成)来配置 framework 使用硬件加速来合成 View 组件,而应用的 View Tree 越复杂则可能占用的 GPU 内存越多。

U4 Core 维持着一定数量的 GL 资源缓存以渲染当前可见页面,随着页面越复杂,该占用越大。该占用为所有页面服务。简单的页面中,该占用仅几 MB。

2、线程内存

每个线程都需要一块内存用于栈,一般 UI 线程为 8MB,普通线程为 1MB。

线程还可能有 TLS(线程本地存储)、Signal 等占用。

应用中总线程数目 300 个时,一般意味着 300MB 内存的占用。

U4 渲染引擎一般在初始化时只建立比较固定的二十几个线程。当加载的页面 Javascript 里启动 Worker 时,每一个 Worker 将需要额外新开线程。

3、文件内存

应用的运行离不开文件读写,apk、jar、dex、优化后的 oat、so 文件、字体文件、配置、数据文件等。

Android 的文件读写一般采用 mmap 的方式,无论是否需要,将整个文件大小全部 mmap 进内存,因而占用大量的地址空间。我们就曾遇到过应用启动后由于文件原因导致应用动不动就 700MB 以上的地址占用。

U4 渲染引擎运行过程中除了必须的可执行 jar、dex、oat、so 外,有较少的配置文件读写,根据页面需要可能有部分的 http 资源文件读写,这些文件都按需打开和关闭,不会长期占用。

4、总线内存

应用进行网络或者磁盘 IO 读写时用到的设备以及总线缓冲区,一般几十 MB。

JavaVM、其他 Native 内存


1、JavaVM占用

Android 平台下 JavaVM 占用是绕不开的大头。按照目前应用的配置,一般 JavaVM 都要占去 1GB 多的地址空间,哪怕它的实际物理内存需要不到 200MB。这里面固然有 bigHeap 配置选项的影响,也有 JavaVM 内存回收算法中的 Copy and Compact 的碎片整理算法的需要(双倍占用)。

U4 渲染引擎基本上不用 Java 端来保存数据,但客户端接入 Web 渲染引擎时可能有日志、Bridge Api 等用到 Java 逻辑的地方。页端需要衡量并了解 Api 的行为,从而考虑到其占用。

2、其它 Native 内存

特指其它模块产生的 Native 内存,例如 Java Bitmap 对象的解码数据、其它 Native 模块使用 new 分配的数据或者用 mmap 分配的地址。

U4 渲染引擎使用自己的 UCMalloc 堆分配普通内存,页端需考虑的是非 Web 标准,例如混合渲染中使用的其它 SDK 当中是否会产生大量内存占用。