1. 线程
1.1 线程(Thread)的作用
在上篇文章我们讲到了进程。进程,它是操作系统资源分配的基本单位,通过多进程编程或分时复用的方式能实现并发执行的效果 。所以进程在操作系统来说是一个较为 "重" 的概念,因为不论是在创建进程还是销毁进程,都对于磁盘 I/O的开销比较大。
尤其是对于现代的服务器,如果只用多进程的方式来执行任务,由于服务器一天执行的操作可能有几千万甚至上亿条,这样数量级的任务频繁地创建销毁对于资源的消耗非常大 。为了解决这个问题,开发大佬的创建出了------线程。我们可以将线程比喻成 " 轻量级进程 "。所以,相对于进程,线程目前有以下优势:
(1)创建线程比创建进程速度更快,销毁线程相同;
(2)调度线程比调度进程更快;
(3)线程的 I/O 开销比进程更少。
最后,线程虽然比进程更加轻量。但是人们还不满足,于是又有了 " 线程池 " 和 " 协程" 的概念。不过这俩概念我们之后再提。
1.2 线程与进程之间的关系
-
进程包含着线程,每个进程至少会有一个线程存在,只有一个线程时该线程即为主线程。
-
进程与进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间。彼此之间互不干扰,体现出了稳定性。
-
进程是系统资源分配的基本单位,线程是系统调度的基本单位。系统的调度方式为 " 随机调度 "
比如现在在一个进程中有线程1,2,3。在系统随机调度情况下:
第一种可能,线程 1 去 CPU 核心 1 执行,线程 2 去 CPU 核心 2 上执行,3 去 CPU 核心 3 上执行;
第二种可能,线程 1 和 2 都在核心 1 中执行,线程 3 在 核心 2 执行;
第三种可能,线程 1,2,3 都在核心1执行......具体这些线程是怎么调度的,该过程程序员是无法干预的,全部都由操作系统内部的 调度器 自行完成的。
- 一个进程崩溃了,一般不会影响到其他进程;但是一个线程崩溃了,可能该进程下的其他线程就会一起崩溃 。所以一个进程内部的线程可能会相互影响,这也是多线程编程的难点------ " 线程安全问题"。但是如果及时捕获了异常或处理了崩溃原因,进程也就不一定会终止了。
1.3 线程的特性及注意点
对于线程来说,只有在第一个线程创建的时候才会申请资源(即和进程一起创建的时候),后续再创建线程时,就不涉及资源申请。只有当所有线程都销毁才会真正的释放资源(即进程销毁时),在运行过程中销毁某个线程,也不会释放资源。
在上篇文章中我们提到了,在一个进程中有进程状态、上下文、记账信息和优先级。这样的属性在该进程下的每个线程都会有一份这样的数据存在 。但是需要注意的是,文件描述符表和内存指针是所有线程都共用的。
虽然提高线程的数目能够提升效率,但效率也不是 " 线性增长 "的,凡事都有个度。线程数目达到一定数量的时候,就算线程再多也无法提升效率了 ;而且线程数目如果太多,线程的调度开销也会十分明显,甚至有可能因为线程开销拖累性能。
以上所讲的都是面试高频考点,一定要好好理解。
2. 创建线程
在创建线程过程中,操作系统都各自提供了一些各不相同的 API(应用程序编程接口,例如在 Java 中的标准类:String、ArrayList、Scanner等,目的是用来编程)给程序员来使用,而 Java 对上述内容统一封装,应运而生了 ------ Thread 类。以下有五种方法来创建线程:
1. 继承 Thread, 重写 run 方法
我们先在 IDEA 中新建出 Java 类,叫做 Demo1,在该 Demo1 中分别写两个死循环打印出 " hello Thread " 和 " hello main ",为了避免死循环运行速度过快, 在两个死循环中使用方法Thread.sleep(1000),该方法表示:让当前的线程暂时放弃 CPU 并休眠1000毫秒(即1秒),时间过了之后再来执行循环。之后其他的方法也是如此来创建。先来看常用的两个语法:
创建线程实例化标准语法:
java
Thread t = new Thread();
线程开始标准语法(线程名.start):
java
t.start();
由于是继承 Thread,所以我们先自己创建出类 MyThread,然后再在里面重写构造方法 run。run 方法相当于 " 回调函数 "(你写的函数,自己不去调用而交给别人来调用即为回调函数),在这之后写上死循环代码和 Thread.sleep(1000)。需要注意的是 sleep 方法需要手动捕获一下异常,如果是在类中那就用 try - catch 语句,如果是在 main 方法内部那就在 main(String[] args) 后 抛出异常 throws InterruptedException(中断异常)即可。这些操作 IDEA 会提示你如何去做的。
java
class MyThread extends Thread {
public void run(){
while(true){
System.out.println("hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
while (true) {
System.out.println("hello main");
Thread.sleep(1000);
}
}
}
写好之后,我们来在 IDEA 上运行一下。

这就是一个基本的多线程代码。可以看到,由于系统随机调度的原因,有的时候是先打印 Thread,然后才打印 main。有的时候则是先 main 后 Thread 。这种方式也叫 " 抢占式执行 "。所以我们在编写代码的时候,不能光看逻辑的执行顺序。
提问:
1.1* 查看线程的第三方工具
针对之后的 Java 进程,我们可以借助第三方工具来查看线程的详细情况。找到你之前下载好的 JDK17 的 jdx 17\bin\jconsole,该 jconsole.exe 就是这个工具。在进行连接的时候会提示我们不安全的连接,不用管它直接连接就行了。然后选择到 Demo1 来查看。如果看不到进程列表,那就右键点管理员方式执行即可。

该 Thread-0 就是我所创建的 Demo1 中的 Thread ,因为如果线程创建没有给它取名,则系统会自动创建出 Thread-0、Thread-1诸如此类的名字,main 就是我的主线程 。其他线程都是 JVM 内置的线程,不论你启动什么进程都会自带这些线程。
堆栈跟踪:它能获取线程状态的时刻并能观察代码执行到哪里了。例如:我现在线程图片上的MyThread.run(Demo1.java:8) 则代表着代码已执行到 run 方法的第八行了,以此类推。
2. 实现 Runnable,重写 run
该方法是先实现 Runnable 接口,然后再重写构造方法 run。因为是接口,所以在自己创建好子类 MyRunnable 后是要 implements Runnable 而不是继承(extends)。
在 main 方法内部,我们由于实现的是 Runnable,所以要先实例化一个 Runnable。即:
java
Runnable runnable = new MyRunnable();
然后为了实现多线程编程,还要实例化出一个线程:该线程需要将实例化好的对象 runnable 放入 Thread 类中,再来进行实例化:
java
Thread t = new Thread(runnable);
最后再像刚才一样把死循环代码写上即可,这种就是第二种方法的主要写法,完整代码及执行结果如下:
java
class MyRunnable implements Runnable{
@Override
public void run() {
while (true){
System.out.println("hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public class Demo2 {
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new MyRunnable();
Thread t = new Thread(runnable);
t.start();
while(true){
System.out.println("hello main");
Thread.sleep(1000);
}
}
}

我们一般推荐 Runnable 的这种写法。为什么呢?
首先,我们可以将 Runnable 的实例化看做是一个任务,任务写好之后让 Thread 来真正地创建线程,线程里要干什么由 Runnable 来表示。这样子就有效的**将任务和线程分开,相对于第一种代码就达到解耦合的效果了。即两个代码之间的关联关系降低,在之后如果要修改代码时会更加方便。**代码中,我们所追求的是 " 低耦合,高内聚 "。降低耦合,是很重要的事情,也是体现程序员代码编写能力的一项重要体现。
3. 继承 Thread, 重写 run, 使用匿名内部类
该方法本质上就是方法一,但是不同的是不需要再另外具体创建子类。由于是匿名内部类,所以该类需要在 main 方法内部,创建线程 Thread 的基础后直接加上 { } ,在括号内编写匿名子类的代码及重写 run 方法。在写好后会创建这个匿名内部类的示例,并将实例引用赋值给 t。即:

最后将剩余代码补上,可参考方法一,执行结果基本相同,就不放运行结果了:

总结:这样写的好处是少定义了类 ,如果某些代码是 " 一次性使用" 的话可以使用匿名内部类的写法,比较简洁。
4. 实现 Runnable, 重写 run, 使用匿名内部类
该方法就是对方法二和方法三的糅合版。大家可以自己来尝试一下,需要注意的就是 匿名内部类的创建和 Thread 线程之间和 Runnable 之间的交互。以下是参考代码:

5. 使用 lambda 表达式
该写法是对方法三和四的进一步改进 ------ 引入了 lambda 表达式 ( ) - > { } 。它本质上是一个 " 匿名函数 ",最主要的用途和 run 一样都是回调函数。 lambda 表达式在这里就相当于创建了一个那么多函数式接口的子类,并创建出对应的实例同时重写了里面的方法。它也是需要写到类的括号中,依托于类存在的。在这里,我们就写到 Thread 类中,即:

这种方法也是平时咱们练习、创建示例等时,最推荐的写法。到这里五种方法就全部阐述完毕了,我想说这些写法只有适应场景是各不相同的,没有好坏之分。在以后公司的项目中,很可能这几种写法都会出现的。
3. Thread 类的常见属性和方法
3.1 常见的构造方法
|------------------------------------------------|------------------------------------------|
| 方法 | 使用说明 |
| Thread( ) | 创建线程对象,与方法一相同 |
| Thread(Runnable target) | 使用 Runnable 对象创建线程对象,target 表示接口名。与方法二相同 |
| Thread(String name) | 创建线程对象并命名 |
| Thread(Runnable target,String name) | 使用 Runnable 对象创建线程对象,并命名 |
| (了解)Thread (ThreadGroup group,Runnable target) | 线程被用来分组管理 |
各个方法的示例:
java
1. Thread t1 = new Thread();
2. Thread t2 = new Thread(new MyRunnable());
3. Thread t3 = new Thread("线程一");
4. Thread t4 = new Thread(new MyRunnable(), "线程二");
方法一和二我们都见过,方法三和四都是创建出名字,该名字可以随意取:中英文、符号等都可以。然后我们可以通过 jconsole.exe 第三方工具来查看,现在我们随便创建一个来展示一下:


如果没有去命名,那默认名字就是诸如 Thread - 0、Thread - 1的名字。
3.2 常见的属性
|---------|------------------|
| 属性 | 获取方法 |
| ID | getId( ) |
| 名称 | getName( ) |
| 状态 | getState( ) |
| 优先级 | getPriority( ) |
| 是否为后台线程 | isDaemon( ) |
| 是否存活 | isAlive( ) |
| 是否被中断 | isInterrupted( ) |
先来简单说明下它们都有什么作用和特别的:
(1)ID 是线程的 " 身份证 ",不同线程之间的 ID 是不会重复的,类似于PID;
(2)状态在下篇文章会详细讲,他表示这线程当前的所处情况。
(3)后台线程是指,有一些线程不管存在与否,这些线程都不会影响到进程是否继续存在的 ,例如在第三方工具中看到的 JVM 自带的线程,那些就属于后台线程。如果进程结束了,后台线程也会随之结束。而前台线程就会影响到线程的存在,例如 main 线程。我们自己的代码默认都是前台线程,还可以通过 setDaemon 方法来修改。IDEA 软件本身也是一个 Java 进程。
(4)线程是否存活。在 Java 代码中创建的 Thread 对象,和系统中的线程是一一对应的关系。但是 Thread 对象的生命周期和线程的生命周期,有可能是不同的。举个例子就明白了:

Thread 存在了三秒,但是打印出来了四个 true,t 线程 " 还有一口气 "。这是由于系统随机调度的原因引起的,即有可能是三个 true,也有可能是四个。
(5)是否被中断,这个之后来详细和其他内容结合讲。
3.3 线程相关操作
不久之前我们才讲过,调用 start 方法才是真正地在操作系统底层创建出一个线程。要注意,每个 Thread 对象都只能 start 一次。除了这个操作,还有其他的操作,我们现在来逐个一一了解。
3.3.1 中断(终止)线程
操作系统中的中断线程是让线程直接停止,不会在恢复了。关于中断线程的 API 有两种方法,分别用于不同的场景:
java
判断是否中断了: 线程名.isInterruptted();
使线程中断: 线程名.interrupt();
现在给出代码示例,来帮助理解它的用途。

这里的 Thread.currentThread() 很像之前在类与方法中学到的 this.方法名,用法是一样的 。需要注意的是在 catch 中根据你写的不同字段会有不同的效果,如图所示。如果是什么代码都没写,即使执行了 interrupt 也不会停止,而是会继续执行下去。
这是由于 sleep 内部代码在发力。正常来说调用 interrupt 就是将 isInterruptted 方法内部的参数设为 true(这一部分可以看该方法的源码,这里不展示),由于上述代码中,main 会将 sleep 提前唤醒后再来中断。这样的情况下,sleep 就会在唤醒之后将 isInterruptted 参数设置回 false ,因此就会继续执行下去。
所以 Java中的线程终止,不是强制性的措施 。他会根据你的 t 线程代码怎么写的来判断是否真的可以终止。换句话说,中断选择权在 t 线程手上,并非 main 线程手上。
3.3.2 等待线程
在多线程执行时,由于操作系统随机调度原因,线程会随机、并发地执行。站在程序员的角度,随机这个词可太令人烦躁了,所以引入了 join 方法来约束多个线程之间的执行先后顺序。
java
线程名.join();
例:t.join();
例如在主线程中调用 join ,那就是让主线程去等 t 线程先结束,main 再执行 。虽然也可以通过 sleep 休眠来控制执行顺序,但是这样并不科学。因为有些时候就是需要 t 线程先结束,然后紧跟着 main 再执行,这样的场景是 sleep 做不到的。现在给出一个示例:

当执行到 t.join(); ,此时 main 线程就会进入**" 阻塞等待 "的状态,一直等到 t 线程结束才来执行。那么问题来了,是不是如果 t 线程一直结束不了,main 线程就也会一直等下去呢?答案是正确的,所以这种情况产生的影响不是很好,大佬们就改进了方法,使得 join 方法可以传参,代表最大等待时间**。这样就可以有效解决 " 死等 " 问题。
java
例:t.join(10000);
指该线程最多阻塞等待 10 秒钟
如果期间 t 线程执行完了,则就立即继续向下执行。超过时间的话,就不等了,此时 join 也继续向下走。
3.3.3 休眠线程
我们之前就已经很熟悉这个方法了,就是 sleep 。这里要讲的是特殊版本并来阐述一些特点。
比如你写了代码 sleep(1000); ,那么真是等待 1 秒吗?其实不是,很可能会比 1 秒略多一点 。这是因为在调用 sleep 后,相当于让当前线程让出 CPU 的资源;之后时间到了之后,就需要操作系统内核把这个线程重新调度到 CPU 上,才能继续执行。这个过程也需要 ms 级别的时间。
还有一个就是 sleep(0); 。这个是 sleep 的特殊写法,写了 sleep(0) 意味着让当前的线程,立即放弃 CPU 资源,等待操作系统重新调度。
总结:到目前为止,我们学了 Thread 的创建线程、关键属性、终止线程、线程等待、获取线程引用( Thread.currentThread())、线程休眠等。知识点还是很多,但是都不难,下来需要多去复习,动手敲敲代码。就能够更好地理解了。
那么,本篇文章到此结束!希望能对你有帮助。