Java EE小白看过来,你真的了解多线程吗?一文带你扎实入门并发编程!

1. 并发编程

1.1 理解并发编程

在前面JavaSE和数据结构阶段所写的代码,什么排序算法,栈,堆,队列…无论如何优化,都只能在1个CPU核心上运行,就算这个核心已经跑满了,其他核心也不会分担它的工作.通过写特殊的代码,把多个CPU核心都利用起来,写这样的代码就叫做"并发编程".

并发编程就是让一个程序"一心多用"的编程范式,程序被设计为可以同时处理多个任务.这个"同时"有两种主要情况:

  1. 真同时(并行):在多核CPU上,多个任务真正地在同一时刻执行,比如,4核CPU可以同时执行4个线程
  2. 假同时(并发):在单核CPU上,通过极快地在多个任务之间切换,给人一种"同时执行"的错觉.就像杂耍艺人,他一次只能抛接一个球,但通过快速切换,看起来像是在同时处理多个球.

1.2 为什么需要并发编程

主要有三大原因:

  1. 充分利用多核CPU提高性能:现代CPU都是多核的。如果没有并发编程,你的程序可能只使用了一个核心,让其他核心闲置,无法发挥硬件的全部威力。并发可以将工作负载分配到多个核心上,加快处理速度。
  2. 改善响应能力防止阻塞:对于图形界面程序(如桌面软件、手机App),如果所有操作都在一个线程中顺序执行,那么当你点击一个按钮进行一个耗时操作(如下载文件)时,整个界面就会“卡死”,直到操作完成。通过并发,你可以把耗时操作放在后台线程执行,同时保持前台界面的流畅响应。
  3. 更好地模拟现实世界:很多现实世界的问题本质就是并发的。例如,一个网络服务器需要同时处理成千上万个客户端的请求;一个游戏需要同时处理玩家输入、物理模拟、画面渲染和AI决策。

1.3两种主要模型

在编程中,通常使用以下两种方式来实现并发编程:

  1. 多进程(Multiprocessing)

    • 概念:启动多个独立的进程,每个进程都有自己独立的内存空间
    • 优点:稳定性更高,一个进程崩溃不会影响其他进程.
    • 缺点:创建和销毁进程的开销比线程大,进程间通信也更复杂,更慢
    • 例子:Python的 multiprocessing 模块.
  2. 多线程(Multithreading)

    • 概念:一个进程可以包含多个线程,这些线程共享同一片内存空间(如全局变量),但各自有独立的执行流.
    • 优点:线程间通信速度快(因为共享内存).
    • 缺点:非常复杂,容易引发竞态条件、死锁等问题.需要开发者小心翼翼地使用锁等机制来保护共享数据
    • 例子:Java的 Thread 类,Python的 threading 模块.

Java生态中更普遍使用多线程,而C++则根据场景更自由地选择多线程或多进程.

为什么Java生态更"偏爱"多线程?

  1. 语言与平台设计哲学:
    • “编写一次,到处运行”:JVM作为一个抽象层,屏蔽了底层操作系统的差异.Java线程在JVM上被统一管理,无论是在Windows,Linux还是macOS,代码行为都是一致的.如果Java主要推广多进程,那么不同系统上进程管理的差异会破坏这种一致性.
    • 强大的标准库:Java拥有及其丰富和成熟的并发工具包(java.util.concurrent),提供了线程池,锁,并发集合,原子变量等高级组件,大大降低了多线程编程的门槛.
  2. 对象共享的便利性:
    • Java是纯粹的面向对象语言,对象在堆中创建,可以非常轻松地被多个线程引用和操作.这种共享内存模型对于常见的业务应用(如Web服务器处理请求)非常自然.
  3. 性能考量:
    • 线程创建,销毁和上下文切换的开销通常远低于进程.对于需要处理大量短期任务的服务端应用,使用线程池是更高效的选择.

为什么C++更"自由"且多进程地位更重要?

  1. 对控制权和性能的极致追求:
    • C++的哲学是"零开销抽象".开发者需要完全掌握资源,包括内存布局,生命周期等.多进程提供了更强的隔离性,一个进程的崩溃不会影响其他进程.这对于构建大型,高可用的系统(如数据库,浏览器)至关重要.
    • 避免锁的复杂性:多线程最大的痛点—数据竞争和死锁—在多进程模型中天然被避免,因为进程拥有更独立的地址空间.进程间通信虽然比共享内存慢,但它强制了更清晰,更安全的交互边界.
  2. 历史和生态系统:
    • Unix/Linux 哲学本身就是“小工具,通过管道连接”,这本质上是多进程协作。C++ 与这个生态系统紧密结合,大量系统级软件(如 Shell、数据库、Web 服务器 Nginx)都是基于多进程模型构建的。
    • Nginx 就是一个经典例子:它使用 Master-Worker 多进程模型,每个 Worker 进程是独立的,即使一个 Worker 意外崩溃,Master 可以立刻重启一个新的,整个服务不会中断。
  3. 充分利用多核CPU:
    • 无论是多进程还是多线程,都可以充分利用多核CPU。对于计算密集型任务,C++ 程序完全可以使用多进程来并行计算,进程数可以与核心数绑定,享受真正的并行,同时又享有进程隔离的稳定性。

现代发展趋势与总结

  1. 混合模型:在现代复杂系统中,纯粹的单一模型很少见。一个典型的 C++ 服务器可能使用多进程来隔离不同的服务模块(例如,一个进程处理用户认证,一个进程处理数据查询),而在每个进程内部,又使用线程池来处理并发请求。这就是 “进程用于隔离,线程用于并发” 的典范。
  2. Java的补充:Java 也并非完全排斥多进程。可以通过 ProcessBuilder 来创建和管理外部进程,通常用于执行系统命令或调用其他程序。

2.线程安全问题

什么是线程安全问题?

简单来说,当线程数目多了以后就容易发生"冲突"(例如多个线程针对统一变量进行写操作),一旦发生冲突,就可能使程序出现问题,这就是"线程安全问题".

在后续文章中将重点讲解.

3.如何用Java编写多线程程序

3.1 创建线程的三种写法

3.1.1 朴素写法

首先明确一个观点,这里创建的线程就是电脑CPU属性之一的那个"线程"吗?比如我的电脑是8核16线程的,我用Java创建一个Thread对象,这个Thread和主线程就真的能在我电脑上的16个线程中各占一个线程运行吗?这个Thread和电脑上的线程真的是一样的吗?

是的,你创建的Thread最终需要一个操作系统原生线程来承载和执行.这16个线程指的就是硬件层面的逻辑处理器,它们是由CPU通过超线程技术模拟出来的执行单元。然而需要明确的一点是:Java线程和OS线程并非严格的一对一绑定,你的程序不能独占CPU.

深入理解一下,这个过程可以概括为:Java Thread -> JVM线程调度 -> OS线程 -> OS内核调度 -> CPU硬件线程(逻辑处理器).

  • 代码层面:当你用 new Thread().start() 在Java中创建一个线程时,你创建的是一个Java语言层面的线程对象。
  • JVM与操作系统交互:JVM接收到这个请求后,会通过其所在的操作系统的接口,去创建一个真正的操作系统原生线程.在大多数JVM实现中(如HotSpot/OpenJDK),Java线程与OS原生线程是1:1映射的.这意味着你创建一个Java线程,操作系统内核就真的会多一个线程实体.
  • 操作系统调度:主线程和新创建的线程都成为了两个被操作系统内核管理的“任务”。操作系统的调度器负责决定在下一个时刻,哪个线程可以在哪个CPU逻辑核心上运行。你的电脑有16个逻辑处理器,操作系统管理着成百上千个线程(包括系统本身的和所有运行中程序的),它会非常公平地在所有可运行的线程之间进行切换。
  • 获得CPU时间:在某个极短的瞬间(一个时间片,通常是几毫秒),操作系统的调度器可能会决定:让你的主线程在逻辑处理器5上运行,让你的新线程在逻辑处理器12上运行,这时,它们就是真正地在物理上并行执行,各自占了一个硬件线程.但在下一个瞬间,调度器可能会把它们都换下来,让给其他更紧急的系统线程或别的程序的线程,然后再在未来的某个时刻又把它们调度上去。

通过上述分析,我们得知,线程本身是操作系统提供的概念,程序员可以通过操作系统提供的API来进行调用.不同的系统提供的API是不同的,然而Java的JVM把这些系统的API都封装好了,因此,不需要关注系统原生API,之需要理解Java提供的这一套API即可.

Thread这个类是Java中与线程操作相关的核心类,它位于java.lang.Thread包下,无需显式导入.

通过查阅jdk在线文档(Java 8 中文版 - 在线API手册 - 码工具)可知创建一个新线程主要有两种方法:

  1. 继承Thread类,重写run()方法

  2. 实现Runnable接口,重写run()方法.

然后讲解一下写法,首先是朴素写法:

// 1. 第一步,自己创建一个类继承标准库中的Thread
class Thread1 extends Thread {
    // 2. 第二步,重写run()方法
    public void run() {
        // run()方法内的就是需要线程执行的逻辑
        System.out.println("hello thread");
    }
}

public class CreateThread {
    public static void main(String[] args) {
        // 3. 第三步,在main方法中创建类的实例,并调用start()方法.
        Thread1 t1=new Thread1();
        // 调用start()方法,就会创建出一个新线程,这个线程会执行线程中的run()方法
        t1.start();
    }
}

第一步,自己创建一个类继承标准库中的Thread;

第二步,重写run()方法,run()方法内的就是需要线程执行的逻辑;

第三步,在main方法中创建类的实例,并调用start()方法,就会在主线程中创建出一个新线程,这个线程会执行线程中的run()方法.

上述创建过程大概是如下流程:

程序员代码 Thread类 JVM/操作系统
在这里插入图片描述

向run()这种手动定义但又没有手动调用的方法,有一个专门的名字->回调函数.
其实回调函数并非是个新概念,在进行对象的比较时,需要自定义比较逻辑,我们可以通过两种方式来处理:

  1. 实现comparable接口,需要重写compareTo()方法

  2. 定义comparator,重写compare()方法

无论是compareTo()方法还是compare(),都没有主动调用,而是通过标准库本身的内部逻辑负责调用的,这就是回调函数.

上述代码运行起来是一个进程,这个进程包含了两个线程:

  1. 调用main方法的线程->主线程(多线程中必不可少的一个线程)
  2. 调用start()手动创建的一个线程.

上述两个线程是并发/并行在CPU上的.在两个线程中加上循环打印,可以清楚地看到这个特点:

在这里插入图片描述

可以看到,"hello thread"和"hello main"的出现看起来像是"随机交叉"的.

需要明确的是:多线程中哪个线程先去执行是不确定的,但是这个"随机"并非数学意义上自然随机的,数学意义上的随机->结果无法预测,有概率分布,例如抛硬币,掷骰子.而多线程的线程调度顺序则是由底层的复杂状态决定的,理论上可预测但实践中不可行.

理论上可预测但实际上不可行是什么意思?

理论上,如果知道所有初始状态,就能预测结果.调度决策依赖的确定因素有:系统时钟,CPU缓存状态,内存布局,中断向量表…这些影响调度的因素是确定的,它们遵循特定的物理定律和算法,且状态转换有明确的规则.然而影响因素太多,导致状态空间爆炸,比如假设每个因素只有2种状态:

public class StateSpaceExplosion {
   // - CPU缓存: 2^20 种状态 (1MB缓存)
   // - 内存: 2^30 种状态 (1GB内存)  
   // - 进程: 2^10 种状态 (1000个进程)
   // - 时间: 2^40 种状态 (1纳秒精度,1小时范围)
   
   // 总状态数: 2^(20+30+10+40) = 2^100 ≈ 1.3×10^30
   // 这远远超过可计算的范围
}

总状态数远远超过了可计算的范围.

此外我们无法控制所有环境变量,何观测行为都会改变系统状态,因此实际是无法预测的.

总之,线程调度并非数学意义的"随机",这个过程影响因素过多无法预测,只能视作是"随机".

上述创建方法是通过继承Thread类,接下来介绍通过实现Runnable接口来创建:

// 1. 创建一个类实现Runnable接口
class MyRunnable implements Runnable {
    // 2. 重写run()方法,在里面写想要该线程执行的逻辑
    public void run() {
        System.out.println("hello thread2");
    }
}

public class CreateThread {
    public static void main(String[] args) {
        // 3. 创建Runnable实例
        MyRunnable myRunnable=new MyRunnable();
        // 4. 使用Thread类创建对象,传入Runnable实例
        Thread t2=new Thread(myRunnable);
        // 5. 启动新线程
        t2.start();
        System.out.println("hello main");
    }
}

第一步:创建一个类实现Runnable接口;

第二步:重写run()方法,在里面写想要该线程执行的逻辑

第三步:创建Runnable实例

第四步:使用Thread类创建对象,传入Runnable实例

第五步:thread对象调用run()方法,启动新线程

3.1.2 匿名内部类写法

上述是创建线程的朴素写法,此外,还可以通过匿名内部类来实现.

匿名内部类是没有名字的内部类,它在声明的同时直接实例化.

基本格式:

父类/接口 对象名 = new 父类/接口() {
   // 匿名内部类里的逻辑
   // 可以重写方法,添加新成员等
};
// 继承Thread类的匿名内部类实现
public static void main(String[] args) {
    Thread t3=new Thread() {
        public void run() {
            System.out.println("hello thread3");
        }
    };
    t3.start();
    System.out.println("hello main");
}
// 实现Runnable接口的匿名内部类实现
public static void main(String[] args) {
    Runnable MyRunnable2=new Runnable() {
        public void run() {
            System.out.println("hello thread4");
        }
    };
    Thread t4=new Thread(MyRunnable2);
    t4.start();
    System.out.println("hello main");
}

3.1.3Lambda表达式写法

还有一种写法,非常简便–>Lambda表达式

什么是Lambda表达式?

Lambda表达式是Java 8引入的一种简洁的匿名内部类表示方法,用于简化函数式接口的实现.
什么又是函数式接口?

  • 函数式接口是有且仅有一个抽象方法的接口
@FunctionalInterface    // 该注解不是必须的,但是建议加上
interface FunctionalInterfaceDemo1 {
    //抽象方法必须有且只能有一个
    void func1();
    // 其他方法的种类数量则没有要求则不做要求
    default void func2(){};//默认方法
    static void fun3(){};//静态方法
    private void func4(){};//jdk 9+开始也可以有私有方法
}

加上@FunctionalInterface有利无弊:

  1. 编译器检查:告诉编译器,这个接口打算被设计成一个函数式接口。如果它不满足函数式接口的条件(比如没有抽象方法,或者有多个抽象方法),编译器就会报错。
  2. 文档化:让阅读代码的人一眼就能看出这是一个函数式接口.

Lambda表达式的写法:

(参数列表)->{表达式或代码块}
  • (参数列表):函数式接口中哪个抽象方法的参数
  • ->:箭头操作符,将参数列表和函数体分开
  • {函数体}:函数式接口中那个抽象方法的实现

通过语法格式也能得知:用Lambda表达式来代替匿名内部类需要程序员对该匿名内部类其中的方法有一定的了解.

示例:若将上述FunctionalInterfaceDemo1分别用匿名内部类和Lambda表达式来调用是这么个情况:

public static void main(String[] args) {
    // 使用匿名内部类来实现
    FunctionalInterfaceDemo1 fl=new FunctionalInterfaceDemo1(){
        public void func1(){
            System.out.println("hello FunctionalInterface");
        }
    };
    
    //使用Lambda表达式来实现
    FunctionalInterfaceDemo1 fl2=()->System.out.println("hello FunctionalInterface");//当函数体内只有一条语句时,甚至可以把{}给去掉
}

可以看到,Lambda表达式去掉了所有冗余的语法,只保留了最核心的逻辑.

回过头来看一下Thread的Runnable接口:

在这里插入图片描述

Runnable接口也标注了@FunctionalInterface,函数体内只有一个抽象方法,完全符合函数式接口的要求,于是,上个写法的匿名内部类就可以简化为:

public static void main(String[] args) {
        Runnable r=()-> System.out.println("hello Runnable");
        Thread t6=new Thread(r);
        t6.start();
        System.out.println("hello main");
    }

将Runnable的实现直接放到Thread传参的括号里,可以进一步简化:

public static void main(String[] args) {
    Thread t6=new Thread(()-> System.out.println("hello Runnable"));
    t6.start();
    System.out.println("hello main");
}

至此,一开始需要十余行才能完成的代码简化到了三行.

上面几种写法都比较常见,Lambda表达式是其中最常用的写法.

3.2 查看线程信息

JDK自带了一个程序,可以通过该程序看到线程信息.步骤如下:

  1. 找到JDK安装路径.若你不知道你的JDK安装在哪里了,那么打开你的IDEA,左上角File下拉菜单选择project structure,点进去后选择左侧的SDKs:

在这里插入图片描述

  1. 在磁盘上找到该路径,进入bin文件夹下,找到jconsole.exe:

在这里插入图片描述

  1. 把你的线程运行起来,为了方便查看,你可以编写一个带有循环的程序,就比如:
public static void main(String[] args) {
    Thread t1=new Thread(()-> {
        while(true) {
            System.out.println("hello thread");
        }
    });
    t1.start();
    while(true) {
        System.out.println("hello main");
    }
}
  1. 把程序运行起来,别关
  2. 双击jconsole.exe,在本地连接选项下选择你的类,点击连接

在这里插入图片描述

选择"不安全连接".

在这里插入图片描述

上方菜单栏选择"线程":

在这里插入图片描述

你的公共类中只有一个线程,系统默认命名为"Thread-0",可以看到它此时处于RUNNABLE状态,表示该线程正在运行.下方还能看到main线程.

下面那个总阻止数表达两个线程竞争锁时该线程被阻塞的次数,竞争的资源就是System.out,看似简单的一个打印操作,实际上需要:获取锁->执行多个方法调用->执行native系统调用->释放锁 这些步骤,当锁被main线程使用时,Thread-0线程的使用请求就会被拒绝,Thread-0在获取到锁之前会被阻塞.

实际上,大量的线程上下文切换和锁竞争会严重降低程序性能.这其实就是一种线程安全问题,后面会详细展开.

3.3 Thread类的属性和方法

3.3.1 构造方法

最基本的三个构造方法:

方法说明
Thread()创建线程对象
Thread(Runnable r)使用Runnable对象创建线程对象
Thread(Runnable r,String name)使用Runnable对象创建线程对象并命名
(了解)Thread(ThreadGroup group,Runnable r)把多个线程放到一个组里,方便统一管理

前两个前面创建时已经讲到,演示一下第三个:

Thread t1=new Thread(()-> System.out.println("thread1"),"thread");
System.out.println(t1.getName());

在这里插入图片描述

若不指定名字,则编译器会默认给"Thread-0"这样的名字,不是很直观.改完名之后,就变得直观了不少.

当你打开jconsole查看线程信息时,显示的就是你指定的这个名字,不再是"Thread-0"之类的了.

3.3.2 常见属性
属性获取方法
IDgetId()
名称getName()
优先级getPriority()
是否是后台进程isDaemon()
是否存活isAlive()
是否被中断isInterrupted()
状态getState()

前三个:

public static void main(String[] args) {
    Thread t1=new Thread(()-> {
        while(true) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    });
    t1.start();
    System.out.println(t1.getId());//获取线程ID
    System.out.println(t1.getName());//获取线程名字
    System.out.println(t1.getPriority());//获取线程优先级
}

ID:

  • 每个Thread对象都有一个唯一的线程ID
  • 无论线程是否启动,是否运行,是否结束,都有ID.
  • ID在单个JVM实例运行期间不回收,且分配的ID是单调递增的,保证不会出现ID冲突的情况.程序重启后,ID会重新从初始值开始分配.

优先级:范围1-10,默认是5(NORM_PRIORITY)


是否是后台线程.

Java中,线程分为前台线程(用户线程)和后台线程(守护线程).

  • 前台线程(User Thread):若某线程在执行过程中能阻止进程的结束,那么它就是前台线程.前台线程主要执行程序的核心业务逻辑.
  • 后台线程(Daemon Thread):若某进程在执行过程中不能阻止进程的结束,那么它就是后台线程.后台线程是为其他线程提供服务的.

直白点说,前台线程若没执行完,进程是不能退出的,进程要等待所有前台线程执行完才能结束.而后台线程若没执行完那就没执行完,进程不会等待后台线程,直接结束.

举个生动的例子,比如酒桌文化:一帮小弟和几个大哥一起喝酒.若小弟不想喝了大哥还想喝,那可以提前走,酒局继续.直到所有大哥都喝够了酒局才结束.若大哥都不想喝了,小弟还没喝够,酒局直接结束,小弟只能心怀遗憾.后台进程就类似于小弟,前台进程就类似于大哥.

默认创建的线程都是前台线程,主线程也是前台线程.

一个进程至少有一个前台线程,也就是主线程.可以有多个后台线程,也可以没有.

一个进程中可以有多个前台线程.进程会等待所有前台线程都结束才能结束

代码演示:

public static void main(String[] args) {
    Thread t1=new Thread(()-> {
        while(true) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    });
    t1.start();
    System.out.println(t1.isDaemon());
    System.out.println("hello main");
}

执行结果为false.表明创建的这个t1是前台线程.

可以通过setDaemon(true)的方式将前台线程改为后台线程.当我修改之后,执行结果为:

在这里插入图片描述

可以看到,这次t1只打印了1次就结束了,这就是因为将t1改为后台线程之后不能再阻止进程结束,当进程中唯一的前台线程->主线程结束之后,进程就立刻结束了.


是否存活–>isAlive()

代码中创建的new Thread对象,它的生命周期和内核中实际的线程是不一样的,

内核中的线程还存在,那么Thread对象一定存在.

然而若Thread对象依然存在,但是内核的线程是可能不存在的.出现这种现象可能是因为:

  • 调用start之前的时候,内核的线程还没有创建
  • 线程的run执行完毕的时候,内核的线程就没了,但是对象还没销毁.

isAlive()就是用来判断内核的线程是否还存在,返回true就表示存在,false表示已经没了.

public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(()-> {
        for(int i=0;i<3;i++) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    });
    System.out.println(t1.isAlive());// 在线程启动之前打印
    t1.start();
    System.out.println(t1.isAlive());//线程启动后打印
    Thread.sleep(3000);
    System.out.println(t1.isAlive());// 主线程休眠3秒后打印
    Thread.sleep(3000);
    System.out.println(t1.isAlive());// 主线程休眠6秒后打印
}

上述代码在我的电脑上的运行结果截图是:

在这里插入图片描述

可以看到,第一次打印是在线程启动之前,此时虽然线程对象存在,但是内核的线程还没创建,因此结果是false;

第二次打印是在t1调用start之后,内核的线程已经创建,结果是true;

主线程和t1线程并发执行,主线程休眠了3秒,在次期间t1线程内部打印了3次"hello thread";

主线程休眠结束,再次进行打印,内核的线程还未销毁,结果仍然是true

主线程再次休眠3秒,然后再次打印,结果为false,说明在主线程休眠的这3秒内,内核的线程被销毁了.


getState()将在下一小节的"线程状态"讲解.

3.3.3 核心操作
3.3.3.1 核心操作1-> 创建线程

代码已经在本文3.1 小节讲述完毕,这里区分一个重要问题:

谈谈start()和run()的差别?

run()是一个普通方法,描述了线程要执行的任务,也可以称为"线程的入口".调用run()不会创建新线程,方法内部逻辑将在本线程执行.

start()用于启动新线程.start()会调用系统函数,真正在系统内核创建线程,start会根据不同的系统,分别调用不同的API.创建好线程之后再来单独执行run,start的执行速度是很快的,一旦start执行完毕,新线程就会开始执行,调用start的线程也会开始执行.

一个Thread对象只能调用一次start,如果多次调用就会出问题,换句话说,一个Thread对象只能对应系统中的一个线程.
在这里插入图片描述

如图,当你用同一个线程对象调用两次start时,程序抛出了IllegalThreadStateException即非法线程状态异常.深究一点,这是Java希望一个Thread对象只对应一个线程,因此会在start执行之前根据线程状态做出判定:如果Thread对象还没有start过,此时的状态就是New,接下来就可以顺利调用start;如果已经调用过了,就会进入其他状态.只要不是NEW状态,接下来执行start都会抛出异常.


3.3.3.2 核心操作2-> 线程终止

Java多线程中的终止是很"温柔"的,假设B正在运行,A想让B终止运行,是通过想办法让B的run方法执行完毕,B自然而然就结束了.而不是强制让B结束.

下面介绍两种线程终止的方法:自然终止使用中断机制.

自然终止:

public static boolean isQuit=false;
public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(()-> {
        while(!isQuit) {
            System.out.println("hello thread");
        }
    });
    t1.start();
    Thread.sleep(1000);
    isQuit=true;
}

在这里插入图片描述

使用一个全局的布尔变量isQuit来作为循环的条件,将isQuit改为true,破坏了循环的执行条件,循环就自然终止了.

注意,上述代码将isQuit定义为了全局,能不能将public去掉,也就是将其放在与t1相同作用域里呢?

在这里插入图片描述

答案是不行,这涉及到Lambda中的一个需要注意的点–>变量捕获.

变量捕获是指Lambda表达式内部可以访问与Lambda在同一个作用域内的变量,但前提是该变量必须是final或事实final.

也就是说你访问的这个变量若是与Lamabda表达式在同一个作用域内,必须是常量,不是常量也行,但是值不能被改动(事实常量).但若是将变量定义为全局范围,那就不在变量捕获的范围内了,是可以修改的

使用中断机制:

public static void main(String[] args) throws InterruptedException {
    // 此时线程还未创建,所以要现获取线程引用
    Thread t1=new Thread(()-> {
        Thread currentThread=Thread.currentThread();//获取线程引用
        while(!currentThread.isInterrupted()) {
            System.out.println("hello thread");
        }
    });
    t1.start();
    Thread.sleep(1000);
    t1.interrupt();
}

这种方法是通过调用interrupt()方法来请求线程中断的,点开这个interrupt方法的源码:

在这里插入图片描述

public void interrupt() {
    // 检查当前线程是否在中断自己,若是进入if循环
    if (this != Thread.currentThread()) {
        checkAccess();// 安全检查:当前线程是否有权限中断自己的目标线程
        // 处理I/O阻塞情况
        synchronized (blockerLock) {
            Interruptible b = blocker;
            if (b != null) {
                interrupted = true;// 设置中断标志
                interrupt0();  // 向JVM通知中断
                b.interrupt(this);// 调用阻塞器中的中断方法
                return;
            }
        }
    }
    interrupted = true;//设置中断标志
    interrupt0();// 向JVM通知中断
}

可以看到,源码处理了两种情况:普通中断和I/O中断,我们这里只关心普通中断,也就是最后两行代码,首先,设置interrupted标志为true,然后调用native方法interrupt0()通知JVM.

点开这个interrupted,可以看到,它返回的是一个isInterrupted变量.

在这里插入图片描述

回到我们写的代码上,经过上述interrupt()方法,isInterrupted被设置为true,破坏了t1的循环执行条件,于是线程终止.

然而需要注意的是当线程代码中含有sleep()时,中断状态会被清除.

public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(()-> {
        Thread currentThread=Thread.currentThread();//获取线程引用
        while(!currentThread.isInterrupted()) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t1.start();
    Thread.sleep(3000);
    t1.interrupt();
}

在这里插入图片描述

在上述代码中,在捕获InterruptedException异常后,使用printStackTrace()来打印异常.运行上述代码,你会发现,在打印结束之后,程序继续执行,就像什么都没发生一样.这是因为:当sleep()被中断时,JVM会清除中断状态并抛出InterruptedException异常,而在catch中若你仅仅只是将异常打印下来,那么当打印结束之后,此时中断状态已经被清除了,那么程序就会继续执行下去.
若是在你选择在catch中,抛出一个新的异常,那么新抛出的异常无人catch,程序便会终止:

在这里插入图片描述

然而这种方式不够"好看"和"优雅",有没有能够不抛出"显眼"的异常,优雅地结束呢?

有的,既然sleep()会清除中断,那么在它清除之后再恢复中断状态,也就是在catch里再调用一遍interrupt().

在这里插入图片描述

可以看到,程序正常退出,无报错.

  1. 这里写不写return的差别就在于退出的时机有差别,有return立即退出,无return则在下次循环检查时退出.
  2. 在线程外部,应通过线程对象的引用调用 interrupt() 方法以发出中断请求;
  3. 在线程内部,若捕获到 InterruptedException,应通过 Thread.currentThread().interrupt() 恢复中断状态.

那为什么Java要设置这么迂回的中断机制呢?又是设置标志位,又是恢复中断状态的…直接提供一个类似于kill()的方法来强制终止,不行吗?

其实早期Java是有这样一个方法来强制终止的–> stop().但后来被彻底废弃,官方强烈建议不要使用,这是因为强制终止可能导致严重的后果:

  • 破坏数据一致性:
public void transfer(Account from, Account to, int amount) {
    synchronized(from) {
        synchronized(to) {
            from.balance -= amount;
            to.balance += amount;
        }
    }
}

如果线程在执行中,已经执行了from.balance -= amount;扣了款,但是在执行 to.balance += amount;之前被强制杀死,那么钱就凭空消失了,不会到账.程序状态立刻变得不一致、无法恢复.

  • 可能导致锁无法释放(死锁):如果线程在持有锁的情况下被强行终止,锁永远不会释放,其他线程也就永远阻塞,整个系统挂死。

interrupt()的设计理念是"协作式中断":中断不应该是暴力的"杀死",而应该是线程之间的"礼貌请求".也就是说:

  • 调用者:“我想让你停下来”
  • 线程本身:“我收到信号了,我决定停不停止”
    • 若线程想"无视"请求,就可以通过sleep清除标志位,继续执行
    • 若线程也想立即结束,就可以在catch中加上return/break.
    • 若B想先完成一些逻辑后再结束,就可以在catch中加上一些其他逻辑,比如释放资源,清理一些数据,提交一些结果等首尾工作.

3.3.3.3 核心操作3-> 线程等待

操作系统对于每个线程的执行,是"随机调度,抢占式执行"的过程.这样的特点注定了无法确定两个线程调度执行的顺序,但是可以控制谁先结束,谁后结束.线程等待,就是在确定两个线程的"结束顺序".

实现这个功能的核心方法是->join()

方法说明
public void join()等待线程结束(死等)
public void join(long millis)等待线程结束,最多等待millis毫秒

无参形态.

比如现在有两个线程a,b,在a线程中调用b.join()的意思就是让a等待b结束,然后a再执行.具体来说就是等待线程先进入阻塞,直到被等待线程执行完毕,等待线程解除阻塞,开始执行.

public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(()-> {
        for(int i=0;i<3;i++) {
            System.out.println("thread线程开始执行");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        System.out.println("thread线程结束执行");
    });
    t1.start();
    System.out.println("main线程开始等待");
    t1.join();
    System.out.println("main线程结束等待");
}

在这里插入图片描述

上述代码中,t1.join()表示让main线程等待t1线程执行完毕,main再执行.在t1执行过程中,main线程一直处于阻塞状态.

若被等待线程先结束了,等待线程才开始join(),那还会出现阻塞吗?

答案是不会,join就是为了确保被等待进程能先结束,如果已经结束了,那join就不会等待了.

在这里插入图片描述

上述代码中,在主线程join之前,先sleep了4秒,sleep会释放主线程的资源,在此期间,足够t1执行完毕了.

在这里插入图片描述

执行代码可以看到最后两行打印几乎同时出现,也说明了,主线程本次没有阻塞等待.


有参形态

刚刚使用的join是无参版本,意思是"死等",“不见不散”,被等待线程只要不执行完,等待线程就会持续阻塞.然而这并非是一个好选择,因为一旦被等待线程出现了bug,就可能使这个线程迟迟无法结束,从而使等待线程一直阻塞下去,无法执行其他操作.

有参形态的join()设置了超时时间,最多等待这么久,如果还没结束就不等了.

在这里插入图片描述

上述代码中,给join传了3000的参数表示主线程会等待t1 3秒,由执行结果可看到,在此期间t1执行了三次.然后主线程结束等待,主线程结束执行,t1继续执行.

3.3.3.4 核心操作4-> 获取线程引用

在某个线程内使用currentThread()方法可以获取自身Thread对象的引用.

在这里插入图片描述

上述代码在一开始先获取了主线程的引用,然后建立了一个新线程t1,在对该线程进行初始化时借助主线程的对象引用调用了join()方法,让t1等待主线程执行完毕再执行.最后结果也是符合预期的.

主线程的创建是由JVM在启动时自动完成的,这个过程对Java程序员是透明的,因此对于主线程的控制或操作,几乎都需要通过线程引用来实现.

就比如上方案例,我们必须先通过Thread.currentThread()获取主线程引用,才能让子线程通过join()来控制它.


线程引用常见应用:

  1. 检查或控制当前线程状态:
Thread current = Thread.currentThread();
while (!current.isInterrupted()) {
    doWork();
}
  1. 恢复中断状态:
catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // 恢复中断状态
}
  1. 在任务中给当前线程命名,打日志,排查问题:
Thread.currentThread().setName("Worker-1");
System.out.println(Thread.currentThread().getName());
  1. 与外部线程引用对比

例如,判断当前执行的线程是不是你启动的某个特定线程:

Thread t1 = new Thread(...);
t1.start();

if (Thread.currentThread() == t1) {
    System.out.println("同一个线程");
}
3.3.3.5 核心操作5-> 线程休眠

通过Thread内置静态方法sleep()来实现,在前面已经出现过.正式再说明一下:线程休眠是指让当前线程主动进入阻塞状态(暂停执行)一段时间,在这段时间内,CPU不再分配时间片给它,这样的操作也被称为"放权".

在一些场景中,若发现某个线程的CPU占有率过高,就可以通过sleep()来改善.虽然给线程设置不同的优先级也可以产生影响,但是比较有限,此时就可以通过sleep来更明显地影响CPU占用.

休眠的本质:

在操作系统层面来看,Thread.sleep()使得线程进入一种叫做TIMED_WAITING(计时等待)状态.在该状态下:线程不会被CPU调度执行,但是仍然占用内存(线程对象,栈帧等),到达指定时间后,线程自动恢复为可运行状态(RUNNABLE),等待操作系统更新调度.

休眠的常见用途:

  1. 控制执行节奏

比如打印信息时加个sleep来控制打印速度:

while (true) {
    System.out.println("Heartbeat");
    Thread.sleep(1000); // 每秒打印一次
}
  1. 等待资源或条件

在某些情况下,线程可能要等待别的线程完成初始化工作:

while (!ready) {
    Thread.sleep(100); // 等待标志位改变
}
  1. 模拟耗时操作

在测试并发程序时经常用到:

System.out.println("任务处理中...");
Thread.sleep(3000);
System.out.println("任务完成");

注意:

  1. 休眠不是精确计时:Thread.sleep(1000) 并非一定正好是1秒,它只会保证不少于1秒,但可能稍多(取决于系统调度,CPU负载等).
  2. 可能抛出InterruptedException,如果线程在休眠时被其他线程调用interrupt(),它会立刻抛出异常.
3.3.3.6 核心操作6->线程状态

Java用Thread.State枚举定义了线程的所有可能状态:

在这里插入图片描述

  1. NEW(新建状态)->线程对象已被创建,但还没调用start()
public static void main(String[] args) {
    Thread t1=new Thread(()-> {
        while(true) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    });
    System.out.println(t1.getState());//NEW
    t1.start();
}
  1. RUNNABLE(就绪状态)->正在CPU上运行/随时可以去CPU上运行的状态
public static void main(String[] args) {
    Thread t1=new Thread(()-> {
        while(true) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    });
    t1.start();
    System.out.println(t1.getState());//RUNNABLE
}
  1. BLOCKED(阻塞状态)->由于锁竞争引起的阻塞

将在下篇文章关于锁的章节讲解.

  1. WAITING(无限等待)->线程等待由另一个线程显式唤醒

出现在没有等待时间的等待场合,比如sleep()

public static void main(String[] args) throws InterruptedException {
    Thread mainThread=Thread.currentThread();
    Thread t1=new Thread(()-> {
        System.out.println(mainThread.getState());
    });
    t1.start();
    t1.join();//WAITING
}

你是否有这样的疑问:调用完start之后,主线程和t1不是并发的吗?也就是说t1的getState()和主线程的join()谁先调度是不确定的,那getState()能如此迅速且准确地获取到线程状态,难道是getState()一直阻塞到主线程执行join(),才获取的状态吗?

不是的.

首先,需要明确一点:getState()是非阻塞的瞬时快照,它只是查询线程当前的状态,不会等待任何状态变化.

而getState()的确是在主线程执行到join()时才获取的状态,这不是阻塞得来的,而是操作系统调度器决定的.在大多数情况下,主线程有更高的调度优先级,join()的动作是很快的,因此主线程几乎总在子线程打印前进入了等待状态.主线程进入join之后,主动放弃CPU,调度器自然就选择t1执行,这就造就了"主线程等待->子线程执行"的时序.

  1. TIMED_WAITING(计时等待)->线程在等待指定时间后自动恢复,或提前被唤醒

出现在设定了等待时间的等待场合,比如sleep(1000)和join(1000)

public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(()-> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    });
    t1.start();
    t1.sleep(500);// 让主进程先让权0.5秒,让t1进入sleep后再往下执行
    System.out.println(t1.getState());//TIMED_WAITING
}
public static void main(String[] args) throws InterruptedException {
    Thread mainThread=Thread.currentThread();
    Thread t1=new Thread(()-> {
        System.out.println(mainThread.getState());
    });
    t1.start();
    t1.join(5);//TIMED_WAITING
}
  1. TERMINATED(终止状态)->run()方法执行完毕,线程生命周期结束.
public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(()-> {
    });
    t1.start();
    t1.sleep(1000);
    System.out.println(t1.getState());//TERMINATED
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值