[操作系统]线程

线程

你可能已经很熟悉多任务(multitasking),这是操作系统的一种能力,看起来可以在同一时刻运行多个程序。例如,在编辑或下载邮件的同时可以打印文件。如今,人们往往都有多CPU的计算机,但是,并发执行的进程数目并不受限于CPU数目。操作系统会为每个进程分配CPU时间片,给人并行处理的感觉。多线程程序在更低一层扩展了多任务的概念:单个程序看起来在同时完成多个任务。每个任务在一个线程(thread)中执行,线程是控制线程的简称。如果一个程序可以同时运行多个线程,则称这个程序是多线程的(multithreaded)。

多进程与多线程有哪些区别?#

本质的区别在于每个进程都拥有自己的一整套变量,而线程则共享数据。这听起来似乎有些风险,的确也是这样,本章稍后将介绍这个问题。不过,共享变量使线程之间的通信比进程之间的通信更有效、更容易。此外,在有些操作系统中,与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。在实际应用中,多线程非常有用。例如,一个浏览器可以同时下载几幅图片。一个Web服务器需要同时服务并发的请求。图形用户界面(GUI)程序用一个独立的线程从宿主操作环境收集用户界面事件。本章将介绍如何为Java应用程序添加多线程功能。温馨提示:多线程编程可能会变得相当复杂。本章涵盖了应用程序员可能需要的所有工具。尽管如此,对于更复杂的系统级程序设计,建议参看更高级的参考文献,例如,BrianGoetz等撰写的《JavaConcurrencyinPractice》®(Addison-WesleyProfessional,2006)。

什么是线程#

首先来看一个使用了两个线程的简单程序。这个程序可以在银行账户之间完成资金转账。我们使用了一个Bank类,它可以存储给定数目的账户的余额。transfer方法将一定金额从一个账户转移到另一个账户。具体实现见程序清单12-2。在第一个线程中,我们将钱从账户0转移到账户1。第二个线程将钱从账户2转移到账户3。下面是在一个单独的线程中运行一个任务的简单过程:

  1. 将执行这个任务的代码放在一个类的run方法中,这个类要实现Runnable接口。Runnable接口非常简单,只有一个方法:

    public interface Runnable{
    	void run();
    }
    

    由于Runnable是一个函数式接口,可以用一个lambda表达式创建一个实例:

    Runnable r=()->{task code};
    
  2. 从这个Runnable构造一个Thread对象:

    var t = new Thread(r);
    
  3. 启动线程:

    t.start();
    

    为了建立单独的线程来完成转账,我们只需要把转账代码放在一个Runnable的run方法中,然后启动一个线程:

    Runnabler=()->{
    try{
    	for(int i= 0 ;i<STEPS;i++)
    	{
    		doubleamount=MAXAMOUNT*Math.random();
    		bank.transfer(0,1,amount);
    		Thread.sleep((int)(DELAY*Math.random()));
    	}
    catch(InterruptedException e)	{
    	trvart = new Thread(r);
    	t.start();
    

    对于给定的步骤数,这个线程会转账一个随机金额,然后休眠一个随机的延迟时间。我们要捕获sleep方法有可能抛出的InterruptedException异常。这个异常会在12.3.1节讨论。般来说,中断用来请求终止一个线程。相应地,出现InterrptedException时,rn方法会退出。程序还会启动第二个线程,它从账户2向账户3转账。运行这个程序时,可以得到类似这样的输出:

    Thread[Thread-1,5,mainThread[Thread-0.5.mai0ThreadThreachrea0ThreadtThread[ThreamainThreadfThread.mainThreadfThread.11.5.ma1nThread[Thread.5.ma1n686.77from2to3TotalBalance:40日Q日日.00TotalBalance:400000.0098.99from@to1476.78TotalBalance:400Q00.00十rom2t03400Q00.80653.64t01TotalBalance:十广o前Q408080.日G807.14from2to3TotalBalance:481.49from@tolTotalBalance:408080.00408000.00203.73from0tolTotalBalance:408000.00111.76from2to3TotalBalance:400日日.日0794.88from03TotalBalance:
    

    可以看到,两个线程的输出是交错的,这说明它们在并发运行。实际上,两个输出行交错显示时,输出有时会有些混乱。你要了解的就是这些!现在你已经知道了如何并发地运行任务。这一章余下的部分会介绍如何控制线程之间的交互。程序的完整代码见程序清单12-1。注释:还可以通过建立Thread类的一个子类来定义线程,如下所示:

    class MyThread extends Thread
    {
    	public void run()
    	{
    		taskcode
    	}
    }
    

然后可以构造这个子类的一个对象,并调用它的start方法。不过,现在不再推荐这种方法。应当把要并行运行的任务与运行机制解耦合。如果有多个任务,为每个任务分别创建一个单独的线程开销会太大。实际上,可以使用一个线程池,参见12.6.2节的介绍。

警告:不要调用Thread类或Runnable对象的run方法。直接调用run方法只会在同一个线程中执行这个任务,而没有启动新的线程。实际上,应当调用Thread.start方法这会创建一个执行run方法的新线程。

程序清单12-1 threads/ThreadTest.java
package threads;1
*@version1.302004-08-01
5*@author Cay Horstmann
6 来/
7 public class ThreadTest
日
多
16
public static final int DELAY = 10;
public static final int STEPS = 108;
public static final double MAX AMOUNT = 1800;
public static void main(String[] args)
var bank=new Bank(4,100800);Runnable task1 =()->
try
for(inti=0;i<STEPS; i++)
double amount = MAX AMOUNT* Math.random();bank.transfer(@,1,amount);Thread.sleep((int)(DELAY * Math.random()));
catch(InterruptedException e)
Runnable task2=()->
try
for(inti=0;i< STEPS; i++)
double amount = MAX AMOUNT * Math.random();bank.transfer(23,amount);Thread.sleep((int)(DELAY * Math.random()));
catch(InterruptedException e)
new Thread(taskl).start();new Thread(task2).start();

java.lang.Thread 1.0

  • Thread(Runnable target)
    构造一个新线程,调用指定目标的run()方法。
  • void start()
    启动这个线程,从而调用run()方法。这个方法会立即返回。新线程会并发运行
  • void run()
    调用相关 Runnable 的 run 方法。
  • static void sleep(long millis)
    休眠指定的毫秒数。

java.lang.Runnable 1.0

  • void run()
    必须覆盖这个方法,提供你希望执行的任务指令。

线程的状态#

线程可以有如下6种状态:

  • New(新建)
  • Runnable(可运行)
  • Blocked(阻塞)
  • Waiting(等待)
  • Timedwaiting(计时等待)
  • Terminated(终止)

下面分别对每一种状态进行解释。要确定一个线程的当前状态,只需要调用getstate()方法。

新建线程#

当用new操作符创建一个新线程时,如newThread(r),这个线程还没有开始运行。这意味着它的状态是新建(new)。当一个线程处于新建状态时,程序还没有开始运行线程中的代码。在线程运行之前还有一些基础工作要做。

可运行线程#

一旦调用start方法,线程就处于可运行(runnable)状态。一个可运行的线程可能正在运行也可能没有运行。要由操作系统为线程提供具体的运行时间。(不过,Java规范没有将正在运行作为一个单独的状态。一个正在运行的线程仍然处于可运行状态。一旦一个线程开始运行,它不一定始终保持运行。事实上,运行中的线程有时需要暂停,让其他线程有机会运行。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完时,操作系统剥夺该线程的运行权,并给另一个线程一个机会来运行(见图12-2)。当选择下一个线程时,操作系统会考虑线程的优先级。

现在所有的桌面以及服务器操作系统都使用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度。在这样的设备中,一个线程只有在调用yield方法或者被阻塞或等待时才失去控制权。在有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器还是需要分配时间片。记住,在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行(正是因为这样,这个状态称为“可运行”而不是“运行”)。

java.lang.Thread 1.0

  • static void yield()
    使当前正在执行的线程向另一个线程交出运行权。注意这是一个静态方法。

阻塞和等待线程#

当线程处于阻塞或等待状态时,它暂时是不活动的。它不运行任何代码,而且消耗最少的资源。要由线程调度器重新激活这个线程。具体细节取决于它是怎样到达非活动状态的。

  • 当一个线程试图获取一个内部的对象锁(而不是java.util.concurrent库中的Lock),而这个锁目前被其他线程占有,该线程就会被阻塞(我们将在12.4.3节讨论java.util.concurrent锁,在12.4.5节讨论内部对象锁)。当所有其他线程都释放了这个锁,并且线程调度器允许该线程持有这个锁时,它将变成非阻塞状态。

  • 当线程等待另一个线程通知调度器出现一个条件时,这个线程会进入等待状态。我们会在12.4.4节讨论条件。调用object.wait方法或Thread.join方法,或者是等待java.util.concurrent库中的Lock或Condition时,就会出现这种情况。实际上,阻塞状态与等待状态并没有太大区别。

  • 有几个方法有超时参数,调用这些方法会让线程进人计时等待(timed-waiting)状态。这一状态将一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有Thread.sleep计时版的object.wait、Thread.join、Lock.tryLock以及Condition.await

图12-1展示了线程可能的状态以及从一个状态到另一个状态可能的转换。当一个线程阻塞或等待时(或终止时),可以调度另一个线程运行。当一个线程被重新激活(例如,因为超时期满或成功地获得了一个锁),调度器检査它是否具有比当前运行线程更高的优先级。如果是这样,调度器会剥夺某个当前运行线程的运行权,选择一个新线程运行。

image

image

终止线程#

线程会由于以下两个原因之一而终止:

  • run方法正常退出,线程自然终止。
  • 因为一个没有捕获的异常终止了run方法,使线程意外终止。具体来说,可以调用线程的stop方法杀死一个线程。该方法抛出一个ThreadDeath错误对象,这会杀死线程。不过,stop方法已经废弃,不要在你自己的代码中调用这个方法。

java.lang.Thread 1.0

  • void join()
    等待终止指定的线程,
  • void join(longmillis)
    等待指定的线程终止或者等待经过指定的毫秒数
  • Thread.State getState()
    得到这个线程的状态;取值为NEW、RUNNABLE、BLOCKED、WAITING、TIMEDWAITING或TERMINATED。
  • void stop()
    停止该线程。这个方法已经废弃。
  • void suspend()
    暂停这个线程的执行。这个方法已经废弃。
  • void resume()
    恢复线程。这个方法只能在调用suspend()之后使用。这个方法已经废弃

线程的属性#

线程通信#

  • volatile

  • synchronized/wait()/notify()/notifyall()

  • ReentrantLock/condition/await()/signal()/signalall()

  • 管道输入输出流
    面向字节的PipedOutputStream、PipedInputStream
    面向字符的PipedReader、PipedWriter

  • join

  • threadlocal
    Java提供了一个InheritableThreadLocal来解决线程本地变量在父子线程中传递的问题

参考文章

image

线程在一定条件下,状态会发生变化。线程一共有以下几种状态:

  1. 新建状态(New):新创建了一个线程对象。
  2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于“可运行线程池”中,变得可运行,只等待获取CPU的使用权。即在就绪状态的进程除CPU之外,其它的运行所需资源都已全部获得。
  3. 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
  4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
    • 等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒。
    • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入“锁池”中。
    • 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

——当线程试图获取某个对象的同步锁时,如果该锁被其他线程所持有,则当前线程进入阻塞状态,如果想从阻塞状态进入就绪状态必须得获取到其他线程所持有的锁。
——当线程调用了一个阻塞式的IO方法时,该线程就会进入阻塞状态,如果想进入就绪状态就必须要等到这个阻塞的IO方法返回。
——当线程调用了某个对象的wait()方法时,也会使线程进入阻塞状态,notify()方法唤醒。
——调用了Thread的sleep(long millis)。线程睡眠时间到了会自动进入阻塞状态。
——一个线程调用了另一个线程的join()方法时,当前线程进入阻塞状态。等新加入的线程运行结束后会结束阻塞状态,进入就绪状态。

5、死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

线程变化的状态转换图如下:
image.png
注:拿到对象的锁标记,即为获得了对该对象(临界区)的使用权限。即该线程获得了运行所需的资源,进入“就绪状态”,只需获得CPU,就可以运行。因为当调用wait()后,线程会释放掉它所占有的“锁标志”,所以线程只有在此获取资源才能进入就绪状态。
下面小小的作下解释:
1、线程的实现有两种方式,一是继承Thread类,二是实现Runnable接口,但不管怎样, 当我们new了这个对象后,线程就进入了初始状态;
2、当该对象调用了start()方法,就进入就绪状态;
3、进入就绪后,当该对象被操作系统选中,获得CPU时间片就会进入运行状态;
4、进入运行状态后情况就比较复杂了
4.1、run()方法或main()方法结束后,线程就进入终止状态;
4.2、当线程调用了自身的sleep()方法或其他线程的join()方法,进程让出CPU,然后就会进入阻塞状态(该状态既停止当前线程,但并不释放所占有的资源即调用sleep ()函数后,线程不会释放它的“锁标志”。)。当sleep()结束或join()结束后,该线程进入可运行状态,继续等待OS分配CPU时间片。典型地,sleep()被用在等待某个资源就绪的情形:测试发现条件不满足后,让线程阻塞一段时间后重新测试,直到条件满足为止。
4.3、线程调用了yield()方法,意思是放弃当前获得的CPU时间片,回到就绪状态,这时与其他进程处于同等竞争状态,OS有可能会接着又让这个进程进入运行状态; 调用 yield() 的效果等价于调度程序认为该线程已执行了足够的时间片从而需要转到另一个线程。yield()只是使当前线程重新回到可执行状态,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行。
4.4、当线程刚进入可运行状态(注意,还没运行),发现将要调用的资源被synchroniza(同步),获取不到锁标记,将会立即进入锁池状态,等待获取锁标记(这时的锁池里也许已经有了其他线程在等待获取锁标记,这时它们处于队列状态,既先到先得),一旦线程获得锁标记后,就转入就绪状态,等待OS分配CPU时间片;

4.5.suspend() 和 resume()方法:两个方法配套使用,suspend()使得线程进入阻塞状态,并且不会自动恢复,必须其对应的resume()被调用,才能使得线程重新进入可执行状态。典型地,suspend()和 resume() 被用在等待另一个线程产生的结果的情形:测试发现结果还没有产生后,让线程阻塞,另一个线程产生了结果后,调用 resume()使其恢复。
4.6、wait()和 notify() 方法:当线程调用wait()方法后会进入等待队列(进入这个状态会释放所占有的所有资源,与阻塞状态不同),进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒(由于notify()只是唤醒一个线程,但我们由不能确定具体唤醒的是哪一个线程,也许我们需要唤醒的线程不能够被唤醒,因此在实际使用时,一般都用notifyAll()方法,唤醒有所线程),线程被唤醒后会进入锁池,等待获取锁标记。

wait() 使得线程进入阻塞状态,它有两种形式:

一种允许指定以毫秒为单位的一段时间作为参数;另一种没有参数。前者当对应的 notify()被调用或者超出指定时间时线程重新进入可执行状态即就绪状态,后者则必须对应的 notify()被调用。当调用wait()后,线程会释放掉它所占有的“锁标志”,从而使线程所在对象中的其它synchronized数据可被别的线程使用。waite()和notify()因为会对对象的“锁标志”进行操作,所以它们必须在synchronized函数或synchronizedblock中进行调用。如果在non-synchronized函数或non-synchronizedblock中进行调用,虽然能编译通过,但在运行时会发生IllegalMonitorStateException的异常。

注意区别:初看起来wait() 和 notify() 方法与suspend()和 resume() 方法对没有什么分别,但是事实上它们是截然不同的。区别的核心在于,前面叙述的suspend()及其它所有方法在线程阻塞时都不会释放占用的锁(如果占用了的话),而wait() 和 notify() 这一对方法则相反。

上述的核心区别导致了一系列的细节上的区别

首先,前面叙述的所有方法都隶属于 Thread类,但是wait() 和 notify() 方法这一对却直接隶属于 Object 类,也就是说,所有对象都拥有这一对方法。初看起来这十分不可思议,但是实际上却是很自然的,因为这一对方法阻塞时要释放占用的锁,而锁是任何对象都具有的,调用任意对象的 wait() 方法导致线程阻塞,并且该对象上的锁被释放。而调用任意对象的notify()方法则导致因调用该对象的 wait()方法而阻塞的线程中随机选择的一个解除阻塞(但要等到获得锁后才真正可执行)。

其次,前面叙述的所有方法都可在任何位置调用,但是wait() 和 notify() 方法这一对方法却必须在 synchronized 方法或块中调用,理由也很简单,只有在synchronized方法或块中当前线程才占有锁,才有锁可以释放。同样的道理,调用这一对方法的对象上的锁必须为当前线程所拥有,这样才有锁可以释放。因此,这一对方法调用必须放置在这样的 synchronized方法或块中,该方法或块的上锁对象就是调用这一对方法的对象。若不满足这一条件,则程序虽然仍能编译,但在运行时会出现IllegalMonitorStateException异常。

wait() 和 notify()方法的上述特性决定了它们经常和synchronized方法或块一起使用,将它们和操作系统的进程间通信机制作一个比较就会发现它们的相似性:synchronized方法或块提供了类似于操作系统原语的功能,它们的执行不会受到多线程机制的干扰,而这一对方法则相当于 block和wake up 原语(这一对方法均声明为 synchronized)。它们的结合使得我们可以实现操作系统上一系列精妙的进程间通信的算法(如信号量算法),并用于解决各种复杂的线程间通信问题。

关于 wait() 和 notify() 方法最后再说明两点:

第一:调用notify() 方法导致解除阻塞的线程是从因调用该对象的 wait()方法而阻塞的线程中随机选取的,我们无法预料哪一个线程将会被选择,所以编程时要特别小心,避免因这种不确定性而产生问题。

第二:除了notify(),还有一个方法 notifyAll()也可起到类似作用,唯一的区别在于,调用 notifyAll()方法将把因调用该对象的 wait()方法而阻塞的所有线程一次性全部解除阻塞。当然,只有获得锁的那一个线程才能进入可执行状态。

谈到阻塞,就不能不谈一谈死锁,略一分析就能发现,suspend()方法和不指定超时期限的wait()方法的调用都可能产生死锁。遗憾的是,Java并不在语言级别上支持死锁的避免,我们在编程中必须小心地避免死锁。

为什么线程切换比进程快?#

首先我们需要了解一个概念:上下文切换
直接说概念可能有点看不懂,我们来举个例子:比如我们正在洗衣服,这时候突然有人打来电话,那么我们就需要放下手中的衣服去接电话,这就好比是进程切换,从洗衣服的进程换到打电话的进程,打完电话,我们还需要接着洗衣服,那么此时我们有什么呢?有洗了一半的衣服,有打开的洗衣液,有水,衣服还处在刚刚洗了一半的状态,这一系列的状态就可以理解为上下文。

回到计算机的世界里,内核为每一个进程维持一个上下文,上下文就是重新启动一个进程所需的状态,包含以下内容:

  • 通用目的寄存器
  • 浮点寄存器
  • 程序计数器
  • 用户栈
  • 状态寄存器
  • 内核栈
  • 各种内核数据结构:比如描绘地址空间的页表,包含有关当前进程信息的进程表,包含进程已打开文件信息的文件表

进程的切换实际上就是上下文切换
那么为什么线程切换比进程快呢?这里我们还需要理解一个概念:虚拟内存
虚拟内存是操作系统为每个进程提供的一种抽象的,私有的,连续地址的虚拟内存空间,但是我们都知道实际上进程的数据以及代码必然要放到物理内存上,那么我们怎么知道虚拟空间中的数组实际上存放的具体位置呢?答案就是页表
每个进程都有属于自己的虚拟内存空间,进程中的所有线程共享进程的虚拟内存空间,所以进程之间互不影响,线程之间可能会相互影响
现在我们可以来回答这个问题了
线程切换比进程块的主要原因就是进程切换涉及虚拟内存地址空间的切换而线程不会。因为每个进程都有自己的虚拟内存地址空间,而线程之间的虚拟地址空间是共享的,因此同一个进程之中的线程切换不涉及虚拟地址空间的转换,然而将虚拟地址转化为物理地址需要查找页表,查找页表是一个很慢的过程,所以线程切换自然就比进程快了。

作者:Esofar

出处:https://www.cnblogs.com/DCFV/p/18302056

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   Duancf  阅读(15)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示