当前位置: 首页 > 科技观察

抖音Android性能优化系列:Java锁优化

时间:2023-03-17 18:48:22 科技观察

背景介绍Java多线程开发中为了保证数据的一致性,引入了synchronized锁。但是过度使用锁可能会导致卡住的问题,甚至ANR:Systrace中的主线程因等待锁而阻塞绘图,导致卡住Slardar平台(字节跳动内部APM平台,以下简称Slardar)搜索等待的lock关键字发现了很多由锁引起的ANR,仅Java锁异常就占了总ANR的3.9%。经典案例与优化实践。在监控方案中,有几种方式可以在运行时获取锁信息。应用范围特点systraceoffline可以发现无调用栈导致的耗时锁customROMoffline可以支持调用栈修改ROM门槛高,只支持特定机型JVMTIOffline只支持Android8+设备,不支持发布包,并且性能开销很高。考虑到很多锁问题需要暴露一定规模的在线用户,而且没有调用栈,很难从根本上定位和解决在线用户的锁问题。.最终我们开发了一套在线锁监控系统,需要满足以下需求:在线监控方案丰富的锁信息,包括Java调用栈数据分析平台,包括聚合能力,设备和版本信息,等可以在开发组合中加入这样一个防止不良代码上线的锁监控系统,可以帮助我们高效定位和解决线上问题,防止退化。锁监控原理先从Systrace说起。有一种常见的耗时类型叫做monitorcontention,其实就是AndroidART虚拟机输出的锁信息。简单介绍下monitorcontentionwithownerwork_thread(27176)atandroid.content.res.Resourcesandroid.app.ResourcesManager.getOrCreateResources(android.os.IBinder,android.content.res.ResourcesKey,java.lang.ClassLoader)(ResourcesManager.java:901)waiters=1blockingfromjava.util.ArrayListandroid.app.ActivityThread.collectComponentCallbacks(boolean,android.content.res.Configuration)(ActivityThread.java:5836)锁定线程:work_thread锁定线程方法:android.app.ResourcesManager.getOrCreateResources(...)等待线程等待锁方法:android.app.ActivityThread.collectComponentCallbacks(...)Java锁,不管是同步方法还是同步块,virtual机器最终会到达MonitorEnter。我们关注的trace是Android6引入的,ATRACE_BEGIN(...)和ATRACE_END()分别在锁的开始和结束时调用。在网上的解决方案中,atrace默认是关闭的,开关在ATRACE_ENABLED()中。我们可以通过将atrace_enabled_tags设置为ATRACE_TAG_DALVIK来开启当前进程的ART虚拟机的atrace。看ATRACE_BEGIN(...)和ATRACE_END()的实现,其实是用write把字符串写入一个特殊的atrace_marker_fd(/sys/kernel/debug/tracing/trace_marker)。因此,通过hooklibcutils.so的write方法,通过atrace_marker_fd过滤,实现了ATRACE_BEGIN(...)和ATRACE_END()的拦截。有了BEGIN和END就可以计算阻塞时长,解析monitorcontentionwithowner...log可以得到我们关心的Java锁信息。获取堆栈至此,我们已经可以监控到在线用户的锁问题了。但这还不够。为了优化锁的性能,我们需要知道等待锁的具体原因,也就是Java调用栈。要获取Java调用堆栈,可以使用Thread.getStackTrace()方法。由于我们hook了虚拟机的等待锁线程,此时线程处于特殊状态,不能直接通过JNI调用Java方法,否则会造成线上崩溃问题。解决办法是异步获取栈,在MonitorBegin时刻5ms后通知子线程抢栈,monitorEnd计算阻塞时间,与栈数据一起放入队列,等待上报给Slardar.如果MonitorEnd时间不满足5ms,则栈抓取和数据上报平台将被取消。由于方案本身有一定的性能开销,我们在灰度测试中只对部分用户启用了锁监控。配置在线采样后,命中用户自动开启锁监控,数据上报Slardar平台后即可消费。具体情况下,可以看到设备信息,阻塞时长,调用栈,根据调用栈找到源码,可以定位到是哪把锁,说明上报的数据是准确的。稳定性方面,10万灰度用户开启锁监控后,没有出现新的稳定性问题。优化实践经过多轮锁的收集和管理,我们取得了一些不错的收益。下面介绍几个锁管理的典型案例。典型案例inflate锁:首先分析什么是inflate:Android中解析xml生成View树的过程称为inflate过程。Inflate是一个耗时的过程。常规的方法是通过异步的方式来减少它在主线程中的耗时,大大减少卡顿、页面打开和启动的时间;但是这种方法也会带来新的问题,比如LayoutInflater的inflate方法中有锁保护的代码块。并行构建会造成锁等待,可能会增加主线程的耗时。这个问题有三种解决方案:cloneLayoutInflater,将线程分为三类:Main,workerthreads和othersThread(wildthread),Context(Activity和App)为每一类提供一个专用的LayoutInflater,可以有效避免inflatelocks。优点:实现简单,兼容性好缺点:LayoutInflater中不安全的静态属性在并发情况下可能会导致稳定性问题代码构建代替xml构建该方法完美绕过了inflate操作,大大提高了View构建速度。优点:复杂度高,性能好缺点:编译速度受影响,View自定义属性需要转换,存在兼容性问题(如厂家改属性)自定义LayoutInflater自定义FastInflater(继承自LayoutInflater)替换系统的phoneLayoutInflater,重写充气操作,解除锁定保护;从统计数据来看,并发时快4%左右。优点:复杂度高,性能好缺点:有兼容性。比如华为的Inflater就是HwPhoneLayoutInflater,不能直接替换。文件目录锁:ContextImpl中获取目录(缓存、文件、DB、preferenceDir)的实现有两个关键耗时点:1、有IPC(IStorageManager.mkdir)和文件检查;2.锁定“nSync”保护;所以IPC加长和并发存在可能会导致App卡顿,如图Anr数据:相关常用的API有getExternalCacheDir、getCacheDir、getFilesDir、getTheme等,考虑到系统的一些目录一般不会发生变化,我们可以一些将不会改变的目录缓存起来,减少带锁方法块的执行,从而有效绕过锁等待。MessageQueue:Android子线程与主线程通信的一般方式是在主线程MessageQueue中插入一个任务(消息),等待这个任务(消息)被主线程Looper调度执行;因此,MessageQueue会对消息列表的修改进行加锁和保护,主要实现在enqueueMessage和next两个方法中。使用Slardar收集在线锁信息。基于这些信息,我们可以很方便地跟踪锁的自身线程和拥有者,最后根据情况将请求(消息)移动到子线程,这样可以大大减轻主线程等待锁的压力。可能性。这个问题的修改方法并不复杂,关键是如何监控这些加锁线程。序列化与反序列化:抖音中一些常用的数据对象以Json格式存储。为了保证这些数据的完整性,在读取和存储的时候加了锁保护,导致锁等待比较常见,这种情况在启动场景尤为明显;因此,为了减少锁等待,需要加快序列化和反序列化,针对这个问题,我们做了三个优化方案:创建Filed和name(key)的映射表;所以我们在编译时针对数据类创建对应的TypeAdapter,这样就大大减少了反序列化的时间消耗。部分类使用parcel序列化和反序列化,大大提高了速度,减少了90%左右的时间消耗。大对象根据情况拆分成多个小对象,可以减少锁粒度和锁等待。以上解决方案已经在抖音项目中使用,取得了很好的效益。AssetManager锁:获取string、size、color或xml等资源的最终实现基本都封装在AssertManager中。为了保证数据的正确性,加入锁(对象AssetManager)进行保护。大致的调用关系如图:常见的调用点是的:在View构造方法中调用context.obtainStyledAttributes(...)获取TypedArray,最后调用AssetManager的lock方法。View的toString也调用了AssetManager的lock方法。随着xml异步inflate的增加,这些方法的并发调用也随之增加,导致主线程的锁等待越来越突出,最终导致卡顿。针对这个问题,我们目前的优化方案主要包括:去除冗余调用,比如View的toString,这个在日志打印中很常见。一个Context根据线程名称提供不同的AssetManager,绕过了AssetManager对象锁;此方法可能会导致一些内存消耗。so加载锁优化:Android提供的加载so的接口实现都封装在Runtime中,比如常用的loadLibrary0和load0,如图1和图2,该方法是锁死的,如果并发加载so会导致锁定等待。通过Slardar的监控数据,我们验证了这个问题。同时,还有一些意想不到的收获。比如平台可能有自己的so需要添加:我们根据so的不同情况主要有以下优化思路:对于cinit加载的so,我们可以提前在child中加载cinit的宿主类线。所以在业务层面可以在子线程中提前加载。使用load0代替loadLibrary0可以减少锁中拼接so路径的时间消耗。so文件加载优化,比如JNI_OnLoad。ActivityThread:在搜集到的资料中,我们也发现了系统层的一些框架锁,如下图:这个问题主要集中在启动阶段,ams会在ActivityThread中向ApplicationThread发送一个trim通知,并且收到通知后,会向Choreographer上报commit列表中添加一个trim任务(这个任务列表不会展开),即在下一个vsync到来时执行;trim过程主要包括收集Applicatioin、Activity、Service、Provider并向它们发送trim消息,这也是系统给业务提供的一个清理自身内存的机会;collection进程被锁(ResourcesManager)保护,如图:考虑到启动阶段不太关心内存的释放,可以在启动阶段尽量不要进行trim操作,比如在within40秒;具体实现如下,首先替换Choreographer的FrameHandler,使其接管vsync的doFrame操作,每次vsync启动后40秒内主动检查或删除commit任务列表中的trim操作。抖音中的收益除了优化上面列出的典型锁,我们还对业务本身的一些锁进行了优化,有的已经通过线上实验验证了收益,有的还在试用阶段;指标分析也证实了锁优化可以带来启动、流畅等技术收益,间接带来良好的商业收益,这也加强了我们在这个方向上的持续探索和深化。总结以上只是一些有代表性的通用Java锁,在实际开发中遇到的要多得多,但不管是哪一种锁,根据进程和代码归属,可以分为以下四类:业务锁,依赖库锁、框架锁、系统锁;不同类型的锁会有不同的优化思路,有的方案可以复用,有的只能逐案解决。具体优化方案包括:减少调用、绕过调用、使用读写锁和无锁等。类别描述流程代码优化方案业务锁源码可见,可直接修改;比如之前的序列化优化。App流程包括直接优化;静态aop依赖库锁包括编译产物,产物可以修改。App流程包括直接优化;运行时加载静态aop框架锁,同时具有兼容性;比如前面提到的inflatelock、AssetManagerlock和MessageQueuelockApp进程不包括调用减少;动态aop系统锁系统为App提供服务和资源,App之间存在竞争,所以服务层需要加锁保护,如IPC、文件系统、数据库服务进程不包括调用缩减。SummaryPassed经过半年的摸索和优化,该方案已经上线使用。作为我们日常防劣化和主动优化的输入工具,我们主要判断以下四点:稳定性:上线激活后,ANR、Crash、OOM与市场一致。准确率:从目前的线上消费数据来看,这个数值已经达到了99%。可扩展性:业务可以根据场景开启和关闭采集功能,也可以在指定时间内采集锁。比如在启动阶段可以收32ms的锁,其他阶段可以收16ms的锁。劣化影响:根据线上实验数据,在一定量(UV)下,业务和性能(丢帧和启动)没有明显的劣化。虽然这个方案只能监控synchronized锁,比如CAS、Native锁、sleep、wait等都无法监控,但是synchronized锁在我们日常开发中占了非常大的比重,所以基本可以满足我们的大部分需求。当然,我们也在继续探索其他锁的监控,验证它们的价值。