一、概念
1. 线程是什么
一个线程就是一个 “执行流”. 每个线程之间都可以按照顺讯执行自己的代码. 多个线程之间 “同时” 执行着多份代码。
2. 为什么会出现线程
-
可以实现并发编程,且更轻量
进程可以实现并发编程效果,但是太重了(消耗资源更多,速度更慢),如果需要大规模频繁地创建和销毁进程(普通用户涉及少,但是服务器上会有这种情况),创建(给进程分配资源)和销毁(释放资源)和调度的开销就会很大。为了解决这个问题,设计了线程(Thread 轻量级进程)ps.虽然是轻量级,但还是在消耗资源
效果:创建进程的时候,只分配一个简单的PCB,而不去分配后续的这些内存硬盘资源,可以做到并发的执行任务以及提升创建/销毁的速度。线程搞出来是为了执行一些任务,执行任务有需要消耗这些硬件资源
-
操作(创建、销毁、调度)线程比进程更快
-
其他
最后, 线程虽然比进程轻量, 但是人们还不满足, 于是又有了 “线程池”(ThreadPool) 和 “协程”(Coroutine)
(1)池把一些要释放的资源先放到一个“池子里”,以备后续使用。申请资源的时候,也是先提前把要申请的资源申请好,也放到一个“池子里”,后续申请的时候也比较方便
(2)协程/纤程即“轻量级线程”,Java标准库目前还没有内置,有一些第三方库实现了。Go天然支持协程,且使用方便高效
3. 进程和线程的区别
- 进程是包含线程的,每一个进程至少有一个进程存在,即主线程
- 进程和进程之间不共享内存空间,同一个进程的线程共享同一个内存空间
- 进程是系统分配资源的最小单位,线程是系统调度的最小单位
4. 线程的使用
-
创建
创建的还是进程,创建进程的时候,把资源(虚拟地址空间、文件描述符表)都分配好,后续创建的线程,让线程在进程内部,后续进程中的新的线程,直接复用前面进程这里创建好的资源。一个进程,至少要包含一个线程。最初创建出来的这个,可以视为是一个只包含一个线程的进程(此时创建的过程需要分配资源,此时第一个线程的创建开销可能是比较大的),但是后续再在这个进程里创建线程,就可以省略分配资源的过程(资源已经有了) -
描述
- 线程同样也是通过PCB(进程控制块)来描述的
- Windows上描述进程和描述线程的是不同的结构体,Linux开发者复用了PCB的结构体,来描述线程,所以会出现【一个PCB对应到一个线程或多个PCB对应一个进程】的情况
- 同一个进程中的这些线程共用同一份资源(内存+硬件……),但是每个线程独立去CPU上调度,即同一个进程的多个PCB的内存指针、文件描述符表是一样的,但是其他都是不一样的。进程是操作系统进行资源分配的基本单位。线程,是操作系统进行调度执行的基本单位。
- 线程同样也是通过PCB(进程控制块)来描述的
-
缺陷
- 随着线程数量的增加,整体的效率会再进一步的提高,但是这个提高并不是无限的提高(考虑CPU的逻辑核心数),线程太多的时候,线程调度的开销,反而会拖慢整个程序的效率
- 线程安全问题,一旦某个线程执行过程中出现异常,并且这个异常还没有被很好的处理,此时就可能会导致整个进程直接被终止(包括进程里面的所有线程)
4. Java 的线程 和 操作系统线程 的关系
线程是操作系统中的概念. 操作系统内核实现了线程这样的机制, 并且对用户层提供了一些 API 供用户使用(例如 Linux 的 pthread 库),系统级别的线程,整个内存空间,任意一个线程都是可以访问的
Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装
二、创建+查询线程
(1)线程本身是操作系统提供的概念,操作系统也提供了一些API供程序员来使用,java中就把操作系统的API进行了封装,提供了Thread类
(2)Java追求“跨平台”,JVM能够屏蔽不同的操作系统的差异
1. 创建线程
- 每个线程都是一个独立的执行流,可以独立执行一段代码,即是“并发”的关系
- 操作系统并发执行主线程和新线程
- 系统在进行多个线程调度的时候,并没有一个非常明确的顺序,而是按着“随机”的方式进行调度,这种“随机”调度的过程,称为“抢占式执行”
- 运行程序后,会先创建出一个java进程,这个进程中包含了至少一个线程(主线程),也就是负责执行main方法的线程。如果直接调用run方法,其实还是在主线程里,没有创建出一个新的线程
方法一:继承 Thread 类
直接把要完成的工作放到了Thread的run方法中
//Thread来自于java.lang这个包,不需要import
class MyThread extends Thread{
@Override
public void run() {
while(true){
System.out.println("hello thread");
//这里sleep方法会抛出一个异常
// 不能throws,因为这里是方法重写,对于父类的run方法来说,是没有throws这个异常
//所以重写的时候,也就不能throws该异常
try {
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
}
public class Demo1 {
public static void main(String[] args) throws InterruptedException{
MyThread myThread = new MyThread();
myThread.start();
while(true){
System.out.println("hello main");
Thread.sleep(1000);
}
}
}
方法二:实现 Runnable 接口
把要完成的工作放到Runnable中,再让Runnable和Thread配合。成功把线程要执行的任务和线程本身,进一步解耦合了
class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("这是实现Runnable接口");
}
}
public class Demo2 {
public static void main(String[] args) {
MyRunnable runnable = new MyRunnable();
Thread t = new Thread(runnable);
t.start();
System.out.println("这里是主线程");
}
}
方法三:继承Thread,基于匿名内部类
public class Demo3 {
public static void main(String[] args) {
//创建了一个子类实例t,继承自Thread,但是这个子类是匿名的,子类重写了run方法
//类的创建在Demo3中
Thread t = new Thread() {
@Override
public void run() {
System.out.println("基于匿名内部类");
}
};
t.start();
System.out.println("这是主线程");
}
}
方法四:实现 Runnable 接口,基于匿名内部类
public class Demo4 {
public static void main(String[] args) {
//创建Runnable的子类,并且重写了run方法
//然后把子类实例传给Thread的构造方法
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("实现Runnable,基于匿名内部类");
}
});
t.start();
}
}
方法五:使用lambda表达式
前面四种方法都太麻烦,java中要求“纯粹”地面向对象,即函数不能脱离类/对象而独立存在,但是对于创建线程来说,最需要的是run方法,类实际用处不大。lamdba相当于特办了一个“函数式接口”的概念。注意,本质上并没有增加新的语言特性,而是把以往能实现的功能以一种更加简洁的方式来编写。
public class Demo5 {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("这是lamdba表达式");
});
t.start();
}
}
- lambda 表达式本质上是一个“匿名函数”,这样的匿名函数主要可以用来作为“回调函数”来使用
- 此处的“回调函数”是在线程创建成功之后,才真正执行的。“回调函数”不需要程序员主动调用,它会在合适的时机被自动地调用
方法六:基于Callable
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序员借助多线程的方式计算结果
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果(可以理解为小票). 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作
- 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
- 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
- 把 callable 实例使用 FutureTask 包装一下.
- 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的
- call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
方法七:基于线程池
2. 查看线程
(1)idea的调试器
(2)jconsole(jdk中提供的调试工具)
三、Thread及常用方法
1. Thread
(1) 概念
Thread 类是JVM用来管理线程的一个类,用来描述一个线程执行流,系统的一个线程就对应Java中的一个Thread对象,围绕线程的各种操作,都是通过Thread展开的。JVM会将这些Thread对象组织起来,用于线程调度、管理
(2)Thread的构造方法
方法 | 说明 |
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象,并命名 |
【了解】Thread(ThreadGroup group,Runnable target) | 线程可以被用来分组管理,分好的组即为线程组,这个目前我们了解即可 |
(3)Thread的几个常见属性
属性 | 获取方法 |
ID | getId() |
名称 | getName() |
状态 | getState() |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被中断 | isInterrupted() |
ID:线程的身份标识,一个线程可以有好几个身份标识,正如一个人可以有多个称呼一样,JVM有一个,Pthread库(系统提供的操作线程的API)有一个,内核也有,这些身份标识彼此相互独立
状态:Java中的线程状态不同于操作系统中的
优先级:线程的调度主要还是由系统内核负责的,由于系统调度速度极快,所以设置/获取优先级作用并不是很大
后台进程:又名“守护线程”,后台线程不影响进程结束,前台进程会影响到进程结束(只有所有的前台进程执行完,进程才结束),如果前台进程都执行完了退出了,但是后台线程没有执行完,此时也会随着线程一起退出。
public class Demo7 {
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
System.out.println("hello thread");
}
});
// 创建的线程默认是前台线程,可以通过setDaemon显式地设置成后台线程
t.setDaemon(true);
t.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
存活:即run方法是否运行结束了
Thread对象的生命周期和系统中的线程不完全一致。一般都是Thread对象先创建好,手动调用start后,内核才真正创建出线程。消亡的时候,可能是thread对象先没有引用指向,也可能thread对象还在,内核中的线程把run执行完了,就结束了
2.Thread 常用方法
(1)线程要执行的指令-run()
- run只是上面的入口方法(主线程是main,其他线程是run/lambda),并没有调用系统API,也没有创建出真正的线程来
- 线程具体跑起来之后,执行的就是run里面的代码。这里如果直接调用run方法,不会创建新的线程,仍然是在原来的主线程内部
(2)启动一个线程-start()
- 在系统里真正创建出线程
- 在底层调用操作系统提供的“创建线程”的API,系统内核就会在内核里创建出对应的PCB结构,并且加入到对应的链表中,此时这个新创建出来的线程就会参与到CPU的调度中,而这个线程接下来要执行的操作,就是调用重写的run方法
- start 方法只是告诉系统创建出一个线程出来,执行是很快的,调用完start之后,代码就会继续执行start之后的逻辑
(3)终止一个线程
方法一:手动设置标志位
public class Demo3 {
// 写作成员变量就不是触发变量捕获的逻辑了. 而是 "内部类访问外部类的成员" , 本身就是 ok 的
public static boolean isQuit = false;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (!isQuit) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
// 主线程这里执行一些其他逻辑之后, 要让 t 线程结束.
Thread.sleep(3000);
// 这个代码就是在修改前面设定的标志位.
isQuit = true;
System.out.println("把 t 线程终止");
}
}
解析:
(1)因为lambda的执行时间是比较靠后的,所以可能当真正执行lamdba的时候,局部变量isQuit已经被销毁了。
(2)为了解决该问题,lambda引入了“变量捕获”这样的机制,即lambda内部看起来是在直接访问外部的变量,本质上是把外部的变量复制了一份,并放到lambda中。变量捕获有个限制:要求捕获的变量得是final,或者说没有修改的状态。因为Java是通过复制的方式来实现“变量捕获”,如果外面的代码修改了这个变量,那么就会出现【外面变量变了,里面没变】的歧义情况
(3)有的语言“变量捕获”是通过直接改变外部变量的生命周期来实现的,保证了lambda在执行的时候肯定能访问到外部的变量,此时该变量捕获就没有final的限制了
方法二:使用Thread提供的现成的标志位
public class Demo4 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
// Thread.currentThread 能获取到当前线程的对象(t)
// Thread对象内部提供了一个标志位(boolean),线程应该要结束(true),线程先不必结束(false)
//lambda 表达式是在构造 t 之前就定义好的. 编译器看到的 lambda 里的 t 就会
// 认为这是一个还没初始化的对象.所以不能用t.isInterrupted()
while (!Thread.currentThread().isInterrupted()) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace(); //打印出了报错信息
break;
}
}
});
t.start();
Thread.sleep(3000);
// 把上述的标志位给设置成 true
t.interrupt();
}
}
(1)方法
现成标志位:Thread.interrupted() 或者 Thread.currentThread().isInterrupted(),使用这两个方法通知线程结束,然后通过run()里的标志位,让线程尽快结束
方法 | 说明 |
public void interrupt() | 中断对象关联的线程,如果线程正在阻塞,则以异常方式通知,否则设置标志位 |
public static boolean interrupted() | 判断当前线程的中断标志位是否设置,调用后清除标志位 |
public boolean isInterrupted() | 判断对象关联的线程的标志位是否设置,调用后不清除标志位 |
(2)thread 收到通知的方式有两种:
- 如果线程因为调用 wait/join/sleep 等方法而阻塞挂起,则以 InterruptedException 异常的形式通知,清除中断标志((万一出现了标志位已经被删除的情况会很麻烦,给程序员留下更多的操作空间))
- 当出现 InterruptedException 的时候, 要不要结束线程取决于 catch 中代码的写法. 可以选择忽略这个异常, 也可以跳出循环结束线程(使用break)
- Java做法是比较温和的(可以由本线程自己决定是否要终止)。激进一点的比如C++,刚提出请求就立即结束,完全不考虑本线程的实际情况,可能会产生负面影响
- 手动设置的标志位,是没法唤醒阻塞状态的
- 否则,只是内部的一个中断标志被设置,thread 可以通过
(4)等待一个线程-join()
是一种规划【线程结束】顺序的手段,让原本由系统调度线程,无法确定【线程执行的顺序】变为确定的。
方法 | 说明 |
public void join() | 等待线程结束 |
public void join(long millis) | 等待线程结束,最多等 millis 毫秒 |
public void join(long millis, int nanos) | 可以更高精度 |
(1)A,B两个线程,如果希望B比A先结束,就可以让A线程中调用B.join()的方法,此时B线程还没执行完,A线程就会进入“阻塞”状态,相当于给B留下了执行的时间,B执行完毕之后,A再恢复过来,并且继续往后执行
(2)如果A执行到B.join的时候,B已经执行完了,A就不必阻塞了,直接往下执行就可以了
(5)获取当前线程引用
方法 | 说明 |
public static Thread currentThread(); | 返回当前线程对象的引用 |
(6)休眠当前线程-sleep()
方法 | 说明 |
public static void sleep(long millis) throws InterruptedException | 休眠当前线程 millis毫秒 |
public static void sleep(long millis, int nanos) throws InterruptedException | 可以更高精度的休眠 |
-
概念
-
Windows版本的休眠Sleep(ms),在Windows.h里。Windows上的头文件,是windows的系统函数
-
Linux版本的休眠sleep(s),unistd.h,Linux上的头文件,Linux的系统函数
-
Thread.sleep:把上述的系统函数进行封装了,如果是在Windows版本的jvm上运行,底层调用的是Windows的Sleep,如果是在Linux版本的jvm运行,底层就是调用Linux的sleep
-
-
使用效果
- 这俩线程在进行sleep之后,就会进入阻塞状态,当时间到了,系统就会唤醒这两个线程,并且恢复对这两个线程的调度,当这两个线程都唤醒了之后,谁先调度,谁后调度,可以认为是“随机的”,没什么规律,不一定保证概率均等
- 本身存在一定误差:sleep(1000)并不是是精确的休眠1000ms,因为线程的调度也是需要时间的,所以sleep(1000):线程在1000ms之后,恢复为“就绪状态”
- sleep(0):不改变线程的状态, 但是会重新去排队(让当前线程放弃CPU,直接准备下一轮的调度),相当于yield();