线程监控 - 线程基础知识扫盲

在分享线程的监控之前,我们要来先讲讲线程的基础知识,一般来说只要我们基础牢固,在写代码的时候大部分情况下不容易犯错。但在 Android 团队人数达到几十人甚至上百人的时候,我们就无法确保所有的同学都能按部就班的写好代码了,所以我们还是要有监控,但光有监控是不行的还需要有理论基础,这样的话出现了问题才能分析解决。有很多同学认为线程有什么好了解的,无非就是 synchronized 、volatile、newThread,启动线程。很多同学可能连线程池和 lock 锁都没接触过,说句实话早先年我也跟大家一样,因为项目中用不上只能自己去看源码学原理。

1. 上下文切换

在过去单 CPU 时代,单任务在一个时间点只能执行单一程序。之后发展到多任务阶段,计算机能在同一时间点并行执行多任务或多进程。虽然并不是真正意义上的“同一时间点”,而是多个任务或进程共享一个 CPU,并交由操作系统来完成多任务间对 CPU 的运行切换,以使得每个任务都有机会获得一定的时间片运行。再后来发展到多线程技术,使得在一个程序内部能拥有多个线程并行执行。一个线程的执行可以被认为是一个 CPU 在执行该程序。当一个程序运行在多线程下,就好像有多个 CPU 在同时执行该程序。多线程比多任务更加有挑战。多线程是在同一个程序内部并行执行,因此会对相同的内存空间进行并发读写操作。这可能是在单线程程序中从来不会遇到的问题。其中的一些错误也未必会在单 CPU 机器上出现,因为两个线程从来不会得到真正的并行执行。然而,更现代的计算机伴随着多核 CPU 的出现,也就意味着 不同的线程能被不同的 CPU 核得到真正意义的并行执行。所以,在多线程、多任务情况下,线程上下文切换是必须的,然而对于 CPU 架构设计中的概念,应先熟悉了解,这样会有助于理解线程上下文切换原理。

多进程多线程在运行的过程中都离不开一个概念,那就是调度。JVM 虚拟机虽是跨平台但是并未接管线程调度,调度还是由操作系统本身来决定,我们在下次看线程创建底层源码便会知道。调度会涉及到一个上下文切换的概念,多任务多线程的本质其实就是 CPU 时间片的轮转,在多任务处理系统中,CPU 需要处理所有程序的操作,当用户来回切换它们时,需要记录这些程序执行到哪里。上下文切换就是这样一个过程,允许 CPU 记录并恢复各种正在运行程序的状态,使它能够完成切换操作。简单一点说就是指 CPU 从一个进程或线程切换到另一个进程或线程。

在上下文切换过程中,CPU 会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB, process control block)中的。PCB 还经常被称作“切换桢”(switchframe)。“页码”信息会一直保存到 CPU 的内存中,直到他们被再次使用。PCB 通常是系统内存占用区中的一个连续存区,它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息,它使一个在多道程序环境下不能独立运行的程序成为一个能独立运行的基本单位或一个能与其他进程并发执行的进程。

对于一个正在执行的进程包括 程序计数器、寄存器、变量的当前值等 ,而这些数据都是 保存在 CPU 的寄存器中的,且这些寄存器只能是正在使用 CPU 的进程才能享用,在进程切换时,首先得保存上一个进程的这些数据(便于下次获得 CPU 的使用权时从上次的中断处开始继续顺序执行,而不是返回到进程开始,否则每次进程重新获得 CPU 时所处理的任务都是上一次的重复,可能永远也到不了进程的结束出,因为一个进程几乎不可能执行完所有任务后才释放 CPU ),然后将本次获得 CPU 的进程的这些数据装入 CPU 的寄存器从上次断点处继续执行剩下的任务。

上下文切换会带来直接和间接两种因素影响程序性能的消耗。直接消耗:指的是 CPU 寄存器需要保存和加载, 系统调度器的代码需要执行, TLB 实例需要重新加载, CPU 的 pipeline 需要刷掉;间接消耗:指的是多核的 cache 之间得共享数据, 间接消耗对于程序的影响要看线程工作区操作数据的大小;因此我们在多线程操作时应该要考虑两个问题:第一个是尽量减少上下文的切换次数,第二个是尽量提高 CPU 的使用率。

2. 内存模型

在介绍 Java 内存模型之前,先来看一下到底什么是计算机内存模型,然后再来看 Java 内存模型在计算机内存模型的基础上做了哪些事情。先看一下为什么要有内存模型。

我们应该都知道,计算机在执行程序的时候,每条指令都是在 CPU 中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存啦。刚开始,还相安无事的,但是随着 CPU 技术的发展,CPU 的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和 CPU 的执行速度比起来差距就会越来越大,这就导致 CPU 每次操作内存都要耗费很多等待时间。所以,人们想出来了一个好的办法,就是在 CPU 和内存之间增加高速缓存。缓存的概念大家都知道,就是保存一份数据拷贝。它的特点是速度快,内存小,并且昂贵。那么,程序的执行过程就变成了:当程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,那么 CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。随着 CPU 能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与CPU结合的紧密程度,CPU 缓存可以分为一级缓存(L1),二级缓存(L3),部分高端 CPU 还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的 技术难度和制造成本是相对递减的,所以其容量也是相对递增的。那么,在有了多级缓存之后,程序的执行就变成了:当 CPU 要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。单核 CPU 只含有一套L1,L2,L3缓存。如果 CPU 含有多个核心,即多核 CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。

从上面的分析来看,这样就会导致一个问题,那就是多线程 CUP 缓存一致性的问题,也就是大家常常说的原子性问题,可见性问题和有序性问题等等。其本质其实就是 CPU 缓存优化后所带来的后遗症。出现问题就得解决问题,按照我们平时普通的思路就是回退版本,废除掉处理器和处理器的优化技术、废除CPU缓存,让CPU直接和主存交互,这肯定是不行的。因此内存模型就诞生了,内存模型就是用来解决 CPU 缓存优化后所带来的后遗症。Java 的内存模型如下:

JVM内存模型

3. 线程常见问题分析

Java 后台工程师经常会碰到一些问题像 CPU 飙高、Load 高、响应很慢等等,作为 Android 工程师由于很少会涉及到并发请求的处理,因此我们很少会刨根问底的去深究线程这一块。虽然遇到的问题可能会千奇百怪但是问题的本质是不会变的,这也是为什么我一再强调大家要把基础打牢,要多花些时间在 Linux 内核和系统源码上面。可以这么说,在 Android 场景下我们遇到的线程问题,只要从 Linux 内核和 JVM 的内存模型这两个方向去分析即可。

3.1. 线程池该怎么用

线程池参数有非常多:核心线程数、最大线程数,队列等等。在实际的过程中怎么用呢?其实无非就是前面提到的两点:

  • 第一是尽量减少上下文的切换次数,尽可能少的创建些线程
  • 第二是尽量提高 CPU 的使用率,尽可能多的创建些线程

直接看上去这两点像是冲突了,但在实际的场景中是不冲突的,比如我们在系统架构时分析过 OkHttp 的源码,我们不妨来看下它内部使用的线程池:

  public synchronized ExecutorService executorService() {
    if (executorService == null) {
      // 核心线程数是 0 ,最大线程数是 Integer.MAX_VALUE
      executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS,
          new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp Dispatcher", false));
    }
    return executorService;
  }

3.2. synchronized 与 lock 的区别

synchronized 的底层实现原理以前分析过这里就不再做过多的介绍,lock 的源码这个需要大家自己去看看,网上有很多的文章大家也可以去辅助了解一下。这里我们只提一个大家可能没留意的一个区别,synchronized 如果竞争不到锁会导致上下文切换,这也是为什么如果没有多线程安全的情况下,就不要随意加锁的原因。但是 lock 采用的一般是 Unsafe 底层的原理就是等待主线上的数据刷新。看上去好像 Lock 更好些,可以减少上下文的切换次数,其实也不完全正确,具体场景需要具体对待。

视频链接: https://pan.baidu.com/s/1pZA2udae2v3f-Xcc-Oh19g
视频密码: 87uh