目录

  1. 🤔为什么需要多线程
  2. 👍多线程优点
    1. 资源利用率更好
    2. 程序响应更快
  3. 场景分析
  4. 😣多线程代价
    1. 上下文切换
    2. 死锁
    3. 资源限制的挑战
  5. 总结
  6. 附录

🤔为什么需要多线程

🎶并发编程可以让程序运行得更快,但是仍然需要注意

  • 并不是启动更多的线程就能让程序最大限度地并发执行
  • 并不是并发执行一定比串行执行快

👍多线程优点

🎶尽管面临很多挑战,多线程有一些优点使得它一直被使用

  • 资源利用率更好
  • 程序响应更快

资源利用率更好

一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说,从磁盘读取一个文件需要 5 秒,处理一个文件需要 2 秒。处理两个文件则需要:

1
2
3
4
5
6
5秒读取文件A
2秒处理文件A
5秒读取文件B
2秒处理文件B
---------------------
总共需要14秒

从磁盘中读取文件的时候,大部分的 CPU 时间用于等待磁盘去读取数据。在这段时间里,CPU 非常的空闲。它可以做一些别的事情。通过改变操作的顺序,就能够更好的使用 CPU 资源。看下面的顺序:

1
2
3
4
5
5秒读取文件A
5秒读取文件B + 2秒处理文件A
2秒处理文件B
---------------------
总共需要12秒

CPU 等待第一个文件被读取完,然后开始读取第二个文件,当第二文件在被读取的时候,CPU 会去处理第一个文件,CPU 一直处于运行状态这正是我们想看到的

✨对于 IO 密集性应用,在等待磁盘读取文件的时候,CPU 大部分时间时空闲的

📓CPU 能够在等待 IO 的时候做一些其他的事情。这个不一定就是磁盘 IO。它也可以是网络的 IO,或者用户输入。通常情况下,网络和磁盘的 IO 比 CPU 和内存的 IO 慢的多

程序响应更快

✨将一个单线程应用程序变成多线程应用程序可以得到响应更快的应用程序

场景分析

设想一个服务器应用,它在某一个端口监听进来的请求。当一个请求到来时,它去处理这个请求,然后再返回去监听。服务器的流程如下所述:

1
2
3
4
while(server is active){
listen for request
process request
}

两种不同风格的设计

  • 如果一个请求需要占用大量的时间来处理,在这段时间内新的客户端就无法发送请求给服务端。只有服务器在监听的时候,请求才能被接收(单线程)
  • 监听线程把请求传递给工作者线程(worker thread),然后立刻返回去监听。而工作者线程则能够处理这个请求并发送一个回复给客户端(多线程)

多线程设计风格下,案例演进为

1
2
3
4
while(server is active){
listen for request
dispatch request to worker thread
}

多线程方式下,服务端线程迅速地返回去监听。因此,更多的客户端能够发送请求给服务端,这个服务也变得响应更快

😣多线程代价

在进行并发编程时,如果希望通过多线程执行任务让程序运行得更快,会面临非常多的挑战( 比如上下文切换的问题、死锁的问题,以及受限于硬件和软件的资源限制问题 ),为什么会存在这些问题,以及如何解决这些问题将在下文详细介绍

上下文切换

❓什么是上下文切换?

  • 上下文切换是在 CPU 上的一系列动作
  • 具体过程是指:正在运行的线程到达切换时间节点,保存当前线程的中间结果,并转让出 CPU 使用权给下一个线程,将下一个线程的上一轮中间结果装载进当前环境中

❓为什么会有上下文切换?

  • 由于 CPU 时间片的轮询执行各个线程,需要通过不停地切换线程执行,让用户感觉多个线程是同时执行的
  • 这也是并发的基石,正是因为此,将切换线程的一系列步骤称之为:上下文切换

🤔如何减少上下文切换

  • 无锁并发编程(多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,避免使用锁能够做到减少上下文切换的频率,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据)
  • CAS 算法(Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁)
  • 使用最少线程(避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态)
  • 使用协程(在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换)

死锁

锁是个非常有用的工具,运用场景非常多,因为它使用起来非常简单,而且易于理解。但同时锁也会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可用

死锁本身是操作系统中的问题,由于操作系统中有若干进程并发执行,它们不断申请、使用、释放系统资源,虽然系统的进程协调、通信机构会对它们进行控制,但也可能出现若干进程都相互等待对方释放资源才能继续运行,否则就阻塞的情况。此时,若不借助外界因素,谁也不能释放资源,谁也不能解除阻塞状态,这些进程就处于死锁状态

📖死锁的含义

  • 操作系统中的死锁被定义为:系统中两个或者多个进程无限期地等待永远不会发生的条件,系统处于停滞状态
  • 广义上死锁是指:两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法继续执行下去

✨产生死锁的原因

  • 系统资源不足
  • 进程运行推进的顺序不合适
  • 资源分配不当

✨产生死锁的四个必要条件

  • 互斥条件:一个资源每次只能被一个进程使用
  • 占有且等待:一个进程因请求资源而阻塞时,对已获得的资源保持不放
  • 不可强行占有:进程已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁

📓在 Java 程序中,避免死锁的几个常用方法

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
  • 尝试使用定时锁,使用 lock.tryLock(timeout)来替代使用内部锁机制
  • 对于数据库锁加锁和解锁必须在一个数据库里,否则会出现解锁失败的情况

资源限制的挑战

❓什么是资源限制?

  • 资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源

如:服务器的带宽只有 2Mb/s,某个资源的下载速度是 1Mb/s 每秒,系统启动 10 个线程下载资源,下载速度不会变成 10Mb/s,而仍然受限于服务器的带宽

🎶在进行并发编程时,要考虑硬件资源(带宽上行与下载速率、硬盘读写速度和 CPU 的处理速度)以及软件资源(数据库的连接数和 socket 连接数)的限制

❓资源限制会出现什么问题?

  • 在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间

🎶并行和并发是不同的,但是并行中仍然存在资源限制的问题

🤔如何解决资源限制问题

  • 对于硬件资源限制,可以考虑使用集群并行执行程序(既然单机的资源有限制,那么就让程序在多机上运行)
  • 对于软件资源限制,可以考虑使用资源池将资源复用

总结

📓本文说明了为什么使用多线程,以及,如果使用多线程,给出了多线程下诸多问题的解决方案,强烈建议使用 JDK 并发包提供的并发容器和工具类来解决并发问题

附录

死锁的四个必要条件
多线程的优点
多线程的代价