多进程和多线程
1. 多进程
现代化操作系统都是采用『多任务』和『分时』设计,从而诞生了『进程』的概念,而后又进一步诞生了『线程』的概念。
提示
从历史的时间线来看,先诞生的是『进程』概念,后诞生的『线程』概念。
通俗地说,『程序』是死的,『进程』是活的。进程就是活着的程序
。
-
程序是在硬盘上,而进程是程序运行起来后的、在操作系统中的所有的相关内存数据和资源。
-
电脑关机后,程序还在,但是进程就没了。
『进程』是程序的一次动态执行的过程,它对应了从代码加载、执行至执行完毕的一个完整过程。这个过程也是进程从产生、发展至消亡的过程。
在操作系统的管控下,多个进程『轮流』使用 CPU 资源(和其他公共资源)。
提示
通俗地说,站在 CPU 的角度而言,根本不存在所谓的同时。这一点很重要!
进程的特点:
-
进程是操作系统运行程序的基本单元;
-
每一个进程都有自己独立的一块内存空间和一组系统资源;
-
每一个进程的内部数据和状态是完全独立的。
2. 进程的状态
进程是有状态的,进程的状态反映了此时此刻是操作系统中的哪个进程在使用 CPU 。
不同的操作系统因为各有特色、各有偏重,导致大家的就能成的状态的数量互有多少。但是所有的操作系统的进程的状态都是围绕如下的、核心三态进行设计、实现的。
状态 | 说明 |
---|---|
Running 运行中 | 表示当前正式这个进程使用 CPU ,也就是说,此时此刻,这个程序的代码正在刷刷刷地执行着。对于单核 CPU 而言,一个进程中无论有多少线程,任意时刻有且仅有一个线程是处于该状态。同理,对于多核 CPU 而言,N 核 CPU 有且仅有 N 个线程处于这种状态。 |
Runnable 可运行 | 因为 CPU 是被进程轮流使用的,那么当下没有『轮到』的进程都是处于 Runnable 状态。通俗的说,这种状态就是『万事俱备,只欠东风』,这里的『东风』指的就是 CPU 。显而易见,任何时刻,大多数进程都是处于这种状态。 |
Blocked 阻塞状态 | 一个正在运行(Running)的进程因为某种原因代码逻辑无法再继续运行下去了,就进入阻塞状态。进入阻塞状态的线程会让出 CPU ,因为即便它强占 CPU 也无法再继续运行下去。当造成该线程阻塞的原因消除后,该线程将又有机会运行。通俗的说,这种状态就是『除了欠东风,还欠别的』,比如,获得用户的输入,但是用户又迟迟没有输入时。 |
在上述的核心三态之外,你可能还会经常听到、见到 2 种状态:
状态 | 说明 |
---|---|
Created 新生态 | 进程刚被创建出来的一段时间内,就是这种状态。 |
Zombie 僵尸态 | 进程在结束执行后的一段时间内,会是这种状态,直到操作系统回收为它分配的资源,将它彻底销毁。 |
以上就是经典操作系统原理中的 5 种进程状态。
3. 多线程
== 提示==
线程是更现代化的概念和技术(它的诞生晚于进程)。
线程是进程中的一个单位,即,一个进程可以有多个线程(至少有一个)。简单来说,进程和线程的数量关系是 一对多
的关系。
进程是操作系统分配资源的最小单位,一个进程下的所有线程共享这个进程的所有资源。其中最关键的资源就是『时间片』,即,使用 CPU 的资格和时长。
- 进程下的某个线程是 running ,进程就是 running ;
- 进程下的所有线程是 blocked ,进程就是 blocked ;
- 进程下的 running 线程变成 blocked ,操作系统选择同进程下的 runnable 线程执行;
- 进程的本轮时间片耗完时,进程的 running 线程会变为 runnable 。操作系统选择其它进程的某个 runnable 线程运行。
通俗地说,每一个线程都有自己的『历史使命』:执行一个方法。线程开始执行,即意味着执行这个方法的第一行代码;方法的最后一行代码结束后,线程则会被销毁。
每一个线程所要执行的那个方法就被称为线程的『执行方法』,也叫『入口方法』。
每一个进程天然、天生、自动拥有一个线程,这个线程以 main 方法作为自己的执行方法,因此,这个线程也被称为 main 线程,即,主线程。
创建并使用线程的过程可以分为 4 个步骤:
-
定义一个线程类,同时指明这个线程的执行方法
-
创建线程对象
-
启动线程
-
终止线程
定义一个线程类通常有两种方法:继承 java.lang.Thread 类和实现 java.lang.Runnable 接口。
Thread 类和 Runnable 接口
1. 使用 Thread 类创建线程
Java 提供了 java.lang.Thread 类支持多线程编程,该类提供了大量的方法来控制和操作线程。
# | 说明 |
---|---|
run 方法 | 直接调用线程的执行函数 |
start 方法 | 启动线程 |
sleep 方法 | 让线程休眠(即进入阻塞状态)指定 毫秒 数 |
getName 方法 | 返回该线程的名称 |
getPriority 方法 | 返回线程的优先级 |
setPriority 方法 | 更改线程的优先级 |
getState 方法 | 返回该线程的状态 |
isAlive 方法 | 测试线程是否处于活动状态 |
join 方法 | 等待该线程终止 |
interrupt 方法 | 中断线程 |
yield 方法 | 暂停正在执行的线程,并执行其他线程 |
使用继承 Thread 类的方式创建线程的实现步骤如下:
- 定义一个类去继承 Thread 类,重写 Thread#run 方法,在 Thread#run 方法中实现代码逻辑;
- 创建线程对象;
- 调用 Thread#start 方法启动线程。
2. 使用 Runnable 接口创建线程
由于 Java 只允许单继承,因此一旦一个类已有父类,那么就无法再继承 Thread 类,从而导致上述实现线程的方式无法使用。
使用 Runnable 接口创建线程能解决上述问题。
Runnable 接口声明了一个 Runnable#run 方法。任何一个类都可以通过实现 Runnable 接口并实现其 Runnable#run 方法来完成线程的所有活动。
使用实现 Runnable 接口的方式创建线程的实现步骤如下:
-
定义 Xxx 类并实现 java.lang.Runnable 接口,并实现它所声明的 run 方法;
-
创建线程对象;
-
调用 Runnable#start 方法启动线程。
线程的调度
1. 实现线程调度的方法
线程调度的实现核心思路只有一个:通过各种手段,迫使当前线程(即,Running 线程)进入 Blocked/Runnable 状态,让出 CPU , 从而让其它线程拥有执行机会。
-
手段一:Thread#join 方法
Thread#join 方法会导致当前线程阻塞(让出 CPU),等待调用该方法的线程(即,Thread 对象所代表的那个线程)结束后再继续执行本线程。
-
手段二:Thread.sleep 方法
Thread.sleep 方法会导致当前线程睡眠(本质上也是阻塞,迫使当前线程让出 CPU),在指定时间到期后,重新进入可运行状态。
-
手段三:Thread.yield 方法
Thread.yield 方法稍微有点不同,它让当前线程让出 CPU ,但并不是进入阻塞状态,而是直接进入 Runnable 状态。
需要注意的是,当前线程让出 CPU 之后,接下来是哪个线程执行(从 Runnable 状态变为 Running 状态)带有『不确定性』。
2. 线程的同步与互斥
当两个或多个线程需要访问同一资源(或执行同一段代码时),需要某一时刻只能被一个线程使用的方式,称为线程『互斥』。
当两个或多个线程以互斥的方式访问完同一资源(或执行同一段代码)后,『通知』其他线程的方式,称为线程『同步』。
同步与互斥通常总是一起出现的。只出现互斥,不出现同步,意味着代码逻辑是一种极简单的多线程状况。
3. synchronized 关键字
使用 synchronized 关键字修饰的方法控制对类成员变量的访问。每个类实例都对应一把锁,方法一旦执行,就独占该锁,直到方法结束时才将锁释放;此后其它被阻塞的线程才能获得该锁,重新进入可执行状态。
这种机制保证了同一时刻,对于每一个实例,其所声明为 synchronized 的方法只能有一个处于可执行状态,从而有效地避免了类成员变量的访问冲突。
语法:
访问修饰符 synchronized 返回类型 方法名 () {
...
}
或
synchronized 访问修饰符 返回类型 方法名 () {
...
}
synchronized 方法的缺陷在于:如果将一个耗时的方法声明为 synchronized 将会使其它线程阻塞时间过长,从而影响系统执行效率和用户体验。
同步代码块是同步方法的缺陷的解决方案,它『锁住』的不是整个方法,而是方法中的一个代码片段。
语法:
synchronized (一个对象) {
...
}
这里的『一个对象』通常是一个字符串常量对象。
锁
Java 并发 API 对『锁』提供了支持。锁是一些对象,它们为 synchronized 提供了替代方案。
锁(Lock) 不仅能实现 synchronized 的互斥功能,还能进一步实现线程间的同步功能。
锁的工作原理如下:在访问共享资源之前,申请用于保护资源的锁;当资源访问结束完成时,释放锁。当某个线程正在使用锁时,如果另一个线程尝试申请锁,那么后者将会阻塞等待,直到锁被前者释放位置。
提示
锁的作用,逻辑上,就是一个令牌、通行证。持有这个令牌、通行证的线程才能继续执行。没有这个令牌、通行证的线程,无法继续执行,直到令牌的持有者放下令牌,而被它拿到后,它才能继续执行。
所有的锁都要实现 Lock 接口,最常用的 Lock 接口的实现类是 ReentrantLock 。
lock(): 进行等待,直到可以获得锁为止
lockInterruptibly(): 除非被中断,否则进行等待,直到可以获得锁为止
newCondition(): 返回与调用锁关联的 Condition 对象
tryLock(): 尝试获得锁,如果锁不可获得,立即返回 false;如果可获得,返回 true
tryLock(wait, unit): 在指定时间内,尝试获得锁。如果超出时间后仍无法获得,则返回 false;
如果可获得,则返回 true
unlock(): 释放锁
ReentrantLock 实现了一种可重入锁,当前持有锁的线程能够重复进入这种锁。当然,对于线程重入锁而言,所有 Lock#lock()
调用必须有相同数量的 Lock#unlock()
调用进行抵消。
更多关于锁、同步、互斥、线程安全的内容参看《Java 线程安全》章节。