【小木箱成长营】启动优化系列文章(排期中):
启动优化 · 工具论 · 启动优化常见的六种工具
启动优化 · 方法论 · 这样做启动优化时长降低70%
启动优化 · 实战论 · 手把手教你破解启动优化十大难题
一、引言
Hello,我是小木箱,欢迎来到小木箱成长营系列教程,今天将分享启动优化·基础论·浅析Android启动优化。小木箱从四个维度将Android启动优化基础论解释清楚。
本文主要说了四部分内容,第一部分内容是启动基础,第二部分内容是启动优化价值,第三部分内容是启动优化业务痛点,第四部分内容是总结与展望。
启动优化业务痛点主要分为五个方面,第一个方面是业务问题背景,第二个方面是防劣化机制建设,第三个方面是优化思路,第四个方面是调度框架,第五个方面是业务框架。
如果学完小木箱成长营启动优化的基础论、工具论、方法论和实战论,那么任何人做启动优化都可以拿到结果。
二、启动基础
我们先进入第二部分内容启动基础,启动基础有六个关键点可以和大家分享一下,第一个关键点是启动进程。第二个关键点是启动方式。第三个关键点是启动流程。第四个关键点是归因分析。第五个关键点是优化方向。第六个关键点是启动指标。
2.1 启动进程
首先,小木箱说说第一个关键点启动进程,按照业务是否可直接操作分为SystemServer 和 App Process 。 其职责划分如下:
SystemServer 负责应用的启动流程调度、进程的创建和管理、窗口的创建和管理(StartingWindow 和 AppWindow) 等
应用进程被 SystemServer 创建后,进行一系列的进程初始化、组件初始化(Activity、Service、ContentProvider、Broadcast)、主界面的构建、内容填充等
2.2 启动方式
接着,小木箱说说第二个关键点启动方式,Android应用的启动方式大概分为热启动、冷启动、温启动三种,关于冷启动、热启动、温启动三者启动方式对比可以参考下面的流程图学习。
2.1.1 冷启动
冷启动具有耗时最多,衡量标准的特征,冷启动常见的场景是APP首次启动或APP被完全杀死,冷启动、热启动和温启动中冷启动CPU时间开销最大。启动流程简化如下,后文会详细介绍。
2.1.2 温启动
当启动应用时,后台已有该应用的进程,但是Activity可能因为内存不足被回收。这样系统会从已有的进程中来启动这个Activity,这个启动方式叫温启动。
温启动常见的场景有两种:第一种是首先用户按连续按返回退出了app,最后重新启动app,第二种是首先系统收回了app的内存,最后重新启动app。
2.1.3 热启动
热启动只执行了冷启动的第二阶段,如果由于内存不足导致对象被回收,那么需要在热启动时重建对象,后面与冷启动时将界面显示到手机屏幕流程是一样的。
热启动时,系统将activity带回前台。如果应用程序的所有activity存在内存中,那么应用程序可以避免重复对象初始化、渲染、绘制操作。
热启动常见的场景如: 当我们按了Home键或其它情况app被切换到后台,再次启动app的过程。
2.3 启动流程
其次,小木箱说说第三个关键点启动流程,应用的启动流程一般指的是冷启动流程,即应用进程不存在的情况下,从点击桌面应用图标,到应用启动的过程。
首先,用户进行了一个点击操作,这个点击事件它会触发一个IPC的操作,之后便会执行到Process的start方法中,这个方法是用于进程创建的。
然后,便会执行到ActivityThread的main方法,这个方法可以看做是我们单个App进程的入口,相当于Java进程的main方法,在其中会执行消息循环的创建与主线程Handler的创建。
接着,创建完成之后,就会执行到 bindApplication 方法,在这里使用了反射去创建 Application以及调用了 Application相关的生命周期。
最后,Application结束之后,便会执行Activity的生命周期,在Activity LifeCycle结束之后,就会执行到 ViewRootImpl,这时才会进行真正的一个页面的绘制。
2.4 优化方向
其四,我们说说第四个关键点优化方向,创建Application、启动主线程、创建HomeActivity、加载布局、布置屏幕和界面首帧绘制完成后,我们就可以认为启动已经结束了。
综上所述,除了Application atttachBaseContext、Appication onCreate 和 Activity LifeCycle,无需Hook源码,其余流程都是系统层面的。因此,我们能优化的空间只有Application atttachBaseContext、Appication onCreate 和 Activity LifeCycle三个流程。
2.5 归因分析
其五,我们先说说第五个关键点归因分析,程序运行最根本的是需要得到CPU时间片,如果一个任务需要较多的CPU时间执行,那么它将影响其他任务的执行,从而影响整体任务队列的运行;
线程切换涉及到 CPU调度,而CPU调度会有系统资源的开销,所以大量的线程频繁切换也会产生巨大的性能损耗;
IO和锁的等待会直接阻塞任务的执行,不能充分地利用CPU等系统资源。
因此,做启动优化的关键点是找到占用过多CPU时间、频繁的CPU调度、I/O等待和锁抢占等不合理消耗资源三个因素,这里先简单的看一下Profile分析文件,工具论会带大家详细学习如何使用工具进行线下监控与治理。
2.6 启动指标
最后,我们说说第六个关键点启动指标,关于启动优化监控当然是在冷启动阶段进行健康预测的。有三个启动指标我们需要额外注意,
第一个是启动开始,启动开始是进程创建的时间
第二个是启动结束,首页首屏渲染完成的时间;
第三个是启动时长,启动时长是指启动结束的时间戳减去启动开始的时间戳;
三、启动优化价值
说完启动基础,我们进入第三部分内容启动优化价值,启动耗时增长可能缩减App用户的留存,因此,启动性能优化是每一家互联网公司在体验优化方向上必须要做的关键技术突破。
启动性能优化目标是以低端机为重点,辐射中高端机,通过技术和产品上的深度优化,可感知的提升用户体验,实现扩大用户规模、提升留存和提升收入。
四、启动优化业务痛点
说完启动优化价值,我们进入第四部分内容启动优化业务痛点。带着问题出发,关于启动优化有低端机性能问题复杂、缺乏整体调度机制、问题定位成本高和监控机制不完善四大业务痛点亟需解决。关于这四大业务痛点我们对如何定义低端机?如何快速发现性能问题? 如何系统化的优化性能问题?如何避免性能优化的同时出现劣化问题,并打造劣化问题修复自运转的飞轮?有了进一步思考。
4.1 如何定义低端机?
关于如何定义低端机?低端机定义要双端对齐思考
Android
Android方面,内存和CPU是描述低端机型的比较关键的两个指标,我们根据Android用户的不同设备做了性能划分,初步可划分为高、中、低3种等级。
根据现有CPU、GPU的跑分软件安兔兔公测跑分维护一份CPU、GPU的设备性能档位表,按照不同档位划分为高、中、低三档。
先判断设备的Android系统版本号,如果Android系统低于Android6.0,可以直接划分为低档机再判断设备的内存和内核数:
public static void isLowerDevice() { return Build.VERSION.RELEASE < 6; } //获取RAM容量 public static long getTotalMemory(Context c) { // memInfo.totalMem not supported in pre-Jelly Bean APIs. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); ActivityManager am = (ActivityManager) c.getSystemService(Context.ACTIVITY_SERVICE); am.getMemoryInfo(memInfo); if (memInfo != null) { return memInfo.totalMem; } else { return DEVICEINFO_UNKNOWN; } } else {long totalMem = DEVICEINFO_UNKNOWN; try { FileInputStream stream = new FileInputStream("/proc/meminfo"); try { totalMem = parseFileForValue("MemTotal", stream); totalMem *= 1024; } finally { stream.close(); } } catch (IOException e) { e.printStackTrace(); } return totalMem; } } // 获取CPU核心数 public static int getNumberOfCPUCores() { int cores; try { cores = getCoresFromFileInfo("/sys/devices/system/cpu/possible");if (cores == DEVICEINFO_UNKNOWN) { cores = getCoresFromFileInfo("/sys/devices/system/cpu/present");} if (cores == DEVICEINFO_UNKNOWN) { cores = new File("/sys/devices/system/cpu/").listFiles(CPU_FILTER).length; } } catch (SecurityException e) { cores = DEVICEINFO_UNKNOWN; } catch (NullPointerException e) { cores = DEVICEINFO_UNKNOWN;}return cores; }
当没有取到CPU、GPU型号或者CPU、GPU型号在设备性能档位表里面不存在时,通过设备的CPU和RAM组合信息来判定。判定规则如下:
高端机型: CPU为骁龙845或麒麟980,RAM大于等于6GB
低端机型: 骁龙或联发科系列,CPU最大主频小于等于1.8GHz且RAM小于4GB。麒麟系列,CPU最大主频小于等于2.1GHz且RAM小于等于4GB
中端机型: 剩余法
//获取CPU型号 public static String getCPUName() { try { FileReader fr = new FileReader("/proc/cpuinfo"); BufferedReader br = new BufferedReader(fr); String text; String last = ""; while ((text = br.readLine()) != null) { last = text; } //一般机型的cpu型号都会在cpuinfo文件的最后一行 if (last.contains("Hardware")) { String[] hardWare = last.SharedPreferenceslit(":\s+", 2); return hardWare[1]; } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } return Build.HARDWARE;}
iOS
iOS方面,机型种类优先,可枚举,因此可通过配置表直接读取机型评分分数,低端机占大盘比例 15%。
4.2 如何快速发现性能问题?
关于如何快速发现性能问题?我们是根据设备类型、Android系统版本号、手机品牌、启动时长、系统平台、app版本号、app名称、闪屏广告阻断次数和设备ID等字段结合数据大盘分析平台,针对预发布环境和正式环境建设应用级的基础调度机制,服务于业务,并协助业务优化性能;如果预发布环境启动超标,那么向开发主力团队发送飞书机器人告警提醒。
4.3 如何系统化的优化性能问题?
关于如何系统化的优化性能问题?我们借助了Dokit工具提效,自建稳定且高效性能工具,通过DoKit进行源码魔改,提高发现问题效率;慢函数闭环监控借助了ASM插桩打点实现,详细内容我们可以参考后续的启动优化 · 实战论 · 手把手教你破解启动优化十大难题。
4.4 如何避免性能优化的同时出现劣化问题,并打造劣化问题修复自运转的飞轮?
关于如何避免性能优化的同时出现劣化问题,并打造劣化问题修复自运转的飞轮?我们首先利用启动器将启动任务颗粒化,然后针对任务的时长统计上报,最后通过 Appium、Mockio、Hamcrest、UIAutomator等自动化测试架构进行测试,app每个版本集成回归时,测试同学会在测试平台跑一遍性能测试并输出测试报告。
报告包含了定义的核心场景下,app的内存、CPU、启动时长等性能指标数据及版本对比波动值。
在允许的波动范围内,比如启动时长波动<100ms,那么就认为测试通过,否则就认为数据有恶化趋势,测试不通过,需要研发排查优化,直到测试通过。
当然未来希望借助已搭建云真机测试平台,可以考虑通过Docker容器化技术在云真机上自动化测试。测试平台直接自动化分析、自动化转发,全程自助,无人工干预。
下面我们一起来学习一下百度低端机启动性能优化观测设施、基础设施和业务优化吧。
4.4.1 防劣化机制建设
百度的观测设施有三个关键点,第一个关键点是低端机标准建设,上文如何定义低端机已经提供了不错的解决方案。
第二个关键点是核心指标建设,设备类型、Android系统版本号、手机品牌、启动时长、系统平台、app版本号、app名称、闪屏广告阻断次数和设备ID等字是我们常用的上报字段。
第三个关键点是防劣化机制建设,是本文的重中之重。客户端防劣化机制建设主要思考两个方向,第一个方向是线下防劣化。第二个方向是线上防劣化。
线下防劣化
首先我们来说一下线下防劣化,关于线下防劣化主要分为四个部分,第一个部分是打包自动化。第二个部分是测试自动化。第三个部分是分析自动化。第四个部分是分发自动化。
打包自动化,一般中大型的互联网公司都有做,如阿里的摩天轮、美团的MCI和货拉拉的MDAP等等。打包自动化是客户端持续化部署关键一步,通过打包自动化的方式方便我们严控开发版本权限,降低发版风险。
测试自动化,通过Docker镜像实现快速部署和迁移Appium自动化测试框架,执行定制化case实现app在云真机启动过程中进行自动化测试。货拉拉好像也在做这件事,目前云真机平台建设有了基础雏形。
分析自动化,Android端上建议参考字节跳动的btrace。我们可以利用该工具记录事件的CPU执行时间开销,写到本地日志后上传分析平台,这样会更方便排查问题。
反混淆解析首先可以参考progard的mapping.txt文件,然后再通过retrace.sh -verbose mapping.txt obfuscated_trace.txt
进行反混淆,最后将obfuscated_trace.txt
透传给测试平台进行渲染即可。
分发自动化,首先通过脚本对劣化问题进行去重、置信度过滤,然后通过分发服务进行问题归属定位,最后通过企业办公软件QA机器人分发。
线上防劣化
线上防劣化主要分为两种,第一种是实验防劣化,第二种是函数级防劣化。
实验防劣化
关于实验防劣化,如果云真机测试平台搭建好了,首先可以通过自动化,启动录制视频,然后将视频分帧,通过算法筛选出点击帧和渲染完成帧。最后检测跑多次数据,关注波动曲线和平均值。
函数级防劣化
关于函数防劣化,用的是ASM字节码插桩技术,启动耗时统计目前有两种方法,即线上统计和线下监测,线上统计是指通过分析统计所有手机的耗时情况,求取每个应用的不同启动时间段占比。
一定程度可以反应每个版本启动耗时情况,可以针对不同版本差异化代码进行优化排查。
线下监测是指利用adb命令或Systrace工具在严格控制的环境下监控应用,该方案存在明显的不足:无法精确到每个函数级别,统计过程会比较复杂,数据也不够直观。
工程编译过程的角度出发,拦截Android构建由Class字节码文件转换成Dex文件的过程,因为每个字节码文件都有来源路径,如果在当前字节码文件内容中能够检测出符合命中策略的指令,我们就可以知道当前的指令所处文件名,调用位置、文件路径,进行插入日志信息,首先我们使用的是自定义注解Anotaton方式处理我们特殊的字节码来源,然后根据来源进行周期性拦截,并统计代码执行周期内耗时信息。
最后,我们给每一个启动执行周期设置一个卡口时间,如果超出卡口时间就证明测试是不通过的。并且输出每一个子函数的耗时长度。
4.4.2 优化思路
关于优化思路,主要有两个方面,第一个方面是SharedPreferences优化,第二个方面是锁优化。
SharedPreferences优化
首先,我们来说一说SharedPreferences优化,为什么要对SharedPreferences进行优化呢?SharedPreferences的缺点很明显,第一明文存储,第二多进程存储数据易丢失,第三效率低,IO读写使用xml数据格式,全量更新效率低。
SharedPreferences存储格式
我们首先来看一下SharedPreferences的数据存储和编码格式,SharedPreferences数据存储和编码格式采用的是Xml,明文存储,可读性强,数据冗余度较高。
<?xml version='1.0' encoding='utf-8' standalone='yes' ?> <map> <int name="MicroKibaco" value="小木箱成长营" /> </map>
SharedPreferences低效读取
SharedPreferences初始化的时候,子线程使用IO读取整个文件,进行XML解析,因为存入内存,SharedPreferences具有Map集合的数据结构特征,在我们每次追加数据更新的时候,只有采用全量更新方式,才能把map中的数据全部序列化为XML,如果文件较大,那么会导致存储效率降低。
SharedPreferences多进程操作
其实SharedPreferences也有支持多进程的模式MODE_MULTI_PROCESS,不过API过时了。
SharedPreferences在MODE_MULTI_PROCESS多进程模式下,读写数据可能出现数据丢失,具体可以参考一下下面的流程图和源码。
class ContextImpl extends Context { @Override public SharedPreferences getSharedPreferences(File file, int mode) { SharedPreferencesImpl sp; synchronized (ContextImpl.class) { ··· } if ((mode & Context.MODE_MULTI_PROCESS) != 0 || getApplicationInfo().targetSdkVersion < VERSION_CODES。HONEYCOMB) { //重新去加载磁盘文件 sp.startReloadIfChangedUnexpectedly(); } return sp; } }
Sharepreferences在Andorid 7.0及以上进行多进程读写操作的时候,会抛出异常,因为Sharepreferences不支持多进程模式。多进程共享文件会出现问题的本质在于,不同进程磁盘读写,线程同步会失效。怎么理解呢?
异步提交过程中,如果此刻SharePreference正在IO磁盘文件,但用户退出当前进程,数据没有及时更新文件,提交操作却提前打断,那么数据就丢失。
要解决Sharepreferences数据丢失问题,我们可以采用跨进程方案,如ContentProvider、AIDL、Service。但ContentProvider、AIDL、Service操作文件有点大才小用,我们可以考虑MMKV或DataStore。
SharedPreferences IO磁盘读写
虚拟内存被操作系统划分成两块:用户空间和内核空间,用户空间是用户程序代码运行的地方,内核空间是内核代码运行的地方。为了安全,用户空间和内核空间是隔离的,即使用户的程序崩溃了,内核也不受影响。
那么, IO读取数据为什么会造成数据不同步呢?以用户修改文件为例,如果是IO读取文件遵循下面的流程,首先调用 write,告诉内核需要写入数据的开始地址与长度,然后内核将数据拷贝到内核缓存,最后由操作系统调用,将数据拷贝到磁盘,完成写入。
如果用户对EditText进行Input输入事件,其他耗时事件导致Input输入事件处于等待状态,时间超过5s,那么会阻塞主线程,导致ANR。经测试发现,SharedPreferences同步更新过程中,大文件读写操作,耗时超过5s很容易出现。因此,为了避免出现ANR,不要使用SharedPreferences进行大文件读写。
MMKV的原理
MMKV的C++层代码比较复杂,小木箱从内存准备、数据组织和写入优化三个方面简单的和大家聊一下原理。
内存准备
第一,内存准备方面,通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往内存写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。
数据组织
第二,数据组织方面,数据序列化方面小木箱选用protobuf协议,pb在性能和空间占用上都有不错的表现。
写入优化
第三,写入优化方面,考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力,我们考虑将增是 kv 对象序列化后,append 到内存末尾。
MMKV之mmap内存读写
因为SharedPreferences有不够精简的xml数据格式、操作文件耗时长、阻塞主线程易出现数据丢失和不支持增量更新弊端,所以有没有SharedPreferences的备胎方案呢?
有! 腾讯的MMKV, MMKV有四大优点,第一是mmap内存映射,读写快,操作内存相当于操作文件,不必担心crash导致数据存储失败。第二是采用protobuf数据格式,性能和大小更有优势。第三是写入优化,增量更新,大小不足时进行扩容。第四是支持多进程模式。首先小木箱说一下第一部分内容mmap,mmap有四个问题需要聊一下。
问题一: mmap是什么?
Linux 通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。
对文件进行 mmap,会在进程的虚拟内存分配地址空间,创建映射关系。实现这样的映射关系后,就可以采用指针的方式读写操作这一段内存,而系统会自动回写到对应的文件磁盘上。
问题二: mmap相对于IO磁盘读写有什么优点?
第一,mmap 对文件的读写操作只需要从磁盘到用户主存的一次数据拷贝过程,减少了数据的拷贝次数,提高了文件读写效率,mmap读写操作可以看一下下面的图。
第二,mmap 使用逻辑内存对磁盘文件进行映射,操作内存就相当于操作文件,不需要开启线程,操作 mmap的速度和操作内存的速度一样快
第三,mmap 提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统如内存不足、进程退出等时候负责将内存回写到文件,不必担心 crash 导致数据丢失
下面来对比一下SharedPreferences 和 MMKV同时存储1000条数据的耗时:
因为MMKV和SharedPreferences都是从map里面读数据,所以读取速度相差不大。因为mmap内存写文件0次拷贝,SharedPreferences IO磁盘写文件多次拷贝,所以MMKV的写入速度优于SharedPreferences。
问题三: mmap映射的内存到磁盘的时机是什么时候?
mmap映射的内存到磁盘的时机有四个,第一个是主动调用msync,第二个是mmap解除映射,第三个是进程退出,第四个是系统关机。
问题四: 如何更深入的学习mmap?
如果想深入的学习mmap的实践,那么小木箱推荐大家看一下微信的Mars、货拉拉的Glog、美团的Logan和网易七鱼日志写入方案。
MMKV数据存储和编码格式
数据存储和编码格式方面,mmkv采用protobuf,protobuf数据紧凑,非明文存储。protobuf底层是基于二进制保存的,保存文件非常小,解析速度更快! protobuf相比XML、JSON、Lua有如下优势:
protobuf
数据结构
protobuf底层是基于二进制保存的,保存文件非常小,解析速度更快!
MMKV 默认把文件存放在 $(FilesDir)/mmkv/ 目录, 我们用010Editor看到protobuf映射文件二进制存储格式如下:
前4个字节表示整个protobuf映射文件中数据的有效长度。那么为什么需要有效长度?
其实,因为mmap内存映射的时候,文件大小必须是4096(这个数字与与操作系统位数有关)或者其整数倍,所以并非整个文件内容都是有效数据,需要用有效数据长度来标明有效数据。
从第五个字节开始,依次为k1长-->k1值-->v1长-->v1值-->k2长->k2值-->v2长-->V2值...>v2值-->
那么问题来了,protobuf是怎么做到数据紧凑的呢?我们来了解一下protobuf的解码规则:
protobuf
解码规则
我们以整型数据来说明,每一个字节的首位作标志位,标志位为1
,则表示该字节无法完整表示数据,需要更多的字节;标志位为0,则表示该字节已经是表示该数据的最后一个字节,该数据的的读取到该字节为止。每一个字节的后七位保存数据。
<=0x7f
的10进制表示为127
,2进制表示为0111111111
。如果写入的数据<=0x7f
,那么一个字节的七个数据位足够表示这个数据,则字节首位置0,后七位写入数据。
如果写入的数据>0x7
,那么一个字节的七个数据位不足以表示这个数据,则字节首位置11后七位写入数据,并将原数右移7位,继续执行判断。
protobuf解码案例分析
编码128
, 即10000000
读取128的protobuf编码, 即1000 000000000 000
编码318242,即00000100110110110010 0010
318242的protobuf
解码
综上所述,普通方式存储是定长的,而protobuf存储方式是变长的。所以,在多数情况下,protobuf的存储方式,会使得数据更小。protobuf的数据格式特征解释了为什么MMKV比SharedPreferences的性能和大小更有优势。
MMKV增量修改
好了,小木箱介绍完了MMKV数据在文件中的存储结构,这种结构如何实现增量修改的呢?
原因是MMKV在内存中是一个map表结构。
完成首次mmap映射系的建立后,之后再次写入一个同名key的键值对,该键值对直接存储在映射文件的末尾,并修改有效长度。
当映射关系断开后重新建立映射关系的时候,旧的键值对先写入map表,新的键值对后读入,将会覆盖掉旧的键值对,也就实现了增量修改。
MMKV是在末尾追加新数据,重复数据不进行覆盖,当要发生扩容时进行数据重整。
MMKV多进程操作
Linux中多进程锁通常考虑pthread_mutex,创建于共享内存的 pthread_mutex 是可以用作进程锁的。
但是Android版本的健壮性不足,进程被kill时并不会释放锁,导致其他进程一直阻塞。
所以mmkv采用的是文件锁进行多进程同步,但是文件锁存在两个问题:
第一个问题是不支持递归加锁:因为文件锁是状态锁,没有计数器,无论加了多少次锁,一个解锁操作就全解掉。只要用到子函数,就非常需要递归锁。
第二个问题是不支持读写锁升级/降级:读锁升级为写锁,写锁可以降级为读锁。对于读锁,我们允许多进程访问,写锁则不允许多进程访问。所以mmkv增加了读写计数器以此支持这个功能,增加CRC文件校验。
具体逻辑:
- 保证每一个文件存储的数据都比较小,也就说需要把数据根据业务线存储分散。内存消耗不会过快,数据不用了可以进行释放。
- 还需要在内存不足的时候释放一部分内存数据,比如在App中监听onTrimMemory方法,在Java内存吃紧的情况下进行MMKV的trim操作。
- 在不需要使用的时候,最好把MMKV给close掉。
MMKV与SharedPreferences优缺点比较
写了这么多,小木箱首先简单的总结一下SharedPreferences和MMKV的存储格式、优点和缺点。最后再通过实验分析验证一下结论。
MMKV与SharedPreferences性能测试
下面进入我们的MMKV与SharedPreferences性能测试环节。读写性能方面,无论是ios还是android上,MMKV的表现均优于SharedPreferences。iOS的MMKV 和 NSUserDefaults 进行对比,重复读写操作 1w 次。性能比较数据如下:
Android,MMKV 和 SharedPreferences、SQLite 进行对比, 重复读写操作 1k 次。结果如下图表。
Android,MMKV 和 SharedPreferences、SQLite 进行对比, 多进程操作性能如下图表。
MMKV 无论是在写入性能还是在读取性能,都远远超越 MultiProcessSharedPreferences & SQLite & SQLite, 基于SharedPreference以上缺陷,小木箱的团队要放弃SharedPreferences使用。
MMKV二次开发与迭代
尽管MMKV已经足够优秀,但是美中不足是不支持强类型、和SharedPreferences接口无法对齐,国内能兼顾这两个优势的数据存储模型的只有Booster和UniKV。但相比MMKV,Booster有点相形见绌了,因为Booster不支持多进程而且线程优化个数表现不佳。在百度App 低端机优化-启动性能优化(概述篇)一文中,UniKV,突破系统限制,彻底解决原生 SharedPreferences 有首次读取性能差、创建线程多、卡顿/ANR、多进程支持差等缺点,实现流程图大概如下:
UniKV是闭源的,从SDK研发到落地上线,百度应该踩了不少坑,未来小木箱希望可以开发一套方便从SharedPreferences切换到MMKV的开源工具,SharedPreferences0风险替换MMKV。因为篇幅有限,关于MMKV的改造提效,可以参考后续文章 架构优化· 框架论 · 什么! 从SharedPreferences过渡MMKV,线上崩溃率提高3%?
锁优化
说完SharedPreferences优化,我们进入优化思路的第二个环节锁优化, 学习锁优化之前,简单的和大家过一下Java的主流锁。
Java主流锁
Java的主流锁一共有六个问题需要搞清楚,搞清楚这6个问题,Java主流锁基础知识掌握的也差不多了。
问题一: 线程要不要锁住同步资源?
从线程是否需要同步角度出发,我们把锁分为两种。第一种是悲观锁,第二种是乐观锁。synchronized、Lock实现类都是悲观锁,作用在于其他线程访问数据的时候,保护数据不会被其他线程修改。很好理解这个概念,只有患得患失,才给自己数据设置修改权限。患得患失的锁普遍悲观。
乐观锁就不一样了,乐观锁比较开放,唯我独尊,认为没人敢乱动自己的数据,所以从不给自己的数据加锁。
只是别的线程访问自己的数据的时候,判断一下有没有偷偷更新自己的数据,如果数据没有被更新,那么当前线程将自己修改的数据成功写入。
如果数据已经被别的线程更新,那么根据不同的实现方式执行抛出异常或者自动重试操作。
那么乐观锁在Java中是怎样实现的呢?
AtomicBoolean等原子类中的递增操作通过CAS自旋实现的。而乐观锁在Java中也是基于类似CAS算法等方式来实现
能不能都用悲观锁保证数据正确性呢?
其实是不建议的,我们都知道如果加锁会使读操作的性能大幅下降。那么什么时候不需要加锁呢?
当然是在不更改数据的场景下啦,比如: 批量读文件、批量删除文件等等。这也是乐观锁适合的场景。
悲观锁相反,加锁可以保证写操作时数据正确,因此,悲观锁适合写操作多的场景。
悲观锁和乐观锁在Java编程中调用方式是怎样的呢?
// ------------------------- 悲观锁的调用方式 ------------------------- // synchronized public synchronized void testMethod() { // 操作同步资源 } // ReentrantLock private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁 public void modifyPublicResources() { lock.lock(); // 操作同步资源 lock.unlock(); } // ------------------------- 乐观锁的调用方式 ------------------------- private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用的是同一个AtomicInteger atomicInteger.incrementAndGet(); //执行自增1
为什么乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?
主要是通过CAS方式实现的,是一种无锁算法。在没有线程被阻塞的情况下实现多线程之间的变量同步。JUC包中的原子类就是通过CAS来实现了乐观锁。
public class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID = 6214790243416807050L; // unsafe: 获取并操作内存的数据。 private static final Unsafe U = Unsafe.getUnsafe(); // VALUE: 存储value在AtomicInteger中的偏移量。 private static final long VALUE; // value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。 private volatile int value; static { try { VALUE = U.objectFieldOffset (AtomicInteger.class.getDeclaredField("value")); } catch (ReflectiveOperationException e) { throw new Error(e); } } } // ------------------------- JDK 8 ------------------------- // AtomicInteger 自增方法 public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; } // Unsafe。class public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; } // ------------------------- OpenJDK 8 ------------------------- // Unsafe。java public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v + delta)); return v; }
CAS虽然很高效,但是CAS有三大缺陷:
- 缺陷一: ABA问题。
CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。 - JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
- 缺陷二: 循环时间长开销大
CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
- 缺陷三: 只能保证一个共享变量的原子操作
对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
问题二: 锁住同步资源失败,线程要不要阻塞
加锁的时候为了让当前线程不阻塞,HotSpot底层引入自旋锁,自旋锁九字真言就是循环加锁 -> 等待的机制,指的是当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会阻塞,间隔一段时间后会再次尝试获取。具体流程参考如下:
自旋锁的优点在于减少CPU切换以及恢复现场导致的消耗。
自旋锁缺点是不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。
自旋锁实现原理是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
在自旋锁,中另有三种常见的锁形式:TicketLock、CLHlock和MCSlock
问题三: 多个线程同步竞争资源的流程细节有没有区别?
说完自旋锁,按照多个线程同步竞争资源的流程细节有没有区别,小木箱把锁划分为四类,第一类是无锁,第二类是偏向锁,第三类是轻量级锁,第四类是重量级锁。
这四种锁是指锁的状态,专门针对synchronized的。
那么Synchronized底层的锁优化机制是怎样的呢?
Synchronized底层的锁优化机制一张图可以解释原理。下面小木箱基于下面这张图详细的和大家聊一下Synchronized。
说到Synchronized底层锁优化机制,我们不得不提到两个概念,Java头对象和Monitor。
我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word主要存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息。而Klass Pointer主要指的是指针指向它的类元数据的指针。
为了探索锁的升级和降级过程,小木箱使用Maven引入openjdk,小木箱打印ClassLayout.parseInstance().toPrintable()方法可以看到object header参数即Java头对象。
我们可以通过-XX:+UseCompressedOops 打开指针压缩,普通对象压缩后对象头结构为:
我们也可以通过- -XX:-UseCompressedOops 打开指针压缩,普通对象对象头结构为:
说完对象头,小木箱再说说Mark Word(标记字段),Mark Word(标记字段)主要用来存储对象自身的运行时数据,如 hashcode、gc 分代年龄等。Mark Word 的位长度为 JVM 的一个 Word 大小。Mark Word锁状态可以参考如下图:
小木箱通过内存信息分析锁状态如下:
public class Main{ public static void main(String[] args) throws InterruptedException { L l = new L(); Runnable RUNNABLE = () -> { while (!Thread.interrupted()) { synchronized (l) { String SPLITE_STR = "==========================================="; System.out.println(SPLITE_STR); System.out.println(ClassLayout.parseInstance(l).toPrintable()); System.out.println(SPLITE_STR); } try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }; for (int i = 0; i < 3; i++) { new Thread(RUNNABLE).start(); } } } class L{ private boolean myboolean = true; } ====================================输出调用日志================================================== OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 5a 97 02 c1 (01011010 10010111 00000010 11000001) (-1056794790) 4 4 (object header) d7 7f 00 00 (11010111 01111111 00000000 00000000) (32727) 8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253) 12 1 boolean L.myboolean true 13 3 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 3 bytes external = 3 bytes total
可以看到在第一行 object header 中 value=5a 对应的 2 进制为 01011010,倒数第三位为 0 表示不是偏量锁,后两位为 10 表示为重量锁。
说完Mark Word(标记字段),小木箱再说说Klass Pointer(类型指针),Klass Pointer访问方式主要有两种:句柄池和直接指针访问。
句柄池访问方式如下:
直接指针访问方式如下:
最后,小木箱整体对比一下句柄访问和指针访问的差异 。
说完Klass Pointer,我们探讨一下Monitor,Monitor可以理解为一个同步工具或一种同步机制,通常用于描述为一个对象。一般通过成对的MonitorEnter和MonitorExit指令来实现。
Monitor在HotSpot是以ObjectMonitor来实现的,从以下源码,
ObjectMonitor() { _header = NULL; _count = 0; _waiters = 0, // 等待中的线程数 _recursions = 0; // 线程重入次数 _object = NULL; // 存储该 monitor 的对象 _owner = NULL; // 指向拥有该 monitor 的线程 _WaitSet = NULL; // 等待线程 双向循环链表_WaitSet 指向第一个节点 _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; // 多线程竞争锁时的单向链表 FreeNext = NULL ; _EntryList = NULL ; // _owner 从该双向循环链表中唤醒线程, _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; _previous_owner_tid = 0; // 前一个拥有此监视器的线程 ID }
关于ObjectMonitor同步队列协作流程可以参考下图:
我们可以看出ObjectMonitor,有两个队列,分别是_WaitSet
、_EntryList
,作用是保存 ObjectWaiter 对象列表。
_owner 是一个临界资源, JVM 是通过 CAS 操作来保证其线程安全的,当获取 Monitor 对象的线程进入 _owner 区时, _count 会 + 1。
如果线程调用了 wait() 方法,此时会释放 Monitor 对象, _owner 恢复为空, _count 会- 1。
_cxq:竞争队列所有请求锁的线程首先会被放在这个队列中(单向)。_cxq 是一个临界资源 JVM 通过 CAS 原子指令来修改_cxq 队列,每当有新来的节点入队,_cxq的next 指针总是指向之前队列的头节点,而_cxq 指针会指向该新入队的节点,所以是后来居上。
同时该等待线程进入 _WaitSet 等待队列中,等待被唤醒。锁的完整执行流程可以看一下下图:
Monitor整体过程可以从竞争、等待、释放和唤醒四个维度分析。首先我们说一下竞争过程
然后我们说一下,等待过程:
接着我们说一下释放过程,当某个持有锁的线程执行完同步代码块时,会释放锁并 unpark
后续线程,这就是释放过程。
最后我们说一下唤醒过程,notify 或者 notifyAll 方法可以唤醒同一个锁监视器下调用 wait 挂起的线程,这就是唤醒过程。
源码就不带大家过了,感兴趣的可以看一下小米的synchronized 实现原理。
总体上来说这四种锁状态升级流程如下:
无锁指的是不锁住资源,多个线程中只有一个能修改资源成功,其他线程会重试。
偏向锁指的是同一个线程执行同步资源时自动获取资源。 持有偏向锁的线程以后每次进入这个锁相关的同步块时,只需比对一下 mark word 的线程 id 是否为本线程,如果是则获取锁成功。如线程访问同步代码并获取锁的处理流程如下:
那么如果获取锁之后,发生线程竞争的情况则如何撤销偏向锁呢?
多个线程竞争偏向锁导致偏向锁升级为轻量级锁,轻量级锁指的是多个线程竞争同步资源时,没有获取资源的线程自旋等待锁释放。下面我们来看一下轻量级锁的加锁过程。
说完加锁过程,我们再来看一下,轻量级锁的解锁过程。
重量级锁指的是多个线程竞争同步资源时,没有获取资源的线程阻塞等待唤。
synchronized 关键字及 wait
、notify
、notifyAll
这三个方法都是管程的组成部分。可以说管程就是一把解决并发问题的万能钥匙。有两大核心问题管程都是能够解决的:
synchronized
的 monitor
锁机制和 JDK 并发包中的 AQS
是很相似的,只不过 AQS
中是一个同步队列多个等待队列。熟悉 AQS
的同学可以拿来做个对比。
说了这么多,可能大家还是有点乱,偏向锁、轻量级锁、重量级锁概念和优缺点是什么,小木箱简单的给大家总结一下:
问题四: 多个线程竞争锁的同时要不要排队?
说完锁的升级过程,我们来探讨一下多个线程竞争锁的同时要不要排队,根据这个问题我们把锁分为两类,第一类是公平锁,第二类是非公平锁。公平锁和非公平锁的概念、优点和缺点可以参考下面的图对比。
关于公平锁我们可以看一下下图进行图形化理解:
关于非公平锁我们可以看一下下图进行图形化理解:
ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。ReentrantLock有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
作者:小木箱
我们对比一下公平锁和非公平锁的源码,公平锁比非公平锁多了一个hasQueuedPredecessors()判断条件。
进入hasQueuedPredecessors(),可以看到hasQueuedPredecessors()方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。
公平锁和非公平锁的区别是公平锁如果在当前线程不是拥有有锁的线程时,就不能添加锁。所以公平锁和非公平锁添加的都是独享锁。独享锁我们在问题六详细了解。
问题五: 一个线程能不能获取同一把锁?
说到一个线程能不能获取同一把锁,我们不得不提到可重入锁和非可重入锁,可重入锁和非可重入锁区别可以看看下面的表格
- synchronized可重入锁
//可重入,就是可以重复获取相同的锁, //synchronized和ReentrantLock都是可重入的 // 可重入降低了编程复杂性 public class WhatReentrant2 { public static void main (String[] args) { ReentrantLock lock = new ReentrantLock (); new Thread ( new Runnable () { @Override public void run () { try { lock. lock (); System.out. println ( "第1次获取锁,这个锁是:" + lock); int index = 1 ; while ( true ) { try { lock. lock (); System.out. println ( "第" + (++index) + "次获取锁,这个锁是:" + lock); try { Thread. sleep ( new Random (). nextInt ( 200 )); } catch (InterruptedException e) { e。 printStackTrace (); } if (index == 10 ) { break ; } } finally { lock. unlock (); } } } finally { lock. unlock (); } } }). start (); }
- 不可重入锁
public class Lock{ private boolean isLocked = false; public synchronized void lock() throws InterruptedException{ while(isLocked){ wait(); } isLocked = true; } public synchronized void unlock(){ isLocked = false; notify(); } } public class Count{ Lock lock = new Lock(); public void print(){ lock.lock(); doAdd(); lock.unlock(); } public void doAdd(){ lock.lock(); //do something lock.unlock(); } }
关于可重入锁我们可以看一下下图进行图形化理解:
关于非可重入锁我们可以看一下下图进行图形化理解:
为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?
ReentrantLock和NonReentrantLock都继承父类AQS。
其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。
如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。
非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); // 取到当前锁的个数 int w = exclusiveCount(c); // 取写锁的个数w if (c != 0) { // 如果已经有线程持有了锁(c!=0) // (Note: if c != 0 and w == 0 then shared count != 0) if (w == 0 || current != getExclusiveOwnerThread()) // 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败 return false; if (w + exclusiveCount(acquires) > MAX_COUNT) // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。 throw new Error("Maximum lock count exceeded"); // Reentrant acquire setState(c + acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。 return false; setExclusiveOwnerThread(current); // 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者 return true; }
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
public void unlock() { sync。releaseShared(1); } //............................................. public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; } //............................................. protected final boolean tryReleaseShared(int unused) { // ............... for (;;) { // 可重入锁同样先获取当前status的值, int c = getState(); int nextc = c - SHARED_UNIT; if (compareAndSetState(c, nextc)) // 在当前线程是持有锁的线程的前提下。如果status-1 == 0,调用doReleaseShared return nextc == 0; } } //............................................. // 真正释放锁 private void doReleaseShared() { }
重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析为什么非可重入锁在重复调用同步资源时会出现死锁。
问题六: 多个线程能不能共享同一把锁?
在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
根据多个线程能不能共享同一把锁,我们把锁分为独享锁和共享锁,独享锁和共享锁的区别可以参考下面的表格:
首先我们来说一下共享锁ReentrantReadWriteLock有两把锁:ReadLock(读锁)和WriteLock(写锁)。
ReadLock和WriteLock是靠内部类Lock实现的锁,而Lock是Sync的实现接口。因此,ReadLock和WriteLock是靠内部类Sync实现的锁。详细代码如下:
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。
读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
那读锁和写锁的具体加锁方式有什么区别呢?首先先看一下写锁的加锁源码:
protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); // 取到当前锁的个数 int w = exclusiveCount(c); // 取写锁的个数w if (c != 0) { // 如果已经有线程持有了锁(c!=0) // (Note: if c != 0 and w == 0 then shared count != 0) if (w == 0 || current != getExclusiveOwnerThread()) // 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败 return false; if (w + exclusiveCount(acquires) > MAX_COUNT) // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。 throw new Error("Maximum lock count exceeded"); // Reentrant acquire setState(c + acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。 return false; setExclusiveOwnerThread(current); // 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者 return true; }
接着看一下读锁的加锁源码:
protected final int tryAcquireShared ( int unused) { Thread current = Thread。currentThread(); int c = getState(); if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) return - 1 ; // 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态 int r = sharedCount(c); // 如果当前线程获取了写锁或者写锁未被获取 if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) { // ,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。 if (r == 0 ) { // 读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。 firstReader = current; firstReaderHoldCount = 1 ; } else if (firstReader == current) { firstReaderHoldCount++; } else { HoldCounter rh = cachedHoldCounter; if (rh == null || rh。tid != getThreadId(current)) cachedHoldCounter = rh = readHolds。get(); else if (rh.count == 0 ) readHolds.set(rh); rh。count++; } return 1 ; } return fullTryAcquireShared(current); // 所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。 }
综上所述:当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。
如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。因此,可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。
因为篇幅有限,关于并发编程基础知识,可以参考后续文章并发编程· 基础篇 ·Android这样学锁,你也能做好性能监控!
Java锁监控
说完Java主流的锁,我们再来聊一聊Java的锁监控。客户端监控的锁有synchronized 锁、 CAS、Native 锁等,但在我们日常开发中synchronized 锁占比是最大的,作为一定量级的国民应用抖音也只对synchronized锁进行了监控。因此,Java的锁优化我们只谈及synchronized 锁的优化。
Java锁的优化评判的点主要有以下4个,第一个是稳定性, 第二个是准确性 ,第三个是拓展性。最后一个是防劣化。首先我们聊聊准确性,准确性当然是指精准的Hook持锁时长。
在上文Java主流锁我们知道了,如果滥用锁,那么后果是阻塞UI线程,导致绘制延迟,出现卡顿,甚至 ANR等。怎么监控锁持有时间是我们APM建设亟需解决的痛点。之前说过Monitor整体过程分为竞争、等待、释放和唤醒四个维度。而影响锁占用时长发生在锁竞争阶段和锁等待阶段。
首先我们说一下锁的竞争阶段,我们通过monitor.cc源码可以看到,锁的开始和锁的结束,分别调用ATRACE_BEGIN(...)
和 ATRACE_END()
,那么ATRACE_BEGIN(...)
和 ATRACE_END()
方法作用是什么呢?
我们通过trace-dev源码可以看到ATRACE_BEGIN(...)
和 ATRACE_END()
的实现,ATRACE_BEGIN(...)
和 ATRACE_END(...)
底层使用 write 将字符串写入一个特殊的 atrace_marker_fd
线上HookATRACE_BEGIN(...)
和 ATRACE_END()
两个点位,ATRACE_END()
时间戳减去ATRACE_BEGIN(...)
时间戳得到的是持锁时长。
因此通过 hook libcutils。so 的 write 方法,并按 atrace_marker_fd 过滤,就实现了对 ATRACE_BEGIN(...)
和 ATRACE_END()
的拦截,计算出阻塞时长,解析 monitor contention with owner...
日志可以监控到线上用户的锁问题。
Native的Hook方案推荐使用爱奇艺的XHook,XHook我们可以看一下 利用xhook安卓系统底层抹机原理 文章进行学习。
然后我们说一下锁的等待阶段,其实就是获取 Java 调用栈,那么怎么获取Java调用栈呢?可以使用Thread.getStackTrace()
方法。
异步获取堆栈,在 MonitorEnter 的时候通知子线程 5ms 之后抓取堆栈,MonitorExit 计算阻塞时长,并结合堆栈数据一起放入队列,等待上报APM监控平台。如果 MonitorExit 时不满足指定的阈值,那么取消抓栈和上报。
当然,因为锁监控会批量写日志加上Hook方案本身就有一定性能开销,所以仅对灰度测试中的部分用户开启了锁监控。字节的PRD方案如下:
我们能看到设备信息、阻塞时长、调用堆栈等信息。
这样,可以通过日志很快定位到所有超过阈值持有时长的锁并进行相应的优化。
至此关于Java锁监控思路讲解完毕,待工具链上线后我们需要灰度一部分用户进行稳定性测试。为了提高扩展性, 业务可以根据场景开启和关闭采集功能,也可以收集指定时间内的锁,比如启动阶段可以收集 32ms 的锁,其它阶段收集 16ms 的锁。为了防劣化,当锁占用时长总数量采集超标,预发步环境利用QA机器人进行预警。
4.4.3 调度框架
说完优化思路,我们再说一下调度框架,对于大型App来说,启动任务多,任务依赖复杂。保障任务逻辑的单一性,解耦启动任务逻辑,合理利用多核CPU优势,提高线程运行效率是重点关注的问题。
为了利用多核cpu,提高任务执行效率,让单个任务职责更加清晰,代码更加优雅进而提高启动速度,我们会尽可能让这些工作并发进行。
但这些工作之间可能存在前后依赖的关系,我们又需要想办法保证他们执行顺序的正确性。
所以我们要做的工作是将任务颗粒化,定义好自己的任务,并描述它依赖的任务,将它添加到Project中。框架会自动并发有序地执行这些任务,并将执行的结果抛出来。
那么怎样对任务进行分类呢?
任务进行分类策阅可以通过Alpha等启动器把启动任务管理起来。具体分为四个步骤: 第一个步骤是将启动任务原子化,分为各个任务。
第二个步骤是使用有向无环图管理启动任务。前后依赖的任务串行,无依赖的任务线程池化并行。优先级高的任务在前,优先级低的任务在后。
第三个步骤是启动任务集中化,分任务区块:核心任务,主要任务,延迟任务,懒加载任务。核心任务在attachBaseContext中执行,主要任务在启动页或首页执行,延迟任务在首页后空闲时间执行,懒加载任务在特定的时机执行。
最后一个步骤是启动任务统计化,提供任务的耗时统计和卡口。
任务管理框架图参考如下:
任务分类管理与业务初始化时机有关,比如像热修复和网络等核心任务,需要优先初始化,推送、地图主要任务优先级比核心任务低。对于耗时长任务,不但要落库而且要借助QA机器人进行监测告警。
为了校验启动器优化体验正向还是负向的,提供稳定的降级方案并随时回归对照版本必不可少。
4.4.4 业务框架
说完调度框架,我们说一下业务框架,业务框架分为发现问题、问题问题协调、问题优化和优化效果验证四个方向
首先,问题发现方面,线下发现问题主要通过工具,如 Trace 工具发现主线程耗时严重问题,主线程锁等待问题等,Hook 工具发现主线程 I/O 问题,线程创建问题等;线上发现问题主要通过线上打点和实验防劣化机制;
然后,问题协同方面,架构组与业务沟通问题及技术方案,必要时协助其分析并优化,确认上线排期;
接着,问题优化方面,主要由业务来完成具体优化,如果涉及基础机制相关工作,则会由架构组来主导优化,在整个优化中,调度优化为主要优化方式,业务可快速接入调度机制实现优化。
最后,问题优化和优化效果验证方面,及时跟进问题修复情况,在发版前回归问题,跟进线上优化效果,有些优化需要平衡业务指标和性能指标,如果优化不及预期需继续协同优化。
总结下来就两个字: 闭环。整个团队开发过程中,大家都是在明确职责边界情况下,做自己可控的任务。无论优化结果正向还是负向,要有始有终,让领导和团队觉的你是一个靠谱的人。
五、总结与展望
浅析Android启动优化主要说了四部分内容,第一部分内容是启动基础,第二部分内容是启动优化价值,第三部分内容是启动优化业务痛点,第四部分内容是总结与展望。
第三部分内容主要分为四个方面,第一个方面是防劣化机制建设,第二个方面是高性能工具,第三个方面是调度框架,第四个方面是业务框架。
2022年企业对app
的启动性能做了更加苛刻的要求。经企业内部稳定性大数据平台分析,启动耗时每增长200ms
将带来5w
用户的留存缩减。
启动性能是app
使用体验的门面,启动过程耗时较长很可能导致用户流失,导致用户对公司产品兴趣骤减。
因此,启动性能优化成为了团队的重中之重的优化专项。而启动防劣化机制建设和锁监控是启动性能优化关键突破口。使用工具降低企业app启动速度是小木箱下一篇着重讲解的话题。
下一篇工具论会从上而下带大家揭秘常见启动优化工具。我是小木箱,我们下一篇见~
优质技术方案参考
- github.com/appium/appi…
- github.com/google/perf…
- github.com/bytedance/t…
- 开发必读:网易专家解读Android ABTest 框架设计
- GitHub - TJHello/ABTest: ABTest for Umeng
- Android SDK 集成(A/B Testing)
- Android ABTest 设计与原理
- hook 应用 trace 日志
- 百度App 低端机优化-启动性能优化(概述篇)
- 货拉拉用户端体验优化--启动优化篇
- MMKV for Android 多进程设计与实现
- 抖音 Android 性能优化系列:Java 锁优化
- 不可不说的Java“锁”事
- 火山引擎 A/B 测试的思考与实践