多线程(4)- 为什么多线程会带来性能问题

最后更新:2019-06-04

1. 上下文切换

上下文是指某一时间点 CPU 寄存器和程序计数器的内容。

寄存器是 CPU 内部的数量较少但是速度很快的内存(与之对应的是 CPU 外部相对较慢的 RAM 主内存)。寄存器通过对常用值(通常是运算的中间值)的快速访问来提高计算机程序运行的速度。

程序计数器是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体依赖于特定的系统。

上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:

  • 挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的某处,
  • 在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复
  • 跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程。

即使是单核CPU也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。

时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程时同时执行的,时间片一般是几十毫秒(ms)。

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。 但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态,从任务保存到再加载的过程就是一次上下文切换。

挂起线程的时候,将当前线程的字节码执行位置记录在“程序计数器”里,当线程再次获取cpu的时间片的时候,就会知道上一次线程进行到哪里,来恢复当前进度

2. 为什么多线程会带来性能问题

多线程编程会带来的性能问题主要有两个方面:

  • 线程调度
  • 线程协作

2.1. 线程调度

2.1.1. 上下文切换

在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核等等,但线程数可能达到成百上千个。 这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。 而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。 但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。

例如当某个线程运行Thread.sleep(1000);的时候,线程调度器就会让当前这个线程阻塞,然后往往会让另一个正在等待CPU资源的线程进入可运行状态,这里会产生“上下文切换”,这是一种比较大的开销.

通常而言,一次上下文切换所带来的开销大约在5000~10000个时钟周期,大约几微秒,看似不起眼,其实已经是不小的性能损耗了。

2.1.2. 缓存失效

由于程序有很大概率会再次访问刚才访问过的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快地获取数据。 可一旦进行了线程调度,切换到其他线程,CPU就会去执行不同的代码,原有的缓存就很可能失效了,需要重新缓存新的数据,这也会造成一定的开销。 所以线程调度器为了避免频繁地发生上下文切换,通常会给被调度到的线程设置最小的执行时间,也就是只有执行完这段时间之后,才可能进行下一次的调度,由此减少上下文切换的次数。 这样就把上下文切换的最小阈值提高,减少上下文切换的次数,从而提高性能。

2.1.3. 何时会导致密集的上下文切换

如果程序频繁地竞争锁,或者由于IO读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销。

2.2. 协作开销

线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化。 也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中等等。

这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。

3. 参考资料

《Java 并发编程 78 讲 》

Edgar

Edgar
一个略懂Java的小菜比