写点什么

面试官惊叹,好小子!你这多线程基础可以啊!

作者:XiaoLin_Java
  • 2022 年 1 月 13 日
  • 本文字数:6874 字

    阅读完需:约 23 分钟

面试官惊叹,好小子!你这多线程基础可以啊!

一、多线程理论

1.1、操作系统的发展

    在计算机发明之前,人们处理大量的计算是通过人工处理的,耗费人力,成本很大而且错误较多。为了处理大量的数学计算问题,人们发明了计算机。


    最初的计算机只能接受一些特定的指令,用户输入一个指令,计算机就做出一个操作。当用户在思考或者输入时,计算机就在等待。显然这样效率低下,在很多时候,计算机都处在等待状态。

1.1.1、批处理操作系统

  既然传统计算机那么慢,那么能不能把一系列需要操作的指令写下来,形成一个清单,一次性交给计算机,计算机通过不断得读取指令进行相应的操作。


  就这样,批处理操作系统诞生了。用户将多个需要执行的程序写在磁带上,然后交由计算机去读取并逐个执行这些程序,并将输出结果写在另一个磁带上。

1.1.2、如何提高 CPU 利用率

    虽然批处理操作系统的诞生提高了任务处理的便捷性(省略了用户输入的时间),但是仍然存在一个很大的问题:


    假如有两个任务 A 和 B,需要读取大量的数据输入(I/O 操作),而其实 CPU 只能处在等待状态,等任务 A 读取完数据再能继续进行,这样就白白浪费了 CPU 资源。于是人们就想,能否在任务 A 读取数据的过程中,让任务 B 去执行,当任务 A 读取完数据之后,暂停任务 B,让任务 A 继续执行?


    这时候又出现了几个问题:内存中始终都只有一个程序在运行,而想要解决上述问题,必然要在内存中装入多个程序,如何处理呢?多个程序使用的数据如何辨别?当一个程序暂停后,随后怎么恢复到它之前执行的状态呢?

1.1.3、进程来了

    这时候,人们就发明了进程,用一个进程对应一个程序,每个进程都对应一定的内存地址和内存空间,并且只能自己使用自己的内存空间,多个进程之间的内存互不共享,且进程之间彼此不打扰。


    进程同时也保存了程序每时每刻的运行状态,为进程切换提供了如可能。


    当进程暂停时,它会保存当前进程的状态(进程标识,进程使用的资源等),在下一次切换回来时根据之前保存的 2 状态进行恢复,接着继续执行。

1.2、并发和并行

1.2.1、并发

    并发是能够让操作系统从宏观上看起来同一时间段执行多个任务。 换句话说,进程让操作体统的并发成为了可能,至此出现多任务操作系统。


    虽然并发从宏观上看是有多个任务在执行,但是实际上对于单核 CPU 来说,任意具体时刻都只有一个任务在占用 CPU 资源,操作系统一般通过 CPU 时间片轮转来实现并发。


    总的来说,并发就是在一段时间内多个进程轮流使用同一个 CPU,多个进程形成并发。


1.2.2、并行

    在同一时刻多个进程使用各自的 CPU多个进程形成并行。并行需要多个 CPU 支持。


1.3、线程

1.3.1、线程出现的原因

    出现了进程之后,操作系统的性能(CPU 利用率)得到了大大的提升。虽然进程的出现解决了操作系统的并发问题,但是人们不满足,逐渐对实时性有了要求。因为一个进程在一个时间段内只能做一个事情,如果一个进程有多个子任务时,只能逐个得执行这些子任务,很影响效率。


    举一个例子:对于监控系统这个进程来说,不仅要与服务器端进行通信获取图像数据并将图像信息显示在画面上,还要处理与用户的交互操作。如果在一个时刻该系统正在与服务器通信获取图像数据,而用户在监控系统上点击了一个按钮,那么系统只能等获取完图像后才能与用户进行交互操作。如果获取图像需要 10s,用户就得等待 10s。显然这样的系统,无法满足人们的需求。

1.3.2、线程

    为了让子任务可以分开执行,即上个例子说的,在与服务器通信获取图形数据的同时相应用户,为了处理这种情况,人们发明了线程,一个线程执行一个子任务,这样一个进程就包含了多个线程,每个线程负责一个单独的子任务。在用户点击按钮的时候,可以暂停获取图像数据的线程,让出 CPU 资源,让 UI 线程获取 CPU 资源,响应用户的操作,响应完后再切换回来,获取图像数据的线程重新获取 CPU 资源。让用户感觉系统在同时做很多事,满足用户对实时性的要求。线程的出现是为了解决实时性的问题


    总的来说,线程是进程的细分,通常,在实时性操作系统中,进程会被划分为多个可以独立运行的子任务,这些子任务被称为线程,多个线程配合完成一个进程的任务


注意


    一个进程包含多个线程,但是这些线程共享进程占有的内存地址空间和资源。进程是操作系统进行资源分配的基本单位(进程之间互不干扰),而线程是操作系统进行 CPU 调度的基本单位(线程间互相切换)。


1.3.3、线程工作的原理

    假设 P 进程抢占 CPU 后开始执行,此时如果 P 进行正在进行获取网络资源的操作时,用户进行 UI 操作,此时 P 进程不会响应 UI 操作。可以把 P 进程可以分为 Ta、Tb 两个线程。Ta 用于获取网络资源,Tb 用于响应 UI 操作。此时如果 Ta 正在执行获取网络资源时、用户进行 UI 操作,为了做到实时性,Ta 线程暂时挂起,Tb 抢占 CPU 资源,执行 UI 操作,UI 操作执行完成后让出 CPU,Ta 抢占 CPU 资源继续执行请求网络资源。


总结


  1. 线程再一次提高了 CPU 的利用率

  2. 线程是包含在进程中,是对进程任务的细分,线程共享进程资源(内存资源等)

  3. 线程细分后称为 CPU 调度的基本单位。进程称为操作系统资源分配的基本单位。

1.4、线程和进程的区别

  1. 根本区别:进程是操作系统资源分配的基本单位,而线程是 CPU 调度和执行的基本单位

  2. 在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

  3. 所处环境:在操作系统中能同时运行多个进程(程序);而在同一个进程(程序)中有多个线程同时执行(通过 CPU 调度,在每个时间片中只有一个线程执行

  4. 内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了 CPU 外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源

  5. 包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分

1.5、线程调度

1.5.1、分时调度

    所有线程轮流使用 CPU 的使用权,平均分配每个线程占用 CPU 的时间。

1.5.2、抢占式调度

    优先让优先级高的线程使用 CPU,如果线程的优先级相同,那么会随机选择一个(线程随机性),Java 使用的为抢占式调度。

二、实现线程的方式

    在 Java 中实现线程的方式有 2 种,一种是继承 Thread,一种是实现 Runnable 接口。


    如果一个进程没有任何线程,我们成为单线程应用程序;如果一个进程有多个线程存在,我们成为多线程应用程序。进程执行时一定会有一个主线程(main 线程)存在,主线程有能力创建其他线程。多个线程抢占 CPU,导致程序的运行轨迹不确定。多线程的运行结果也不确定。

2.1、继承 Thread 类

    线程开启我们需要用到了java.lang.Thread类,API 中该类中定义了有关线程的一些方法,具体如下:


构造方法


  • public Thread():分配一个新的线程对象。

  • public Thread(String name):分配一个指定名字的新的线程对象。

  • public Thread(Runnable target):分配一个带有指定目标新的线程对象。

  • public Thread(Runnable target,String name):分配一个带有指定目标新的线程对象并指定名字。


常用方法


  • public String getName():获取当前线程名称。

  • public void start():导致此线程开始执行; Java 虚拟机调用此线程的 run 方法。

  • public void run():此线程要执行的任务在此处定义代码。

  • public static void sleep(long millis):使当前正在执行的线程以指定的毫秒数暂停(暂时停止执行)。

  • public static Thread currentThread() :返回对当前正在执行的线程对象的引用。


    继承 Thread 实现多线程,必须重写 run 方法启动的时候调用的也是调用线程对象的 start()方法来启动该线程,如果直接调用 run()方法的话,相当于普通类的执行,此时相当于只有主线程在执行。


package day16_thread.classing.thread;
/** * @author Xiao_Lin * @date 2020/12/20 11:40 */public class MyThread extends Thread{
@Override public void run() { for (int i =1;i<501;i++){ System.out.println("A Thread"+i); } }}
复制代码


package day16_thread.classing.thread;
/** * @author Xiao_Lin * @date 2020/12/20 11:41 */public class TestThread {
public static void main(String[] args) { MyThread myThread = new MyThread(); myThread.start();
for (int i=1;i<501;i++){ System.out.println("MainThread"+i); } }}
复制代码



    从结果我们可以看出,每一次抢占 CPU 资源的线程是不同的,多个线程轮流使用 CPU,谁先抢占到谁使用 CPU 并执行线程。所以执行结果不确定。

2.1.1、继承 Thread 类的优点

​ 编码简单

2.1.2、继承 Thread 类的缺点

    线程类已经继承了 Thread 类了就无法再继承其他类了,功能不能通过其他类继承拓展,功能没有那么强大。

2.2、实现 Runnable 接口

    采用java.lang.Runnable也是非常常见的一种,我们只需要重写 run 方法即可。


​ 步骤如下:


  1. 定义 Runnable 接口的实现类,并重写该接口的 run()方法,该 run()方法的方法体同样是该线程的线程执行体。

  2. 创建 Runnable 实现类的实例,并以此实例作为 Thread 的 target 来创建 Thread 对象,该 Thread 对象才是真正的线程对象。

  3. 调用线程对象的 start()方


法来启动线程。


package day16_thread.classing.thread;
/** * @author Xiao_Lin * @date 2020/12/20 13:49 */public class MyRun implements Runnable {
@Override public void run() { for (int i =1;i<501;i++){ System.out.println("A Thread"+i); } }}
复制代码


package day16_thread.classing.thread;
/** * @author Xiao_Lin * @date 2020/12/20 13:49 */public class TestMyRun {
public static void main(String[] args) { Thread thread = new Thread(new MyThread()); thread.start(); for (int i=1;i<501;i++){ System.out.println("MainThread"+i); } }}
复制代码


2.2.1、实现 Runnable 的接口的优点

  1. 线程任务类只是实现了 Runnable 接口,可以继续继承其他类,而且可以继续实现其他接口(避免了单继承的局限性)

  2. 同一个线程任务对象可以被包装成多个线程对象

  3. 适合多个多个线程去共享同一个资源

  4. 实现解耦操作,线程任务代码可以被多个线程共享,线程任务代码和线程独立。

  5. 线程池可以放入实现 Runable 或 Callable 线程任务对象。

  6. 其实 Thread 类本身也是实现了 Runnable 接口的。

  7. 唯一的遗憾是不能直接得到线程执行的结果!

2.3、实现 Callable 接口(拓展)

    实现多线程还有另一种方式,那就是实现Callable接口,前面的两种方式都没办法拿到线程执行返回的结果,因为 run()方法都是 void 修饰的。但是这种方式是可以拿到线程执行返回的结果。


步骤


  1. 定义一个线程任务类实现 Callable 接口 , 申明线程执行的结果类型。

  2. 重写线程任务类的 call 方法,这个方法可以直接返回执行的结果。

  3. 创建一个 Callable 的线程任务对象。

  4. 把 Callable 的线程任务对象包装成一个未来任务对象。

  5. 把未来任务对象包装成线程对象。

  6. 调用线程的 start()方法启动线程


package day16_thread.classing.thread;
/** * @author Xiao_Lin * @date 2020/12/20 13:49 */// 1.创建一个线程任务类实现Callable接口,申明线程返回的结果类型class MyCallable implements Callable<String>{ // 2.重写线程任务类的call方法! @Override public String call() throws Exception { // 需求:计算1-10的和返回 int sum = 0 ; for(int i = 1 ; i <= 10 ; i++ ){ System.out.println(Thread.currentThread().getName()+" => " + i); sum+=i; } return Thread.currentThread().getName()+"执行的结果是:"+sum; }}public class ThreadDemo { public static void main(String[] args) { // 3.创建一个Callable的线程任务对象 Callable call = new MyCallable(); // 4.把Callable任务对象包装成一个未来任务对象 // -- public FutureTask(Callable<V> callable) // 未来任务对象是啥,有啥用? // -- 未来任务对象其实就是一个Runnable对象:这样就可以被包装成线程对象! // -- 未来任务对象可以在线程执行完毕之后去得到线程执行的结果。 FutureTask<String> task = new FutureTask<>(call); // 5.把未来任务对象包装成线程对象 Thread t = new Thread(task); // 6.启动线程对象 t.start();
for(int i = 1 ; i <= 10 ; i++ ){ System.out.println(Thread.currentThread().getName()+" => " + i); }
// 在最后去获取线程执行的结果,如果线程没有结果,让出CPU等线程执行完再来取结果 try { String rs = task.get(); // 获取call方法返回的结果(正常/异常结果) System.out.println(rs); } catch (Exception e) { e.printStackTrace(); }
}}
复制代码

2.3.1、实现 Callable 接口优点

  1. 线程任务类只是实现了 Callable 接口,可以继续继承其他类,而且可以继续实现其他接口(避免了单继承的局限性)

  2. 同一个线程任务对象可以被包装成多个线程对象

  3. 适合多个多个线程去共享同一个资源

  4. 实现解耦操作,线程任务代码可以被多个线程共享,线程任务代码和线程独立。

  5. 线程池可以放入实现 Runable 或 Callable 线程任务对象。

  6. 能直接得到线程执行的结果!

  7. 唯一的遗憾就是编码比较复杂,写的代码会比较多。

2.4、两种实现方式的区别

需求:模拟售票窗口买票的过程,共有五张票

2.4.1、Thread 实现

package day16_thread.classing.thicks;
/** * @author Xiao_Lin * @date 2020/12/20 13:53 */public class MyThread extends Thread{ private static int count = 5;
public MyThread() { }
public MyThread(String name) { super(name); }
@Override public void run() { for (int i=0;i<5;i++){ if (count>0){ count--; System.out.println(super.getName()+"卖了一张票。还剩下"+count+"张票"); } }
}}
复制代码


package day16_thread.classing.thicks;
/** * @author Xiao_Lin * @date 2020/12/20 13:55 */public class TestThread {
public static void main(String[] args) { MyThread t1 = new MyThread("窗口A"); MyThread t2 = new MyThread("窗口B"); MyThread t3 = new MyThread("窗口C"); MyThread t4 = new MyThread("窗口D"); t1.start(); t2.start(); t3.start(); t4.start(); }}
复制代码


2.4.2、Runable 实现

package day16_thread.classing.thicks;
/** * @author Xiao_Lin * @date 2020/12/20 14:15 */public class MyRun implements Runnable { private int count = 5; @Override public void run() { for (int i=0;i<5;i++){ if (count>0){ count--; System.out.println(Thread.currentThread().getName()+"卖了一张票。还剩下"+count+"张票"); } } }}
复制代码


package day16_thread.classing.thicks;
/** * @author Xiao_Lin * @date 2020/12/20 14:17 */public class TestRun {
public static void main(String[] args) { MyRun myRun = new MyRun(); Thread t1 = new Thread(myRun,"窗口A"); Thread t2 = new Thread(myRun,"窗口B"); Thread t3 = new Thread(myRun,"窗口C"); Thread t4 = new Thread(myRun,"窗口D");
t1.start(); t2.start(); t3.start(); t4.start(); }}
复制代码


2.4.3、两者实现的区别

  1. 继承 Thread 类后,不能再继承其他类,而实现了 Runnable 接口后还可以继承其他类。

  2. 实现 Runnable 接口更方便共享资源,同一份资源,多个线程并发访问,如果多个线程需要访问共享资源,优先考虑 Runnable 方式,如果线程不访问共享资源,可以考虑继承 Thread。

  3. Thread 类本身也是实现类 Runnable 接口的。


实现 Runnable 接口比继承 Thread 类所具有的优势:


  1. 适合多个相同的程序代码的线程去共享同一个资源。

  2. 可以避免 Java 中的单继承的局限性。

  3. 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和线程独立。

  4. 线程池可以放入实现 Runable 或 Callable 类线程。

2.5、存在的问题

    多线程访问共享资源的同时,存在一个十分严重的问题,那就是会导致共享资源数据错乱。

2.6、多线程执行轨迹分析

​ 假设我们拿一种执行情况来分析



2.7、总结

  1. 线程通过抢占 CPU 的方式工作,在执行过程中,随时可能 CPU 时间片的时间到了,然后被挂起,在程序的任何地方都有可能被切换出去

  2. 由于随时被挂起或者切换出 CPU,导致访问共享资源会出现数据错乱,解决方法为加锁

用户头像

XiaoLin_Java

关注

问啥啥都会,干啥啥不行。 2021.11.08 加入

问啥啥都会,干啥啥不行。🏆CSDN原力作者🏆,🏆掘金优秀创作者🏆,🏆InfoQ签约作者🏆

评论

发布
暂无评论
面试官惊叹,好小子!你这多线程基础可以啊!