JavaEE(3)(由进程到线程、线程的调度、 进程线程的区别、Java 实现多线程编程、创建线程、Thread 类的其他使用方式、线程启动、中断线程、线程等待、获取当前程引用、休眠当前线程)

本文主要是介绍JavaEE(3)(由进程到线程、线程的调度、 进程线程的区别、Java 实现多线程编程、创建线程、Thread 类的其他使用方式、线程启动、中断线程、线程等待、获取当前程引用、休眠当前线程),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

接上次博客:JavaEE(2)(进程的管理 【进程标识符PID、内存指针、文件描述符表、进程的调度——<状态,优先级,上下文,记账信息,操作系统调度器和调度算法 >、虚拟地址空间、进程之间相互协作和交互数据】)_di-Dora的博客-CSDN博客

目录

由进程到线程 

所谓线程

线程的调度问题

 进程和线程的区别

Java 实现多线程编程

创建线程

创建线程的其他写法

Runnable

使用匿名内部类

基于 Lambda 表达式(推荐) 

Thread 类的其他使用方式

Thread 的常见构造方法

Thread 的几个常见属性

 线程启动

中断一个线程 (终止/打断)

线程等待

获取当前线程引用

休眠当前线程 


由进程到线程 

我们上一次的博客提到了多进程编程,但是没有细讲。这是因为在Java这样的环境中我们并不是很鼓励多进程编程,我们更鼓励使用多线程编程。所以今天就来详细探讨一下多线程编程~

你先回想一下,我们当初为啥要引入多个进程?

我们是为了实现并发编程,因为现在是一个多核CPU的时代,所以实现并发编程是当下的刚需。

多进程实现并发编程其实效果也是非常理想的(C++的CGI技术就是一种基于多进程编程的方式来实现网站后端)。但是多进程编程模型也有明显的缺点:进程太重量,效率不高。

也就是说,我们创建一个进程,消耗的时间比较多;销毁一个进程,消耗的时间也比较多;就连调度一个进程,消耗的时间也比较多……

这里的消耗时间多具体是消耗在哪里呢?消耗在申请资源上。

我们上次重点说过,进程是资源分配的基本单位,而资源分配的任务可不轻啊。

就比如说操作系统分配内存:

内存管理是操作系统中一个复杂的任务,因为它涉及到在有限的物理内存中有效地管理多个进程的虚拟内存。操作系统使用数据结构,如页表或段表,来跟踪进程的虚拟地址与物理地址之间的映射。当一个进程请求内存分配时,操作系统必须执行以下任务:

  1. 分配内存块: 确定分配的内存块大小和位置。这通常需要在内存管理数据结构中查找可用的空闲区域。

  2. 地址映射: 更新进程的页表或段表,将虚拟地址映射到物理地址。这是一个关键的步骤,确保进程能够正确访问分配的内存。

  3. 内存保护: 设置内存区域的访问权限,以确保进程只能访问它有权限的内存。这有助于保护系统的安全性和稳定性。

  4. 回收内存: 当进程不再需要某块内存时,操作系统需要回收它并将它标记为可用,以供其他进程使用。这通常涉及到内存的释放和可能的内存碎片整理。

总结一下就是,操作系统内部有一定的数据结构,把空闲的内存分块管理好。当我们去进行申请内存的时候,系统就会从这样的数据结构中找到一个大小合适的空闲内存,返回给对应的进程。虽然数据结构和算法可以帮助提高内存分配的效率,但内存管理仍然需要花费相当多的计算资源和时间。相比之下(有对比才有伤害嘛)还是一个耗时的操作。

如果我们只是创建或销毁一次进程,那可能还好。但是如果我们需要频繁的创建/销毁进程,那这个时候开销就不能忽视了。

为了解决上述问题,我们就引入了“ 线程(Thread)”。

所谓线程

线程(Thread)是操作系统中的基本执行单元,它是进程中的一个更小的工作单元。与进程不同,线程共享相同进程的地址空间和资源,包括内存、文件句柄和其他系统资源。线程可以被看作是在同一进程内并发执行的多个子任务,它们共享相同的代码和数据,但具有独立的执行上下文。

线程也叫做 “ 轻量级进程 ”,创建线程比创建进程更快;销毁线程比创建进程更快;调度线程比创建进程更快……

线程是不能独立存在的,而是要依附于进程。进程包含线程,可以包含一个,也可以包含多个。

一个进程最开始的时候至少要有一个线程,这个线程负责完成指向代码的工作,每个线程都可以独立的执行一些代码。

我们也可以根据需要创建出更多的线程,从而使当前实现“并发编程”。

关于“进程调度”,我们之前的讨论都是基于“一个进程里面只有一个线程”的情况,实际上一个进程中是可以有多个线程的。且每个线程都是可以独立的进行调度。每一个线程也都有状态、优先级、上下文和记账信息等属性,这也就对我们上次提到的“一个进程使用PCB表示”进行了补充——“一个进程可能使用一个PCB表示,也可能使用多个PCB表示”。每个PCB对应到一个线程上,每个线程都有状态、优先级、上下文和记账信息等信息辅助调度。

除此之外,我们前面提到的 PID 和内存指针、文件描述符表都是共用同一份的。

上述结构就决定了线程的一些关键特点:

  1. 共享资源: 线程共享相同进程的资源。同一个进程的多个线程之间共用同一份内存空间和文件资源。这意味着多个线程可以轻松地访问相同的变量、数据结构和文件,这在一定程度上简化了数据共享和通信。

  2. 独立执行: 线程具有独立的执行上下文,包括程序计数器、寄存器和栈。每个线程可以独立地去CPU上调度执行不同的任务,但它们在同一进程内运行。

  3. 轻量级: 与进程相比,线程更轻量级,因为它们共享相同的地址空间。创建和销毁线程比创建和销毁进程更加高效。

  4. 并发性: 多个线程可以并发执行,从而提高了程序的并发性和性能。在多核处理器上,不同线程可以在不同核上并行执行,从而更有效地利用硬件资源。

  5. 通信和同步: 由于线程共享资源,因此需要进行适当的通信和同步来避免数据竞争和一致性问题。这包括使用互斥锁、信号量、条件变量等同步机制。

  6. 线程调度: 操作系统负责线程的调度和执行。操作系统决定何时切换到不同的线程以实现并发执行。

上述标红了的两条特点就决定了我们创建线程的时候不需要重新申请资源,可以直接复用之前已经分配给进程的资源,就省去了资源分配的开销,于是创建效率就得到了提高。

这样,我们就得到了一条重要的结论:

进程是资源分配的基本单位;

线程是调度执行的基本单位。

想象一家小餐馆是一个进程。这个餐馆目前只有一个厨房,里面有一个主厨(主线程),主厨负责准备所有的菜肴(执行主要的任务)。由于这家餐馆生意火爆,忙不过来,所以餐馆老板就需要多招聘一些人手来维持餐馆的经营。

现在他有两个选择:

选择一:

多加一间厨房,里面多招聘一位主厨,两个厨房一起完成菜肴制作。这样速度当然就会更快。

但是你仔细想想,这样的话餐厅老板就不得不破费一笔修建新厨房,而且两个厨房都有自己的上菜窗口需要服务员负责(相当于两套物流体系),这样算下来成本挺高的。

选择二:

餐馆只多雇佣一位厨师(创建新的线程)来处理顾客的点餐。两个厨师共用一个厨房,但是他们相互之间又是独立的,各自准备各自的菜肴,共用同一组服务生,共用同一套物流体系。这样也可以提高我们的上菜效率,而且没有增加太多的成本。

  • 进程(餐馆): 餐馆是一个独立的单位,它可以接纳顾客,有自己的厨房和餐具等资源。这就是一个进程,它有自己的资源和环境。

  • 共享资源: 主厨和服务员在同一个餐馆内工作,他们共享相同的厨房和餐具(共享资源)。这就是线程共享进程内资源的概念。他们不需要重新建立一个全新的厨房,而是共用同一个。

  • 任务分配和调度: 餐馆老板(操作系统)可以根据需要雇佣更多的厨师(创建新线程)来应对繁忙时段,以提高服务效率。厨师之间可以独立工作,但也需要协调,以确保服务的顺畅运行。

好了,希望这个比喻可以帮助你理解进程和线程之间的关系:一个进程是一个独立的执行环境,而线程是在同一进程内工作的独立执行单元,它们共享进程的资源,减少了资源分配的开销,同时可以并行执行不同的任务,提高了效率。当然,虽然我们提到说创建线程不需要额外的增加开销,但是当你创建第一个线程的时候(相当于是和进程一起创建的),还是需要有一定的开销去申请资源的,后面再创建线程,开销就省下了。

好了,回到我们刚刚餐馆的那个例子,我们还需要在探讨一下线程的其他基本特点。

餐馆很火爆 ---> 老板选择招聘新厨师塞到同一个厨房 ---> 增加一个厨师 ---> 提高效率 ---> 再增加一个 ---> 进一步提到效率 ---> …… ---> 假设当增加到6个厨师的时候发现,锅没了 ---> 还在不停增加新厨师 ---> 厨房越来越拥挤,里面可能有18个厨师了,但是只有其中6个厨师在做菜。

这时你就会发现,前面增加线程的数目还能进一步提高效率。但是到了一定程度,我们再进一步增加线程的数目的时候,效率却无法进一步提升了。这时因为要调度的线程太多了,使调度的开销更大,反而会降低效率。

好了,当厨师多到一定程度,每个厨师都想要完成自己的菜,但是又抢不到锅,打起来了!

当线程数目多了,两个线程同时去访问同一个变量,可能就会产生一定的冲突。我们把这个情况称为“线程不安全问题”。

再进一步,如果某个线程出现异常呢?

比如其中一个厨师生气了,大喝一声,一把火烧了整个厨房……

一个线程抛出异常,如果没有妥善处理(catch),就容易把整个进程搞崩溃,此时其他线程自然也随之消亡。

线程的调度问题

这个时候,一个同学提出了他的疑问——“不同进程里面的线程是轮番被调度的,还是一个进程里面的所有线程都调度结束再去开始另一个进程的线程调度?”

每个线程是独立被调度的,操作系统会根据线程的优先级和调度算法来动态分配CPU时间给各个线程,而不再强调进程的概念。这意味着线程之间的切换是独立的,操作系统会考虑线程的状态、优先级和时间片等信息来进行决策。

说的再详细一点:

不同操作系统的线程调度策略可能不同,但通常情况下,操作系统会采用抢占式调度(Preemptive Scheduling)策略,即线程可以轮番被调度。

在抢占式调度下,不同进程内的线程可以根据其优先级和时间片轮流被调度执行。这意味着一个进程的所有线程并不需要等待所有线程都执行结束才开始调度另一个进程的线程。相反,操作系统会根据一定的调度算法,例如优先级调度或时间片轮转调度,动态地分配CPU时间给不同进程的线程。

例如,假设有两个进程,每个进程有多个线程。操作系统可以按照如下方式调度线程:

  1. 给进程A的线程1分配一定时间片,线程1开始执行。
  2. 在某一时刻,操作系统可能决定切换到进程B的线程2,因为线程2的优先级更高或者时间片用尽。
  3. 进程B的线程2执行一段时间后,操作系统可能再次切换回进程A的线程1,以此类推。

这种抢占式调度策略可以确保不同进程的线程能够在合理的时间内获得CPU时间,从而实现并发执行。线程调度的具体实现和策略可能因操作系统而异,但抢占式调度是常见的方式,它有助于提高系统的响应性和性能。

 进程和线程的区别

  1. 进程包含线程,一个进程里面可以有一个或多个线程:进程是资源分配的基本单位,而线程是在进程内执行的基本单位。一个进程可以包含一个或多个线程,这些线程共享相同的内存和资源,但有独立的执行上下文。

  2. 进程和线程都是用来实现并发编程场景的,但线程比进程更轻量和高效:线程的创建和销毁比进程更高效,因为它们共享相同的资源。线程之间的通信和切换也通常比进程更快速,因此在某些情况下,线程更适合实现轻量级并发

  3. 同一个进程的线程之间共用同一份资源(内存+硬盘),省去了申请资源的开销:进程内的线程共享相同的地址空间和资源,包括内存和文件句柄等。这减少了资源分配的开销,也使得线程之间更容易进行通信和数据共享。

  4. 进程和进程之间具有独立性,一个进程崩溃不会影响其他。但是同一个进程中的线程和线程之间可能会相互影响的(线程安全问题+线程出现异常):进程之间具有较高的隔离性,一个进程的问题通常不会影响其他进程。但是,同一进程内的线程共享相同的资源,因此线程之间需要小心处理数据同步和共享的问题。一个线程的异常可能会影响同一进程内的其他线程。

  5. 进程是资源分配的基本单位,线程是调度执行的基本单位:进程是独立的资源分配单位,每个进程有自己的地址空间和资源。线程是操作系统调度的基本执行单位,多个线程可以在同一进程内并行执行,并共享相同的资源。进程间的切换通常比线程间的切换更昂贵,因为进程需要切换地址空间,而线程不需要。

那么 Java 中如何进行多线程编程呢?

Java 实现多线程编程

线程是操作系统的概念,它代表了操作系统中最小的可调度执行单位。操作系统提供了一些API(如POSIX线程库、Windows线程API等),允许开发者创建、管理和调度线程。这些底层的线程API通常依赖于操作系统的特性,因此它们在不同的操作系统上具有不同的实现。这也带来了跨平台编程的挑战,因为不同操作系统上的线程API可能有不同的语法和行为。

为了解决跨平台问题,Java提供了自己的多线程API,即Java线程API。这个API对底层的线程操作进行了封装和抽象,使得开发者可以使用相同的多线程编程模型来编写跨平台的代码。Java线程API提供了 Thread 类 和 相关工具类,简化了线程的创建、管理和同步操作。开发者只需要掌握Java线程API即可,而不必担心底层操作系统的差异。这使得Java成为一个跨平台的并发编程语言,适用于各种操作系统和硬件环境。

Java线程API提供了 Thread 类,我们可以通过创建 Thread 对象进一步操作系统内部的线程。

创建线程

首先我们先创建一个类,继承自 Thread ,并重写父类的方法:

每个线程都是一个独立的执行流,每个线程都可以执行一系列的逻辑(代码)。

一个线程运行起来从哪行代码开始执行?从它的入口方法,就好比一个Java程序的入口是 main 方法一样。我们现在学习了线程,就需要更专业的表述:

运行Java程序就是跑起来一个Java进程。这个进程里面至少会有一个线程——主线程。主线程的入口方法就是main 方法。main 线程是 jvm 自动创建的,但是和其他的线程相比也没啥特殊的。非要说的话,一个Java进程中,至少会有一个main线程。

//创建一个类,继承自Thread————JAVA标准库内置的类
//此处的Thread不需要import也能直接使用class MyThread extends Thread{@Overridepublic void run() {//这个是方法就是线程的入口方法System.out.println("hello thread");}
}

在Java中,java.lang 包是Java标准库的一部分,它包含了一些Java语言的核心类,例如 String、Object以及 Thread 等。这些类在Java程序中可以直接使用,无需额外的 import 语句。

原因在于 java.lang 包被默认导入(imported by default)到每个Java源文件中。这意味着我们可以在Java程序中直接使用 java.lang 包中的类,而无需显式导入该包。这是Java语言设计的一项方便之举,以简化常见的编程任务。

但需要注意的是,对于其他Java标准库的类或第三方库的类,仍然需要使用 import 语句来显式导入才能在程序中使用。但对于 java.lang 包中的类,这一步骤是可选的,因为它们默认已经被导入了。

现在我们还需要在主线程里面创建一个实例,用 start 方法调用系统API来创建出线程,让线程再调用入口方法。

//创建线程
public class Demo1 {public static void main(String[] args) {Thread t = new MyThread();// start 和 run 都是 Thread 的成员.// run 只是描述了线程的入口(线程要做什么任务)// start 则是真正调用了系统 API, 在系统中创建出线程, 让线程再调用 run.t.start();}
}

现在我们来分别加两个死循环: 

class MyThread extends Thread{@Overridepublic void run() {while(true){//这个是方法就是线程的入口方法System.out.println("hello thread");}}
}//创建线程
public class Demo1 {public static void main(String[] args) {Thread t = new MyThread();// start 和 run 都是 Thread 的成员.// run 只是描述了线程的入口(线程要做什么任务)// start 则是真正调用了系统 API, 在系统中创建出线程, 让线程再调用 run.t.start();while (true){System.out.println("hello main!");}}
}

 

你就会发现这俩个while() 循环在同时执行。看到的结果是两边的日志在交替打印。这也就应证了我们之前的说法“每个线程都是独立执行的逻辑(独立的执行流)”,两个线程 “ 兵分两路,并发执行 ” ,实现并发编程的效果,从而充分的使用多核CPU的资源

public class Demo1 {public static void main(String[] args) {Thread t = new MyThread();// start 和 run 都是 Thread 的成员.// run 只是描述了线程的入口(线程要做什么任务)// start 则是真正调用了系统 API, 在系统中创建出线程, 让线程再调用 run.//t.start();t.run();while (true){System.out.println("hello main!");}}
}

现在我们把 t.start(); 改为 t.run(),此时代码不会创建出新的线程,只有一个主线程。这个主线程里面只能依次执行循环,执行完一个循环再执行一个。所以会一直死循环打印“hello thread”。

在多线程程序运行的时候,我们可以使用 IDEA 或者 jconsole 来观察该进程里的多线程情况。

IDEA对于新手来说不太友好,在调试模式下,IDEA通常提供一个调试窗口,其中包括线程视图。我们可以在该视图中查看所有线程的信息,包括线程的状态、调用栈信息等。启动Java应用程序的调试模式后,可以在IDEA中设置断点,然后启动程序的调试会话。一旦进入调试模式,我们就可以在IDEA的窗口中查看线程视图。通常,它会显示当前正在运行的线程列表以及每个线程的状态和调用栈信息。然后我们就可以使用线程视图来监视多线程程序的运行,我们可以暂停、恢复、单步执行各个线程,以及查看每个线程的详细信息。

想要了解的自己去了解一下,我们主要用第二种方法。

jconsole 是JDK中带有的一个程序。如果你找不到了,可以这样:

这里就会显示出我们JDK当前所在的路径。

启动之前确保你IDEA的程序正在运行(代码跑着呢)

所以一个Java进程启动之后,JVM会在后面默默的完成很多任务,比如垃圾回收、资源统计、远程方法调用等等。

右边就是线程的一个详细的信息,最重要的是下面的堆栈跟踪。这个是线程的调用栈 。

线程的调用栈(Call Stack),也称为执行栈,是用于跟踪线程执行过程中方法调用的一种数据结构。每个线程都有自己的调用栈,用于记录当前线程的方法调用链。调用栈以栈的方式组织,最后进入的方法在栈顶,最早进入的方法在栈底。

在多线程程序中,每个线程都有自己独立的调用栈,用于记录该线程的方法调用情况。当一个方法被调用时,其对应的栈帧(Stack Frame)会被推入调用栈,表示该方法的执行。当方法执行完毕时,其对应的栈帧会被弹出,返回到上一个方法。

调用栈的主要作用包括:

  1. 跟踪方法调用链: 调用栈记录了方法的调用关系,允许您了解哪个方法调用了哪些方法,形成了调用链。

  2. 保存局部变量和方法参数: 每个栈帧中存储了方法的局部变量和方法参数的值,这些值在方法执行期间可以被访问。

  3. 实现方法的返回: 当一个方法执行完毕时,其对应的栈帧被弹出,控制流返回到上一个方法,继续执行。

  4. 异常处理: 如果在方法执行期间抛出异常,调用栈记录了异常抛出的位置,以便异常处理代码可以定位问题。

在调试多线程程序时,查看线程的调用栈信息对于理解程序的执行流程和识别问题非常有用。调用栈中的每个栈帧包含了方法的信息,包括方法名称、参数值和局部变量值,这有助于定位问题和追踪程序的执行路径。

这个东西非常有用,未来我们在写一些多线程程序的时候,就可以借助这个功能看到线程实时的运行情况。比如你写的程序“卡住了”,这个时候你就可以打开 jconsole 来查看当下是哪个线程出了问题。

当前这两个线程的while循环转的太快了,我们想要它慢一点,就需要加上 sleep方法。

你还记得我们之前在什么地方看见过sleep这个方法吗?C语言里面的拨号码、三子棋、扫雷等等都有。但是这里有一个需要明确的点,当时我们C语言中使用 Sleep() 方法 需要我们包含Windows.h 头文件,这个头文件里面包含的都是Windows系统的函数,而不是C语言的标准函数。如果你换成其他的系统,比如苹果的,就不存在这个头文件了……

这个方法在我们的Java中也是有所封装的:Thread.sleep,sleep 是 Thread 的一个静态方法,或者更好的,把它称为“类方法”。

Thread.sleep() 方法可能会抛出 InterruptedException 异常,这是一个受查异常,因此需要我们处理异常或将其抛出。

 这里必须 try catch,不能 throws,因为这里是方法重写,父类的 run() 没有,子类也不能有。

 这里就可以 throws 了。

运行一下:

你会发现,这里每秒钟打印出来的内容顺序都是不确定的。也就是说,这两个线程都是休眠1000毫秒(1秒),当时间到了之后,这两个线程谁先执行谁后执行不一定。这个过程可以视为是随机的。换句话说,操作系统对于多个线程的调度顺序是不确定的,“随机”的。

当然,这个“随机”并不是数学概率论里面的那个随机。而是指取决于操作系统对于线程调度的模块(调度器)的实现。

创建线程的其他写法

Runnable

我们刚刚用的是“继承 Thread,重写 run”。你应该注意到我刚刚贴在上面的图了,那是 Thread 类的源代码,它实现了一个接口,叫做 Runnable,并实现了里面的 run 方法。所以我们创建一个新的线程还有一种方法是“ 实现 Runnable ,重写 run”。

也就是说,在Java中,创建线程有两种主要方式: 

  1. 继承Thread类:我们可以创建一个类,继承自Thread类,并覆盖 run( ) 方法来定义线程的任务。然后,通过创建这个自定义的Thread类的实例,调用其 start( ) 方法来启动线程。

  2. 实现Runnable接口:您可以创建一个类,实现Runnable接口,并实现 run( ) 方法来定义线程的任务。然后,通过将这个实现了Runnable接口的类的实例传递给Thread类的构造方法,创建一个Thread对象,并调用其 start( ) 方法来启动线程。

注意,Runnable 并非是一个类,而是一个接口。

package thread;class MyRunnable implements Runnable {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}public class Demo2 {public static void main(String[] args) {Runnable runnable = new MyRunnable();Thread t = new Thread(runnable);t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

这里 Runnable 还是需要配合 Thread 来使用,Runnable 本身表示的是一个“可以运行的任务”,这个任务是交给线程负责执行,还是交给其他的实体来执行,Runnable 并不关心。

Runnable runnable = new MyRunnable();

这一行代码中, MyRunnable 类实现了Runnable 接口,并且通过Runnable 接口的引用变量runnable来引用 MyRunnable 的实例。这是一种向上转型的示例,因为子类对象(MyRunnable)被赋值给了父类引用变量(Runnable)。这种转型允许我们使用通用的父类引用来引用具体的子类对象,以提高代码的灵活性和可维护性。通俗来讲,就是我们经常说的Java的向上转型。

使用 Runnable 的写法和直接继承 Thread 有什么区别呢?

使用 Runnable 接口和直接继承 Thread 类的方式来创建线程之间的主要区别在于代码的组织方式和面向对象的设计原则。

区别主要就是“解耦合”

首先你需要明确 “ 创建一个线程,需要进行的两个关键操作 ” :

1、明确现场要执行的任务;2、调用系统API创建出线程。

这个任务本身不一定和线程概念强相关,这个任务可能只是单纯的执行一段代码。它是使用单个线程执行还是多个线程执行,又或是通过其他的方式(信号处理函数/协程/线程池……)都没啥区别。

我们可以把任务本身提取出来,此时就可以随时把代码改成使用其他方式来执行这个任务。

所以总结一下,区别主要是下面这三点,可以大概先了解一下,以后碰到更复杂的代码更方便我们理解其中的含义:

1. 代码组织方式:

  • 使用 Runnable 接口:通过实现 Runnable 接口,将线程的任务(run()方法中的代码)与线程本身( Thread 对象)分离。这种方式更符合面向对象的设计原则中的单一职责原则,使代码更加模块化和可维护。
  • 直接继承 Thread类:直接继承 Thread 类将线程的任务与线程本身紧密耦合在同一个类中。这可能导致代码的不够清晰,使得任务逻辑与线程管理逻辑混合在一起。

2. 灵活性和复用性:

  • 使用 Runnable 接口:通过实现 Runnable 接口,我们可以将相同的任务用于不同的线程。多个线程可以共享相同的 Runnable 实例,以实现任务的复用。
  • 直接继承 Thread 类:每个继承自 Thread 的子类都代表一个新的独立线程,任务逻辑通常无法被多个线程共享,限制了代码的复用性。

3. 扩展性:

  • 使用 Runnable 接口:由于任务与线程本身分离,就可以更容易地实现更复杂的线程管理模型,如线程池。可以将不同的 Runnable 任务提交给线程池中的线程来执行。
  • 直接继承 Thread 类:直接继承 Thread 类的方式通常不适合与线程池等高级线程管理技术结合使用,因为每个线程都是独立的,不容易集中管理。

综上所述,虽然两种方式都可以用于创建线程,但使用 Runnable 接口更符合面向对象的设计原则,使代码更加模块化、可维护和灵活,特别适合在多线程环境下实现任务的复用和高级线程管理。因此,一般来说,我们推荐使用 Runnable 接口来创建线程。

使用匿名内部类

我们可以选择 “ 继承 Thread,重写 run,但是使用匿名内部类 ”,或者是“ 实现 Runnable ,重写 run,但是使用匿名内部类 ”。

public class Demo3 {public static void main(String[] args) {Thread t = new Thread() {};
}

先创建出新的类,这个类的名字是啥?不知道!我们只知道这个类是Thread 的子类。同时又把这个子类的实例给创建出来了(不知道类名并不影响,因为这个类我们本身就是指打算使用一次),而且因为它是子类,所以还可以重写父类的方法。 

package thread;public class Demo3 {public static void main(String[] args) {Thread t = new Thread() {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}};t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

另一个也是一样:

package thread;public class Demo4 {public static void main(String[] args) {Thread t = new Thread(new Runnable() {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}});t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

基于 Lambda 表达式(推荐) 

Lambda表达式是一种更简化的语法表达方式,通常被称为“语法糖”。在数据结构的最后我们有详细介绍过。它的主要目的是让代码更加简洁和易读,特别是在需要传递函数作为参数的情况下,Lambda表达式能够显著减少代码的冗余,是一种更优雅的表达。(elegant,实在是 elegant~)

这里它相当于是“匿名内部类”的替换写法。Lambda表达式本质上是一个匿名函数(匿名函数不需要像普通函数那样具有名称。它们通常用于一次性的、短期的任务,不需要在整个程序中复用),主要用来实现“回调函数”(回调函数通常涉及到将一个函数作为参数传递给另一个函数,并在特定事件发生时或特定条件满足时调用传递)的效果。

说到回调函数,在我们学C语言进阶的时候,函数指针那里涉及到。现在我们回过头去想想,指针是指向一个内存空间的。你写的代码都是一个一个的文件,放在硬盘上的。一编译,得到一个exe.的文件,当你要运行程序时,双击exe. ,操作系统就会加载这个可执行程序,把这个文件里的数据和指令加载到内存中,构建成一个进程。这个时候我们才能够拿指针指向它。

package thread;public class Demo5 {public static void main(String[] args) {Thread t = new Thread(() -> {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

Thread t = new Thread(() -> { ... }); :通过 new Thread(...),我们创建了一个新线程,并使用Lambda表达式作为参数传递给这个线程的构造函数。

在这个程序中,回调函数指的是Lambda表达式中的代码块,它是在线程中执行的逻辑。回调函数会在子线程t中执行,因为子线程t启动后,它会执行与Lambda表达式中定义的代码块。这段代码块包括打印"hello thread"和休眠1秒的操作,会一直循环执行。

总结一下这段代码的执行顺序:

  1. 创建子线程t并指定其执行逻辑。
  2. 启动子线程t,子线程t开始执行回调函数中的逻辑。
  3. 主线程进入无限循环,不断打印"hello main"。
  4. 子线程t不断执行回调函数中的逻辑,打印"hello thread"并休眠1秒,然后重复执行。
  5. 主线程和子线程同时运行,执行它们的循环逻辑,不会退出。

可能你会问,为什么这里我们就不用重写 run() 方法了?

我们之前说,在Java中,Thread类有一个构造函数,它接受一个Runnable对象作为参数,而Runnable接口有一个抽象方法run,用于定义线程的执行逻辑。

通常,我们需要创建一个实现Runnable接口的类,并在其中实现run方法,然后将该类的实例传递给Thread的构造函数。

但是Lambda表达式的出现简化了这个过程,它允许我们以更紧凑的方式定义Runnable对象的run方法。在这种情况下,Lambda表达式的代码块会自动成为run方法的实现,无需显式编写run方法。 

run ( ) 方法是线程的入口,它是一个逻辑,目的是告诉线程你要干什么。

而Lambda表达式本身就是一个逻辑,使用它就可以直接描述出当前的线程要干啥。它看起来就像是脱离了类存在一样。

Thread 类的其他使用方式

Thread 的常见构造方法

1. Thread( ): 这个构造函数用于创建一个新的线程对象,没有指定线程的执行逻辑。通常需要通过调用 start( ) 方法来启动线程。

2. Thread(Runnable target): 这个构造函数使用一个实现了Runnable接口的对象作为参数,创建一个线程对象,并指定线程的执行逻辑为该Runnable对象的  run()  方法。这样可以在线程中执行Runnable 对象的任务。

3. Thread(String name): 这个构造函数用于创建一个新的线程对象,并给线程取一个名字。线程的名字可以用于标识和区分不同线程。

4. Thread(Runnable target, String name): 这个构造函数结合了前两种方式,它使用一个Runnable对象作为线程的执行逻辑,并为线程指定一个名字。

举个例子:

    public static void main(String[] args) {// 创建三个线程,并指定线程名,每个线程名分别用A,B,C表示Thread t1 = new Thread(() -> {// 循环10次for (int i = 0; i < 10; i++) {// 执行的代码加锁synchronized (lock) {// 每次唤醒后都重新判断是否满足条件// 每条线程判断的条件不一样,注意线程t1,t2while (COUNTER % 3 != 0) {try {// 不满足输出条件时,主动等待并释放锁lock.wait();} catch (InterruptedException e) {e.printStackTrace();}}// 满足输出条件,打印线程名,每条线程打印的内容不同System.out.print(Thread.currentThread().getName());// 累加计数COUNTER++;// 唤醒其他线程lock.notifyAll();}}}, "A");//线程被命名为 "A"

5. Thread(ThreadGroup group, Runnable target) : 这个构造函数用于创建一个线程对象,并将其分配到指定的线程组(ThreadGroup)中。线程组是一种线程的逻辑分组,可以用于对线程进行管理,但通常较少使用,这里我们不做过多讨论。

package thread;public class Demo6 {public static void main(String[] args) {Thread t = new Thread(() -> {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}, "新线程");t.start();}
}

Thread 的几个常见属性

  • getId(): 返回线程的唯一标识符(ID)。每个线程都有一个独一无二的ID,不同线程的ID不会重复。这个ID是Java给这个线程分配的,不是系统API提供的线程ID,也不是PCB的那个PID。
  • getName(): 返回线程的名称。线程的名称通常用于调试和标识不同的线程。
  • getState(): 返回线程的当前状态。线程状态表示线程目前所处的情况,例如,线程可能处于运行状态、等待状态、阻塞状态等。线程状态是一个枚举类型,用于描述线程的运行情况。
  • getPriority(): 返回线程的优先级。线程的优先级是一个整数值,通常在1到10之间,较高的优先级理论上更容易被调度执行。然而,具体的调度行为取决于操作系统和线程调度算法。
  • isDaemon(): 返回一个布尔值,表示线程是否是后台线程。后台线程通常用于执行一些辅助任务,当一个进程的所有非后台线程结束时,JVM会自动结束运行。
  • isAlive(): 返回一个布尔值,表示线程是否存活。线程存活意味着它的 run() 方法是否正在执行或已经执行结束。
  • isInterrupted(): 返回一个布尔值,表示线程是否被中断。线程中断是一种机制,允许一个线程请求另一个线程停止正在执行的任务。这个方法可以用来检查线程是否被其他线程中断。

我们现在着重讲解一下 isDaemon():我们也把它叫做:守护线程(后台线程),相对的就有:前台线程。

  1. 前台线程(Foreground Threads):前台线程是程序中的主要线程,通常由程序启动时创建。如果程序中的任何前台线程尚未完成其任务,那么整个程序将继续运行,直到所有前台线程完成为止。前台线程通常用于执行程序的核心任务,它们不会轻易终止,除非它们的工作完成或出现了不可恢复的错误。

  2. 守护线程(Daemon Threads,后台线程):守护线程是一种特殊类型的线程,它的任务是为其他线程提供服务和支持。与前台线程不同,如果所有的前台线程都已经完成并且只剩下守护线程在运行,JVM 就会结束整个程序,即使守护线程还没有完成任务。守护线程通常用于执行辅助任务,例如垃圾回收、日志记录等。在Java中,可以使用 setDaemon(true) 方法将一个线程标记为守护线程。

默认情况下一个线程是前台线程,你也可以把它手动 setDaemon(true),这样就变成了后台线程。

package thread;public class Demo6 {public static void main(String[] args) {Thread t = new Thread(() -> {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}, "新线程");// 设置 t 为后台线程.t.setDaemon(true);t.start();}
}

还什么都没有执行呢,整个程序就随着主线程的结束结束了…… 

需要注意的是,一旦线程被设置为守护线程,就不能再改变其状态为前台线程。通常,守护线程用于执行不需要阻止程序结束的任务,因为它们不会阻止程序的正常退出。

总之,守护线程和前台线程之间的主要区别在于它们是否影响整个程序的结束。前台线程必须完成其工作,否则程序不会退出,而守护线程则不会阻止程序的退出。这使得守护线程适用于执行后台任务,而不会干扰主要任务的执行

我们还得提一下“isAlive()”:

Thread 对象的生命周期要比系统内核中的线程更长一些,所以可能会出现Thread 对象还在,但是内核中的线程已经销毁了这种情况。

注意,"Thread 对象的生命周期要比系统内核中的线程更长一些" 是指 Thread 对象的生命周期要比与其关联的底层操作系统内核线程更长,而不是比系统主线程(main之类的)长。

Thread 对象是 Java 中用于表示线程的抽象,每个 Thread 对象都代表一个线程任务。Thread 对象的生命周期是从创建它开始,到线程任务结束,然后 Thread 对象可能会被垃圾回收。

与 Thread 对象关联的底层操作系统内核线程是由操作系统管理的,它负责执行 Thread 对象中定义的线程任务。这个底层线程的生命周期通常与 Thread 对象的生命周期不完全一致。

当一个 Thread 对象的 run ( ) 方法执行完毕或线程被显式地终止时,Thread 对象的生命周期可能会结束,但与之关联的底层操作系统内核线程不一定会立即终止。操作系统可能会等待一段时间来确保资源的释放和清理。所以,操作系统内核线程的结束不会立刻导致相关联的 Thread 对象被销毁。

这个时候我们就可以使用isAlive()方法可以用来检查Thread对象关联的线程是否仍然活动(回调方法执行完毕,线程就没了)。这可以在多线程编程中用于确认线程是否已经完成其任务或已经终止。当 isAlive() 返回 false 时,表示与 Thread 对象相关联的线程已经终止,可以执行适当的处理逻辑,如清理资源或进行其他操作。

注意:isAlive() 方法是 Thread 类的实例方法,主要用于检查 Thread 对象关联的线程是否还活动(即是否还在运行)。它并不直接用于检查底层操作系统内核线程是否结束,因为Java线程和操作系统线程之间有一定的抽象和层次结构。

也就是说,在Java中,Thread 对象是对底层操作系统线程的抽象和封装,Thread 对象关联了一个操作系统线程。isAlive() 方法只能告诉我们与Thread对象关联的线程是否仍然处于活动状态,而不能直接用于检查操作系统线程的状态。

就像之前我们说的,要检查操作系统线程是否结束,通常需要使用操作系统提供的工具或API(还记得大明湖畔的 jconsole 吗?)

package thread;public class Demo7 {public static void main(String[] args) {Thread t = new Thread(() -> {System.out.println("线程开始");try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("线程结束");});System.out.println(t.isAlive());t.start();System.out.println(t.isAlive());try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(t.isAlive());}
}

 

 线程启动

Thread 类是 Java 中用于多线程编程的关键类之一,包含了 run 方法和 start 方法,这两者在使用方式和运行结果上有明显的区别。让我们来分别说明一下Thread类中run和start的区别:

1. run 方法:
run 方法是 Thread 类的一个普通方法,它用于定义线程的任务或逻辑。当直接调用 run 方法时,它会在当前线程的上下文中执行,而不会创建新的线程。这就意味着它是普通的方法调用,不会实现多线程的并发执行。
运行 run 方法通常不会产生多线程的效果,因为它只是在当前线程中执行定义的任务,没有启动新的线程。这意味着任务将按照顺序在同一个线程中执行。

2. start 方法:
start 方法是 Thread 类的方法,用于启动一个新的线程并执行 run 方法中定义的任务。当我们调用 start 方法时,它会创建一个新的线程,该线程会执行 run 方法中的代码。
运行 start 方法会实现多线程的效果,因为它会启动一个新的线程,并且可以让多个线程并发执行。

总之,

run 方法是普通的方法调用,不会创建新的线程,任务在当前线程中执行。
start 方法用于启动新的线程,并且可以实现多线程的并发执行效果。

start方法内部是会调用到系统API,然后在系统内核中创建出线程。

run 方法就只是单纯的描述了该线程要执行的内容,会在start创建好线程之后自动被调用。

这两者看着效果相似,但是本质上的差别在于是否在系统内部创建出新的线程。

中断一个线程 (终止/打断)

在Java中,让一个线程停止运行/销毁一个线程,做法是比较唯一的,就是想办法让 run 方法尽快执行完,自然终止。

在Java中,当一个线程的run方法执行完毕时,该线程会结束。这是因为 run 方法中的代码是线程的执行逻辑,当run方法的代码执行完毕后,线程就完成了其任务,可以被认为是"终止"了。

当线程终止后,它不再是可运行状态,也不会再响应任何任务。线程对象仍然存在,但是它的状态会变为"终止"状态。此时,我们可以使用 isAlive() 方法来检查线程是否仍然活动。当线程终止后, isAlive() 方法会返回 false,表示线程已经完成任务。

方案一:在代码中手动创建出标志位,来作为 run 的执行结束的条件。

很多线程执行的时间久,往往是因为写了一个持续执行的循环,所以要想让 run 执行结束,就需要让循环尽快退出。

package thread;// 线程的打断
public class Demo8 {private static boolean isQuit = false;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (!isQuit) {// 此处的打印可以替换成任意的逻辑来表示线程的实际工作内容System.out.println("线程工作中");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程工作完毕!");});t.start();Thread.sleep(5000);isQuit = true;System.out.println("设置 isQuit 为 true");}
}

当前我们的这个代码是使用了一个成员变量 isQuit 来作为标志位,如果把 isQuit 改成 main 方法中的局部变量,是否可以呢?为什么?

package thread;// 线程的打断
public class Demo8 {public static void main(String[] args) throws InterruptedException {boolean isQuit = false;Thread t = new Thread(() -> {while (!isQuit) {// 此处的打印可以替换成任意的逻辑来表示线程的实际工作内容System.out.println("线程工作中");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程工作完毕!");});t.start();Thread.sleep(5000);isQuit = true;System.out.println("设置 isQuit 为 true");}
}

会不会是因为 Lambda表达式无法捕捉到 isQuit ?

这个不是问题。 Lambda表达式有一个语法规则——变量捕获,也就是说 Lambda表达式里面的代码是可以自动捕获到上层作用域中涉及到的局部变量的。

但是其实确实和 Lambda表达式的变量捕获有关。

Lambda表达式的变量捕获是指Lambda表达式可以访问外部作用域中的变量。当Lambda表达式引用外部变量时,它可以捕获(或绑定)这些变量的值,以便在Lambda表达式内部使用。这种行为称为"变量捕获"。所谓变量捕获其实就是让Lambda表达式把当前作用域中的变量在Lambda表达式内部再复制一份,所以里面的 isQuit 和外面的不是同一个。

Lambda表达式的变量捕获有两种方式:

  1. 捕获局部变量:Lambda表达式可以捕获包含它的方法中的局部变量。这些局部变量在Lambda表达式内部必须是 “ 隐式最终(effectively final)” 的,也就是说它们不可以被修改。这确保了Lambda表达式内部可以安全地引用这些变量。

    int x = 10;
    Runnable runnable = () -> {System.out.println(x); // Lambda表达式捕获了外部的局部变量x
    };
    

  2. 捕获实例变量(成员变量):Lambda表达式可以捕获包含它的类的实例变量(成员变量)。与局部变量不同,Lambda表达式可以修改实例变量的值,因为实例变量的生命周期与对象一致。

    public class MyClass {private int x = 10;public Runnable getLambda() {return () -> {x++; // Lambda表达式可以修改实例变量xSystem.out.println(x);};}
    }
    

需要注意的是,Lambda表达式可以访问外部变量,但对于局部变量,它们必须是隐式最终的,这是因为Lambda表达式在运行时可能会捕获这些变量的值,而如果这些变量被修改,可能导致不一致性或错误的结果。实例变量没有这个限制,因为它们的生命周期与对象一致,可以被Lambda表达式修改。

所以折回我们原先的问题,Java中,变量捕获语法的前提限制就是必须只能捕获一个 final 或者实际上是 final 的变量,这样,变量也就变成了常量,我们就可以很顺利的进行捕获:

package thread;// 线程的打断
public class Demo8 {public static void main(String[] args) throws InterruptedException {final boolean isQuit = false;Thread t = new Thread(() -> {while (!isQuit) {// 此处的打印可以替换成任意的逻辑来表示线程的实际工作内容System.out.println("线程工作中");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程工作完毕!");});t.start();Thread.sleep(5000);// isQuit = true;System.out.println("设置 isQuit 为 true");}
}

事实上的“ final ”:变量虽然没有使用 final ,但是却没有修改内容,就是事实上的“ final ”。

所以不加 final 也可以,只要不要修改。

把 isQuit 改成成员变量,此时 Lambda 访问这个成员,就不再是局部变量捕获的语法了。而是“内部类访问外部类的属性”,就没有 final 的限制了。 

好了,到了这里方案一的内容就结束了,但是上述方案有一些缺点:

1、需要手动创建变量;

2、当线程内部在 sleep 的时候,主线程修改变量,新线程内部不能够及时响应。

所以相比之下,我们就需要使用另外的方案:

方案二:

package thread;// 线程终止
public class Demo9 {public static void main(String[] args) {Thread t = new Thread(() -> {// Thread 类内部, 有一个现成的标志位, 可以用来判定当前的循环是否要结束.while (!Thread.currentThread().isInterrupted()) {System.out.println("线程工作中");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("让 t 线程终止. ");t.interrupt();}
}

那我们可以直接把 Thread.currentThread() 写成 t 吗?当然不可以!我们现在正在构造 t 啊,它处于还没有被初始化的状态,当然是不能够被使用的。

isInterrupted():在 Thread 内部有一个标志位,这个标志位就可以用来判定线程是否结束。

t.interrupt():这个操作就是把上述 Thread对象内部的标志位设置为 true。而且还有一点好处就是,即使我们线程内部的逻辑出现阻塞(sleep),也是可以使用这个方法唤醒。

正常来说,sleep会休眠到时间到才会唤醒,此处给出的 interruput 就可以使 sleep 内部触发一个异常,从而被提前唤醒。这一点,我们自己手动实现标志位是无法实现这个效果的。 

这里异常确实如我们预期的那样出现了,但是超出预期的是,t 仍然再继续工作,并没有正在结束。

原因在此:interruput 唤醒线程之后,sleep 会抛出异常,同时会自动清除刚才设置的标志位。这样就使“设置标志位”这样的效果好像没有生效一样。

更深层的原因是:Java 是期望,当线程收到“要中断”这样的信号的时候,它能够自由决定接下来怎么处理。 所以我们有以下几种方法(见注释):

package thread;// 线程终止
public class Demo9 {public static void main(String[] args) {Thread t = new Thread(() -> {// Thread 类内部, 有一个现成的标志位, 可以用来判定当前的循环是否要结束.while (!Thread.currentThread().isInterrupted()) {System.out.println("线程工作中");try {Thread.sleep(1000);} catch (InterruptedException e) {// 1. 假装没听见, 循环继续正常执行.e.printStackTrace();// 2. 加上一个 break, 表示让线程立即结束.// break;// 3. 做一些其他工作, 完成之后再结束.// 其他工作的代码放到这里.break;}}});t.start();try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("让 t 线程终止. ");t.interrupt();}
}

这样就可以让我们有更多的可操作空间。但是你要明确事情的先后顺序。有这个可操作空间的前提是“你要 sleep ,然后需要通过异常的方式去唤醒的”,如果没有 sleep,也就不存在异常,就更不可能有什么操作空间。 

另外,我们其实还有一个方法来设置标志位:

public static boolean interrupted() :判断当前线程的中断标志位是否设置,调用后清除标志位

但是我不建议使用这个方法,因为它是一个静态方法,判定的是一个静态成员作为标志位,在有很多线程的情况下,这种做法可能会导致所有的线程都要受到这一个静态成员的制约。

线程等待

线程等待是指一个线程暂停执行,直到满足某个条件或者某个特定事件发生,再继续执行。本质上就是控制线程结束的顺序。在多线程编程中,线程等待通常用于协调不同线程之间的执行顺序或共享资源的访问。

使用 join() 方法:join()方法是Thread类提供的方法,它允许一个线程等待另一个线程完成执行。当一个线程调用另一个线程的 join()方法时,它将等待被调用线程执行完毕。这通常用于确保某个线程在另一个线程之后执行。

比如:主线程中调用 t.join(),此刻就是主线程等待 t 线程结束。

package thread;public class Demo10 {public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {for (int i = 0; i < 5; i++) {System.out.println("t 线程工作中!");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();// 让主线程来等待 t 线程执行结束.// 一旦调用 join, 主线程就会触发阻塞. 此时 t 线程就可以趁机完成后续的工作.// 一直阻塞到 t 执行完毕了, join 才会解除阻塞, 才能继续执行System.out.println("join 等待开始");t.join();System.out.println("join 等待结束");}
}

t.join()的工作过程可以分为以下几种情况:

1. 如果线程 t 正在运行中,调用 t.join() 的线程会阻塞,一直等待线程 t 执行结束。在这种情况下, join 方法会等待 t 线程的执行完成,然后才会返回。

2. 如果线程 t 已经执行结束,即 t 线程的任务已经完成,调用了 join 的线程会立即返回,不会阻塞。这是因为 t 线程已经不再运行,不需要等待。

3、如果被调用线程尚未执行完毕且等待时间未到期,调用线程将继续等待,直到满足条件。

  • public void join(long millis)    等待线程结束,最多等 millis 毫秒;
  •  public void join(long millis, int nanos)    同理,但可以更高精度,nanos 纳秒。

实际开发中不建议“死等”,最好要带有“超时时间”。 

4、如果在调用 t.join() 的线程中,被调用的线程 t 被中断,那么 t.join() 方法会抛出InterruptedException 异常,不会阻塞等待。这是因为线程被中断后,t.join() 方法会立即响应中断,并抛出异常,允许调用线程处理中断事件。
这种情况通常用于处理线程的中断请求,以便能够在中断发生时及时退出等待状态,而不会永远阻塞。所以,在使用 t.join()时,需要正确处理 InterruptedException 异常,以保证线程的可靠中断处理。 

在实际应用中,join 方法通常用于等待子线程的完成,以确保主线程在子线程完成后再继续执行后续任务。这种等待可以协调多个线程之间的执行顺序。

获取当前线程引用

这个我们刚刚提过,

public static Thread currentThread() 是Thread类的一个静态方法,它用于返回当前执行这个方法的线程对象的引用。

  1. 在多线程程序中,可能有多个线程在同时执行不同的任务。每个线程都有一个与之相关联的Thread对象,该对象表示了线程的状态和行为。
  2. 当调用Thread.currentThread()方法时,它会返回调用这个方法的线程对象的引用。这意味着它告诉你哪个线程正在执行这段代码。
  3. 这个方法通常用于在多线程环境中获取当前执行线程的信息,例如线程的名称、状态、优先级等。通过这个方法,可以动态地获取当前线程的信息并进行相应的操作。
     

休眠当前线程 

也是我们比较熟悉一组方法,

  • public static void sleep(long millis) throws InterruptedException:这个方法让当前线程休眠指定的毫秒数。它可以用来控制线程的执行速度,但不能精确到纳秒级别。方法的参数是休眠的毫秒数,当线程休眠期间,它不会占用CPU资源,允许其他线程执行。
  • public static void sleep(long millis, int nanos) throws InterruptedException:这个方法允许更高精度的休眠,不仅可以指定毫秒数,还可以指定纳秒数。它允许线程以毫秒和纳秒级别精确控制休眠时间。这对于需要非常精确的时间控制的场景很有用。

需要注意的是,尽管这两个方法允许线程休眠一段时间,但线程的休眠时间是不精确的,因为线程的调度是由操作系统决定的,而操作系统可能会有其他任务需要执行,还存在一个调度的开销。因此,这些方法只能保证线程实际休眠的时间大于等于参数设置的休眠时间的,但不能精确到毫秒或纳秒级别。所以实际上精确到纳秒级别?意义不大……

package thread;public class Demo11 {public static void main(String[] args) throws InterruptedException {long beg = System.currentTimeMillis();Thread.sleep(1000);long end = System.currentTimeMillis();System.out.println("时间: " + (end - beg) + " ms");}
}

系统确实会按照1000这个时间来控制线程休眠,到那时当1000时间到了之后,系统就会唤醒这个线程,使其从阻塞状态到就绪状态。但是不是说这个线程成了就绪状态就能立刻回到CPU上运行,这中间还有一个调度开销。对于Windows 或者 Linux 这样的系统来说,调度开销可能很大,达到 ms 级别。 

有些场景对时间精度要求是极高的,比如发射卫星或者导弹拦截,这个时候往往需要使用“实时操作系统”(任务调度的开销在一定的时间范围之内)(VxWorks)。但是为了实现实时,它在功能上是不如Windows 或者 Linux 这样的系统,有很多限制。

小练习:

在子线程执行完毕后再执行主线程代码
有20个线程,需要同时启动。
每个线程按0-19的序号打印,如第一个线程需要打印0
请设计代码,在main主线程中,等待所有子线程执行完后,再打印 ok

public class Demo {public static void main(String[] args) {int threadCount = 20;Thread[] threads = new Thread[threadCount];for (int i = 0; i < threadCount; i++) {threads[i] = new Thread(new Worker(i));threads[i].start();}// 等待每个子线程执行完毕for (Thread thread : threads) {try {thread.join();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("ok");}static class Worker implements Runnable {private int threadNumber;public Worker(int threadNumber) {this.threadNumber = threadNumber;}@Overridepublic void run() {System.out.println("Thread " + threadNumber + " is running.");// 在这里执行子线程的任务// ...}}
}

这篇关于JavaEE(3)(由进程到线程、线程的调度、 进程线程的区别、Java 实现多线程编程、创建线程、Thread 类的其他使用方式、线程启动、中断线程、线程等待、获取当前程引用、休眠当前线程)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/643738

相关文章

C++使用栈实现括号匹配的代码详解

《C++使用栈实现括号匹配的代码详解》在编程中,括号匹配是一个常见问题,尤其是在处理数学表达式、编译器解析等任务时,栈是一种非常适合处理此类问题的数据结构,能够精确地管理括号的匹配问题,本文将通过C+... 目录引言问题描述代码讲解代码解析栈的状态表示测试总结引言在编程中,括号匹配是一个常见问题,尤其是在

Java实现检查多个时间段是否有重合

《Java实现检查多个时间段是否有重合》这篇文章主要为大家详细介绍了如何使用Java实现检查多个时间段是否有重合,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录流程概述步骤详解China编程步骤1:定义时间段类步骤2:添加时间段步骤3:检查时间段是否有重合步骤4:输出结果示例代码结语作

Java中String字符串使用避坑指南

《Java中String字符串使用避坑指南》Java中的String字符串是我们日常编程中用得最多的类之一,看似简单的String使用,却隐藏着不少“坑”,如果不注意,可能会导致性能问题、意外的错误容... 目录8个避坑点如下:1. 字符串的不可变性:每次修改都创建新对象2. 使用 == 比较字符串,陷阱满

Java判断多个时间段是否重合的方法小结

《Java判断多个时间段是否重合的方法小结》这篇文章主要为大家详细介绍了Java中判断多个时间段是否重合的方法,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录判断多个时间段是否有间隔判断时间段集合是否与某时间段重合判断多个时间段是否有间隔实体类内容public class D

使用C++实现链表元素的反转

《使用C++实现链表元素的反转》反转链表是链表操作中一个经典的问题,也是面试中常见的考题,本文将从思路到实现一步步地讲解如何实现链表的反转,帮助初学者理解这一操作,我们将使用C++代码演示具体实现,同... 目录问题定义思路分析代码实现带头节点的链表代码讲解其他实现方式时间和空间复杂度分析总结问题定义给定

IDEA编译报错“java: 常量字符串过长”的原因及解决方法

《IDEA编译报错“java:常量字符串过长”的原因及解决方法》今天在开发过程中,由于尝试将一个文件的Base64字符串设置为常量,结果导致IDEA编译的时候出现了如下报错java:常量字符串过长,... 目录一、问题描述二、问题原因2.1 理论角度2.2 源码角度三、解决方案解决方案①:StringBui

Java覆盖第三方jar包中的某一个类的实现方法

《Java覆盖第三方jar包中的某一个类的实现方法》在我们日常的开发中,经常需要使用第三方的jar包,有时候我们会发现第三方的jar包中的某一个类有问题,或者我们需要定制化修改其中的逻辑,那么应该如何... 目录一、需求描述二、示例描述三、操作步骤四、验证结果五、实现原理一、需求描述需求描述如下:需要在

Debezium 与 Apache Kafka 的集成方式步骤详解

《Debezium与ApacheKafka的集成方式步骤详解》本文详细介绍了如何将Debezium与ApacheKafka集成,包括集成概述、步骤、注意事项等,通过KafkaConnect,D... 目录一、集成概述二、集成步骤1. 准备 Kafka 环境2. 配置 Kafka Connect3. 安装 D

Java中ArrayList和LinkedList有什么区别举例详解

《Java中ArrayList和LinkedList有什么区别举例详解》:本文主要介绍Java中ArrayList和LinkedList区别的相关资料,包括数据结构特性、核心操作性能、内存与GC影... 目录一、底层数据结构二、核心操作性能对比三、内存与 GC 影响四、扩容机制五、线程安全与并发方案六、工程

JavaScript中的reduce方法执行过程、使用场景及进阶用法

《JavaScript中的reduce方法执行过程、使用场景及进阶用法》:本文主要介绍JavaScript中的reduce方法执行过程、使用场景及进阶用法的相关资料,reduce是JavaScri... 目录1. 什么是reduce2. reduce语法2.1 语法2.2 参数说明3. reduce执行过程