为什么要使用多线程?
从整体上来看
- 从计算机底层来说: 线程可以看作是轻量级的进程,是最小的程序执行单位,线程间的切换和调度的成本远远小于进程。另外,多核CPU时代,多个线程可以同时运行,这减少了线程上下文切换的开销。
- 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
深入到计算机底层来看
- 单核时代: 单核时代多线程主要是为了提高单进程利用CPU和IO系统的效率。假设只运行了一个Java进程的情况下,当我们请求IO的时候,如果只有一个线程,此线程被IO阻塞则整个进程被阻塞。CPU和IO设备只有一个在运行,那么可以简单地说系统整体的效率只有50%。如果使用多线程,一个线程被IO阻塞,其他线程还可以继续使用CPU,从而提高了Java进程利用系统资源的整体效率。
- 多核时代: 多核时代多线程主要是为了提高进程利用多核CPU的能力。假设我们在计算一个复杂的任务,如果只有一个线程,无论有多少个CPU核心,都只会有一个CPU核心被利用到。如果使用多线程,这些线程可以被映射到底层多个CPU核心上执行,任务执行的效率就会显著性的提高,约等于(单核时执行时间/CPU 核心数)。
使用多线程可能带来哪些问题?
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
线程的生命周期和状态
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
- NEW:初始状态,线程被创建出来,但是还没有调用start()方法。
- RUNABLE:运行中状态,调用了start()方法,Java线程将操作系统中的就绪/可运行(READY)和运行(RUNNING)两种状态统称为RUNABLE(运行中)状态。
- BLOCKED:阻塞状态,线程阻塞于锁,需要等待锁释放。
- WATING:等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIMED_WATING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:表示当前线程已经执行完毕。
-
由上图可以看出:线程创建之后它将处于 NEW(初始) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(就绪/可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。
-
在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态,所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。
-
为什么 JVM 没有区分这两种状态呢?
java 现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。
-
当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
-
TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。
-
当线程进入 synchronized 方法/块或者调用 wait 后,(被 notify)想要重新进入 synchronized 方法/块时,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。
-
线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态。
上下文切换
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如说每个线程都有自己的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
- 主动让出 CPU,比如调用了 sleep(), wait() 等。
- 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
- 被终止或结束运行
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
上下文切换是现代操作系统的基本功能,因其每次需要保存“信息恢复”信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
死锁
死锁描述
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示:线程A持有资源1,线程B拥有资源2,他们都想拥有对方的资源,所以这两个线程就会相互等待而进入死锁状态。
死锁产生的四个必要条件
- 互斥条件: 该资源任意一个时刻只能被一个线程占用。
- 请求与保持条件: 一个线程因请求资源阻塞,对已获得的资源保持不放。
- 不剥夺条件: 线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放。
- 循环等待条件: 若干线程之间形成一种头尾相接循环等待资源的关系。
如何预防死锁
破坏死锁的产生的必要条件即可:
破坏互斥条件 : 这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
破坏请求与保持条件 : 一次性申请所有的资源。
破坏不剥夺条件 : 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件 : 靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放,破坏循环等待条件。
锁排序法: 指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁? 通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。
使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁。
如何避免死锁?
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3.....Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,
使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。
sleep() 方法和 wait() 方法对比
共同点:
- 两者都可以暂停线程的执行。
- 两者都可以响应中断。
不同点:
- sleep()方法没有释放锁,而wait()方法释放了锁。
- sleep()方法通常用于暂停线程的执行,wait()方法通常用于线程间交互/通信。
- sleep() 方法执行完成后,线程会自动苏醒;wait() 方法被调用后,线程不会自动苏醒,需要其他线程调用同一个对象上的 notify()或者 notifyAll() 方法。或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
- sleep()方法是Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。
- wait()、notify()方法必须写在同步方法/同步代码块中,是为了防止死锁和永久等待,使线程更安全,而sleep()方法没有这个限制。
为什么 wait() 方法不定义在Thread中?
wait()方法是让获得对象锁的线程实现等待,并自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入WAITING状态,自然要操作对应的对象(Object)而非当前的线程(Thread)。
为什么 sleep() 方法定义在Thread中?
因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
可以直接调用 Thread 类的 run 方法吗?
new 一个 Thread,这个线程进入初始状态。让这个线程调用start()方法,会启动这个线程并使这个线程进入就绪/可运行(Ready)状态,当这个线程分配到时间片后就可以开始运行了(RUNNING状态)。start()方法会执行线程的相应准备工作,然后自动执行run()方法的内容,这是真正的多线程工作。直接执行run()方法,会把run()方法当成main线程下的一个普通方法去执行,并不会在某个线程中去执行,所以这并不是多线程工作。
总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。