Dart VM 有多种执行代码的方式,例如:
- 使用源代码或内核二进制文件的 JIT 模式;
- 使用快照:
- 来自 AOT 快照;
- 来自 AppJIT 快照;
然而它们之间的主要区别在于: VM “何时”以及“如何”将 Dart 源代码转换为可执行代码,然后保证执行的运行时环境保持不变。
VM 中的任何 Dart 代码都在某个 isolate
中运行,可以将其描述为:具有自己的内存(堆)并且通常具有自己的控制线程(mutator 线程)的 Dart 隔离宇宙。
VM 可以有许多 isolate
同时执行 Dart 代码,但它们不能直接共享任何状态,只能通过端口传递消息进行通信(不要与网络端口混淆!)。
这里的 OS 线程和 isolate
之间的关系有点模糊,并且高度依赖于虚拟机嵌入到应用程序的方式,但是主要需要保证以下内容:
- 一个 OS 线程一次只能进入一个
isolate
,如果它想进入另一个isolate
,它必须离开当前isolate
; - 一次只能有一个与
isolate
相关联的 Mutator 线程,Mutator 线程是执行 Dart 代码并使用 VM 的公共 C API 的线程。
然而同一个 OS 线程可以先进入一个 isolate
执行 Dart 代码,然后离开这个 isolate
并进入另一个 isolate
继续执行;或者有许多不同的 OS 线程进入一个 isolate
并在其中执行 Dart 代码,只是不会同时发生。
当然,除了单个 Mutator 线程之外,isolate
还可以关联多个辅助线程,例如:
- 一个后台 JIT 编译器线程;
- GC sweeper 现场;
- 并发 GC marker 线程;
VM 在内部使用线程池 (dart::ThreadPool
) 来管理 OS 线程,并且代码是围绕 dart::ThreadPool::Task
概念而不是围绕 OS 线程的概念构建的。
例如在 GC VM 中将 dart::ConcurrentSweeperTask
发布到全局 VM 的线程池,而不是生成专用线程来执行后台清除,并且线程池实现要么选择空闲线程,要么在没有可用线程时生成新线程;类似地,用于 isolate
来消息处理事件循环的默认实现实际上,并没有产生专用的事件循环线程,而是在新消息到达时将dart::MessageHandlerTask
发布到线程池。
dart::Isolate
类相当于一个isolate
,dart::Heap
类相当于isolate
的堆,dart::Thread
类描述了线程连接到isolate
相关的状态。请注意,该名称
Thread
可能会让人有些困惑,因为所有 OS 线程都附加到与 Mutator 相同的isolate
,将重用相同的 Thread 实例。有关isolate
消息处理的默认实现,请参阅Dart_RunLoop
和dart::MessageHandler
。
通过 JIT 运行源代码
本节将介绍当从命令行执行 Dart 时会发生什么:
// hello.dart
main() => print(‘Hello, World!’);
$ dart hello.dart
Hello, World!
Dart 2 VM 开始不再具有从原始代码直接执行 Dart 的能力,相反 VM 希望获得包含序列化内核 AST 的内核二进制文件(也称为 dill 文件)。将 Dart 源代码翻译成 Kernel AST 的任务是由通用前端 (CFE)处理的,CFE 是用 Dart 编写并在不同 Dart 工具上共享(例如 VM、dart2js、Dart Dev Compiler)。
为了保持直接从源代码执行 Dart ,这里托管一个名为 kernel service 的辅助 isolate
,它处理将 Dart 源代码编译到内核中,然后 VM 运行生成的内核二进制文件。
然而这种设置并不是 CFE 和 VM 运行 Dart 代码的唯一方法,例如 Flutter 是将编译到 Kernel 的过程和从 Kernel 执行的过程完全分离,并将它们放在不同的设备上实现:编译发生在开发者机器(主机)上,执行在目标移动设备上处理,目标移动设备接收由 flutter 工具发送给它的内核二进制文件。
这里需要注意,该 Flutter 工具不处理 Dart 本身的解析, 相反它会生成另一个持久进程 frontend_server
,它本质上是围绕 CFE 和一些 Flutter 特定的 Kernel-to-Kernel 转换的封装。
frontend_server
将 Dart 源代码编译为内核文件, 然后 flutter 将其发送到设备, 当开发人员请求热重载时 frontend_server
开始发挥作用:在这种情况下 frontend_server
可以重用先前编译中的 CFE 状态,并重新编译实际更改的库。
一旦内核二进制文件加载到 VM 中,它就会被解析以创建代表各种程序实体的对象,然而这个过程是惰性完成的:首先只加载关于库和类的基本信息,源自内核二进制文件的每个实体都保留一个指向二进制文件的指针,以便以后可以根据需要加载更多信息。
每当我们引用 VM 内部分配的对象时,我们都会使用 Untagged 前缀,因为这遵循了 VM 自己的命名约定:内部 VM 对象的布局由 C++ 类定义,名称以 Untagged头文件
runtime/vm/raw_object.h
开头。例如dart::UntaggedClass
是描述一个 Dart 类 VM 对象,dart::UntaggedField
是一个 VM 对象
只有在运行时需要它时(例如查找类成员、分配实例等),有关类的信息才会完全反序列化,在这个阶段,类成员会从内核二进制文件中读取,然而在此阶段不会反序列化完整的函数体,只会反序列化它们的签名。
此时 methods 在运行时可以被成功解析和调用,因为已经从内核二进制文件加载了足够的信息,例如它可以解析和调用 main
库中的函数。
package:kernel/ast.dart
定义了描述内核 AST 的类;package:front_end
处理解析 Dart 源代码并从中构建内核 AST。dart::kernel::KernelLoader::LoadEntireProgram是
将内核 AST 反序列化为相应 VM 对象的入口点;pkg/vm/bin/kernel_service.dart
实现了内核服务隔离,runtime/vm/kernel_isolate.cc
将 Dart 实现粘合到 VM 的其余部分;package:vm
承载大多数基于内核的 VM 特定功能,例如各种内核到内核的转换;由于历史原因一些特定于 VM 的转换仍然存在于package:kernel
中。
最初所有的函数都会有一个占位符,而不是它们的主体的实际可执行代码:它们指向 LazyCompileStub
,它只是要求运行时系统为当前函数生成可执行代码,然后 tail-calls
这个新生成的代码。
第一次编译函数时,是通过未优化编译器完成的。
未优化编译器分两遍生成机器代码:
- 1、遍历函数体的序列化 AST 以生成函数体的控制流图( CFG ),CFG 由填充有中间语言( IL ) 指令的基本块组成。在此阶段使用的 IL 指令类似于基于堆栈的虚拟机的指令:它们从堆栈中获取操作数,执行操作,然后将结果推送到同一堆栈。
实际上并非所有函数都具有实际的 Dart / Kernel AST 主体,例如在 C++ 中定义的本地函数或由 Dart VM 生成的人工
tear-off
函数,在这些情况下,IL 只是凭空创建,而不是从内核 AST 生成。
- 2、生成的 CFG 使用一对多的底层 IL 指令直接编译为机器代码:每个 IL 指令扩展为多个机器语言指令。
在此阶段没有执行任何优化,未优化编译器的主要目标是快速生成可执行代码。
这也意味着:未优化的编译器不会尝试静态解析内核二进制文件中未解析的任何调用,VM 当前不使用基于虚拟表或接口表的调度,而是使用内联缓存实现动态调用。
内联缓存的原始实现,实际上是修补函数的 native 代码,因此得名内联缓存,内联缓存的想法可以追溯到 Smalltalk-80,请参阅 Smalltalk-80 系统的高效实现。
内联缓存背后的核心思想,是在特定的调用点中缓存方法解析的结果,VM 使用的内联缓存机制包括:
-
一个调用特定的缓存(
dart::UntaggedICData
),它将接收者的类映射到一个方法,如果接收者是匹配的类,则应该调用该方法,缓存还存储一些辅助信息,例如调用频率计数器,用于跟踪给定类在此调用点上出现的频率; -
一个共享查找 stub ,它实现了方法调用的快速路径。这个 stub 搜索给定的缓存,以查看它是否包含与接收者的类匹配的条目。如果找到该条目,则 stub 将增加频率计数器和
tail-calls
用缓存方法。否则 stub 将调用一个运行时系统助手来实现方法解析逻辑。如果方法解析成功,则缓存将被更新,后续调用将不需要进入运行时系统。
如下图所示,展示了与 animal.toFace()
调用关联的内联缓存的结构和状态,该缓存使用 Dog
的实例执行了两次,使用 Cat
的实例执行了一次C。
未优化的编译器本身足以执行任何 Dart 代码,然而它产生的代码相当慢,这就是为什么 VM 还实现了自适应优化编译管道的原因,自适应优化背后的想法是:使用运行程序的执行配置文件来驱动优化决策。
当未优化的代码运行时,它会收集以下信息:
- 如上所述,内联缓存收集有关在调用点观察到的接收器类型的信息;
- 函数和函数内的基本块相关联的执行计数器跟踪代码的热点区域;
当与函数关联的执行计数器达到一定阈值时,该函数被提交给后台优化编译器进行优化。
优化编译的启动方式与非优化编译的启动方式相同:通过遍历序列化内核 AST ,为正在优化的函数构建未优化的 IL。
然而不是直接将 IL 处理为机器代码,而是基于表单的优化 IL, 优化编译器继续将未优化的 IL 转换为静态单赋值(SSA) ,然后基于 SSA 的 IL 根据收集的类型反馈进行专业化的推测,并通过一系列Dart 的特定优化,例如:
- 内联(inlining);
- 范围分析(range analysis);
- 类型传播( type propagation);
- 代理选择(representation selection);
- 存储加载和加载转发(store-to-load and load-to-load forwarding);
- 全局值编号(global value numbering);
- 分配下沉(,allocation sinking)等,;
最后使用线性扫描寄存器和简单的一对多降低 IL 指令,将优化的 IL 转化为机器代码。
编译完成后,后台编译器会请求 mutator 线程进入安全点并将优化的代码附加到函数中。
广义上讲,当与线程相关联的状态(例如堆栈帧、堆等)一致,并且可以在不受线程本身中断的情况下访问或修改时,托管环境(虚拟机)中的线程被认为处于安全点。通常这意味着线程要么暂停,要么正在执行托管环境之外一些代码,例如运行非托管 native 代码。
下次调用此函数时, 它将使用优化的代码。 某些函数包含非常长的运行循环,对于那些函数,在函数仍在运行时,将执行从未优化代码切换到优化代码是有意义的。
这个过程被称为堆栈替换( OSR ),它的名字是因为:一个函数版本的堆栈帧被透明地替换为同一函数的另一个版本的堆栈帧。
编译器源代码位于
runtime/vm/compiler
目录中;编译管道入口点是dart::CompileParsedFunctionHelper::Compile
;IL 在runtime/vm/compiler/backend/il.h
中定义;内核到 IL 的转换从dart::kernel::StreamingFlowGraphBuilder::BuildGraph
开始,该函数还处理各种人工函数的 IL 构建;当InlineCacheMissHandler
处理 IC 的未命中,dart::compiler::StubCodeCompiler::GenerateNArgsCheckInlineCacheStub
为内联缓存存根生成机器代码;runtime/vm/compiler/compiler_pass.cc
定义了优化编译器传递及其顺序;dart::JitCallSpecializer
大多数基于类型反馈的专业化。
最后
都说三年是程序员的一个坎,能否晋升或者提高自己的核心竞争力,这几年就十分关键。
技术发展的这么快,从哪些方面开始学习,才能达到高级工程师水平,最后进阶到Android架构师/技术专家?我总结了这 5大块;
我搜集整理过这几年阿里,以及腾讯,字节跳动,华为,小米等公司的面试题,把面试的要求和技术点梳理成一份大而全的“ Android架构师”面试 PDF(实际上比预期多花了不少精力),包含知识脉络 + 分支细节。
Java语言与原理;
大厂,小厂。Android面试先看你熟不熟悉Java语言
高级UI与自定义view;
自定义view,Android开发的基本功。
性能调优;
数据结构算法,设计模式。都是这里面的关键基础和重点需要熟练的。
NDK开发;
未来的方向,高薪必会。
前沿技术;
组件化,热升级,热修复,框架设计
网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。
我在搭建这些技术框架的时候,还整理了系统的高级进阶教程,会比自己碎片化学习效果强太多,GitHub可见;《Android架构视频+学习笔记》
当然,想要深入学习并掌握这些能力,并不简单。关于如何学习,做程序员这一行什么工作强度大家都懂,但是不管工作多忙,每周也要雷打不动的抽出 2 小时用来学习。
不出半年,你就能看出变化!
%BC%9F%E5%A6%82%E4%BD%95%E9%9D%A2%E8%AF%95%E6%8B%BF%E9%AB%98%E8%96%AA%EF%BC%81.md)**
当然,想要深入学习并掌握这些能力,并不简单。关于如何学习,做程序员这一行什么工作强度大家都懂,但是不管工作多忙,每周也要雷打不动的抽出 2 小时用来学习。
不出半年,你就能看出变化!