Java核心基础-多线程
本文简单介绍了Java多线程相关入门知识,包括线程的相关概念,以及Java中线程的实现、线程的同步以及线程池的使用。
1. 线程、进程、程序
1.1 程序
计算机程序是一组计算机能识别和执行的指令,运行于电子计算机上,满足人们某种需求的信息化工具。
通俗地说,计算机程序就是一系列指令或代码,告诉计算机该如何完成某个任务。就像一份步骤清单,它指导计算机一步步地执行,最终实现特定的功能,比如播放视频、处理文字或进行计算。我们常用的QQ、微信、浏览器等都是程序。
1.2 进程
进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的资源分配单元,也是基本的执行单元。
进程是计算机中正在运行的一个程序实例,也即是“正在运行的程序”。可以理解为一个程序从静态的代码变成动态的活动,它有自己的资源(如内存)和运行环境。一个进程一般程序段、相关数据段、PCB(进程控制块,用来描述进程的基本情况和活动过程,进而控制和管理进程)这三部分组成。
每个进程都是一个独立的任务,比如你打开的一个浏览器窗口就是一个进程。或者可以打开Windows系统的任务管理器,可以看到在系统中运行的进程:
在多任务操作系统中,表面上看,进程是并发执行的,比如你可以一边听音乐一边聊天,但实际上这些进程并非是同一时刻运行的。
在计算机中,所有程序都是由CPU执行的,一个CPU在某个时间点只能运行一个程序,即只能执行一个进程,然后在下一个时间段会切换到其他进程执行。由于CPU运行速度非常快,能在极短时间内在不同的进程间切换,所以给人以同时执行多个进程的感觉。
并行与并发:
并行:指的是多个任务在多个处理器上真正同时运行。比如,双核或多核CPU可以同时处理多个任务,每个核都在运行不同的任务,这是物理上的“同时进行”。
并发:指的是多个任务在同一个处理器上交替进行。在并发环境中,任务快速切换,给人一种“同时运行”的感觉,但其实每个时刻只有一个任务在运行。
区别:并行是硬件支持的真正“同时进行”,并发则是通过快速切换模拟的“同时进行”。
1.3 线程
线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
一个进程中可以有多个执行单元同时运行,来共同完成一个或多个程序任务,这些执行单元可以看作进程的一条条线索,称为线程。
操作系统中每一个进程中都至少有一个线程。以Java程序为例,当一个Java程序启动时,就会产生一个进程,该进程中默认会创建一个线程,这个线程会运行main()方法中的代码。
一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。
线程分为单线程与多线程,一个进程中只有一条线索按顺序执行代码的为单线程;若有多条线索,可以交替并发执行,则为多线程,如图:
多线程可以充分利用CPU资源,进一步提升程序的执行效率。
多线程与进程一样,由CPU控制并轮流执行,由于CPU运行速度非常快,给人同时执行的感觉。
进程与线程的区别:
- 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位。
- 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线。
- 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见。
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
1.4 协程
协程不是进程或线程,其执行过程更类似于子例程,或者说不带返回值的函数调用。
协程是一种比线程更轻量的执行方式,可以在同一个线程内切换执行多个任务。协程允许任务之间暂停和恢复,避免频繁切换线程或进程,效率更高。协程是一种基于线程之上,但又比线程更加轻量级的存在,这种由程序员自己写程序来管理的轻量级线程叫做“用户空间线程”,具有对内核来说不可见的特性。
协程的特点是“合作式”运行,任务之间主动让出控制权,而不是被系统强制切换。因此,协程特别适合处理I/O密集型任务,比如网络请求或文件读写,因为可以在等待期间执行其他任务,提升效率。
线程的切换由操作系统负责调度,协程则由用户自己进行调度,减少了上下文切换,提高了效率。
Python、Go 和 JavaScript 都支持协程。Python中可以用 async
和 await
来定义和使用协程,JavaScript 中则用 async
和 await
关键字实现。
2. 线程的创建
2.1 Thread类
Thread类是java.lang包下的一个线程类,用来实现Java多线程。
通过继承Thread类的方式来实现多线程非常简单,主要步骤如下:
- 创建一个Thread线程类的子类(子线程),同时重写Thread类的run()方法;
- 创建该子类的实例对象,并通过调用start()方法启动线程。
接下来通过一个案例来演示如何通过继承Thread类的方式来实现多线程:
1 | /** |
执行结果:
Example01.java中,MyThread1继承了Thread类,并重写了run()方法,打印5次线程名称。在main方法中创建了两个MyThread1的线程实例,并通过start()方法启动这两个线程。由运行结果可以看出,线程名并非按顺序打印的,而是交替打印,说明程序不是先执行第一个run()方法再执行第二个run()方法,而是交替执行,说明程序实现了多线程功能。
注意:在该程序中,除了thread01和thread02两个线程实例,还有main()方法创建的主线程,是程序的入口,其作用是创建并启动了两个子线程。
2.2 Runnable接口
通过继承Thread类来实现多线程的方式有一定的局限性,因为Java只允许类的单继承,如果这个类继承了其他父类,就无法通过继承Thread类的方式来实现多线程。此时就可以考虑通过实现Runnable接口的方式来实现多线程。
使用实现Runnable接口的方式来实现多线程的主要步骤如下:
- 创建一个Runnable接口的实现类,同时重写接口中的run()方法;
- 创建Runnable接口的实现类对象;
- 使用Thread有参构造方法创建线程实例,并将Runnable接口的实现类的实例对象作为参数传入;
- 调用线程实例的start()方法启动线程。
接下来通过一个案例来演示如何通过实现Runnable接口的方式来实现多线程:
1 | /** |
执行结果:
在Example2.java中,MyThread2实现了Runnable接口,并重写了run()方法,随后在main()方法中先后创建并启动了两个线程实例,先创建了Runnable接口的实现类对象myThread2,然后将myThread2作为Thread构造方法的参数来创建线程实例,最后同样通过start()方法启动了线程实例。
注意:Runnable接口中只有一个抽象的run()方法,那么该接口就属于JDK 8中定义的函数式接口,在使用时,可以直接通过Lambda表达式的方式更简洁的来实现线程实例。
2.3 Callable接口
通过Thread类和Runnable接口实现多线程时,需要重写run()方法,但是由于该方法没有返回值,因此无法从多个线程中获取返回结果。为了解决这个问题,从JDK 5开始,Java提供了一个新的Callable接口,来满足这种既能创建多线程又可以有返回值的需求。
通过Callable接口实现多线程的方式与Runnable接口实现多线程的方式一样,都是通过Thread类的有参构造方法传入Runnable接口类型的参数来实现多线程,不同的是,这里传入的是Runnable接口的子类FutureTask对象作为参数,而FutureTask对象中则封装带有返回值的Callable接口实现类。
使用实现Callable接口的方式来创建并启动线程实例的主要步骤如下:
- 创建一个Callable接口的实现类,同时重写Callable接口的call()方法;
- 创建Callable接口的实现类对象;
- 通过FutureTask线程结果处理类的有参构造方法来封装Callable接口实现类对象;
- 使用参数为FutureTask类对象的Thread有参构造方法创建Thread线程实例;
- 调用线程实例的start()方法启动线程。
接下来通过一个案例来演示如何通过实现Callable接口的方式来实现多线程:
1 | /** |
执行结果:
Callable接口方式实现的多线程是通过FutureTask类来封装和管理返回结果的,该类的直接父接口是RunnableFuture,从名称上可以看出RunnableFuture是由Runnable和Future组成的结合体。下面就通过一个示意图来展示FutureTask类的继承关系:
FutureTask本质是Runnable接口和Future接口的实现类,而Future则是JDK 5提供的用来管理线程执行返回结果的。其中Future接口中共有5个方法,用来对线程结果进行管理:
方法声明 | 功能描述 |
---|---|
boolean cancel(boolean mayInterruptIfRunning) | 用于取消任务,参数mayInterruptIfRunning表示是否允许取消正在执行却没有执行完毕的任务,如果设置true,则表示可以取消正在执行的任务 |
boolean isCancelled() | 判断任务是否被取消成功,如果在任务正常完成前被取消成功,则返回 true |
boolean isDone() | 判断任务是否已经完成,若任务完成,则返回true |
V get() | 用于获取执行结果,这个方法会发生阻塞,一直等到任务执行完毕才返回执行结果 |
V get(long timeout, TimeUnit unit) | 用于在指定时间内获取执行结果,如果在指定时间内,还没获取到结果,就直接返回null |
2.4 三种方式的对比
多线程的实现方式有三种,其中Runnable接口和Callable接口实现多线程的方式基本相同,主要区别就是Callable接口中的方法有返回值,并且可以声明抛出异常。那么通过继承Thread类和其他两种接口实现多线程的方式有什么区别呢?接下来通过一个应用场景来分析说明。
假设售票厅有四个窗口可发售某日某次列车的100张车票,这100张车票就可以看做共享资源,四个售票窗口相当于四个线程,为了更直观显示窗口的售票情况,可以通过Thread的currentThread()方法得到当前线程的实例对象,然后调用getName()方法获取到线程名称。接下来,通过继承Thread类的方式来实现多线程,如下所示。
1 | /** |
执行结果:
从图1可以看出,每张票都被发售了四次。出现这种现象的原因是:四个线程没有共享100张票,而是各自出售了100张票。在程序中创建了四个TicketWindow对象,就等于创建了四个售票程序,而每个TicketWindow对象中都有一个tickets票据变量,这样每个线程在执行任务时都会独立地处理各自的资源,而不是共同处理同一个售票资源。
注意:上述代码创建并启动4个线程时并没有指定线程的名称,但运行结果却显示有Thread-0、Thread-1…这样的线程名称。这是因为,在创建多线程时如果没有通过构造方法指定线程名称,则系统默认生成线程名称。
由于现实中,售票系统中的票资源是共享的,因此上面的运行结果显然不合理。为了保证售票资源共享,在程序中只能创建一个售票对象,然后开启多个线程去共享同一个售票对象来执行售票方法,简单来说就是四个线程运行同一个售票程序,这时就需要通过实现Runnable接口的方式来实现多线程。
接下来将代码进行修改,并使用构造方法Thread(Runnable target, String name)在创建线程对象的同时指定线程的名称,如下所示:
1 | class TicketWindow2 implements Runnable { |
执行结果:
上述代码中,通过实现Runnable接口的方式只创建了一个TicketWindow2对象,然后创建了四个线程,在每个线程上都调用同一个TicketWindow2对象中的run()方法,这样就可以确保四个线程访问的是同一个tickets变量,共享100张车票。
通过上面的售票案例,并结合实际情况进行分析,通过实现Runnable接口(或者Callable接口)相对于继承Thread类实现多线程来说,有如下显著的好处:
- 适合多个线程去处理同一个共享资源的情况,把线程同程序代码、数据有效的分离,很好的体现了面向对象的设计思想。
- 可以避免Java单继承带来的局限性,由于一个类不能同时有两个父类,所以在当前类已经有一个父类的基础上,那么就只能采用实现Runnable接口或者Callable接口的方式来实现多线程。
事实上,实际开发中大部分的多线程应用都会采用Runnable接口或者Callable接口的方式实现多线程。