关于程序员:启动优化基础论浅析-Android-启动优化

3次阅读

共计 42186 个字符,预计需要花费 106 分钟才能阅读完成。

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

启动优化 · 工具论 · 启动优化常见的六种工具(待更~)

启动优化 · 方法论 · 这样做启动优化时长升高 70%(待更~)

启动优化 · 实战论 · 手把手教你破解启动优化十大难题(待更~)

一、引言

Hello,我是小木箱,欢送来到小木箱成长营系列教程,明天将分享启动优化·根底论·浅析 Android 启动优化。小木箱从四个维度将 Android 启动优化根底论解释分明。

本文次要说了四局部内容,第一局部内容是启动根底,第二局部内容是启动优化价值,第三局部内容是启动优化业务痛点,第四局部内容是总结与瞻望。

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

如果学完小木箱成长营启动优化的根底论、工具论、方法论和实战论,那么任何人做启动优化都能够拿到后果。

二、启动根底

咱们先进入第二局部内容启动根底,启动根底有六个关键点能够和大家分享一下,第一个关键点是启动过程。第二个关键点是启动形式。第三个关键点是启动流程。第四个关键点是归因剖析。第五个关键点是优化方向。第六个关键点是启动指标。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/53dc606aa12c4a63bf3d04fd1d7c7ce0~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

2.1 启动过程

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/689a7cb02849412ba2581a7b09c34333~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

SystemServer 负责利用的启动流程调度、过程的创立和治理、窗口的创立和治理(StartingWindow 和 AppWindow) 等

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

2.2 启动形式

接着,小木箱说说第二个关键点启动形式,Android 利用的启动形式大略分为热启动、冷启动、温启动三种,对于冷启动、热启动、温启动三者启动形式比照能够参考上面的流程图学习。

<p align=center><img src=”https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/14d07fcc54cc49bab1cde4f309f1372c~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

2.1.1 冷启动

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

<p align=center> <img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3993b475bd944f869626c3fbd55c3b8d~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /> </p>

2.1.2 温启动

当启动利用时,后盾已有该利用的过程,然而 Activity 可能因为内存不足被回收。这样零碎会从已有的过程中来启动这个 Activity,这个启动形式叫温启动。

温启动常见的场景有两种: 第一种是首先用户按间断按返回退出了 app,最初重新启动 app,第二种是首先零碎发出了 app 的内存,最初重新启动 app。

2.1.3 热启动

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

热启动时,零碎将 activity 带回前台。如果应用程序的所有 activity 存在内存中,那么应用程序能够防止反复对象初始化、渲染、绘制操作。

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

2.3 启动流程

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

<p align=center><img src=”https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ddd1671294f446b6927c8e58f225cc13~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8a15ba0f40f04ed989422ad72be1bd2f~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

2.5 归因剖析

其五,咱们先说说第五个关键点归因剖析,程序运行最基本的是须要失去 CPU 工夫 片,如果一个工作须要较多的 CPU 工夫执行,那么它将影响其余工作的执行,从而影响整体工作队列的运行;

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

IO 锁的期待 会间接阻塞工作的执行,不能充沛地利用 CPU 等系统资源。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d64978fa8c4a417db385287ed6b525af~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1052d5085cc6491297b8c17d302235c3~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f5a6338215674808a3b843bd19c29fc4~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

2.6 启动指标

最初,咱们说说第六个关键点启动指标,对于启动优化监控当然是在冷启动阶段进行衰弱预测的。有三个启动指标咱们须要额定留神,

第一个是启动开始,启动开始是过程创立的工夫

第二个是启动完结,首页首屏渲染实现的工夫;

第三个是启动时长,启动时长是指启动完结的工夫戳减去启动开始的工夫戳;

三、启动优化价值

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

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

四、启动优化业务痛点

说完启动优化价值,咱们进入第四局部内容启动优化业务痛点。带着问题登程,对于启动优化有低端机性能问题简单、不足整体调度机制、问题定位老本高和监控机制不欠缺四大业务痛点亟需解决。对于这四大业务痛点咱们对如何定义低端机?如何疾速发现性能问题?如何系统化的优化性能问题?如何防止性能优化的同时呈现劣化问题,并打造劣化问题修复自运行的飞轮?有了进一步思考。

4.1 如何定义低端机?

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

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

Android

Android 方面,内存和 CPU 是形容低端机型的比拟要害的两个指标,咱们依据 Android 用户的不同设施做了性能划分,初步可划分为高、中、低 3 种等级。

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a0d9c1ee94a240fc8e04491a8982a6b3~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center> <img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/52bd7c5930004aa98db5e599e538b32e~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /> </p>

当没有取到 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,那么就认为测试通过,否则就认为数据有恶化趋势,测试不通过,须要研发排查优化,直到测试通过。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/358ba99d73fc42b7a73c56752ae7c692~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

当然将来心愿借助已搭建云真机测试平台,能够思考通过 Docker 容器化技术在云真机上自动化测试。测试平台间接自动化剖析、自动化转发,全程自助,无人工干预。

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a27934a5d8e94f2986bf95af702f7347~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

4.4.1 防劣化机制建设

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

第二个关键点是外围指标建设,设施类型、Android 零碎版本号、手机品牌、启动时长、零碎平台、app 版本号、app 名称、闪屏广告阻断次数和设施 ID 等字是咱们罕用的上报字段。

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/49e0e33a40954c52b52ca5b7a8316461~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

线下防劣化

首先咱们来说一下线下防劣化,对于线下防劣化次要分为四个局部,第一个局部是打包自动化。第二个局部是测试自动化。第三个局部是剖析自动化。第四个局部是散发自动化。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e2feb490b24243039d755ee4dddcf1dd~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

打包自动化,个别中大型的互联网公司都有做,如阿里的摩天轮、美团的 MCI 和货拉拉的 MDAP 等等。打包自动化是客户端继续化部署要害一步,通过打包自动化的形式不便咱们严控开发版本权限,升高发版危险。

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

剖析自动化,Android 端上倡议参考字节跳动的 btrace。咱们能够利用该工具记录事件的 CPU 执行工夫开销,写到本地日志后上传剖析平台,这样会更不便排查问题。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/462b94d53a364fdc8e7d818369923674~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/924b211240064e41878907e7f07a2b41~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

线上防劣化

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

试验防劣化

对于试验防劣化,如果云真机测试平台搭建好了,首先能够通过自动化,启动录制视频,而后将视频分帧,通过算法筛选出点击帧和渲染实现帧。最初检测跑屡次数据,关注稳定曲线和平均值。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2f608b1a758b46f5a32b3bfc604bb4cf~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/15f9580498db4b98b25a46f72c8ab52d~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

函数级防劣化

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/988cc77cba984333a91b42f1607716d8~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

肯定水平能够反馈每个版本启动耗时状况,能够针对不同版本差异化代码进行优化排查。

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

工程编译过程的角度登程,拦挡 Android 构建由 Class 字节码文件转换成 Dex 文件的过程,因为每个字节码文件都有起源门路,如果在以后字节码文件内容中可能检测出合乎命中策略的指令,咱们就能够晓得以后的指令所处文件名,调用地位、文件门路,进行插入日志信息,首先咱们应用的是自定义注解 Anotaton 形式解决咱们非凡的字节码起源,而后依据起源进行周期性拦挡,并统计代码执行周期内耗时信息。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/631ef9186d2d4a998069cbe7a7d726c2~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/089e87af0d334d5abdc18c45288168d0~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

4.4.2 优化思路

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5253762fab804a18bd005fe8a6c2e971~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

SharedPreferences 优化

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

SharedPreferences 存储格局

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e063fcb3bd0f4961a6a0d54b0ad32d39~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/aa068f49648a4ed0853a339bdeaebfaf~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cfc3dae29ef94bcfb91f06415a504d66~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

SharedPreferences 在 MODE_MULTI_PROCESS 多过程模式下,读写数据可能呈现数据失落,具体能够参考一下上面的流程图和源码。

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e446dcac337b4da6babbf6b34fc64e7f~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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 不反对多过程模式。多过程共享文件会呈现问题的实质在于,不同过程磁盘读写,线程同步会生效。怎么了解呢?

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2d3aca860d664902bee921f8ffd6ad80~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

要解决 Sharepreferences 数据失落问题,咱们能够采纳跨过程计划,如 ContentProvider、AIDL、Service。但 ContentProvider、AIDL、Service 操作文件有点大才小用,咱们能够思考 MMKV 或 DataStore。

SharedPreferences IO 磁盘读写

虚拟内存被操作系统划分成两块: 用户空间和内核空间,用户空间是用户程序代码运行的中央,内核空间是内核代码运行的中央。为了平安,用户空间和内核空间是隔离的,即便用户的程序解体了,内核也不受影响。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/294e442186d5464482b56ebd8f71f2da~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/23ad0063af4a42b2a451624466766c67~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

如果用户对 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,会在过程的虚拟内存调配地址空间,创立映射关系。实现这样的映射关系后,就能够采纳指针的形式读写操作这一段内存,而零碎会主动回写到对应的文件磁盘上。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e964584a4fd543aea42a1b8721dfdb6f~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

问题二: mmap 绝对于 IO 磁盘读写有什么长处?

第一,mmap 对文件的读写操作只须要从磁盘到用户主存的一次数据拷贝过程,缩小了数据的拷贝次数,进步了文件读写效率,mmap 读写操作能够看一下上面的图。

<p align=center><img src=”https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a97137e4d68f4bbc9346a07379f5ed15~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

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

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2e0d647a4560476898ddcd47effb36bd~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/761a780ec1c9493ba8d05c87f11dbd45~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

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

mmap 映射的内存到磁盘的机会有四个,第一个是被动调用 msync,第二个是 mmap 解除映射,第三个是过程退出,第四个是零碎关机。

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

如果想深刻的学习 mmap 的实际,那么小木箱举荐大家看一下微信的 Mars、美团的 Logan。

MMKV 数据存储和编码格局

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

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c0784841178f4a92bb20df54de960d87~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

protobuf数据结构

protobuf 底层是基于二进制保留的,保留文件十分小,解析速度更快!

MMKV 默认把文件寄存在 &dollar;(FilesDir)/mmkv/ 目录, 咱们用 010Editor 看到 protobuf 映射文件二进制存储格局如下:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/84d469bd4d6543dcb5e2f2c63cdb592e~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

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

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/69a2322b2e7940548ba4c04ad98dd30f~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

protobuf解码规定

咱们以整型数据来阐明,每一个字节的首位作标记位,标记位为1,则示意该字节无奈残缺示意数据,须要更多的字节; 标记位为 0,则示意该字节曾经是示意该数据的最初一个字节,该数据的的读取到该字节为止。每一个字节的后七位保留数据。

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

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

<p align=center><img src=”https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e5c871e7beb7401dab2633585718f844~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

protobuf 解码案例剖析

编码128,即10000000

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7bdb536823f54097b22a6c3b21f85e36~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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


编码 318242,即 00000100110110110010 0010

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9517803e5925417e8383c3af0eee0bd8~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

318242 的 protobuf 解码

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/78102b96cc7a4383bb8e1fbc439294ac~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

综上所述,一般形式存储是定长的,而 protobuf 存储形式是变长的。所以,在少数状况下,protobuf 的存储形式,会使得数据更小。protobuf 的数据格式特色解释了为什么 MMKV 比 SharedPreferences 的性能和大小更有劣势。

MMKV 增量批改

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

起因是 MMKV 在内存中是一个 map 表构造。

实现首次 mmap 映射系的建设后,之后再次写入一个同名 key 的键值对,该键值对间接存储在映射文件的开端,并批改无效长度。

当映射关系断开后从新建设映射关系的时候,旧的键值对先写入 map 表,新的键值对后读入,将会笼罩掉旧的键值对,也就实现了增量批改。

MMKV 是在开端追加新数据,反复数据不进行笼罩,当要产生扩容时进行数据重整。

MMKV 多过程操作

Linux 中多过程锁通常思考 pthread_mutex,创立于共享内存的 pthread_mutex 是能够用作过程锁的。

然而 Android 版本的健壮性有余,过程被 kill 时并不会开释锁,导致其余过程始终阻塞。

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

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

第二个问题是不反对读写锁降级 / 降级:读锁降级为写锁,写锁能够降级为读锁。对于读锁,咱们容许多过程拜访,写锁则不容许多过程拜访。所以 mmkv 减少了读写计数器以此反对这个性能,减少 CRC 文件校验。

具体逻辑:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5cc79c6f6eb345d786c433feebbb1431~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

  1. 保障每一个文件存储的数据都比拟小,也就说须要把数据依据业务线存储扩散。内存耗费不会过快,数据不必了能够进行开释。

2) 还须要在内存不足的时候开释一部分内存数据,比方在 App 中监听 onTrimMemory 办法,在 Java 内存吃紧的状况下进行 MMKV 的 trim 操作。

  1. 在不须要应用的时候,最好把 MMKV 给 close 掉。

MMKV 与 SharedPreferences 优缺点比拟

写了这么多,小木箱首先简略的总结一下 SharedPreferences 和 MMKV 的存储格局、长处和毛病。最初再通过试验剖析验证一下论断。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d5b7d20adbc54256b0f32272d6cb4f48~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

MMKV 与 SharedPreferences 性能测试

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1f190904b9e541a9965e6da5265dff40~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c66baa6d398e4b5788ea1fd711bb8cbb~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/797527545c734a558d38f33f19fae943~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

MMKV 二次开发与迭代

只管 MMKV 曾经足够优良,然而美中不足是不反对强类型、和 SharedPreferences 接口无奈对齐,国内能兼顾这两个劣势的数据存储模型的只有 Booster 和 UniKV。但相比 MMKV,Booster 有点黯然失色了,因为 Booster 不反对多过程而且线程优化个数体现不佳。在百度 App 低端机优化 - 启动性能优化(概述篇)一文中,UniKV,冲破零碎限度,彻底解决原生 SharedPreferences 有首次读取性能差、创立线程多、卡顿 /ANR、多过程反对差等毛病,实现流程图大略如下:

<p align=left><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bfcc8ada39b24f67972aa6c2f4a95df7~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

<p align=left><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7c204ac82711413e9bd7c1b4f87cf282~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

UniKV 是闭源的,从 SDK 研发到落地上线,百度应该踩了不少坑,将来小木箱心愿能够开发一套不便从 MMKV 切换到 SharedPreferences 的开源工具,SharedPreferences0 危险替换 MMKV。因为篇幅无限,对于 MMKV 的革新提效,能够参考后续文章 架构优化· 框架论 · 什么! 从 SharedPreferences 过渡 MMKV,线上解体率进步 3%?

锁优化

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0d245567d81f4f628fd8b895ac3433b0~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

Java 支流锁

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

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

从线程是否须要同步角度登程,咱们把锁分为两种。第一种是乐观锁,第二种是乐观锁。synchronized、Lock 实现类都是乐观锁,作用在于其余线程拜访数据的时候,爱护数据不会被其余线程批改。很好了解这个概念,只有患得患失,才给本人数据设置批改权限。患得患失的锁广泛乐观。

乐观锁就不一样了,乐观锁比拟凋谢,唯我独尊,认为没人敢乱动本人的数据,所以从不给本人的数据加锁。

只是别的线程拜访本人的数据的时候,判断一下有没有偷偷更新本人的数据,如果数据没有被更新,那么以后线程将本人批改的数据胜利写入。

如果数据曾经被别的线程更新,那么依据不同的实现形式执行抛出异样或者主动重试操作。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fdd094e5d91e457da76a866362d28b45~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/14582c93b6b14170ab6588adb786ef2b~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

<p align=center> </p>
<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/216f517dc733466084eac8d71365ba98~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e695aac93fc942d7b58c69a33426a4b1~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3a646821dcdf46849876d3636d9ffc29~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

CAS 尽管很高效,然而 CAS 有三大缺点:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7a99698935774def80ed31a69c30e5ad~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fe0bfda48fd84ceaa36670a8a3e8654e~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>
自旋锁的长处在于缩小 CPU 切换以及复原现场导致的耗费。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4502e85eccab4479ba8734444527b6f3~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

自旋锁毛病是不能代替阻塞。自旋期待尽管防止了线程切换的开销,但它要占用处理器工夫。如果锁被占用的工夫很短,自旋期待的成果就会十分好。反之,如果锁被占用的工夫很长,那么自旋的线程只会白节约处理器资源。

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6680ef569fd44a6992322ca7481539ba~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

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

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

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

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/96c4211dd33b437ead51760c7fd495eb~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

Synchronized 底层的锁优化机制一张图能够解释原理。上面小木箱基于上面这张图具体的和大家聊一下 Synchronized。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b12ebbc067de4b169b7d3b0561155ee5~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/71ad5c9a03c84ccb8e5a61544aee57e0~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3d1abf786dd14b039508bcbe2865703f~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

为了摸索锁的降级和降级过程,小木箱应用 Maven 引入 openjdk,小木箱打印 ClassLayout.parseInstance().toPrintable()办法能够看到 object header 参数即 Java 头对象。

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e000605413cb4644857a7388afffb211~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>
咱们能够通过 -XX:+UseCompressedOops 关上指针压缩,一般对象压缩后对象头构造为:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3dbcbe569f20469cb47abecb18f7033a~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

咱们也能够通过 - -XX:-UseCompressedOops 关上指针压缩,一般对象对象头构造为:

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3d4b39f2ca7f4884b2fed7de51c4a608~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3f3b7cf7424645ec98d4d99bf1f2f440~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

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 拜访形式次要有两种:句柄池和间接指针拜访。

句柄池拜访形式如下:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/adda7c90680043f89eaaaad90c83c8b5~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

间接指针拜访形式如下:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2d4907a07b5046058f7fd5088c10730f~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

最初,小木箱整体比照一下 句柄拜访 指针拜访 的差别。

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fc54af9859bf4669a3217ce0e232c855~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

说完 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 同步队列合作流程能够参考下图:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f33331c7b8ed4a2892df031075e07753~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

咱们能够看出 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 期待队列中,期待被唤醒。锁的残缺执行流程能够看一下下图:

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ec32fd087e104e8e849dd638c14fea10~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

Monitor 整体过程能够从竞争、期待、开释和唤醒四个维度剖析。首先咱们说一下竞争过程

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cff62dc0c7d543c09dd6eacb6403cef9~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

而后咱们说一下,期待过程:

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/98b7eebdb0d84dacab15abede7594bdb~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

最初咱们说一下唤醒过程,notify 或者 notifyAll 办法能够唤醒同一个锁监视器下调用 wait 挂起的线程,这就是唤醒过程。

源码就不带大家过了,感兴趣的能够看一下小米的 synchronized 实现原理。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fa7dc899348e45208c7e8b9674e15ae8~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1dc86f9804a144489ec69b57ca9e78f4~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

无锁指的是不锁住资源,多个线程中只有一个能批改资源胜利,其余线程会重试。

偏差锁指的是同一个线程执行同步资源时主动获取资源。持有偏差锁的线程当前每次进入这个锁相干的同步块时,只需比对一下 mark word 的线程 id 是否为本线程,如果是则获取锁胜利。如线程拜访同步代码并获取锁的解决流程如下:

<p align=center><img src=”https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9f5dbae66d6c458b88066b9e578d7183~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>
那么如果获取锁之后,产生线程竞争的状况则如何撤销偏差锁呢?

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/380fd130a9a14bfaa4de39df23899071~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>
多个线程竞争偏差锁导致偏差锁降级为轻量级锁,轻量级锁指的是多个线程竞争同步资源时,没有获取资源的线程自旋期待锁开释。上面咱们来看一下轻量级锁的加锁过程。

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/da5071658d4745e6a623febe93296c36~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>
说完加锁过程,咱们再来看一下,轻量级锁的解锁过程。

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6a4428c48d144456b4af0c692ae94a08~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c5448abea938463f8cbb6d9468131d61~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

synchronizedmonitor锁机制和 JDK 并发包中的 AQS 是很类似的,只不过 AQS 中是一个同步队列多个期待队列。相熟 AQS 的同学能够拿来做个比照。

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

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d5613b3128b14e9495566dae19a88f06~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

说完锁的降级过程,咱们来探讨一下多个线程竞争锁的同时要不要排队,依据这个问题咱们把锁分为两类,第一类是偏心锁,第二类是非偏心锁。偏心锁和非偏心锁的概念、长处和毛病能够参考上面的图比照。

<p align=center><img src=”https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5a0451ae0b3c4fcd854db0e63a1fc137~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

对于偏心锁咱们能够看一下下图进行图形化了解:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b3a93ae45f4f4fff8f5937ecb93124a0~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

对于非偏心锁咱们能够看一下下图进行图形化了解:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5da822f1e4c0484eab4e81cef0ffcd0e~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

ReentrantLock 外面有一个外部类 Sync,Sync 继承 AQS(AbstractQueuedSynchronizer),增加锁和开释锁的大部分操作实际上都是在 Sync 中实现的。ReentrantLock 有偏心锁 FairSync 和非偏心锁 NonfairSync 两个子类。ReentrantLock 默认应用非偏心锁,也能够通过结构器来显示的指定应用偏心锁。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bc980786ea74492fa71348694d2aaae7~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

咱们比照一下偏心锁和非偏心锁的源码,偏心锁比非偏心锁多了一个 hasQueuedPredecessors()判断条件。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/10ebc156fd2843339819c374d3b5a442~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

进入 hasQueuedPredecessors(),能够看到 hasQueuedPredecessors()办法次要做一件事件:次要是判断以后线程是否位于同步队列中的第一个。如果是则返回 true,否则返回 false。

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1dbcdcbc48d64970b858530304c472f7~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

综上,偏心锁就是通过同步队列来实现多个线程依照申请锁的程序来获取锁,从而实现偏心的个性。非偏心锁加锁时不思考排队期待问题,间接尝试获取锁,所以存在后申请却先取得锁的状况。

偏心锁和非偏心锁的区别是偏心锁如果在以后线程不是领有有锁的线程时,就不能增加锁。所以偏心锁和非偏心锁增加的都是独享锁。独享锁咱们在问题六具体理解。

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

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

<p align=left><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f177d9eea13940deaf1ab1a56216da7c~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

  • 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),不会因为之前曾经获取过还没开释而阻塞。

依据多个线程能不能共享同一把锁,咱们把锁分为独享锁和共享锁,独享锁和共享锁的区别能够参考上面的表格:

<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ed3124bcc38648729b4598d679675963~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

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

ReadLock 和 WriteLock 是靠外部类 Lock 实现的锁,而 Lock 是 Sync 的实现接口。因而,ReadLock 和 WriteLock 是靠外部类 Sync 实现的锁。具体代码如下:

<p align=center><img src=”https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2935d8839b0d4ac6b5c6abd1d392ea7a~tplv-k3u1fbpfcp-zoom-1.image” alt=”” /></p>

在 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 中执行,次要工作在启动页或首页执行,提早工作在首页后闲暇工夫执行,懒加载工作在特定的机会执行。

最初一个步骤是启动工作统计化,提供工作的耗时统计和卡口。

工作治理框架图参考如下:

<p align=center><img src=”https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7a2398b702d0437f83064028da47d483~tplv-k3u1fbpfcp-watermark.image?” alt=”image.png” /></p>

工作分类管理与业务初始化机会无关,比方像热修复和网络等外围工作,须要优先初始化,推送、地图次要工作优先级比外围工作低。对于耗时长工作,岂但要落库而且要借助 QA 机器人进行监测告警。

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

4.4.4 业务框架

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

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

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

接着,问题优化方面,次要由业务来实现具体优化,如果波及根底机制相干工作,则会由架构组来主导优化,在整个优化中,调度优化为次要优化形式,业务可疾速接入调度机制实现优化。

最初,问题优化和优化成果验证方面,及时跟进问题修复状况,在发版前回归问题,跟进线上优化成果,有些优化须要均衡业务指标和性能指标,如果优化不迭预期需持续协同优化。

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

五、总结与瞻望

浅析 Android 启动优化次要说了四局部内容,第一局部内容是启动根底,第二局部内容是启动优化价值,第三局部内容是启动优化业务痛点,第四局部内容是总结与瞻望。

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

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

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

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

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

优质技术计划参考

  • https://github.com/appium/app…
  • https://github.com/google/per…
  • https://github.com/bytedance/…
  • 开发必读: 网易专家解读 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 测试的思考与实际

本文由 mdnice 多平台公布

正文完
 0