探索 Android 多线程优化

code小生, 一个专注大前端领域的技术平台
公众号回复 Android 加入我的技术群

作者:灯不利多灯不利多投稿发表,转发等请联系原作者授权

640?wx_fmt=png
首图.png
640?wx_fmt=png
目录.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 的等待状态。

线程的生命周期不仅可以由开发者触发,还会受到其他线程的影响,下面是线程各个状态之间的转换示意图。

640?wx_fmt=png
线程的生命周期.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 的内存模型。

640?wx_fmt=png
Java 内存模型.png

Java 内存模型(Java Memory Model,JMM)规定了所有变量都存储在主内存中,每条线程都有自己的工作内存。

JVM 把内存划分成了好几块,其中方法区和堆内存区域是线程共享的。

假如现在有三个线程同时对值为 5 的变量 a 进行自增操作,那最终的结果应该是 8 。

但是自增的真正实现是分为下面三步的,而不是一个不可分割的(原子的)操作。

  1. 将变量 a 的值赋值给临时变量 temp

  2. 将 temp 的值加 1

  3. 将 temp 的值重新赋给变量 a。

假如线程 1 在进行到第二步的时候,其他两条线程读取了变量 a ,那么最终的结果就是 7,而不是预期的 8 。

这种现象就是线程安全的其中一个问题:原子性。

4.2 高速缓存

4.2.1 高速缓存简介
640?wx_fmt=png
现代计算机系统高速缓存结构.png

现代处理器的处理能力要远胜于主内存(DRAM)的访问速率,主内存执行一次内存读/写操作需要的时间,如果给处理器使用,处理器可以执行上百条指令。

为了弥补处理器与主内存之间的差距,硬件设计者在主内存与处理器之间加入了高速缓存(Cache)。

处理器执行内存读写操作时,不是直接与主内存打交道,而是通过高速缓存进行的。

高速缓存相当于是一个由硬件实现的容量极小的散列表,这个散列表的 key 是一个对象的内存地址,value 可以是内存数据的副本,也可以是准备写入内存的数据。

4.2.2 高速缓存内部结构
640?wx_fmt=png
高速缓存内部结构.png

从内部结构来看,高速缓存相当于是一个链式散列表(Chained Hash Table),它包含若干个桶,每个桶包含若干个缓存条目(Cache Entry)。

4.2.3 缓存条目结构
640?wx_fmt=png
缓存条目结构.png

缓存条目可进一步划分为 Tag、Data Block 和 Flag 三个部分。

    • 0
      点赞
    • 2
      收藏
      觉得还不错? 一键收藏
    • 0
      评论

    “相关推荐”对你有帮助么?

    • 非常没帮助
    • 没帮助
    • 一般
    • 有帮助
    • 非常有帮助
    提交
    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值