【JavaEE】多线程01
1.为何需要线程
通过多进程的方式,可以实现 "并发编程" 的效果,但是,进程是一个比较重的概念,在创建或者销毁一个进程的时候,开销都比较大,尤其是在需要频繁创建进程的时候。
(关于进程/CPU/操作系统/进程调度 等知识,请看:https://blog.csdn.net/Zzzzmo_/article/details/159430255?spm=1001.2014.3001.5502)
为了解决这个问题,就有了 线程(Thread) 这个概念,它是 "轻量级的进程" ,即创建销毁的开销更小,且调度线程比调度进程速度更快。
⼀个线程就是⼀个 "执行流" 。每个线程之间都可以按照顺序执行自己的代码,多个线程之间 "同时" 执行着多份代码。
举例理解线程:
⼀家公司要去银办理业务,既要进行财务转账,又要进行福利发放,还得进行缴社保。
如果只有张三⼀个会计就会忙不过来,耗费的时间特别长。为了让业务更快的办理好,张三⼜找来两位同事李四、王五⼀起来帮助他,三个⼈分别负责⼀个事情,分别申请⼀个号码进行排队,自此就有 了三个执行流共同完成任务,但本质上他们都是为了办理⼀家公司的业务。
此时,我们就把这种情况称为多线程,将⼀个大任务分解成不同小任务,交给不同执行流就分别排队执行。其中李四、王五都是张三叫来的,所以张三⼀般被称为主线程(Main Thread)。
2.进程和线程的区别
每个进程相当于一个要执行的任务(运行的一段代码命令),而每一个线程也是一个要执行的任务。
- 进程是包含线程的,每个进程至少有⼀个线程存在,即主线程(一个进程=主线程)。
- 进程创建时需要申请资源,销毁时需要释放资源,而对于线程来说,只需要第一个线程创建时(和进程一起创建的时候)才需要申请资源,后续再创建,不涉及资源的申请操作(干的事少,快),只有当所有线程都销毁(进程销毁),才真正释放了资源,在运行过程中销毁某个线程,不会释放资源。
- 进程是操作系统资源分配的基本单位,如分配CPU、内存、硬盘等的资源,进程内部管辖的多个进程之间会共享这些资源,也就是说进程内部的线程之间容易相互影响(线程不安全),而进程和进程之间,所涉及到的资源则是各自独立的,彼此之间互不干扰。
- 进程是操作系统资源分配的最小单位,而线程则是操作系统调度的基本单位(CPU 最终切换和运行的,是线程,不是进程)。
- 系统调度 = 操作系统决定:现在让谁在 CPU 上跑、跑多久、什么时候换下一个。


- 所以,进程调度其实就是线程调度。
- 系统调度 = 操作系统决定:现在让谁在 CPU 上跑、跑多久、什么时候换下一个。
- ⼀个进程挂了⼀般不会影响到其他进程,但是⼀个线程挂了,可能把同进程内的其他线程⼀起带⾛(整 个进程崩溃)。
- 线程之间容易相互影响,那么就有可能发生冲突,当一个线程抛出异常时,可能带走整个进程,所有线程都无法继续工作,但是如果及时捕获这个异常处理掉,也不一定导致进程终止。
一句话核心:进程太重、切换太慢、资源浪费;线程轻量、共享资源、切换快,能让程序 “同时干多件事还不卡顿”。

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,执行结束。
我们继续最开始的例子举例:
- 李四⼀旦进到工作状态,他就会按照行动指南上的步骤去进行工作,不完成是不会结束的。但有时我们需要增加⼀些机制,例如老板突然来电话了,说转账的对方是个骗子,需要赶紧停⽌转账,那张三 该如何通知李四停止呢?这就涉及到我们的停⽌线程的方式了。
目前常见的有以下两种方式:
- 通过共享的标记来进行沟通
- 调用 interrupt() 方法来通知
1)使用自定义的变量作为标记位
定义一个静态成员变量isQuit,表示标记线程是否中断。


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

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

因此,不能是局部变量,而是一个成员变量,这样就不再是一个变量捕获的语法了,而是切换成"内部类(匿名)访问外部类的成员" 的语法,这样也不必限制 final 之类的。
2)使用 thread.interrupted() 和 Thread.currentThread.isInterrupted() 代替自定义

第二三个方法作用相同。
- Thread.currentThread.isInterrupted() —— 表示判断线程是否被终止,相当于判断 Thread 里的 boolean 变量的值。
- 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.线程状态
在前一篇文章中,我们了解过操作系统进程调度中的进程状态:
- 就绪状态
- 阻塞状态
而我们现在要学习的线程状态,其实是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:也是一种阻塞状态,比较特殊,是由于 锁 导致的阻塞。
- 这个在讲到 锁 时在学习。
----------------------------------------

更多推荐




所有评论(0)