【JavaEE】多线程01

1.为何需要线程

通过多进程的方式,可以实现 "并发编程" 的效果,但是,进程是一个比较重的概念,在创建或者销毁一个进程的时候,开销都比较大,尤其是在需要频繁创建进程的时候。

(关于进程/CPU/操作系统/进程调度 等知识,请看:https://blog.csdn.net/Zzzzmo_/article/details/159430255?spm=1001.2014.3001.5502)

为了解决这个问题,就有了 线程(Thread) 这个概念,它是 "轻量级的进程" ,即创建销毁的开销更小,且调度线程比调度进程速度更快。

⼀个线程就是⼀个 "执行流" 。每个线程之间都可以按照顺序执行自己的代码,多个线程之间 "同时" 执行着多份代码

举例理解线程

⼀家公司要去银办理业务,既要进行财务转账,又要进行福利发放,还得进行缴社保。

如果只有张三⼀个会计就会忙不过来,耗费的时间特别长。为了让业务更快的办理好,张三⼜找来两位同事李四、王五⼀起来帮助他,三个⼈分别负责⼀个事情,分别申请⼀个号码进行排队,自此就有 了三个执行流共同完成任务,但本质上他们都是为了办理⼀家公司的业务。

此时,我们就把这种情况称为多线程,将⼀个大任务分解成不同小任务,交给不同执行流就分别排队执行。其中李四、王五都是张三叫来的,所以张三⼀般被称为主线程(Main Thread)。

2.进程和线程的区别

每个进程相当于一个要执行的任务(运行的一段代码命令),而每一个线程也是一个要执行的任务。

  1. 进程是包含线程的,每个进程至少有⼀个线程存在,即主线程(一个进程=主线程)。
  2. 进程创建时需要申请资源,销毁时需要释放资源,而对于线程来说,只需要第一个线程创建时(和进程一起创建的时候)才需要申请资源,后续再创建,不涉及资源的申请操作(干的事少,快),只有当所有线程都销毁(进程销毁),才真正释放了资源,在运行过程中销毁某个线程,不会释放资源。
  3. 进程是操作系统资源分配的基本单位,如分配CPU、内存、硬盘等的资源,进程内部管辖的多个进程之间会共享这些资源,也就是说进程内部的线程之间容易相互影响(线程不安全),而进程和进程之间,所涉及到的资源则是各自独立的,彼此之间互不干扰。
  4. 进程是操作系统资源分配的最小单位,而线程则是操作系统调度的基本单位 (CPU 最终切换和运行的,是线程,不是进程)。
    1. 系统调度 = 操作系统决定:现在让谁在 CPU 上跑、跑多久、什么时候换下一个。
    2. 所以,进程调度其实就是线程调度
  5. ⼀个进程挂了⼀般不会影响到其他进程,但是⼀个线程挂了,可能把同进程内的其他线程⼀起带⾛(整 个进程崩溃)。
    1. 线程之间容易相互影响,那么就有可能发生冲突,当一个线程抛出异常时,可能带走整个进程,所有线程都无法继续工作,但是如果及时捕获这个异常处理掉,也不一定导致进程终止。

一句话核心进程太重、切换太慢、资源浪费;线程轻量、共享资源、切换快,能让程序 "同时干多件事还不卡顿"。

3.Java的线程 和 操作系统线程 的关系

线程是操作系统中的概念,操作系统内核实现了线程这样的机制,并且对++用户层提供了一些 API 供用户使用++。

Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进⼀步的抽象和封装。

什么是 API?

  • API 即 Application Programming Interface - 应用程序编程接口,就是别人写好的,可以直接调用的功能 / 方法 / 接口 / 类,不用管内部怎么实现,只要知道:
    1.叫什么名字
    2.传什么参数
    3.能得到什么结果。

4.创建线程

如下图,当创建一个Thread 对象时,并没有像ArrayList类一样自动导入包,说明Thread类是在 java.lang 包下的一个类,默认import。

创建一个 Thread 线程的方法有两种。

方法一:继承 Thread 类创建一个线程类

创建一个类继承时需要重写 run() 方法。

方法二:实现 Runnable 接口

创建一个类实现 Runnable 接口,创建 Thread 类实例, 调用 Thread 的构造方法时将 Runnable 对象作为参数。

(这种写法能够更好的解耦合)

方法三:匿名内部类创建 Thread 子类对象

该方法,本质上就是 方法一 ,只是将方法一的形式使用匿名内部类的方式创建。

方法四:匿名内部类创建 Runnable 子类对象

该方法,本质上就是 方法二 ,只是将方法二的形式使用匿名内部类的方式创建。

方法五:lambda 表达式创建 Runnable 子类对象

这种方法比 方法四 更简洁。

5.启动线程 start()

之前我们已经看到了如何通过覆写 run 方法创建⼀个线程对象,但线程对象被创建出来并不意味着线程就开始运行了。

  • 覆写 run 方法是提供给线程要做的事情的指令清单
  • 线程对象可以认为是把李四、王五叫过来了
  • 而调用 start() 方法,相当于让李四,王五行动起立,线程才真正独立去执行了

只有调用Thread 的 start 方法,才真的在操作系统的底层创建出⼀个线程. (JVM调用操作系统的API 完成线程创建操作 ------ start 是 Java 标准库/JVM 提供的方法,本质上是调用操作系统的API)

如果只是调用了重写的run 方法(Thread/Runnable的方法),并没有启动线程,只是执行调用,这时整个进程中,就只有main这个线程。(run是线程入口方法,新的线程启动了,就要执行这里的代码,并不需要自己手动调用,新的线程创建好之后自动去执行,相当于回调函数)

启动线程后,除了 main 这个主线程外,又多了一个线程:

感受多线程程序和普通程序的区别:

  • 每个线程都是⼀个独立的执行流
  • 多个线程之间是 "并发" 执行的

我们可以通过以下的示例验证:使用 Thread类的++sleep()方法,让当前正在执行的线程,暂停执行(休眠)指定的时间,让出 CPU,CPU 就可以去执行其他线程 → 实现线程切换、并发效果++。必须捕获 InterruptedException 异常。

注意:sleep方法是静态方法,通过 类名.sleep ,即Tread.sleep() 调用。

看以上的运行结果,有时是 main在前,thread在后,有时候又相反,多个线程的调度顺序是随机的,无法预测。

jconsole 程序 观察线程

我们可以使用 jconsole 程序 观察线程:(在 jdk 的 bin 目录中)

注意:保证上面的代码是运行起来的状态。

选择我们所创建的线程:

6.Thread类及常见方法

Thread 类是 JVM 用来管理线程的⼀个类,换句话说,每个线程都有⼀个唯⼀的 Thread 对象与之关联。

用我们上面的例⼦来看,每个执行流,也需要有⼀个对象来描述,类似下图所示,而 Thread 类的对象 就是用来描述⼀个线程执行流的,JVM 会将这些 Thread 对象组织起来,用于线程调度,线程管理。

Thread 的常见构造方法

  • Thread() ------ 使用这个方法,必须重写Thread 的 run() 方法
  • Thread(Runnable tarhet) ------ 必须重写 Runnable 的 run() 方法
  • Thread(String name) ------ 在创建线程的同时给线程起名字t1,t2,t3

注意 ,之前通过 jconsole 程序 观察的线程 的名称 就像 Thread-0,Thread-1 等,是线程的默认名称,我们可以以上的方法为线程自定义一个名字,此时可以再次观察线程,发现线程的名字是我们自定义的:

以上的所有线程与之前的对比,发现少了一个 main 主线程原因:main方法执行完毕了,主线程就结束了,以前的认知中,main方法执行结束,那么代表整个程序(进程)就结束了,实际上,以前的认知只是针对单线程程序的,现在是多线程的程序,主线程main结束了,但是还有其他的线程没有结束。

如果想要看到 main 线程,那么只要让 该线程不要结束即可:

  • Thread(ThreadGroup group,Runnable target) ------ 第一个参数表示线程组,该构造方法表示把多个线程放到一个组里,统一针对这个线程组里所有的线程进行一些属性设置。

Thread 的几个常见属性

  • ID 是Java中给每个运行的线程分配的id,线程的唯⼀标识,类似于PID,不同线程不会重复
  • 名称 是各种调试工具用到
  • 状态表示线程当前所处的⼀个情况
  • 优先级高的线程理论上来说更容易被调度到
  • 关于后台线程,需要记住⼀点:JVM会在⼀个进程的所有非后台线程结束后,才会结束运行
  • 是否存活,即简单的理解,为 run 方法是否运行结束了
  • 线程的中断问题,下面我们进⼀步说明

# isDaemon() ------ 是否后台线程

什么是后台,什么是前台?

isDaemon 其实表示 是否是守护线程,而守护线程 = 后台线程,即默默守护,没有存在感,这样的线程就称为后台线程

而像前面 t1 , t2 , t3 这几个线程的存在,会影响到 进程 继续存在,这样的线程,就称为前台线程。

而像这些JVM自带的线程,他们的++存在,不影响进程结束,即使它们继续存在,如果进程结束,它们也会随之结束(JVM 提供的这些线程属于有特殊功能的线程,跟随整个进程持续执行,比如垃圾回收的线程)++ ,也就是后台线程

总结:前台线程是否结束决定进程是否结束,后台线程无论是否结束都不影响进程,如果有多个前台线程,那么必须所有前台线程都结束,进程才会结束。

我们在创建线程时,包括main主线程默认都是前台线程,可以通过 setDaemon() 方法来修改,注意区分,isDaemon() 是查看石是否是后台线程,而 setDaemon() 方法可以修改前后台线程(参数是true表示修改为了后台线程 ),但是setDaemon的设置必须在启动线程start之前设置

如以下代码 ,main 在执行3次之后就会结束,但是该线程结束之后,另一个线程会继续执行:

为了让 main主线程结束之后,进程彻底结束,我们可以在另一个线程启动之前将其修改为后台线程,既让该进程中只有 main 这一个前台线程:

(IDEA 本身也是一个Java进程,在 IDEA 中运行一个Java代码,通过IDEA进程,又创建了一个新的Java进程,这两个进程是有 父子关系 的。进程之间有父子关系,线程之间不存在父子关系)

# isAlive() ------ 是否存活(run方法是否运行结束)

Java代码中创建的Thread 对象,和系统中的线程是一一对应的关系,但是,Thread对象的生命周期和系统中线程的生命周期是不同的,可能存在Thread对象还存活,但是系统中的线程已经销毁的情况。

示例:以下代码的逻辑是,3s之后,线程的入口方法run()里的逻辑结束了,操作系统中对应的线程就随之销毁了,但是 thread 这个对象仍然存在:

也就是说,当线程未销毁前,使用 对象.isAlive(),即thread.isAlive() 结果返回的是 true,证明线程未销毁/结束,当3秒之后,线程结束了,而 thread.isAlive() 仍然可以返回,只不过变成了 false,因为线程销毁了。这就是 Thread对象 还存活而县城已经销毁了的情况

总结:Thread对象与线程一一对应,一个对象只能创建一个线程,但是一个对象的生命周期与线程是不同的。即每个Thread对象,都只能 start 启动一次,每次想创建一个新的线程,都得创建一个新的Thread对象。

7.中断线程

准确来说是终止一个线程,让这个线程彻底结束,即让线程的入口方法 run() 尽快 return,执行结束。

我们继续最开始的例子举例:

  • 李四⼀旦进到工作状态,他就会按照行动指南上的步骤去进行工作,不完成是不会结束的。但有时我们需要增加⼀些机制,例如老板突然来电话了,说转账的对方是个骗子,需要赶紧停⽌转账,那张三 该如何通知李四停止呢?这就涉及到我们的停⽌线程的方式了。

目前常见的有以下两种方式:

  1. 通过共享的标记来进行沟通
  2. 调用 interrupt() 方法来通知

1)使用自定义的变量作为标记位

定义一个静态成员变量isQuit,表示标记线程是否中断。

如果把isQuit定义成局部变量,是否可以?

如下图,是不可以的,我们可以看报错的原因 ------ isQuit应该是 final 或者 实际上是final 类型的变量,而 final 修饰的变量其实就是常量,也就是说,这句话的意思是 isQuit应该是不可修改的。

这就涉及到了 lambda 表达式中的变量捕获,在 lambda中被捕获的变量是不允许修改的

因此,不能是局部变量,而是一个成员变量,这样就不再是一个变量捕获的语法了,而是切换成"内部类(匿名)访问外部类的成员" 的语法,这样也不必限制 final 之类的。

2)使用 thread.interrupted() 和 Thread.currentThread.isInterrupted() 代替自定义

第二三个方法作用相同。

  1. Thread.currentThread.isInterrupted() ------ 表示判断线程是否被终止,相当于判断 Thread 里的 boolean 变量的值。
  2. thread.interrupted() ------ 表示主动去进行终止,相当于修改 boolean 变量的值为 true。

为何是先++.currentThread++ 然后再 ++.isInterrupted()++

------ 原因:lambda 这里的定义是在 new Thread 之前的,也是在 Thread thread 声明之前的(lambda 的定义相当于 Runnable 类子类的创建 ,即 需要子类作为参数 传入 Thread才能创建出 线程)

因此我们不能直接通过 Thread 的引用 thread 直接调用isInterrupted() 方法,在调用 isInterrupted() 方法之前应该先调用 currentThread() 静态方法,来获取到当前线程的引用(该方法的作用在哪个线程中调用,就获得哪个线程的 Thead 引用 )。


如运行结果,缺失终止了:

而且会报错/异常,原因:修改完 boolean 变量的值之后,唤醒了 sleep 这样的阻塞方法,即 使得休眠失效/不成功,抛出 sleep 的异常。

如果想要让结果更好看,可以 break,但是本质不变:

针对sleep的异常处理,如果不加 break,让 catch 部分空着呢,会发生什么?

看运行结果发现:当 main 尝试 interrupt 终止 thread 线程时,并不能终止,线程thread还是可以继续执行下去。

原因 :针对这个情况,其实是 sleep 在搞鬼,正常来说,调用了 Interrupt 方法就会修改 isInterrupt 方法内部的标志位,设置为 true,但是由于将 sleep 唤醒了,这种提前唤醒的情况下,sleep 就会在唤醒之后把 isInterrupt 的标志位给设置回 false,因此,如果继续执行到循环的条件判断,就会发现能够继续执行。

总结:

  • 加上 break 就是立即终止
  • 什么都不写就是不终止
  • catch 中先执行一些其他的逻辑再执行 break,就是稍后终止。
  • Java中的线程终止,不是一个"强制性"的措施,不是main线程让thread终止就终止,选择权在thread自己手里。

8.等待一个线程 join()

多个线程之间,并发执行,随机调度,而 join 能够要求多个线程之间结束的先后顺序。

有时,我们需要等待⼀个线程完成它的工作后,才能进行自己的下⼀步工作。例如,张三只有等李四 转账成功,才决定是否存钱,这时我们需要⼀个方法明确等待线程的结束。

虽然可以使用 sleep 休眠的时间来控制线程结束的顺序,但是有的情况下这种设定并不科学,有时候希望 thread 先结束,main 就可以紧跟着结束了,此时通过设置休眠时间的方式,不一定靠谱。

示例:

以上代码运行的结果有两种可能:

我们应该使用 join() 方法来控制。

示例 :在主线程 main 中调用 join,意味着让主线程等待 thread 线程先结束。那么当执行到 thread.join 时,main线程就会**"阻塞等待"**,一直等到 thread 线程执行完毕,join 才能继续执行。

join 也会抛出和 sleep 一样的异常: InterruptedException

这样无论怎么重新运行,输出的结果都是 thread 线程先结束:

我们可以通过 jconsole 程序观察 main 的阻塞状态:

可以看到,main一直在 27行处阻塞,也就是 thread.join 处:只要 thread 不结束,主线程就会一直等待下去:

除了以上不带参数的 join() 方法外,还有带参数的版本,指定了"超时时间" ,即等待的最大时间,超过这个最大时间,就不再等待。

  • 第三个方法,精确到 纳秒 级别,一般很少用。

示例:如果在 2000毫秒之内,thread 就结束了,那么此时 join 就立即继续执行,不会等满 2000毫秒,如果 2000 毫秒 之内 thread 没有结束,超时了,此时 join 也继续执行下去,就不会再等了。

9.获取当前线程引用

这个方法我们已经熟悉了,这里不再展开。

10.休眠当前线程

该方法也一样。

要记得,因为线程的调度是不可控的,所以,这个方法只能保证 实际休眠时间是大于等于参数设置的休眠时间的。

代码调用 sleep,相当于让当前线程让出CPU资源,当前线程会回到**阻塞状态,**后续时间到了,需要操作系统内核,把这个线程重新调度到CPU上,才能继续执行,即时间到了,意味着允许被调度了,而不是立即执行。

sleep 的特殊写法 ------sleep(0) ,该写法的作用是让当前的线程立即主动放弃CPU资源,等待操作系统重新调度,而不需要时间等待,让出 CPU 后,当前线程会回到就绪状态 ,调度器可能重新选它,也可能选其他线程。

一句话区别:

  • sleep(0) = 我暂时不用 CPU 了,你们先用
  • sleep(N) = 我睡 N 毫秒,期间别叫我

总结:

sleep(0) 特殊作用:主动触发线程调度,让出 CPU 使用权,不真正休眠;
和普通 sleep 区别:普通 sleep 是固定时长阻塞,sleep (0) 是立即让位调度;

10.线程状态

在前一篇文章中,我们了解过操作系统进程调度中的++进程状态++:

  1. 就绪状态
  2. 阻塞状态

而我们现在要学习的线程状态,其实是Java针对以上进程状态的重新封装。

查看线程的所有状态

线程的状态是⼀个枚举类型 Thread.State

10.1 NEW

NEW:安排了工作,还未开始行动,即 new 了Thread对象,还没有 start。

示例:使用 getState() 属性 获取当前线程状态

10.2 TERMINATED

TERMINATED:工作完成了,即内核中的线程已经结束了,但是 Thread 对象还在。

示例:以下代码中,thread线程已经结束了,但是 Thread对象还在。

10.3 RUNNABLE

RUNNABLE:可工作的,又可以分为正在工作中和即将开始工作,也就是就绪状态

  • 线程正在CPU上执行
  • 线程随时可以去CPU上执行
  • 随叫随到,即就绪状态

示例:如下图 thread线程正在执行中(while循环中虽然什么都没写,但是本身也是一条指令,一直循环执行也是需要在CPU上运行的)

RUNNABLE 是处于 NEW 和 TERMINATED 之间的状态。

10.4 TIMED_WAITING

TIMED_WAITING:表示排队等着其他事情,也就是阻塞状态

  • 指定时间的阻塞,期间不参与CPU调度,不继续执行

示例:

在 jconsole 中观察:

另外,join(时间) 也会进入该状态:

10.5 WAITING

WAITING:表示排队等其他事情,也是阻塞状态。

  • 区别,该状态是死等,也就是没有超时时间的阻塞等待join()

示例:

10.6 BLOCKED

BLOCKED:也是一种阻塞状态,比较特殊,是由于 导致的阻塞。

  • 这个在讲到 锁 时在学习。

相关推荐
希望永不加班2 小时前
SpringBoot Web 模块核心组件:从 DispatcherServlet 讲起
java·前端·spring boot·后端·spring
王者鳜錸2 小时前
闲鱼商品自动发布实战:基于Java实现API轮询与批量上架
java·开发语言·python·商品自动发布
sjmaysee2 小时前
PostgreSQL异常:An IO error occurred while sending to the backend
java
大白要努力!2 小时前
Android libVLC 3.5.1 实现 RTSP 视频播放完整方案
android·java·音视频
liuyao_xianhui2 小时前
优选算法_岛屿数量_floodfill算法)_bfs_C++
java·开发语言·数据结构·c++·算法·链表·宽度优先
321茄子2 小时前
idea 撤销吴提交代码
java·ide·intellij-idea
woai33642 小时前
JVM学习-基础篇-堆&方法区
jvm·学习
蜜獾云2 小时前
windows java jar 包后台运行
java
€8112 小时前
Java入门级教程29——Spring Cloud:Eureka 注册发现 + MySQL 数据交互 + 负载均衡
java·开发语言·mysql·spring cloud·eureka·负载均衡