前言
操作系统发展过程中,操作系统由单进程到多进程转变,然而,进程的初始化需要注册各种信号机制以及初始化各种进程内部管理器,导致他的启动速度一直不太理想,不过随着Copy On Write技术的出现,fork出的子进程允许在未修改资源的情况下访问父进程的资源的内存,同时也能并发处理一些任务,进程创建效率显著提高。但是计算机领域这种提升对内存之间的写操作、通信以及进程占用内存问题显然不够满意,因此提出了线程的概念,允许线程之间无论读写都能共享内存地址,另有也不需要注册各种非必要的信号机制和管理器,显然这种方式极大的提高了cpu的处理能力,后来进程也被称为重量级线程,而我们所说的线程是轻量级线程。
常见问题
在本篇开始之前,我们需要梳理一下常见的一些问题:
1.1 进程之间的内存是相互隔离的?
其实这句话显然不完全对,目前linux体系的进程创建都是Copy on Write 技术,Android中的进程分裂形象的是Zygote进程,为什么是Zygote呢?其实就是"复制"或者"分裂"的意思,因为Zygote进程已经初始化了很多进程管理器和信号监听器,只需要在Zygote进程上复制即可,而这种复制在写操作时才会进行隔离内存。我们知道,每一个app进程都能加载系统资源,但其实他们所加载的资源都在Zygote进程中,并不在app进程中,主要还是利用了COW机制,由此避免了app进程的内存占用问题,无论创建多少个app进程,所能加载的系统资源都不会被复制多分。因此,可以说,系统进程的内存并不完全隔离,至少是"读"的时候【可能】存在未隔离的情况。
1.2 为什么进程的id号和主线程的id号一样?
在logcat中我们经常能看到,进程id和线程id一致的信息,一般由此我们来判断是否主线程日志。我们开头说过,早期进程既是系统调度任务的单元,也是CPU执行任务的单元,而进程属于重量级线程,一个进程至少有一个线程,进程和线程的创建最终都调用了同样的pthread的clone方法,只是参数不同,从这些可以很明确的推导出进程和进程中的主线程是同一个东西。至于子线程创建进程,可以明确的是依然拷贝的是主进程,但是在一些资料中也提到,在一些系统中所有的线程和进程一样,具体情况还需要进一步确定,但是在Android中可以肯定的是,主线程和进程是同一个东西。
1.3 多进程的好处?
- 获得更多的内存空间
- 进程crash互不影响,提高稳定性
- 进程可以托孤,是保活的技术手段
1.4 进程为什么会托孤?
我们知道,一个进程死亡那么进程内的所有线程都会死亡,但是进程不一样,父进程死亡但是子进程是不会被影响的,那么子进程成为孤儿,但这个其实存在无法回收的风险,因此系统为了防止孤儿进程无法回收,会将孤儿进程托孤给其他高优先级进程。
1.5 Android创建进程为什么不使用Binder创建进程 ?
这个是面试中经常遇到的问题,一致的回答是多线程创建Binder会被死锁或者Binder无法释放,但是linux明确的提供了pthread_atfork方法用于解锁,另外也未能找到明确的代码能复现这种场景。
我们再思考一个问题,一些开源工具会在fork进程之后再dump堆栈,而且还在子线程中fork的,为什么没有出现死锁的问题?
从另一方面我们知道进程拷贝的过程中只会拷贝线程对象和主线程,那么锁从何来呢?显然这个说法还是有点问题的,不过问了下chat-gpt,给的答案显然比较合理。
-
- 多线程创建进程可能引入更多的开销和复杂性。在 Android 中,每个进程都是独立的运行空间,因此创建一个新的进程需要分配资源、加载代码等操作。如果使用多线程来创建进程,会增加资源竞争和管理复杂度,可能会导致性能下降和不稳定。
-
- Binder 是 Android 中用于进程间通信(IPC)的机制,它提供了安全、高效的进程间通信服务。多线程创建进程意味着需要使用不同的线程来管理进程和 IPC,这会导致代码复杂化,并且可能会引入与 Binder 相关的竞态条件和其他并发问题。
-
- 面对多线程创建进程的需要,Android 已经提供了优秀的进程管理机制。例如,Zygote_Server 作为进程孵化器,专用于创建新的应用进程。它通过单线程方式来处理进程创建请求,确保了创建进程的过程是有序且高效的。此外,Android 还提供了进程池等机制,使得进程的复用和管理更加容易。
1.5 Zygote复制进程为什么使用Pipe
Zygote复制进程后,Pipe主要用来确定子进程创建成果并且返回子进程的pid,使用Pipe本身是单工通信,这里主要原因应该是使用起来更加简单。
1.6 一个进程死亡为什么不会影响其他进程
主要是进程存在隔离关系,我们前文说过,只有父进程与子进程之间存在"读"时共享内存的关系,但是写内存的情况下就会严格隔离,同时操作系统也不会允许进程之间互相影响,避免出现稳定性问题。
实际上基于这层关系,很多app利用多进程来实现更稳定的app。多进程的好处是减少单一进程的内存占用,同时Crash的时候不会影响到其他进程。使用最多的优化方案主要应用在Webview和播放器(除MediaPlayer)部分。
1.7 其他不同
子线程没有FD,进程有FD,实际上进程具备I/O特性,换句话说主线程有FD,其他线程没有FD。
常用优化手段
我们前文主要在进程和线程的关系上,主要目的还是为了正确认识线程和进程的关系,以及多进程的优势和缺点。显然进程的启动速度是慢于线程的,那么线程优化的重点应该是以下方向:
- 降低主线程任务量
- 提高线程执行效率
第一个方向是我们尽可能把一些耗时和频繁调用的任务指派给子线程就行,然而,如何让子线程提高效率,就是重中之重。
避免使用AQS
在Java 1.5 之前的版本中,由于synchronized不能自适应,且锁的是重量级锁,导致性能比较差,以及存在DDL问题,因此非JVM团队开发了AQS,但是JVM团队不甘示弱,大幅度优化synchronized性能,在1.6版本之后,synchronized 显然性能高出了AQS,原因是AQS锁对JVM而言是java api,很难进行锁优化,同时AQS借助volatile 导致刷新线程缓冲区的频率较高,所以性能还是不太理想。
减少线程的创建
线程是CPU调度的基本单位,当线程Wait或者sleep时会进入Object Monitor WaitSet并不会消耗CPU,但是创建的线程是有私有内存的,因此可想而知,线程不销毁内存占用会一直存在。
锁消除
锁消除是Jvm后端编译常用的手段,也就是我们常说的JIT编译,当然这部分也是字节码级别的优化,不过如果我们能提前知道加锁的意义不大,就像http请求一样,为什么不提前移除呢。要知道JIT的只是进行图排序和热点采样的临时优化,达到一定阈值才会提交给C1、C2编译器做静态编译优化,显然能提前知晓的情况下移除锁也是必要的。
降低锁粒度 & 锁粗化
所谓降低锁粒度是指在尽可能减少锁的范围,这里核心点是在保证线程安全的前提下,其实是减少对非必要加锁的代码段加锁。而锁粗化是尽可能避免多次申请锁,自然要扩大范围。其实锁粗化和降低锁粒度并不矛盾,我们在优先完成降低锁粒度度之后才能考虑锁粗化,如果两个锁相邻或者相隔的代码耗时明显可以忽略的情况下才可以考虑锁粗化。当然,这部分JIT也可以做得到,只是情况和前面一样,需要采样分析热点代码。
避免对象逃逸
逃逸分析仅仅是分析而已,而逃逸分析的最终目的是为了对象分配到线程的私有内存或者TLAB区域,当然还有逃逸分析可以促进对象标量化(对象内部的成员拆分成外部变量)。当然,这个前提是你有适当的内存,如果是高并发+基本类型数组或者大字符串等大对象,要考虑stackSize或者TLAB是否是合适大小。
那如何不逃逸呢?其实原则就是方法内创建的对象不return到外部,线程中创建的对象尽可能不被其他线程引用,这种情况下如果线程的私有内存满足条件,机会触发逃逸分析,分析结果最终确定此段代码是否优化。
选择合适的数据结构
如果访问存在边界,可以选择使用ArrayList,如果不存在边界,使用队列或者集合比较合适,至于CopyOnWriteArrayList,建议能少用就少用。Map方面可以使用ConcurrentHashMap,JDK1.8之前是片段锁,JDK 1.8之后的ConcurrentHashMap借助红黑树的作用,锁的粒度更小。
使用线程池
使用线程池可以有效避免线程频繁创建的导致内存不稳定以及创建线程耗时的问题,同时也能提高并发处理能力。但是线程池一般需要创建两种,一种是I/O密集,这个时候线程数可以多一些,当然要避免内存问题;另一种是CPU优先,可以选择CPU数相当的核心线程数,当然也可以是2n+1,为什么是2n+1,主要是CPU多级缓存处理时,如果有空闲缓存时,单核的处理能力可以达到双核的效果。
享元模式
实际上享元模式是优化高频I/O或者消费者和观察者模式最常用的手段,享元模式的实现可以分为多种,很多开发者拘泥于使用哪种数据结构,导致选型比较困难,其实只要考虑三个点:有没有内存碎片、有没有回收、队列满了容灾方案是否合适。如果三个点都是符合的,那么你的设计就是符合享元模式的。
- android.os.Message
- android.app.servertransaction.ObjectPool
- 消费者链表 + 回收链表
- BufferPool 等
比如我们可以用来优化音频处理、播放器缓存,当然还可以优化Builder。Android很多地方都有Builder,build之后的机会销毁,如果build频繁的话,是不是可以考虑保存下Builder。
减少volatile和final修饰
我们知道volatile的两大作用是禁止重排序和可见性(可见性的实现是利用刷新内存缓冲区方式),而final是禁止重排序,显然会影响性能。对于final显然有些复杂,这里我们只需要记住:基本类型和字符串常量的修饰可以促进常量传播提高性能,修饰其他类型会有一定的性能损耗。
这里补充一下禁止重排序的问题:
在jvm中只有volatile、sychronized、final可以禁止重排序,其本质是避免当前指令段前和指令段后的指令互相交换位置,这种主要利用内存屏障实现。
非常规优化
上面我们说的优化往往考验开发者的个人水平,以及产品运行环境,往往需要进行长期打磨。特别是涉及字节码的优化,如果达不到dex级别,效果可能不会很明显。
Redex
如果村字节码的角度去优化,单纯的依靠开发者其实很难达到量变的效果,Redex是非常优秀的框架,对字节码级别的优化可以选用Redex和个人代码配合,从而引起质变。
调度优化
我们知道,jvm默认是多线程同步I/O模型,但其实很多事时候如网络、磁盘文件读取、录音、拍照、解码等都是通过专用芯片实现,一台设备上往往不只有CPU这样的处理器,可能还有声卡、网卡、DMA芯片、DSP芯片等,他们主要配合中央处理器工作,有些工作其实并不是CPU处理的,而是由这些硬件处理的,因此JVM的同步阻塞机制往往很无意义,另外这种阻塞是通过锁来实现。伴随着C10K 问题的出现,基于多路复用的select、poll、epoll从机制上改良了调度,这种机制后续为实现协程提供了一些参考。 当然怎么理解多路复用呢?我们举个例子食堂买饭的例子:
- 传统模式是点完菜专门守着,等师傅做好之后交给你。
- 新模式是点完菜你可以去忙其他的,等师傅做好通知你。
传统让你点完菜之后只能等着(什么事都不能干),而新模式你可以去做很多事,比如娱乐、学习等,这显然让你有了更多的处理能力,同时也减少了被锁住无法动弹的问题,从而降低了线程切换上下文的概率。
其实多路复用在Zygote创建进程的时候就有用到,但是多路复用作为前端app使用的场景其实并不多,除非涉及udp、tcp通信。毕竟,作为一般app其实和I/O相比很少,反而是用户态的比较多,因此选择协程是不错的选择。
当然,基于多路复用的技术,java在此基础上实现了AIO,但相比kotlin语法糖加持的协程,使用起来还是不太顺手。
StackSize 优化
一般情况下StackSize是1M,当然运行期间实际上可能会变大,但是一些线程并不需要太大的内存,显然是可以设置的更小的,修改StackSize之前很多人使用的Hook方式,不过有博客发现其实可以是通过负数来设置的,如 stackSize 设置为 -512*1024,相当于默认的1M减去512KB。
文件I/O优化
另一方面我们的优化点其实数据处理快慢,这部分内容很多,且互有交叉,这里我们对常用的处理进行优化:
- 修改文件名使用file.renameTo方法,此方法的原理是修改路径链接,不会有I/O
- fileChannel.map方法是mmap内存映射,必要时可以提高读写速度,但是要频繁扩容。另外Android 6.0提供了Os类,但是如果支持6.0之前的平台,只能使用native方式调用,mmap一定要记住场景,大量数据实时频繁写入,比较适合日志,如果单次或者小数据的写入,反而性能会劣化。
- fileChannel.transferToXXX和filechannel.tranferFromXXX 方法是 linux中send方法的实现,0拷贝(主要是DMA拷贝不算次,用户态只需要把内存映射到内核就像),效率更高
- 使用字节锁,java FileLock存在死锁的风险,因此推荐使用flock去实现,而且可以分段锁住片段字节。
- 压缩文件:尽可能选择有损压缩
FD优化
前面有一篇文章《Android HandlerThread FD优化》,本质通过恭喜HandlerThread是避免FD的创建,同时又能达到Handler的效果。当然其他方面我们要及时关闭FD、句柄,或者共享FD的资源。
其他性能方面
这方面其实和内存、算法有一定的关系,当然也会影响线程性能,这里简单列举一下:
- 使用缓存
- 减少重复计算
- 算法优化
- 避免频繁申请内存
- 尽可能避免清洗基本类型buffer脏数据,因为buffer可以标记开始位置和能读取的有效长度
- 音频线程优先级调高
- 销毁不必要的idle状态线程
.....
总结
本篇文字性内容偏多,其实很多技术都是常用的,而且是通用手段,这里相当做了总结,方便大家参考,当然,性能优化方面,有很多地方和I/O优化交叉的,后续我们专门梳理下I/O优化的。另外其实还有些非通用的,如相似照片算法、相同照片算法、搜索树其本质很需求有关,后续有机会我们也整理一下。