启动优化·基础论·浅析 Android 启动优化

【小木箱成长营】启动优化系列文章(排期中):

启动优化 · 工具论 · 启动优化常见的六种工具

启动优化 · 方法论 · 这样做启动优化时长降低 70%

启动优化 · 实战论 · 手把手教你破解启动优化十大难题

一、引言

Hello,我是小木箱,欢迎来到小木箱成长营系列教程,今天将分享启动优化·基础论·浅析 Android 启动优化。小木箱从四个维度将 Android 启动优化基础论解释清楚。

本文主要说了四部分内容,第一部分内容是启动基础,第二部分内容是启动优化价值,第三部分内容是启动优化业务痛点,第四部分内容是总结与展望。

启动优化业务痛点主要分为五个方面,第一个方面是业务问题背景,第二个方面是防劣化机制建设,第三个方面是优化思路,第四个方面是调度框架,第五个方面是业务框架。

如果学完小木箱成长营启动优化的基础论、工具论、方法论和实战论,那么任何人做启动优化都可以拿到结果。

二、启动基础

我们先进入第二部分内容启动基础,启动基础有六个关键点可以和大家分享一下,第一个关键点是启动进程。第二个关键点是启动方式。第三个关键点是启动流程。第四个关键点是归因分析。第五个关键点是优化方向。第六个关键点是启动指标。

alt

2.1 启动进程

alt

首先,小木箱说说第一个关键点启动进程,按照业务是否可直接操作分为 SystemServer 和 App Process 。 其职责划分如下:

SystemServer 负责应用的启动流程调度、进程的创建和管理、窗口的创建和管理(StartingWindow 和 AppWindow) 等

应用进程被 SystemServer 创建后,进行一系列的进程初始化、组件初始化(Activity、Service、ContentProvider、Broadcast)、主界面的构建、内容填充等

2.2 启动方式

接着,小木箱说说第二个关键点启动方式,Android 应用的启动方式大概分为热启动、冷启动、温启动三种,关于冷启动、热启动、温启动三者启动方式对比可以参考下面的流程图学习。

image.png

2.1.1 冷启动

冷启动具有耗时最多,衡量标准的特征,冷启动常见的场景是 APP 首次启动或 APP 被完全杀死,冷启动、热启动和温启动中冷启动 CPU 时间开销最大。启动流程简化如下,后文会详细介绍。

alt

2.1.2 温启动

当启动应用时,后台已有该应用的进程,但是 Activity 可能因为内存不足被回收。这样系统会从已有的进程中来启动这个 Activity,这个启动方式叫温启动。

温启动常见的场景有两种:第一种是首先用户按连续按返回退出了 app,最后重新启动 app,第二种是首先系统收回了 app 的内存,最后重新启动 app。

2.1.3 热启动

热启动只执行了冷启动的第二阶段,如果由于内存不足导致对象被回收,那么需要在热启动时重建对象,后面与冷启动时将界面显示到手机屏幕流程是一样的。

热启动时,系统将 activity 带回前台。如果应用程序的所有 activity 存在内存中,那么应用程序可以避免重复对象初始化、渲染、绘制操作。

热启动常见的场景如: 当我们按了 Home 键或其它情况 app 被切换到后台,再次启动 app 的过程。

2.3 启动流程

其次,小木箱说说第三个关键点启动流程,应用的启动流程一般指的是冷启动流程,即应用进程不存在的情况下,从点击桌面应用图标,到应用启动的过程。

image.png

首先,用户进行了一个点击操作,这个点击事件它会触发一个 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 三个流程。

alt

2.5 归因分析

其五,我们先说说第五个关键点归因分析,程序运行最根本的是需要得到CPU 时间片,如果一个任务需要较多的 CPU 时间执行,那么它将影响其他任务的执行,从而影响整体任务队列的运行;

线程切换涉及到 CPU 调度,而 CPU 调度会有系统资源的开销,所以大量的线程频繁切换也会产生巨大的性能损耗;

IO锁的等待会直接阻塞任务的执行,不能充分地利用 CPU 等系统资源。

alt

因此,做启动优化的关键点是找到占用过多 CPU 时间、频繁的 CPU 调度、I/O 等待和锁抢占等不合理消耗资源三个因素,这里先简单的看一下 Profile 分析文件,工具论会带大家详细学习如何使用工具进行线下监控与治理。

alt

alt

2.6 启动指标

最后,我们说说第六个关键点启动指标,关于启动优化监控当然是在冷启动阶段进行健康预测的。有三个启动指标我们需要额外注意,

第一个是启动开始,启动开始是进程创建的时间

第二个是启动结束,首页首屏渲染完成的时间;

第三个是启动时长,启动时长是指启动结束的时间戳减去启动开始的时间戳;

三、启动优化价值

说完启动基础,我们进入第三部分内容启动优化价值,启动耗时增长可能缩减 App 用户的留存,因此,启动性能优化是每一家互联网公司在体验优化方向上必须要做的关键技术突破。

启动性能优化目标是以低端机为重点,辐射中高端机,通过技术和产品上的深度优化,可感知的提升用户体验,实现扩大用户规模、提升留存和提升收入。

四、启动优化业务痛点

说完启动优化价值,我们进入第四部分内容启动优化业务痛点。带着问题出发,关于启动优化有低端机性能问题复杂、缺乏整体调度机制、问题定位成本高和监控机制不完善四大业务痛点亟需解决。关于这四大业务痛点我们对如何定义低端机?如何快速发现性能问题? 如何系统化的优化性能问题?如何避免性能优化的同时出现劣化问题,并打造劣化问题修复自运转的飞轮?有了进一步思考。

4.1 如何定义低端机?

传送门: http://www.jianshu.com/p/76d68d13c475

关于如何定义低端机?低端机定义要双端对齐思考

Android

Android 方面,内存和 CPU 是描述低端机型的比较关键的两个指标,我们根据 Android 用户的不同设备做了性能划分,初步可划分为高、中、低 3 种等级。

根据现有 CPU、GPU 的跑分软件安兔兔公测跑分维护一份 CPU、GPU 的设备性能档位表,按照不同档位划分为高、中、低三档。

alt

先判断设备的 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;
            }

image.png

当没有取到 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[1]工具提效,自建稳定且高效性能工具,通过 DoKit 进行源码魔改,提高发现问题效率;慢函数闭环监控借助了 ASM 插桩打点实现,详细内容我们可以参考后续的启动优化 · 实战论 · 手把手教你破解启动优化十大难题。

4.4 如何避免性能优化的同时出现劣化问题,并打造劣化问题修复自运转的飞轮?

关于如何避免性能优化的同时出现劣化问题,并打造劣化问题修复自运转的飞轮?我们首先利用启动器将启动任务颗粒化,然后针对任务的时长统计上报,最后通过 Appium[2] 、Mockio、Hamcrest、UIAutomator等自动化测试架构进行测试,app 每个版本集成回归时,测试同学会在测试平台跑一遍性能测试并输出测试报告。

报告包含了定义的核心场景下,app 的内存、CPU、启动时长等性能指标数据及版本对比波动值。

在允许的波动范围内,比如启动时长波动<100ms,那么就认为测试通过,否则就认为数据有恶化趋势,测试不通过,需要研发排查优化,直到测试通过。

alt

当然未来希望借助已搭建云真机测试平台[3],可以考虑通过 Docker 容器化技术在云真机上自动化测试。测试平台直接自动化分析、自动化转发,全程自助,无人工干预。

下面我们一起来学习一下百度低端机启动性能优化观测设施、基础设施和业务优化吧。

alt

4.4.1 防劣化机制建设

百度的观测设施有三个关键点,第一个关键点是低端机标准建设,上文如何定义低端机已经提供了不错的解决方案。

第二个关键点是核心指标建设,设备类型、Android 系统版本号、手机品牌、启动时长、系统平台、app 版本号、app 名称、闪屏广告阻断次数和设备 ID 等字是我们常用的上报字段。

第三个关键点是防劣化机制建设,是本文的重中之重。客户端防劣化机制建设主要思考两个方向,第一个方向是线下防劣化。第二个方向是线上防劣化。

alt

线下防劣化

首先我们来说一下线下防劣化,关于线下防劣化主要分为四个部分,第一个部分是打包自动化。第二个部分是测试自动化。第三个部分是分析自动化。第四个部分是分发自动化。

alt

打包自动化,一般中大型的互联网公司都有做,如阿里的摩天轮、美团的 MCI 和货拉拉的 MDAP 等等。打包自动化是客户端持续化部署关键一步,通过打包自动化的方式方便我们严控开发版本权限,降低发版风险。

测试自动化,通过 Docker 镜像实现快速部署和迁移 Appium 自动化测试框架,执行定制化 case 实现 app 在云真机启动过程中进行自动化测试。货拉拉好像也在做这件事,目前云真机平台建设有了基础雏形。

分析自动化,Android 端上建议参考字节跳动的btrace[4]。我们可以利用该工具记录事件的 CPU 执行时间开销,写到本地日志后上传分析平台,这样会更方便排查问题。

alt

反混淆解析首先可以参考 progard 的 mapping.txt 文件,然后再通过retrace.sh -verbose mapping.txt obfuscated_trace.txt 进行反混淆,最后将obfuscated_trace.txt 透传给测试平台进行渲染即可。

alt

分发自动化,首先通过脚本对劣化问题进行去重、置信度过滤,然后通过分发服务进行问题归属定位,最后通过企业办公软件 QA 机器人分发。

线上防劣化

线上防劣化主要分为两种,第一种是实验防劣化,第二种是函数级防劣化。

实验防劣化

关于实验防劣化,如果云真机测试平台搭建好了,首先可以通过自动化,启动录制视频,然后将视频分帧,通过算法筛选出点击帧和渲染完成帧。最后检测跑多次数据,关注波动曲线和平均值。

alt

alt

函数级防劣化

关于函数防劣化,用的是 ASM 字节码插桩技术,启动耗时统计目前有两种方法,即线上统计和线下监测,线上统计是指通过分析统计所有手机的耗时情况,求取每个应用的不同启动时间段占比。

alt

一定程度可以反应每个版本启动耗时情况,可以针对不同版本差异化代码进行优化排查。

线下监测是指利用 adb 命令或 Systrace 工具在严格控制的环境下监控应用,该方案存在明显的不足:无法精确到每个函数级别,统计过程会比较复杂,数据也不够直观。

工程编译过程的角度出发,拦截 Android 构建由 Class 字节码文件转换成 Dex 文件的过程,因为每个字节码文件都有来源路径,如果在当前字节码文件内容中能够检测出符合命中策略的指令,我们就可以知道当前的指令所处文件名,调用位置、文件路径,进行插入日志信息,首先我们使用的是自定义注解 Anotaton 方式处理我们特殊的字节码来源,然后根据来源进行周期性拦截,并统计代码执行周期内耗时信息。

alt

最后,我们给每一个启动执行周期设置一个卡口时间,如果超出卡口时间就证明测试是不通过的。并且输出每一个子函数的耗时长度。

alt

4.4.2 优化思路

关于优化思路,主要有两个方面,第一个方面是 SharedPreferences 优化,第二个方面是锁优化。

alt

SharedPreferences 优化

首先,我们来说一说 SharedPreferences 优化,为什么要对 SharedPreferences 进行优化呢?SharedPreferences 的缺点很明显,第一明文存储,第二多进程存储数据易丢失,第三效率低,IO 读写使用 xml 数据格式,全量更新效率低。

SharedPreferences 存储格式

我们首先来看一下 SharedPreferences 的数据存储和编码格式,SharedPreferences 数据存储和编码格式采用的是 Xml,明文存储,可读性强,数据冗余度较高。

alt

<?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 过时了。

alt

alt

SharedPreferences 在 MODE_MULTI_PROCESS 多进程模式下,读写数据可能出现数据丢失,具体可以参考一下下面的流程图和源码。

image.png

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 不支持多进程模式。多进程共享文件会出现问题的本质在于,不同进程磁盘读写,线程同步会失效。怎么理解呢?

image.png

异步提交过程中,如果此刻 SharePreference 正在 IO 磁盘文件,但用户退出当前进程,数据没有及时更新文件,提交操作却提前打断,那么数据就丢失。

要解决 Sharepreferences 数据丢失问题,我们可以采用跨进程方案,如 ContentProvider、AIDL、Service。但 ContentProvider、AIDL、Service 操作文件有点大才小用,我们可以考虑 MMKV 或 DataStore。

SharedPreferences IO 磁盘读写

虚拟内存被操作系统划分成两块:用户空间和内核空间,用户空间是用户程序代码运行的地方,内核空间是内核代码运行的地方。为了安全,用户空间和内核空间是隔离的,即使用户的程序崩溃了,内核也不受影响。

image.png

那么, IO 读取数据为什么会造成数据不同步呢?以用户修改文件为例,如果是 IO 读取文件遵循下面的流程,首先调用 write,告诉内核需要写入数据的开始地址与长度,然后内核将数据拷贝到内核缓存,最后由操作系统调用,将数据拷贝到磁盘,完成写入。

image.png

如果用户对 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,会在进程的虚拟内存分配地址空间,创建映射关系。实现这样的映射关系后,就可以采用指针的方式读写操作这一段内存,而系统会自动回写到对应的文件磁盘上。

alt

问题二: mmap 相对于 IO 磁盘读写有什么优点?

第一,mmap 对文件的读写操作只需要从磁盘到用户主存的一次数据拷贝过程,减少了数据的拷贝次数,提高了文件读写效率,mmap 读写操作可以看一下下面的图。

image.png

第二,mmap 使用逻辑内存对磁盘文件进行映射,操作内存就相当于操作文件,不需要开启线程,操作 mmap 的速度和操作内存的速度一样快

第三,mmap 提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统如内存不足、进程退出等时候负责将内存回写到文件,不必担心 crash 导致数据丢失

下面来对比一下 SharedPreferences 和 MMKV 同时存储 1000 条数据的耗时:

alt

alt

因为 MMKV 和 SharedPreferences 都是从 map 里面读数据,所以读取速度相差不大。因为 mmap 内存写文件 0 次拷贝,SharedPreferences IO 磁盘写文件多次拷贝,所以 MMKV 的写入速度优于 SharedPreferences。

问题三: mmap 映射的内存到磁盘的时机是什么时候?

mmap 映射的内存到磁盘的时机有四个,第一个是主动调用 msync,第二个是 mmap 解除映射,第三个是进程退出,第四个是系统关机。

问题四: 如何更深入的学习 mmap?

如果想深入的学习 mmap 的实践,那么小木箱推荐大家看一下微信的 Mars[5]美团的 Logan[6]

MMKV 数据存储和编码格式

数据存储和编码格式方面,mmkv 采用 protobuf,protobuf 数据紧凑,非明文存储。protobuf 底层是基于二进制保存的,保存文件非常小,解析速度更快! protobuf 相比 XML、JSON、Lua 有如下优势:

image.png

protobuf数据结构

protobuf 底层是基于二进制保存的,保存文件非常小,解析速度更快!

MMKV 默认把文件存放在 $(FilesDir)/mmkv/ 目录, 我们用 010Editor 看到 protobuf 映射文件二进制存储格式如下:

alt

前 4 个字节表示整个 protobuf 映射文件中数据的有效长度。那么为什么需要有效长度?

其实,因为 mmap 内存映射的时候,文件大小必须是 4096(这个数字与与操作系统位数有关)或者其整数倍,所以并非整个文件内容都是有效数据,需要用有效数据长度来标明有效数据。

从第五个字节开始,依次为k1长-->k1值-->v1长-->v1值-->k2长->k2值-->v2长-->V2值...>v2值-->

alt

那么问题来了,protobuf 是怎么做到数据紧凑的呢?我们来了解一下 protobuf 的解码规则:

protobuf解码规则

我们以整型数据来说明,每一个字节的首位作标志位,标志位为1,则表示该字节无法完整表示数据,需要更多的字节;标志位为 0,则表示该字节已经是表示该数据的最后一个字节,该数据的的读取到该字节为止。每一个字节的后七位保存数据。

<=0x7f的 10 进制表示为127,2 进制表示为0111111111。如果写入的数据<=0x7f,那么一个字节的七个数据位足够表示这个数据,则字节首位置 0,后七位写入数据。

如果写入的数据>0x7,那么一个字节的七个数据位不足以表示这个数据,则字节首位置 11 后七位写入数据,并将原数右移 7 位,继续执行判断。

image.png

protobuf 解码案例分析

编码128, 即10000000

image.png

读取 128 的 protobuf 编码, 即 1000 000000000 000

image.png
image.png

编码 318242,即 00000100110110110010 0010

image.png

318242 的protobuf解码

image.png

综上所述,普通方式存储是定长的,而 protobuf 存储方式是变长的。所以,在多数情况下,protobuf 的存储方式,会使得数据更小。protobuf 的数据格式特征解释了为什么 MMKV 比 SharedPreferences 的性能和大小更有优势。

MMKV 增量修改

好了,小木箱介绍完了 MMKV 数据在文件中的存储结构,这种结构如何实现增量修改的呢?

原因是 MMKV 在内存中是一个 map 表结构。

完成首次 mmap 映射系的建立后,之后再次写入一个同名 key 的键值对,该键值对直接存储在映射文件的末尾,并修改有效长度。

当映射关系断开后重新建立映射关系的时候,旧的键值对先写入 map 表,新的键值对后读入,将会覆盖掉旧的键值对,也就实现了增量修改。

MMKV 是在末尾追加新数据,重复数据不进行覆盖,当要发生扩容时进行数据重整。

MMKV 多进程操作

Linux 中多进程锁通常考虑 pthread_mutex,创建于共享内存的 pthread_mutex 是可以用作进程锁的。

但是 Android 版本的健壮性不足,进程被 kill 时并不会释放锁,导致其他进程一直阻塞。

所以 mmkv 采用的是文件锁进行多进程同步,但是文件锁存在两个问题:

第一个问题是不支持递归加锁:因为文件锁是状态锁,没有计数器,无论加了多少次锁,一个解锁操作就全解掉。只要用到子函数,就非常需要递归锁。

第二个问题是不支持读写锁升级/降级:读锁升级为写锁,写锁可以降级为读锁。对于读锁,我们允许多进程访问,写锁则不允许多进程访问。所以 mmkv 增加了读写计数器以此支持这个功能,增加 CRC 文件校验。

具体逻辑:

alt

  1. 保证每一个文件存储的数据都比较小,也就说需要把数据根据业务线存储分散。内存消耗不会过快,数据不用了可以进行释放。
  1. 还需要在内存不足的时候释放一部分内存数据,比如在 App 中监听 onTrimMemory 方法,在 Java 内存吃紧的情况下进行 MMKV 的 trim 操作。
  1. 在不需要使用的时候,最好把 MMKV 给 close 掉。

MMKV 与 SharedPreferences 优缺点比较

写了这么多,小木箱首先简单的总结一下 SharedPreferences 和 MMKV 的存储格式、优点和缺点。最后再通过实验分析验证一下结论。

image.png

MMKV 与 SharedPreferences 性能测试

下面进入我们的 MMKV 与 SharedPreferences 性能测试环节。读写性能方面,无论是 ios 还是 android 上,MMKV 的表现均优于 SharedPreferences。iOS 的 MMKV 和 NSUserDefaults 进行对比,重复读写操作 1w 次。性能比较数据如下:

alt

Android,MMKV 和 SharedPreferences、SQLite 进行对比, 重复读写操作 1k 次。结果如下图表。

alt

Android,MMKV 和 SharedPreferences、SQLite 进行对比, 多进程操作性能如下图表。

alt

MMKV 无论是在写入性能还是在读取性能,都远远超越 MultiProcessSharedPreferences & SQLite & SQLite, 基于 SharedPreference 以上缺陷,小木箱的团队要放弃 SharedPreferences 使用。

MMKV 二次开发与迭代

尽管 MMKV 已经足够优秀,但是美中不足是不支持强类型、和 SharedPreferences 接口无法对齐,国内能兼顾这两个优势的数据存储模型的只有 Booster 和 UniKV。但相比 MMKV,Booster 有点相形见绌了,因为 Booster 不支持多进程而且线程优化个数表现不佳。在百度 App 低端机优化-启动性能优化(概述篇)[7]一文中,UniKV,突破系统限制,彻底解决原生 SharedPreferences 有首次读取性能差、创建线程多、卡顿/ANR、多进程支持差等缺点,实现流程图大概如下:

alt

alt

UniKV 是闭源的,从 SDK 研发到落地上线,百度应该踩了不少坑,未来小木箱希望可以开发一套方便从 MMKV 切换到 SharedPreferences 的开源工具,SharedPreferences0 风险替换 MMKV。因为篇幅有限,关于 MMKV 的改造提效,可以参考后续文章 架构优化· 框架论 · 什么! 从 SharedPreferences 过渡 MMKV,线上崩溃率提高 3%?[8]

锁优化

说完 SharedPreferences 优化,我们进入优化思路的第二个环节锁优化, 学习锁优化之前,简单的和大家过一下 Java 的主流锁。

alt

Java 主流锁

Java 的主流锁一共有六个问题需要搞清楚,搞清楚这 6 个问题,Java 主流锁基础知识掌握的也差不多了。

问题一: 线程要不要锁住同步资源?

从线程是否需要同步角度出发,我们把锁分为两种。第一种是悲观锁,第二种是乐观锁。synchronized、Lock 实现类都是悲观锁,作用在于其他线程访问数据的时候,保护数据不会被其他线程修改。很好理解这个概念,只有患得患失,才给自己数据设置修改权限。患得患失的锁普遍悲观。

乐观锁就不一样了,乐观锁比较开放,唯我独尊,认为没人敢乱动自己的数据,所以从不给自己的数据加锁。

只是别的线程访问自己的数据的时候,判断一下有没有偷偷更新自己的数据,如果数据没有被更新,那么当前线程将自己修改的数据成功写入。

如果数据已经被别的线程更新,那么根据不同的实现方式执行抛出异常或者自动重试操作。

alt

那么乐观锁在 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 来实现了乐观锁。

alt

image.png

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;
}

alt

image.png

CAS 虽然很高效,但是 CAS 有三大缺陷:

alt

  • 缺陷一: 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 底层引入自旋锁,自旋锁九字真言就是循环加锁 -> 等待的机制,指的是当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会阻塞,间隔一段时间后会再次尝试获取。具体流程参考如下:

image.png

自旋锁的优点在于减少CPU切换以及恢复现场导致的消耗。

alt

自旋锁缺点是不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。

自旋锁实现原理是 CAS,AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

alt

在自旋锁,中另有三种常见的锁形式:TicketLock、CLHlock 和 MCSlock

问题三: 多个线程同步竞争资源的流程细节有没有区别?

说完自旋锁,按照多个线程同步竞争资源的流程细节有没有区别,小木箱把锁划分为四类,第一类是无锁,第二类是偏向锁,第三类是轻量级锁,第四类是重量级锁。

这四种锁是指锁的状态,专门针对 synchronized 的。

那么 Synchronized 底层的锁优化机制是怎样的呢?

alt

Synchronized 底层的锁优化机制一张图可以解释原理。下面小木箱基于下面这张图详细的和大家聊一下 Synchronized。

alt

说到 Synchronized 底层锁优化机制,我们不得不提到两个概念,Java 头对象和 Monitor。

我们以 Hotspot 虚拟机为例,Hotspot 的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

alt

Mark Word 主要存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息。而 Klass Pointer 主要指的是指针指向它的类元数据的指针。

alt

为了探索锁的升级和降级过程,小木箱使用 Maven 引入 openjdk,小木箱打印 ClassLayout.parseInstance().toPrintable()方法可以看到 object header 参数即 Java 头对象。

image.png

我们可以通过-XX:+UseCompressedOops 打开指针压缩,普通对象压缩后对象头结构为:

image.png

我们也可以通过- -XX:-UseCompressedOops 打开指针压缩,普通对象对象头结构为:

image.png

说完对象头,小木箱再说说 Mark Word(标记字段),Mark Word(标记字段)主要用来存储对象自身的运行时数据,如 hashcode、gc 分代年龄等。Mark Word 的位长度为 JVM 的一个 Word 大小。Mark Word 锁状态可以参考如下图:

alt

小木箱通过内存信息分析锁状态如下:

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 访问方式主要有两种:句柄池和直接指针访问。

句柄池访问方式如下:

alt

直接指针访问方式如下:

alt

最后,小木箱整体对比一下句柄访问指针访问的差异 。

image.png

说完 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 同步队列协作流程可以参考下图:

alt

我们可以看出 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 等待队列中,等待被唤醒。锁的完整执行流程可以看一下下图:

image.png

Monitor 整体过程可以从竞争、等待、释放和唤醒四个维度分析。首先我们说一下竞争过程

image.png

然后我们说一下,等待过程:

image.png

接着我们说一下释放过程,当某个持有锁的线程执行完同步代码块时,会释放锁并 unpark 后续线程,这就是释放过程。

最后我们说一下唤醒过程,notify 或者 notifyAll 方法可以唤醒同一个锁监视器下调用 wait 挂起的线程,这就是唤醒过程。

源码就不带大家过了,感兴趣的可以看一下小米的synchronized 实现原理[9]

alt

总体上来说这四种锁状态升级流程如下:

alt

无锁指的是不锁住资源,多个线程中只有一个能修改资源成功,其他线程会重试。

偏向锁指的是同一个线程执行同步资源时自动获取资源。 持有偏向锁的线程以后每次进入这个锁相关的同步块时,只需比对一下 mark word 的线程 id 是否为本线程,如果是则获取锁成功。如线程访问同步代码并获取锁的处理流程如下:

image.png

那么如果获取锁之后,发生线程竞争的情况则如何撤销偏向锁呢?

image.png

多个线程竞争偏向锁导致偏向锁升级为轻量级锁,轻量级锁指的是多个线程竞争同步资源时,没有获取资源的线程自旋等待锁释放。下面我们来看一下轻量级锁的加锁过程。

image.png

说完加锁过程,我们再来看一下,轻量级锁的解锁过程。

image.png

重量级锁指的是多个线程竞争同步资源时,没有获取资源的线程阻塞等待唤。

synchronized 关键字及 waitnotifynotifyAll 这三个方法都是管程的组成部分。可以说管程就是一把解决并发问题的万能钥匙。有两大核心问题管程都是能够解决的:

alt

synchronizedmonitor锁机制和 JDK 并发包中的 AQS 是很相似的,只不过 AQS 中是一个同步队列多个等待队列。熟悉 AQS 的同学可以拿来做个对比。

说了这么多,可能大家还是有点乱,偏向锁、轻量级锁、重量级锁概念和优缺点是什么,小木箱简单的给大家总结一下:

image.png

问题四: 多个线程竞争锁的同时要不要排队?

说完锁的升级过程,我们来探讨一下多个线程竞争锁的同时要不要排队,根据这个问题我们把锁分为两类,第一类是公平锁,第二类是非公平锁。公平锁和非公平锁的概念、优点和缺点可以参考下面的图对比。

image.png

关于公平锁我们可以看一下下图进行图形化理解:

alt

关于非公平锁我们可以看一下下图进行图形化理解:

alt

ReentrantLock 里面有一个内部类 Sync,Sync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。ReentrantLock 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。ReentrantLock 默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

alt

我们对比一下公平锁和非公平锁的源码,公平锁比非公平锁多了一个 hasQueuedPredecessors()判断条件。

alt

进入 hasQueuedPredecessors(),可以看到 hasQueuedPredecessors()方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回 true,否则返回 false。

alt

综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

公平锁和非公平锁的区别是公平锁如果在当前线程不是拥有有锁的线程时,就不能添加锁。所以公平锁和非公平锁添加的都是独享锁。独享锁我们在问题六详细了解。

问题五: 一个线程能不能获取同一把锁?

说到一个线程能不能获取同一把锁,我们不得不提到可重入锁和非可重入锁,可重入锁和非可重入锁区别可以看看下面的表格

image.png

  • 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();
   }
}

关于可重入锁我们可以看一下下图进行图形化理解:

alt

关于非可重入锁我们可以看一下下图进行图形化理解:

alt

为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?

ReentrantLock 和 NonReentrantLock 都继承父类 AQS。

alt

其父类 AQS 中维护了一个同步状态 status 来计数重入次数,status 初始值为 0。当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果 status == 0 表示没有其他线程在执行同步代码,则把 status 置为 1,当前线程开始执行。

alt

如果 status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行 status+1,且当前线程可以再次获取锁。

alt

非可重入锁是直接去获取并尝试更新当前 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 的源码来对比分析为什么非可重入锁在重复调用同步资源时会出现死锁。

alt

问题六: 多个线程能不能共享同一把锁?

在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。

根据多个线程能不能共享同一把锁,我们把锁分为独享锁和共享锁,独享锁和共享锁的区别可以参考下面的表格:

image.png

首先我们来说一下共享锁 ReentrantReadWriteLock 有两把锁:ReadLock(读锁)和 WriteLock(写锁)。

ReadLock 和 WriteLock 是靠内部类 Lock 实现的锁,而 Lock 是 Sync 的实现接口。因此,ReadLock 和 WriteLock 是靠内部类 Sync 实现的锁。详细代码如下:

alt

在 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[10]源码可以看到,锁的开始和锁的结束,分别调用ATRACE_BEGIN(...)ATRACE_END(),那么ATRACE_BEGIN(...)ATRACE_END()方法作用是什么呢?

我们通过trace-dev[11]源码可以看到ATRACE_BEGIN(...)ATRACE_END() 的实现,ATRACE_BEGIN(...)ATRACE_END(...)底层使用 write 将字符串写入一个特殊的 atrace_marker_fd

alt
alt

线上 HookATRACE_BEGIN(...)ATRACE_END()两个点位,ATRACE_END()时间戳减去ATRACE_BEGIN(...)时间戳得到的是持锁时长。

alt
alt

因此通过 hook libcutils。so 的 write 方法,并按 atrace_marker_fd 过滤,就实现了对 ATRACE_BEGIN(...)ATRACE_END() 的拦截,计算出阻塞时长,解析 monitor contention with owner... 日志可以监控到线上用户的锁问题。

Native 的 Hook 方案推荐使用爱奇艺的 XHook,XHook 我们可以看一下 利用 xhook 安卓系统底层抹机原理[12] 文章进行学习。

然后我们说一下锁的等待阶段,其实就是获取 Java 调用栈,那么怎么获取 Java 调用栈呢?可以使用Thread.getStackTrace()方法。

异步获取堆栈,在 MonitorEnter 的时候通知子线程 5ms 之后抓取堆栈,MonitorExit 计算阻塞时长,并结合堆栈数据一起放入队列,等待上报 APM 监控平台。如果 MonitorExit 时不满足指定的阈值,那么取消抓栈和上报。

alt

当然,因为锁监控会批量写日志加上 Hook 方案本身就有一定性能开销,所以仅对灰度测试中的部分用户开启了锁监控。字节的 PRD 方案如下:

alt

我们能看到设备信息、阻塞时长、调用堆栈等信息。

alt

这样,可以通过日志很快定位到所有超过阈值持有时长的锁并进行相应的优化。

alt

至此关于 Java 锁监控思路讲解完毕,待工具链上线后我们需要灰度一部分用户进行稳定性测试。为了提高扩展性, 业务可以根据场景开启和关闭采集功能,也可以收集指定时间内的锁,比如启动阶段可以收集 32ms 的锁,其它阶段收集 16ms 的锁。为了防劣化,当锁占用时长总数量采集超标,预发步环境利用 QA 机器人进行预警。

4.4.3 调度框架

说完优化思路,我们再说一下调度框架,对于大型 App 来说,启动任务多,任务依赖复杂。保障任务逻辑的单一性,解耦启动任务逻辑,合理利用多核 CPU 优势,提高线程运行效率是重点关注的问题。

为了利用多核 cpu,提高任务执行效率,让单个任务职责更加清晰,代码更加优雅进而提高启动速度,我们会尽可能让这些工作并发进行。

但这些工作之间可能存在前后依赖的关系,我们又需要想办法保证他们执行顺序的正确性。

所以我们要做的工作是将任务颗粒化,定义好自己的任务,并描述它依赖的任务,将它添加到 Project 中。框架会自动并发有序地执行这些任务,并将执行的结果抛出来。

那么怎样对任务进行分类呢?

任务进行分类策阅可以通过 Alpha 等启动器把启动任务管理起来。具体分为四个步骤: 第一个步骤是将启动任务原子化,分为各个任务。

第二个步骤是使用有向无环图管理启动任务。前后依赖的任务串行,无依赖的任务线程池化并行。优先级高的任务在前,优先级低的任务在后。

第三个步骤是启动任务集中化,分任务区块:核心任务,主要任务,延迟任务,懒加载任务。核心任务在 attachBaseContext 中执行,主要任务在启动页或首页执行,延迟任务在首页后空闲时间执行,懒加载任务在特定的时机执行。

最后一个步骤是启动任务统计化,提供任务的耗时统计和卡口。

任务管理框架图参考如下:

image.png

任务分类管理与业务初始化时机有关,比如像热修复和网络等核心任务,需要优先初始化,推送、地图主要任务优先级比核心任务低。对于耗时长任务,不但要落库而且要借助 QA 机器人进行监测告警。

为了校验启动器优化体验正向还是负向的,提供稳定的降级方案并随时回归对照版本必不可少。

4.4.4 业务框架

说完调度框架,我们说一下业务框架,业务框架分为发现问题、问题问题协调、问题优化和优化效果验证四个方向

alt

首先,问题发现方面,线下发现问题主要通过工具,如 Trace 工具发现主线程耗时严重问题,主线程锁等待问题等,Hook 工具发现主线程 I/O 问题,线程创建问题等;线上发现问题主要通过线上打点和实验防劣化机制;

然后,问题协同方面,架构组与业务沟通问题及技术方案,必要时协助其分析并优化,确认上线排期;

接着,问题优化方面,主要由业务来完成具体优化,如果涉及基础机制相关工作,则会由架构组来主导优化,在整个优化中,调度优化为主要优化方式,业务可快速接入调度机制实现优化。

最后,问题优化和优化效果验证方面,及时跟进问题修复情况,在发版前回归问题,跟进线上优化效果,有些优化需要平衡业务指标和性能指标,如果优化不及预期需继续协同优化。

总结下来就两个字: 闭环。整个团队开发过程中,大家都是在明确职责边界情况下,做自己可控的任务。无论优化结果正向还是负向,要有始有终,让领导和团队觉的你是一个靠谱的人。

五、总结与展望

浅析 Android 启动优化主要说了四部分内容,第一部分内容是启动基础,第二部分内容是启动优化价值,第三部分内容是启动优化业务痛点,第四部分内容是总结与展望。

第三部分内容主要分为四个方面,第一个方面是防劣化机制建设,第二个方面是高性能工具,第三个方面是调度框架,第四个方面是业务框架。

2022 年企业对app的启动性能做了更加苛刻的要求。经企业内部稳定性大数据平台分析,启动耗时每增长200ms将带来5w用户的留存缩减。

启动性能是app使用体验的门面,启动过程耗时较长很可能导致用户流失,导致用户对公司产品兴趣骤减。

因此,启动性能优化成为了团队的重中之重的优化专项。而启动防劣化机制建设和锁监控是启动性能优化关键突破口。使用工具降低企业 app 启动速度是小木箱下一篇着重讲解的话题。

下一篇工具论会从上而下带大家揭秘常见启动优化工具。我是小木箱,我们下一篇见~

优质技术方案参考

  • https://github.com/appium/appium-desktop
  • https://github.com/google/perfetto
  • https://github.com/bytedance/tailor
  • 开发必读:网易专家解读 Android ABTest 框架设计 [13]
  • GitHub - TJHello/ABTest: ABTest for Umeng [14]
  • [Android SDK 集成(A/B Testing)](https://manual.sensorsdata.cn/abtesting/latest/android-sdk-a-b-testing-92635404.html "Android SDK 集成(A/B Testing "Android SDK 集成(A/B Testing)")")
  • Android ABTest 设计与原理 [15]
  • hook 应用 trace 日志 [16]
  • 百度 App 低端机优化-启动性能优化(概述篇) [17]
  • 货拉拉用户端体验优化--启动优化篇 [18]
  • MMKV for Android 多进程设计与实现 [19]
  • 抖音 Android 性能优化系列:Java 锁优化
  • 不可不说的 Java“锁”事 [20]
  • 火山引擎 A/B 测试的思考与实践 [21]

参考资料

[1]

Dokit: https://github.com/didi/DoKit

[2]

Appium: https://appium.io/docs/cn/about-appium/intro/

[3]

搭建云真机测试平台: https://juejin.cn/post/7179864538383122491

[4]

btrace: https://github.com/bytedance/btrace

[5]

微信的 Mars: https://mp.weixin.qq.com/s/JVsVrKwJlOwoB3Rz0e17wQ

[6]

美团的 Logan: https://mp.weixin.qq.com/s/BAcB_LQ1Nr00Y7RxjRDK1g

[7]

百度 App 低端机优化-启动性能优化(概述篇): https://xie.infoq.cn/article/355a9ab1da53078fe41adb11d

[8]

架构优化· 框架论 · 什么! 从 SharedPreferences 过渡 MMKV,线上崩溃率提高 3%?: https://swe84nm8d7.feishu.cn/wiki/wikcntKfdiFg6ayBVmXIGUiJEph

[9]

synchronized 实现原理: https://xiaomi-info.github.io/2020/03/24/synchronized/

[10]

monitor.cc: https://cs.android.com/android/platform/superproject/+/android-9.0.0_r53:art/runtime/monitor.cc

[11]

trace-dev: https://cs.android.com/android/platform/superproject/+/master:system/core/libcutils/trace-dev.cpp;l=19;drc=c0c3882c1e411c9769c4645d07a24eeac9490f27?q=trace-dev&sq=&ss=android%2Fplatform%2Fsuperproject

[12]

利用 xhook 安卓系统底层抹机原理: https://bbs.pediy.com/thread-265351.htm

[13]

开发必读:网易专家解读 Android ABTest 框架设计: https://juejin.cn/post/6844903807550242823

[14]

GitHub - TJHello/ABTest: ABTest for Umeng: https://github.com/TJHello/ABTest

[15]

Android ABTest 设计与原理: https://www.jianshu.com/p/4e7a271cd54a

[16]

hook 应用 trace 日志: https://juejin.cn/post/6933021276554395656

[17]

百度 App 低端机优化-启动性能优化(概述篇): https://xie.infoq.cn/article/355a9ab1da53078fe41adb11d

[18]

货拉拉用户端体验优化--启动优化篇: https://juejin.cn/post/7087773731061235743

[19]

MMKV for Android 多进程设计与实现: https://github.com/Tencent/MMKV/wiki/android_ipc

[20]

不可不说的 Java“锁”事: https://tech.meituan.com/2018/11/15/java-lock.html

[21]

火山引擎 A/B 测试的思考与实践: https://juejin.cn/post/7007244097664745502

本文由 mdnice 多平台发布

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值