热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

扒一扒抖音是如何做线程优化的

背景 最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的&#x

背景

最近在对一些大厂App进行研究学习,在对某音App进行研究时,发现其在线程方面做了一些优化工作,并且其解决的问题也是之前我在做线上卡顿优化时遇到的,因此对其具体实现方案做了深入分析。本文是对其相关源码的研究加上个人理解的一个小结。

问题


创建线程卡顿

我们可以可以知道 start()函数底层涉及到一系列的操作,包括 栈内存空间分配、内核线程创建 等操作,这些操作在某些情况下可能出现长耗时现象,比如由于linux系统中,所有系统线程的创建在内核层是由一个专门的线程排队实现,那么是否可能由于队列较长同时内核调度出现问题而出现长耗时问题? 具体的原因因为没有在线下复现过此类问题,因此只能大胆猜测,不过在线上确实收集到一些case, 以下是线上收集到一个阻塞现场样本:

那么是不是不要直接在主线程创建其他线程,而是直接使用线程池调度任务就没有问题? 让我们看下 ThreadPoolExecutor.execute(Runnable command)的源码实现

从文档中可以知道,execute函数的执行在很多情况下会创建(JavaThread)线程,并且跟踪其内部实现后可以发现创建Java线程对象后,也会立即在当前线程执行start函数。

来看一下线上收集到的一个在主线程使用线程池调度任务依旧发生卡顿的现场。

线程数过多的问题

在ART虚拟机中,每创建一个线程都需要为其分配独立的Java栈空间,当Java层未显示设置栈空间大小时,native层会在 FixStackSize 函数会分配默认的栈空间大小.

从这个实现中,可以看出每个线程至少会占用1M的虚拟内存大小,而在32位系统上,由于每个进程可分配的用户用户空间虚拟内存大小只有3G,如果一个应用的线程数过多,而当进程虚拟内存空间不足时,创建线程的动作就可能导致OOM问题.

另一个问题是某些厂商的应用所能创建的线程数相比原生Android系统有更严格的限制,比如某些华为的机型限制了每个进程所能创建的线程数为500, 因此即使是64位机型,线程数不做控制也可能出现因为线程数过多导致的OOM问题。

优化思路


线程收敛

首先在一个Android App中存在以下几种情况会使用到线程

  • 通过 Thread类 直接创建使用线程
  • 通过 ThreadPoolExecutor 使用线程
  • 通过 ThreadTimer 使用线程
  • 通过 AsyncTask 使用线程
  • 通过 HandlerThread 使用线程

线程收敛的大致思路是, 我们会预先创建上述几个类的实现类,并在自己的实现类中做修改, 之后通过编译期的字节码修改,将App中上述使用线程的地方都替换为我们的实现类。

使用以上线程相关类一般有几种方式:

  1. 直接通过 new 原生类 创建相关实例
  2. 继承原生类,之后在代码中 使用 new 指令创建自己的继承类实例

因此这里的替换包括:

  • 修改类的继承关系,比如 将所有 继承 Thread类的地方,替换为 我们实现 的 PThread
  • 修改上述几种类直接创建实例的地方,比如将代码中存在 new ThreadPoolExecutor(…) 调用的地方替换为 我们实现的 PThreadPoolExecutor

通过字码码修改,将代码中所有使用线程的地方替换为我们的实现类后,就可以在我们的实现类做一些线程收敛的操作。

Thread类 线程收敛

在Java虚拟机中,每个Java Thread 都对应一个内核线程,并且线程的创建实际上是在调用 start()函数才开始创建的,那么我们其实可以修改start()函数的实现,将其任务调度到指定的一个线程池做执行, 示例代码如下

class ThreadProxy : Thread() {override fun start() {SuperThreadPoolExecutor.execute({this@ThreadProxy.run()}, priority = priority)}
}

线程池 线程收敛

由于每个ThreadPoolExecutor实例内部都有独立的线程缓存池,不同ThreadPoolExecutor实例之间的缓存互不干扰,在一个大型App中可能存在非常多的线程池,所有的线程池加起来导致应用的最低线程数不容小视。

另外也因为线程池是独立的,线程的创建和回收也都是独立的,不能从整个App的任务角度来调度。举个例子: 比如A线程池因为空闲正在释放某个线程,同时B线程池确可能正因为可工作线程数不足正在创建线程,如果可以把所有的线程池合并成 一个统一的大线程池,就可以避免类似的场景。

核心的实现思路为:

  1. 首先将所有直接继承 ThreadPoolExecutor的类替换为 继承 ThreadPoolExecutorProxy,以及代码中所有new ThreadPoolExecutor(…)类 替换为 new ThreadPoolExecutorProxy(…)
  2. ThreadPoolExecutorProxy 持有一个 大线程池实例 BigThreadPool ,该线程池实例为应用中所有线程池共用,因此其核心线程数可以根据应用当前实际情况做调整,比如如果你的应用当前线程数平均是200,你可以将BigThreadPool 核心线程设置为150后,再观察其调度情况。
  3. 在 ThreadPoolExecutorProxy 的 addWorker 函数中,将任务调度到 BigThreadPool中执行

AsyncTask 线程收敛

对于AsyncTask也可以用同样的方式实现,在execute1函数中调度到一个统一的线程池执行

public abstract class AsyncTaskProxy extends AsyncTask{private static final Executor THREAD_POOL_EXECUTOR &#61; new PThreadPoolExecutor(0,20,3, TimeUnit.MILLISECONDS,new SynchronousQueue<>(),new DefaultThreadFactory("PThreadAsyncTask"));public static void execute(Runnable runnable){THREAD_POOL_EXECUTOR.execute(runnable);}/*** TODO 使用插桩 将所有 execute 函数调用替换为 execute1* &#64;param params The parameters of the task.* &#64;return This instance of AsyncTask.*/public AsyncTask execute1(Params... params) {return executeOnExecutor(THREAD_POOL_EXECUTOR,params);}}

Timer类

Timer类一般项目中使用的地方并不多&#xff0c;并且由于Timer一般对任务间隔准确性有比较高的要求&#xff0c;如果收敛到线程池执行&#xff0c;如果某些Timer类执行的task比较耗时&#xff0c;可能会影响原业务&#xff0c;因此暂不做收敛。

卡顿优化

针对在主线程执行线程创建可能会出现的阻塞问题&#xff0c;可以判断下当前线程&#xff0c;如果是主线程则调度到一个专门负责创建线程的线程进行工作。

private val asyncExecuteHandler by lazy {val worker &#61; HandlerThread("asyncExecuteWorker")worker.start()return&#64;lazy Handler(worker.looper)}fun execute(runnable: Runnable, priority: Int) {if (Looper.getMainLooper().thread &#61;&#61; Thread.currentThread() && asyncExecute){//异步执行asyncExecuteHandler.post {mExecutor.execute(runnable,priority)}}else{mExecutor.execute(runnable, priority)}}

32位系统线程栈空间优化

在问题分析中的环节中&#xff0c;我们已经知道 每个线程至少需要占用 1M的虚拟内存&#xff0c;而32位应用的虚拟内存空间又有限&#xff0c;如果希望在线程这里挤出一点虚拟内存空间来&#xff0c;其利用PLT hook需改了创建线程时的栈空间大小。

在Java层直接配置一个 负值&#xff0c;从而起到一样的效果

OOM了? 我还能再抢救下&#xff01;

针对在创建线程时由于内存空间不足或线程数限制抛出的OOM问题&#xff0c;可以做一些兜底处理, 比如将任务调度到一个预先创建的线程池进行排队处理, 而这个线程池核心线程和最大线程是一致的 因此不会出现创建线程的动作&#xff0c;也就不会出现OOM异常了。

另外由于一个应用可能会存在非常多的线程池&#xff0c;每个线程池都会设置一些核心线程数&#xff0c;要知道默认情况下核心线程是不会被回收的&#xff0c;即使一直处于空闲状态&#xff0c;该特性是由线程池的 allowCoreThreadTimeOut控制。

该参数值可通过 allowCoreThreadTimeOut(value) 函数修改

从具体实现中可以看出&#xff0c;当value值和当前值不同 且 value 为true时 会触发 interruptIdleWorkers()函数, 在该函数中&#xff0c;会对空闲Worker 调用 interrupt来中断对应线程

因此当创建线程出现OOM时&#xff0c;可以尝试通过调用线程池的 allowCoreThreadTimeOut 来触发 interruptIdleWorkers 实现空闲线程的回收。 具体实现代码如下:

因此我们可以在每个线程池创建后&#xff0c;将这些线程池用弱引用队列保存起来&#xff0c;当线程start 或者某个线程池execute 出现OOM异常时&#xff0c;通过这种方式来实现线程回收。

线程定位

线程定位 主要是指在进行问题分析时&#xff0c;希望直接从线程名中定位到创建该线程的业务&#xff0c;关于此类优化的文章网上已经介绍的比较多了&#xff0c;基本实现是通过ASM 修改调用函数&#xff0c;将当前类的类名或类名&#43;函数名作为兜底线程名设置。

字节码修改工具

前文讲了一些优化方式&#xff0c;其中涉及到一个必要的操作是进行字节码修改&#xff0c;这些需求可以概括为如下

  • 替换类的继承关系&#xff0c;比如将 所有继承于 java.lang.Thread的类&#xff0c;替换为我们自己实现的 ProxyThread
  • 替换 new 指令的实例类型&#xff0c;比如将代码中 所有 new Thread(…) 的调用替换为 new ProxyThread(…)

针对这些通用的修改&#xff0c;没必要每次遇到类似需求时都 进行插件的单独开发&#xff0c;因此我将这种修改能力集成到 LanceX插件中&#xff0c;我们可以通过以下 注解方便实现上述功能。

替换 new 指令

&#64;Weaver
&#64;Group("threadOptimize")
public class ThreadOptimize {&#64;ReplaceNewInvoke(beforeType &#61; "java.lang.Thread",afterType &#61; "com.knightboost.lancetx.ProxyThread")public static void replaceNewThread(){}}

这里的 beforeType表示原类型&#xff0c;afterType 表示替换后的类型&#xff0c;使用该插件在项目编译后&#xff0c;项目中的如下源码

会被自动替换为

替换类的继承关系

&#64;Weaver
&#64;Group("threadOptimize")
public class ThreadOptimize {&#64;ChangeClassExtends(beforeExtends &#61; "java.lang.Thread",afterExtends &#61; "com.knightboost.lancetx.ProxyThread")public void changeExtendThread(){};}

这里的beforeExtends表示 原继承父类&#xff0c;afterExtends表示修改后的继承父类&#xff0c;在项目编译后&#xff0c;如下源码

会被自动替换为

总结

本文主要介绍了有关线程的几个方面的优化

  • 主线程创建线程耗时优化
  • 线程数收敛优化
  • 线程默认虚拟空间优化
  • OOM优化

这些不同的优化手段需要根据项目的实际情况进行选择&#xff0c;比如主线程创建线程优化的实现方面比较简单、影响面也比较低&#xff0c;可以优先实施。 而线程数收敛需要涉及到字节码插桩、各种对象代理 复杂度会高一些&#xff0c;可以根据当前项目的实际线程数情况再考虑是否需要优化。

线程OOM问题主要出现在低端设备 或一些特定厂商的机型上&#xff0c;可能对于某些大厂的用户基数来说有一定的收益&#xff0c;如果你的App日活并没有那么大&#xff0c;这个优化的优先级也是较低的。


其实不管你是在做项目中&#xff0c;还是面试中&#xff0c;都会发现有一些性能优化的相关问题出现&#xff0c;我们一般采用的方法是发现问题→定位问题→解决问题&#xff0c;但有时可能有些问题的出现&#xff0c;第一时间想不起来解决方法或是面试时答不上来&#xff0c;这也就证明了你对这一块掌握的不是很熟练。为了帮助到大家快速熟练掌握性能优化的知识点&#xff0c;整理了《Android 性能优化》的核心笔记大家可以参考&#xff1a;https://qr18.cn/FVlo89

Android 性能优化核心笔记

包含内容有&#xff1a;启动优化、内存优化、启动优化速度、卡顿优化、布局优化、崩溃优化、应用启动全流程&#xff08;源码深度解析&#xff09;……等内容


推荐阅读
  • Java学习笔记之面向对象编程(OOP)
    本文介绍了Java学习笔记中的面向对象编程(OOP)内容,包括OOP的三大特性(封装、继承、多态)和五大原则(单一职责原则、开放封闭原则、里式替换原则、依赖倒置原则)。通过学习OOP,可以提高代码复用性、拓展性和安全性。 ... [详细]
  • JVM 学习总结(三)——对象存活判定算法的两种实现
    本文介绍了垃圾收集器在回收堆内存前确定对象存活的两种算法:引用计数算法和可达性分析算法。引用计数算法通过计数器判定对象是否存活,虽然简单高效,但无法解决循环引用的问题;可达性分析算法通过判断对象是否可达来确定存活对象,是主流的Java虚拟机内存管理算法。 ... [详细]
  • 本文介绍了iOS数据库Sqlite的SQL语句分类和常见约束关键字。SQL语句分为DDL、DML和DQL三种类型,其中DDL语句用于定义、删除和修改数据表,关键字包括create、drop和alter。常见约束关键字包括if not exists、if exists、primary key、autoincrement、not null和default。此外,还介绍了常见的数据库数据类型,包括integer、text和real。 ... [详细]
  • 本文介绍了一个Java猜拳小游戏的代码,通过使用Scanner类获取用户输入的拳的数字,并随机生成计算机的拳,然后判断胜负。该游戏可以选择剪刀、石头、布三种拳,通过比较两者的拳来决定胜负。 ... [详细]
  • Java容器中的compareto方法排序原理解析
    本文从源码解析Java容器中的compareto方法的排序原理,讲解了在使用数组存储数据时的限制以及存储效率的问题。同时提到了Redis的五大数据结构和list、set等知识点,回忆了作者大学时代的Java学习经历。文章以作者做的思维导图作为目录,展示了整个讲解过程。 ... [详细]
  • 本文讨论了一个关于cuowu类的问题,作者在使用cuowu类时遇到了错误提示和使用AdjustmentListener的问题。文章提供了16个解决方案,并给出了两个可能导致错误的原因。 ... [详细]
  • Tomcat/Jetty为何选择扩展线程池而不是使用JDK原生线程池?
    本文探讨了Tomcat和Jetty选择扩展线程池而不是使用JDK原生线程池的原因。通过比较IO密集型任务和CPU密集型任务的特点,解释了为何Tomcat和Jetty需要扩展线程池来提高并发度和任务处理速度。同时,介绍了JDK原生线程池的工作流程。 ... [详细]
  • 本文介绍了Java高并发程序设计中线程安全的概念与synchronized关键字的使用。通过一个计数器的例子,演示了多线程同时对变量进行累加操作时可能出现的问题。最终值会小于预期的原因是因为两个线程同时对变量进行写入时,其中一个线程的结果会覆盖另一个线程的结果。为了解决这个问题,可以使用synchronized关键字来保证线程安全。 ... [详细]
  • 自动轮播,反转播放的ViewPagerAdapter的使用方法和效果展示
    本文介绍了如何使用自动轮播、反转播放的ViewPagerAdapter,并展示了其效果。该ViewPagerAdapter支持无限循环、触摸暂停、切换缩放等功能。同时提供了使用GIF.gif的示例和github地址。通过LoopFragmentPagerAdapter类的getActualCount、getActualItem和getActualPagerTitle方法可以实现自定义的循环效果和标题展示。 ... [详细]
  • 标题: ... [详细]
  • 本文介绍了如何使用C#制作Java+Mysql+Tomcat环境安装程序,实现一键式安装。通过将JDK、Mysql、Tomcat三者制作成一个安装包,解决了客户在安装软件时的复杂配置和繁琐问题,便于管理软件版本和系统集成。具体步骤包括配置JDK环境变量和安装Mysql服务,其中使用了MySQL Server 5.5社区版和my.ini文件。安装方法为通过命令行将目录转到mysql的bin目录下,执行mysqld --install MySQL5命令。 ... [详细]
  • 本文讨论了在手机移动端如何使用HTML5和JavaScript实现视频上传并压缩视频质量,或者降低手机摄像头拍摄质量的问题。作者指出HTML5和JavaScript无法直接压缩视频,只能通过将视频传送到服务器端由后端进行压缩。对于控制相机拍摄质量,只有使用JAVA编写Android客户端才能实现压缩。此外,作者还解释了在交作业时使用zip格式压缩包导致CSS文件和图片音乐丢失的原因,并提供了解决方法。最后,作者还介绍了一个用于处理图片的类,可以实现图片剪裁处理和生成缩略图的功能。 ... [详细]
  • Java中包装类的设计原因以及操作方法
    本文主要介绍了Java中设计包装类的原因以及操作方法。在Java中,除了对象类型,还有八大基本类型,为了将基本类型转换成对象,Java引入了包装类。文章通过介绍包装类的定义和实现,解答了为什么需要包装类的问题,并提供了简单易用的操作方法。通过本文的学习,读者可以更好地理解和应用Java中的包装类。 ... [详细]
  • 本文介绍了Swing组件的用法,重点讲解了图标接口的定义和创建方法。图标接口用来将图标与各种组件相关联,可以是简单的绘画或使用磁盘上的GIF格式图像。文章详细介绍了图标接口的属性和绘制方法,并给出了一个菱形图标的实现示例。该示例可以配置图标的尺寸、颜色和填充状态。 ... [详细]
  • 重入锁(ReentrantLock)学习及实现原理
    本文介绍了重入锁(ReentrantLock)的学习及实现原理。在学习synchronized的基础上,重入锁提供了更多的灵活性和功能。文章详细介绍了重入锁的特性、使用方法和实现原理,并提供了类图和测试代码供读者参考。重入锁支持重入和公平与非公平两种实现方式,通过对比和分析,读者可以更好地理解和应用重入锁。 ... [详细]
author-avatar
曾家宏惠茹冠宇
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有