公众号回复 Android 加入我的技术群
作者:灯不利多
灯不利多
投稿发表,转发等请联系原作者授权
![首图.png 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/6d0ba6ce8b40d28c6fb134cfef6d9ec1.png)
![目录.png 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/947bf705e38c96a9e11cb31c5df82679.png)
前言
1. 基本介绍
在我学习 Android 多线程优化方法的过程中,发现我对多线程优化的了解太片面。
写这篇文章的目的是完善我对 Android 多线程优化方法的认识,分享这篇文章的目的是希望大家也能从这些知识从得到一些启发。
这篇文章分为下面三部分。
第一部分
第一部分讲的是多线程优化的基础知识,包括线程的介绍和线程调度基本原理的介绍。
第二部分
第二部分讲的是多线程优化需要预防的一些问题,包括线程安全问题的介绍和实现线程安全的办法。
第三部分
第三部分讲的是多线程优化可以使用的一些方法,包括线程之间的协作方式与 Android 执行异步任务的常用方式。
2. 阅读技巧
在阅读本文时,画图和思考可以帮助你更好地记忆和理解文中的内容。
画图
画图指的是把每一节的重点画在思维导图的节点上。
思维导图可以让随意信息在视觉上建立起一种视觉上的关联。
随意信息指的是不存在逻辑关系的信息,比如线程的名字和线程的状态就是一种随意信息。
随意信息的特点就是它们之间不存在逻辑关联,导致记忆困难。
通过建立关联,我们大脑能更好地记忆随意信息。
思考
学习不是为了被现有的知识所束缚,而是以现有的知识为基石,发展出新的思想。
阅读本文时,可以带着下面这些问题边思考边阅读。
这个说法的依据是什么?
怎么以自己的方式去解释这个概念?
怎么在自己的项目中应用这个技巧?
这个概念的具体代码实现是怎样的?
这个实现存在哪些问题?
3. 缩略词
AS
Android Studio(Android 应用开发工具)
GC
Garbage Collector(垃圾回收器)
Garbage Collection(垃圾回收动作)
ART
Android Runtime(Android 应用运行时环境)
JVM
Java Virtual Machine(Java 虚拟机)
JUC
java.util.concurrent(Java 并发包)
1. 能不能不用多线程?
不管你懂不懂多线程,你也必须要用多线程。
GC 线程
假如我们现在运行的是用 AS 建的一个啥也没有的 demo 项目,那也不代表我们运行的是一个单线程应用。
因为这个应用是运行在 ART 上的,而 ART 自带了 GC 线程,再加上主线程,它依旧是一个多线程应用。
第三方线程
在我们开发应用的过程中,即使我们没有直接创建线程,也间接地创建了线程。
因为我们日常使用的第三方库,包括 Android 系统本身都用到了多线程。
比如 Glide 就是使用工作线程从网络上加载图片,等图片加载完毕后,再切回主线程把图片设置到 ImageView 中。
硬性要求
假如我们的应用中只有一个线程,意味着加载图片时 Loading 动画无法播放,界面是卡死的,用户会失去耐心。
而且 Android 强制要求开发者在发起网络请求时,必须在工作线程,不能在主线程,也就是开发 Android 应用必须使用多线程。
2. 为什么要做多线程优化?
做多线程优化是为了解决多线程的安全性和活跃性问题。
这两个问题会导致多线程程序输出错误的结果以及任务无法执行,下面我们就来看看这两个问题的表现。
安全性问题
假如现在有两个厨师小张和老王,他们两个人分别做两道菜,大家都知道自己的菜放了多少盐,多少糖,在这种情况下出问题的概率比较低。
但是如果两个人做一个菜呢?
小张在做一个菜,做着做着锅被老王抢走了,老王不知道小张有没有放盐,就又放了一次盐,结果炒出来的菜太咸了,没法吃,然后他们就决定要出去皇城 PK。
这里的“菜”对应着我们程序中的数据。
而这种现象就是导致线程出现安全性的原因之一:竞态(Race Condition)。
之所以会出现竞态是由 Java 的内存模型和线程调度机制决定的,关于 Java 的线程调度机制,在后面会有更详细的介绍。
活跃性问题
自从上次出了皇城 PK 的事情后,经理老李出了一条规定,打架扣 100,这条规定一出,小张和老王再也不敢 PK 了,不过没过几天,他们就找到了一种新的方式来互怼。
有一天,小张在做菜,小张要先放盐再放糖,而老王拿着盐,老王要先放糖再放盐,结果过了两个小时两个人都没把菜做出来,经理老李再次陷入懵逼的状态。
这就是线程活跃性问题的现象之一:死锁(Deadlock)。
关于线程安全性的三个问题和线程活跃性的四个问题,在本文后面会做更详细的介绍。
3. 什么是线程?
3.1 线程简介
线程是进程中可独立执行的最小单位,也是 CPU 资源分配的基本单位。
进程是程序向操作系统申请资源的基本条件,一个进程可以包含多个线程,同一个进程中的线程可以共享进程中的资源,如内存空间和文件句柄。
操作系统会把资源分配给进程,但是 CPU 资源比较特殊,它是分配给线程的,这里说的 CPU 资源也就是 CPU 时间片。
进程与线程的关系,就像是饭店与员工的关系,饭店为顾客提供服务,而提供服务的具体方式是通过一个个员工实现的。
线程的作用是执行特定任务,这个任务可以是下载文件、加载图片、绘制界面等。
下面我们就来看看线程的四个属性、六个方法以及六种状态。
3.2 线程的四个属性
线程有编号、名字、类别以及优先级四个属性,除此之外,线程的部分属性还具有继承性,下面我们就来看看线程的四个属性的作用和线程的继承性。
3.2.1 编号
作用
线程的编号(id)用于标识不同的线程,每条线程拥有不同的编号。
注意事项
不能作为唯一标识
某个编号的线程运行结束后,该编号可能被后续创建的线程使用,因此编号不适合用作唯一标识
只读
编号是只读属性,不能修改
3.2.2 名字
每个线程都有自己的名字(name),名字的默认值是 Thread-线程编号,比如 Thread-0 。
除了默认值,我们也可以给线程设置名字,以我们自己的方式去区分每一条线程。
作用
给线程设置名字可以让我们在某条线程出现问题时,用该线程的名字快速定位出问题的地方
3.2.3 类别
线程的类别(daemon)分为守护线程和用户线程,我们可以通过 setDaemon(true) 把线程设置为守护线程。
当 JVM 要退出时,它会考虑是否所有的用户线程都已经执行完毕,是的话则退出。
而对于守护线程,JVM 在退出时不会考虑它是否执行完成。
作用
守护线程通常用于执行不重要的任务,比如监控其他线程的运行情况,GC 线程就是一个守护线程。
注意事项
setDaemon() 要在线程启动前设置,否则 JVM 会抛出非法线程状态异常(IllegalThreadStateException)。
3.2.4 优先级
作用
线程的优先级(Priority)用于表示应用希望优先运行哪个线程,线程调度器会根据这个值来决定优先运行哪个线程。
取值范围
Java 中线程优先级的取值范围为 1~10,默认值是 5,Thread 中定义了下面三个优先级常量。
最低优先级:MIN_PRIORITY = 1
默认优先级:NORM_PRIORITY = 5
最高优先级:MAX_PRIORITY = 10
注意事项
不保证
线程调度器把线程的优先级当作一个参考值,不一定会按我们设定的优先级顺序执行线程
线程饥饿
优先级使用不当会导致某些线程永远无法执行,也就是线程饥饿的情况,关于线程饥饿,在第 7 大节会有更多的介绍
3.2.5 继承性
线程的继承性指的是线程的类别和优先级属性是会被继承的,线程的这两个属性的初始值由开启该线程的线程决定。
假如优先级为 5 的守护线程 A 开启了线程 B,那么线程 B 也是一个守护线程,而且优先级也是 5 。
这时我们就把线程 A 叫做线程 B 的父线程,把线程 B 叫做线程 A 的子线程。
3.3 线程的六个方法
线程的常用方法有六个,它们分别是三个非静态方法 start()、run()、join() 和三个静态方法 currentThread()、yield()、sleep() 。
下面我们就来看下这六个方法都有哪些作用和注意事项。
3.3.1 start()
作用
start() 方法的作用是启动线程。
注意事项
该方法只能调用一次,再次调用不仅无法让线程再次执行,还会抛出非法线程状态异常。
3.3.2 run()
作用
run() 方法中放的是任务的具体逻辑,该方法由 JVM 调用,一般情况下开发者不需要直接调用该方法。
注意事项
如果你调用了 run() 方法,加上 JVM 也调用了一次,那这个方法就会执行两次
3.3.3 join()
作用
join() 方法用于等待其他线程执行结束。
如果线程 A 调用了线程 B 的 join() 方法,那线程 A 会进入等待状态,直到线程 B 运行结束。
注意事项
join() 方法导致的等待状态是可以被中断的,所以调用这个方法需要捕获中断异常
3.3.4 Thread.currentThread()
作用
currentThread() 方法是一个静态方法,用于获取执行当前方法的线程。
我们可以在任意方法中调用 Thread.currentThread() 获取当前线程,并设置它的名字和优先级等属性。
3.3.5 Thread.yield()
作用
yield() 方法是一个静态方法,用于使当前线程放弃对处理器的占用,相当于是降低线程优先级。
调用该方法就像是是对线程调度器说:“如果其他线程要处理器资源,那就给它们,否则我继续用”。
注意事项
该方法不一定会让线程进入暂停状态。
3.3.6 Thread.sleep(ms)
作用
sleep(ms) 方法是一个静态方法,用于使当前线程在指定时间内休眠(暂停)。
线程不止提供了上面的 6 个方法给我们使用,而其他方法的使用在文章的后面会有一个更详细的介绍。
3.4 线程的六种状态
3.4.1 线程的生命周期
和 Activity 一样,线程也有自己的生命周期,而且生命周期事件也是由用户(开发者)触发的。
从 Activity 的角度来看,用户点击按钮后打开一个 Activity,就相当于是触发了 Activity 的 onCreate() 方法。
从线程的角度来看,开发者调用了 start() 方法,就相当于是触发了 Thread 的 run() 方法。
如果我们在上一个 Activity 的 onPause() 方法中进行了耗时操作,那么下一个 Activity 的显示也会因为这个耗时操作而慢一点显示,这就相当于是 Thread 的等待状态。
线程的生命周期不仅可以由开发者触发,还会受到其他线程的影响,下面是线程各个状态之间的转换示意图。
![线程的生命周期.png 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/ea15855755abb2376a0d348b7d9917d6.png)
我们可以通过 Thread.getState() 获取线程的状态,该方法返回的是一个枚举类 Thread.State。
线程的状态有新建、可运行、阻塞、等待、限时等待和终止 6 种,下面我们就来看看这 6 种状态之间的转换过程。
3.4.2 新建状态
当一个线程创建后未启动时,它就处于新建(NEW)状态。
3.4.3 可运行状态
当我们调用线程的 start() 方法后,线程就进入了可运行(RUNNABLE)状态。
可运行状态又分为预备(READY)和运行(RUNNING)状态。
预备状态
处于预备状态的线程可被线程调度器调度,调度后线程的状态会从预备转换为运行状态,处于预备状态的线程也叫活跃线程。
运行状态
运行状态表示线程正在运行,也就是处理器正在执行线程的 run() 方法。
当线程的 yield() 方法被调用后,线程的状态可能由运行状态变为预备状态。
3.4.4 阻塞状态
当下面几种情况发生时,线程就处于阻塞(BLOCKED)状态。
发起阻塞式 I/O 操作
申请其他线程持有的锁
进入一个 synchronized 方法或代码块失败
3.4.5 等待状态
一个线程执行特定方法后,会等待其他线程执行执行完毕,此时线程进入了等待(WAITING)状态。
等待状态
下面的几个方法可以让线程进入等待状态。
Object.wait()
LockSupport.park()
Thread.join()
可运行状态
下面的几个方法可以让线程从等待状态转变为可运行状态,而这种转变又叫唤醒。
Object.notify()
Object.notifyAll()
LockSupport.unpark()
3.4.6 限时等待状态
限时等待状态 (TIMED_WAITING)与等待状态的区别就是,限时等待是等待一段时间,时间到了之后就会转换为可运行状态。
下面的几个方法可以让线程进入限时等待状态,下面的方法中的 ms、ns、time 参数分别代表毫秒、纳秒以及绝对时间。
Thread.sleep(ms)
Thread.join(ms)
Object.wait(ms)
LockSupport.parkNonos(ns)
LockSupport.parkUntil(time)
3.4.7 终止状态
当线程的任务执行完毕或者任务执行遇到异常时,线程就处于终止(TERMINATED)状态。
4. 线程调度的原理是什么?
这一节会线程调度原理相关的对 Java 内存模型、高速缓存、Java 线程调度机制进行一个简单的介绍。
4.1 Java 的内存模型
了解 Java 的内存模型,能帮助我们更好地理解线程的安全性问题,下面我们就来看看什么是 Java 的内存模型。
![Java 内存模型.png 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/cb32de42d33e26f893fa63b77a26dd17.png)
Java 内存模型(Java Memory Model,JMM)规定了所有变量都存储在主内存中,每条线程都有自己的工作内存。
JVM 把内存划分成了好几块,其中方法区和堆内存区域是线程共享的。
假如现在有三个线程同时对值为 5 的变量 a 进行自增操作,那最终的结果应该是 8 。
但是自增的真正实现是分为下面三步的,而不是一个不可分割的(原子的)操作。
将变量 a 的值赋值给临时变量 temp
将 temp 的值加 1
将 temp 的值重新赋给变量 a。
假如线程 1 在进行到第二步的时候,其他两条线程读取了变量 a ,那么最终的结果就是 7,而不是预期的 8 。
这种现象就是线程安全的其中一个问题:原子性。
4.2 高速缓存
4.2.1 高速缓存简介
![现代计算机系统高速缓存结构.png 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/03bc12ae5dc56aec9f24327685439d64.png)
现代处理器的处理能力要远胜于主内存(DRAM)的访问速率,主内存执行一次内存读/写操作需要的时间,如果给处理器使用,处理器可以执行上百条指令。
为了弥补处理器与主内存之间的差距,硬件设计者在主内存与处理器之间加入了高速缓存(Cache)。
处理器执行内存读写操作时,不是直接与主内存打交道,而是通过高速缓存进行的。
高速缓存相当于是一个由硬件实现的容量极小的散列表,这个散列表的 key 是一个对象的内存地址,value 可以是内存数据的副本,也可以是准备写入内存的数据。
4.2.2 高速缓存内部结构
![高速缓存内部结构.png 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/633b52e5b3ea187fd8c48252373d8649.png)
从内部结构来看,高速缓存相当于是一个链式散列表(Chained Hash Table),它包含若干个桶,每个桶包含若干个缓存条目(Cache Entry)。
4.2.3 缓存条目结构
![缓存条目结构.png 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/01965e896db0ed4cf4825d17e7600487.png)
缓存条目可进一步划分为 Tag、Data Block 和 Flag 三个部分。