启动优化之有向无环图任务管理
启动性能,是用户在使用APP 过程中的第一感观,可见是相当重要的。相信很多同学都能说出一些常规的手段,比
如只加载必要的模块,延迟加载等。从大的策略上说,是没有问题的,也能够取得一些效果,但仍存在一些问题。
为什么需要启动框架?
对于很多APP而言,因为启动包含很多基础 SDK,SDK 的初始化有着一定的先后顺序;业务 SDK 又是围绕着多个
基础 SDK 建立的。那么如何保证这些 SDK 在正确的阶段、按照正确的依赖顺序、高效地初始化?怎么合理调度任
务,才不至于让系统负载过高?
比如我们存在5个初始化任务,这5个任务之间存在如下图的依赖关系:
初始化 任务1 先执行,任务2 与 任务3 需要等待 任务1 执行完成,任务4 需要等待任务2 执行完成,任务3与任务4
执行完成后,任务5才能执行。
面对上述初始化任务之间复杂的依赖场景,我们应该如何解决?
此时我们按照任务的依赖关系,主动按照任务执行顺序调用:
new Thread{
public void run(){
SDK1.init();
SDK2.init();
SDK3.init();
SDK4.init();
SDK5.init();
}
}
这样做的话就会导致任务3 需要等待任务2 执行完毕才能得到执行!
因此我们可以将 任务2 放入单独的异步线程完成初始化:
new Thread{
public void run(){
SDK1.init();
//单独异步执行
new Thread(){
public void run(){
SDK2.init();
}
}.start();
SDK3.init();
SDK4.init(); //可能先于SDK2完成前执行
SDK5.init();
}
}
根据依赖关系得知:任务4 需要等待任务2 执行完成。但是上述代码又可能会导致任务2 未完成,任务4 就开始执
行的情况。
那我们再次改变:
new Thread{
public void run(){
SDK1.init();
Thread t2 = new Thread(){
public void run(){
SDK2.init();
}
}.start();
t2.start();
SDK3.init();
t2.join(); // join等待SDK2初始化完成
SDK4.init();
SDK5.init();
}
}
此时如果 任务2 先执行完成,应该马上执行任务4,但是上述代码 中任务4 需要等待任务3 执行完成。
最后我们发现,不管我们怎么做,都会遇到不同的问题。并且当我们的 初始任务更多,关系更为复杂 时,此时手
动管理我们的任务执行,变得极为繁琐且容易出错。而且面对千变万化的需求,一旦启动任务发生变化(新增、删
除、依赖改变)如果没有任何设计,那将 “牵一发而动全身”。所以此时,我们需要一个启动框架,帮助我们完成启
动任务的管理与调度。
启动框架设计
其实启动框架就是一个任务调度系统,要做的事情就是把初始任务之间的关系梳理得明明白白,有条不紊,合理安
排位置、调度时间,同时提升硬件资源的利用率。
任务管理
在我们应用端不改变现有启动任务执行逻辑的前提下进行启动优化,本质上就是解决任务的依赖性问题,即先执行
什么,再执行什么。而依赖性问题的本质就是数据结构的问题。
DAG有向无环图
我们根据启动任务之间的关系,绘制对应的图示如下:
在上图中,任务的执行有方向(有序),且没有回环。在图论中,这种一个有向图无法从某个顶点出发经过若干条
边回到该点,那么这个图就是一个有向无环图,简称DAG图。DAG常常被用来表示事件之间的驱动依赖关系,管理
任务之间的调度。
在一个DAG中:
顶点:图中的一个点,比如 任务 1,任务 2;
边 : 连接两个顶点的线段叫做边;
入度:代表当前有多少边指向顶点(依赖多少任务);
出度:代表有多少边从顶点发出(被多少任务依赖)。
拓扑排序
在将我们的启动任务绘制完成DAG之后,我们接下来,就需要求出DAG的拓扑序列,即对我们的启动任务执行顺序
进行排序。
对于上文中的任务依赖关系来说,我们只需要保证2与3在1之后执行,4在2之后,5在3、4任务之后执行即可。因
此我们可以得到排序后的结果为:
1 -> 2 -> 3 -> 4 -> 5
1 -> 2 -> 4 -> 3 -> 5
1 -> 3 -> 2 -> 4 -> 5
因此 图的拓扑排序不是唯一的!只要符合以下两点要求即可:
每个顶点出现且只出现一次。
若存在一条从顶点 A 到顶点 B 的路径,那么在序列中顶点 A 出现在顶点 B 的前面
对DAG进行拓扑排序,我们可以选择BFS(广度优先)或者DFS(深度优先)。利用BFS的算法排序的过程如下:
- 找出图中0入度的顶点;
- 依次在图中删除这些顶点,删除后再找出0入度的顶点;
- 删除后再找出0入度的顶点,重复执行第二步
入度为0的顶点为任务1,得到结果:1
删除任务1后,此时任务2与任务3入度数由1变为0,得到结果:1 -> 2 -> 3
删除任务2后,任务4入度数由1变为0;删除任务3后,任务5入度数由2变为1,得到结果:1 -> 2 -> 3
删除任务4后,任务5入度数由1变为0,得到结果:1 -> 2 -> 3 -> 4 -> 5
代码落地
根据之前的场景,我们设计 Startup 接口:
public interface Startup<T> extends Dispatcher {
T create(Context context); //执行初始化任务
/**
* 本任务依赖哪些任务
*
* @return
*/
List<Class<? extends Startup<?>>> dependencies();
//依赖任务的个数(入度数)
int getDependenciesCount();
}
同时提供一个 AndroidStartup 抽象类,此抽象类目前的作用很简单,根据 dependencies 实现
getDependenciesCount 方法:
public abstract class AndroidStartup<T> implements Startup<T> {
@Override
public List<Class<? extends Startup<?>>> dependencies() {
return null;
}
@Override
public int getDependenciesCount() {
List<Class<? extends Startup<?>>> dependencies = dependencies();
return dependencies == null ? 0 : dependencies.size();
}
}
基于上述的接口与抽象类,我们可以定义自己的各个启动任务类:
public class Task1 extends AndroidStartup<String> {
@Nullable
@Override
public String create(Context context) {
//执行初始化
return "Task1返回数据";
}
}
public class Task2 extends AndroidStartup<Void> {
static List<Class<? extends Startup<?>>> depends;
static {
// 本任务依赖于任务1
depends = new ArrayList<>();
depends.add(Task1.class);
}
@Nullable
@Override
public Void create(Context context) {
//执行初始化
return null;
}
@Override
public List<Class<? extends Startup<?>>> dependencies() {
return depends;
}
}
最后我们完成拓扑排序的代码实现:
1、找出入度为0的任务
在这一步,我们同时记录了如下表:
List<? extends Startup<?>> startupList; //输入的待排序的任务列表
Map<Class<? extends Startup>, Integer> inDegreeMap = new HashMap<>();
Deque<Class<? extends Startup>> zeroDeque = new ArrayDeque<>();
Map<Class<? extends Startup>, Startup<?>> startupMap = new HashMap<>();
Map<Class<? extends Startup>, List<Class<? extends Startup>>> startupChildrenMap
= new HashMap<>();
for (Startup<?> startup : startupList) {
//startupMap任务表
startupMap.put(startup.getClass(), startup);
//inDegreeMap入度表:记录每个任务的入度数(依赖的任务数)
int dependenciesCount = startup.getDependenciesCount();
inDegreeMap.put(startup.getClass(), dependenciesCount);
//zeroDeque(0入度队列):记录入度数(依赖的任务数)为0的任务
if (dependenciesCount == 0) {
zeroDeque.offer(startup.getClass());
} else {
//遍历本任务的依赖(父)任务列表
for (Class<? extends Startup<?>> parent : startup.dependencies()) {
List<Class<? extends Startup>> children = startupChildrenMap.get(parent);
if (children == null) {
children = new ArrayList<>();
//startupChildrenMap任务依赖表:记录这个父任务parent的子任务startup
startupChildrenMap.put(parent, children);
}
children.add(startup.getClass());
}
}
2、删除入度为0的任务
List<Startup<?>> result = new ArrayList<>(); //排序结果
//处理入度为0的任务
while (!zeroDeque.isEmpty()) {
Class<? extends Startup> cls = zeroDeque.poll();
Startup<?> startup = startupMap.get(cls);
result.add(startup);
//删除此入度为0的任务
if (startupChildrenMap.containsKey(cls)){
List<Class<? extends Startup>> childStartup = startupChildrenMap.get(cls);
for (Class<? extends Startup> childCls : childStartup) {
Integer num = inDegreeMap.get(childCls);
inDegreeMap.put(childCls,num-1); //入度数-1
if (num - 1 == 0){
zeroDeque.offer(childCls);
}
}
}
}
在这一步中,我们首先删除入度为0的:Task1并将其记录在结果集合result中;删除Task1之后,通过任务依赖
表:startupChildrenMap,找到Task2与Task3:
然后从入度数表:inDegreeMap 中把Task2与Task3的入度数减一:
如果发现,Task2/Task3 减一后入度数变为0,则将其加入零入度队列:zeroDeque:。
继续循环,直到处理完成。这就是利用广度搜索实现拓扑排序的过程!
线程管理
启动任务经过基于DAG的拓扑排序后能够有序执行了,但是我们在程序启动的时候所有的初始化任务难道都一定需
要在主线程阻塞主线程来初始化吗?所以此时我们就不得不考虑加入线程管理的模块,那么现在我们遇到这样的一
个需求:五个任务现在我们都要放入子线程进行初始化执行,同时又要保证各个任务之间的执行顺序这时候我们该
怎么办?
我们来看看这个面试过程:
Q:假设有A、B两个线程,B线程需要在A线程执行完成之后执行。
A:
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("执行第一个线程任务!");
}
};
t1.start();
t1.join(); //阻塞等待线程1执行完成
Thread t2 = new Thread() {
@Override
public void run() {
System.out.println("执行第二个线程任务!");
}
};
t2.start();
Q: 假设有A、B两个线程,其中A线程中执行分为3步,需要在A线程执行完成第二步之后再继续执行B线程的代码怎
么办?
A:
Object lock = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("第一步执行完成!");
System.out.println("第二步执行完成!");
synchronized (lock) {
lock.notify();
}
System.out.println("第三步执行完成!");
}
};
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("执行第二个线程任务!");
}
};
t2.start();
t1.start();
Q:假设有A、B、C三个线程,其中A、B线程执行分为三步,C线程,需要在A线程与B线程都执行到第二步时才能
执行,怎么办?
A:
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("t1:第一步执行完成!");
System.out.println("t1:第二步执行完成!");
synchronized (lock1) {
lock1.notify();
}
System.out.println("t1:第三步执行完成!");
}
};
Thread t2 = new Thread() {
@Override
public void run() {
System.out.println("t2:第一步执行完成!");
System.out.println("t2:第二步执行完成!");
synchronized (lock2) {
lock2.notify();
}
System.out.println("t2:第三步执行完成!");
}
};
Thread t3 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
try {
lock1.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (lock2) {
try {
lock2.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("执行第三个线程任务!");
};
t3.start();
t2.start();
t1.start();
在上面的问答过程中,最后一个问题:
假设有A、B、C三个线程,其中A、B线程执行分为三步,C线程,需要在A线程与B线程都执行到第二步时才能执行,怎么办?
根据这位面试者的回答,线程3先等待线程1的通知,再等待线程2的通知,才能得到执行。但是,如果线程2的通
知先于线程1的通知到达。那么此时,线程3将一直被阻塞,因为线程1已经发出过通知了。
那么面对上述问题,我们需要采用闭锁—— CountDownLatch 就能够很好的解决此问题:
CountDownLatch 在初始化时,需要指定一个状态值,可以看成一个计数器。
当我们调用 await 方法,若状态值为0则不会发生阻塞,否则会阻塞。而在调用 countDown 方法后,会利用CAS机
制将状态值-1,直到状态值为0, await 将不再阻塞!
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("t1:第一步执行完成!");
System.out.println("t1:第二步执行完成!");
countDownLatch.countDown();
System.out.println("t1:第三步执行完成!");
}
};
Thread t2 = new Thread() {
@Override
public void run() {
System.out.println("t2:第一步执行完成!");
System.out.println("t2:第二步执行完成!");
countDownLatch.countDown();
System.out.println("t2:第三步执行完成!");
}
};
Thread t3 = new Thread() {
@Override
public void run() {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行第三个线程任务!");
};
t3.start();
t2.start();
t1.start();
因此我们改造第一步创建的接口与抽象类增加:
public abstract class AndroidStartup<T> implements Startup<T>{
//.....
// 根据入度数(依赖任务的个数)创建闭锁
private CountDownLatch mWaitCountDown = new CountDownLatch(getDependenciesCount());
//执行此任务时,调用toWait
@Override
public void toWait() {
try {
mWaitCountDown.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//当本人无依赖的任务执行完成后,需要调用本任务的toNotify
@Override
public void toNotify() {
mWaitCountDown.countDown();
}
// 是否在主线程执行
boolean callCreateOnMainThread();
// 若在子线程执行,则指定线程池
Executor executor();
//.....
}
至此我们借助闭锁—— CountDownLatch 很好的解决了线程同步问题!
阻塞问题的解决
目前为止我们解决了任务的执行顺序问题与线程管理问题,但是如果我们面临这样的一个场景怎么办?
任务2必须在主线程执行,其他任务在子线程执行。
若我们排序得出的拓扑序列为:12345。
若 异步任务1 执行完成,由于 同步任务2 需要在主线程执行,此时 异步任务3 只能等待 同步任务2 执行完成才能
得到分发执行!
面对上述的场景,由于任务3需要等待任务2执行完成,造成我们无法合理的运用多线程的资源,对应用启动速度没
有实现彻底的优化。此时我们需要改造我们的拓扑排序。
在拓扑排序代码实现中的第二步,我们将代码改为:
List<Startup<?>> result = new ArrayList<>();
List<Startup<?>> main = new ArrayList<>(); //主线程执行的任务
List<Startup<?>> threads = new ArrayList<>(); //子线程执行的任务
while (!zeroDeque.isEmpty()) {
Class<? extends Startup> cls = zeroDeque.poll();
Startup<?> startup = startupMap.get(cls);
//修改
if (startup.callCreateOnMainThread()) {
main.add(startup);
} else {
threads.add(startup);
}
......
}
//先添加子线程到result
result.addAll(threads);
result.addAll(main);
经过修改后,如果之前排序结果为:12345,那么将变为:13452。
此时执行流程为:
Task1 -> 子线程执行
Task3 -> 子线程等待Task1
Task4 -> 子线程等待Task2
Task5 -> 子线程等待Task3+Task4
Task2 -> 主线程等待Task1
若Task1执行完成,那么Task2与Task3将分别在主线程与子线程执行;
若Task3执行完成,Task5将继续等待Task4;
Task4在等待Task2执行完成,Task2执行完成后,通知Task4执行(toNotify);
Task4执行完成,Task5执行。
因此实际上,我们实现对子线程任务与主线程任务分别拓扑排序,先分发所有子线程任务,再执行主线程任务,同
样满足任务执行顺序。
总结
在2021年8月4日,Android Jetpack组件中发布了AppStartup 1.1.0 正式版本。但是AppStartup只提供了同步初始
化与任务依赖的处理。因此Github上基于AppStartup有一个优化的:Android Startup:https://github.com/idisf
kj/android-startup/blob/master/README-ch.md
实际上上面的内容就和大家介绍了此框架的原理:
那么面对面试被问到启动优化,除了我们补充资料中的冷热暖启动、耗时统计、CPU Profile等内容之外,针对无法
改动的初始化工作,我们就可以根据上述资料中介绍到的启动任务管理与面试官交流其中包含的各项技术。
衍生阅读
支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」
支付宝 App 构建优化解析:通过安装包重排布优化 Android 端启动性能