理解多线程和线程切换
多线程同时执行可以充分利用 CPU 的多核多线程功能来显著提高应用程序的性能,在多核环境中表现的更加明显(目前市面主流 CPU都是几乎都是多核多线程),即使单核 CPU 也支持多线程。
线程是操作系统调度的最小单元,CPU 通过给每个线程分配 CPU 时间片来实现多线程执行,时间片非常短(一般几十毫秒),通过不停切换线程来执行,给人的感觉是同时执行的。
线程
现代操作系统调度的最小单元是线程,一个进程里面可以创建多个线程,每个线程都有各自的计数器,堆栈和局部变量等属性,并且能够访问共享的内存变量。
Java 原生就支持多线程,Java 程序天生就是多线程程序,一个 main() 方法会运行多个线程,也会和多个其他线程同时运行。如下示例:
1 | public class MultiThread { |
输出如下:
1 | Command Reader |
线程上下文
线程上下文切换
CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。
所以任务从保存到再加载的过程就是一次上下文切换。
不合理的创建过多线程或对线程管理不当会造成线程并发问题,或过多的线程上下文切换同样会带来的性能损耗,从而达不到提高应用性能的目的。
可以使用 Lmbench3 测量上下文切换的时长,使用 vmstat 测量上下文切换的次数。
减少上下文切换
减少上下文切换的方法有 无锁并发编程,CAS算法,使用最少的线程 和 使用协程。
- 无锁编程:多线程竞争锁,会引起上下文切换,所以多线程处理数据时,可以预先对数据进行处理,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
- CAS算法:Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
- 使用最少线程:避免创建不必要的线程,若任务很少而创建了大量的线程来处理,这样会存在大量的线程处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维护多个任务间的切换。
使用 jstack 命令 dump 线程信息:
1 | # 3346 是 java pid 进程号 |
多线程死锁
锁是个非常有用的工具,但多线程使用不能可能会引起死锁,一旦死锁就会造成系统功能不可用。
简单死锁示例:
- 有 2 把锁 A 和 B。
- 有 2 个线程 t1 和 t2,每个线程执行有两层锁定,两个线程持有的锁都是对方等待的锁。如,t1 线程外层持 A 锁,内层等待 B 锁;t2 线程外层持 B 锁,内层等待 A 锁。这就生产了死锁。
实际的死锁场景可能更为复杂,例如 t1 拿到锁后,因为异常情况而没有释放锁(死循环);或拿到一个数据库锁,释放锁时抛出了异常,没释放掉锁。
一旦出现死锁,业务是可感知的,因为不能继承提供服务了,那么只能通过 dump 线程查看到底是哪个线程出现了问题(线程通常表现为 Blocked 阻塞状态)。
避免死锁常见方法:
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用 lock.tryLock(timeout) 来代替使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。