Java学习笔记(十三):多线程
声明:本篇笔记部分摘自《Java核心技术(卷Ⅰ) - 机械工业出版社》及Java教程-廖雪峰-2025-06-16,遵循CC BY 4.0协议。
存在由AI生成的小部分内容,仅供参考,请仔细甄别可能存在的错误。
一、什么是多线程
想象这么一个摊煎饼的场景,每个煎饼需要以下几个步骤:
1 | |
现在你有两个煎饼锅(A和B),你一定会这么做:
先在A锅中摊糊糊,然后等它烤半分钟的同时在B锅中摊好糊糊,这时候半分钟差不多过去了,给A锅里的煎饼翻个面,然后给B锅翻个面;现在A锅的煎饼两面都煎好了,可以出锅然后继续摊糊糊,这时B锅的煎饼也好了,出锅后A煎饼又可以翻面了…
在现代操作系统中,经常同时运行着多个服务(相当于同时在煎很多个煎饼),特别是在电脑上,我们经常会同时运行多个程序(虽然手机上也有,只不过大多隐藏在后台运行,所以感觉不明显):

这样同时运行多个服务的技术就称之为“多线程”。CPU执行代码都是一条一条顺序执行的,但是,即使是单核cpu,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行。
1.进程和线程
在计算机中,我们把一个任务(可以认为是一个APP应用)称为一个进程(Process),例如浏览器就是一个进程,视频播放器是另一个进程,音乐播放器、Word软件都是一个个的进程。
同时,某些进程内部还需要同时执行多个子任务。例如,微信不仅要接受我们输入的消息内容,还要时刻同步朋友发送过来的信息,我们把这样的子任务称为线程(Thread)。
进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。
1 | |
操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。
同一个应用程序可能会有多个进程,也可能会有多个线程。想要同时进行多个服务,可以采取这三种策略:
- 多进程+进程内单线程
- 单进程+进程内多线程
- 多进程+进程内多线程(最复杂)
2. 进程与线程的选择
进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。
具体采用哪种方式,要考虑到进程和线程的特点。和多线程相比,多进程的缺点在于:
- 创建进程比创建线程开销大,尤其是在Windows系统上;
- 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。
而多进程的优点在于:
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。
3.Java中的多线程
一个Java程序实际上就是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。对于大多数Java程序来说,我们说的多任务,实际上是说如何使用多线程实现多任务。
和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步协调进行。比如说播放视频时的播放画面和声音,如果两个线程不协调,视频就会出现音画不同步的问题。多线程编程的复杂的更高,调试也更困难。
但是,多线程编程又非常有用:
- 多线程模型是很多程序设计语言中(包含但不限于Java程序)最基本的并发模型;
- 网络编程、数据库、Web前后端开发等都必须依赖Java多线程模型。
掌握了多线程编程,我们写出的代码的技术含量才会再上一个台阶,才能继续深入学习后续的数据库操作、网络编程等技术。
二、创建线程
1.线程的创建
要创建一个新线程非常容易,我们只需要实例化一个Thread实例,然后调用它的start()方法:
1 | |
这个线程上没有什么任务,所以启动后就会立即结束。我们希望让它干点活,那么可以通过以下几种方法实现让新的线程执行一些方法:
方法一:从Thread派生一个自定义类,然后覆写run()方法:
1 | |
执行上述代码,注意到start()方法会在内部自动调用实例的run()方法,输出启动新进程的提示。
方法二:创建Thread实例时,传入一个Runnable实例:
1 | |
我们也可以用Java 8引入的lambda语法进一步简写为:
1 | |
2.线程优先级
设定优先级的方法是:
1 | |
JVM自动把1(低)~10(高)的优先级映射到操作系统实际优先级上需要注意的是虽然操作系统对高优先级线程可能调度得更频繁,但我们无法通过设置优先级来确保高优先级的线程一定会先执行。
三、线程的状态
在Java程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:
- New:新创建的线程,尚未执行;
- Runnable:运行中的线程,正在执行
run()方法的Java代码; - Blocked:运行中的线程,因为某些操作被阻塞而挂起;
- Waiting:运行中的线程,因为某些操作在等待中;
- Timed Waiting:运行中的线程,因为执行
sleep()方法正在计时等待; - Terminated:线程已终止,因为
run()方法执行完毕。
当线程启动后,它可以在Runnable、Blocked、Waiting和Timed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。
线程终止的原因有这么几种:
- 线程正常终止:
run()方法执行到return语句返回; - 线程意外终止:
run()方法因为未捕获的异常导致线程终止; - 对某个线程的
Thread实例调用stop()方法强制终止(不推荐使用)。
一个线程还可以等待另一个线程直到其运行结束。例如,main线程在启动t线程后,可以通过t.join()等待t线程结束后再继续运行,相当于是在t后面“排队”。
1 | |
当main线程调用线程t的join()方法时,主线程将等待变量x线程t结束后,才继续往下执行自身线程。所以,上述代码打印顺序可以肯定是main线程先打印start,t线程再打印hello,main线程最后再打印end。
就像火车站里并排铺设的一条条轨道,并行的多线程就如同铁轨上一辆辆呼啸而过的火车。如何对这些火车进行“调度”,确保乘客上下车准确无误(进程间通信),是我们需要考虑的问题。
四、中断线程
试想这样一个情景:你点击下载了一个文件,创建下载任务后发现文件大小为10GB,此时由于心疼流量/内存不够/下载太慢,你决定放弃下载,于是点击了下载管理器中的“取消”按钮。对于浏览器来说,此时已经没有必要继续下载了,需要提前结束这个尚未完成的下载线程。
提前结束一个未完成的线程,就是线程的中断操作。
1.interrupt()方法
中断一个Java线程非常简单,只需要在其他线程中对目标线程调用interrupt()方法。目标线程一旦发现自己的interrupt()方法被调用,就会立刻结束自身线程。
1 | |
在这个例子中,JVM先启动了主线程,然后主线程又启动了t线程并且暂停等待一秒钟;t线程启动后每过0.2秒执行一次 n++ 并且输出一个 hello x n,直到一秒钟后主线程继续运行,触发了t线程的中断并且等待t线程处理完毕。t线程一共执行 1 / 0.2 = 5 次之后结束,因此会输出到 hello x 5 结束;此时t线程捕获了中断异常,在catch语句中输出了线程中断消息并且退出了循环,因此线程结束。被t.join()阻塞的主线程解除阻塞,得以继续运行,输出“主线程结束”的消息后也运行完毕,自动结束了运行。
类似于老板说马上开会,让我们去打印文件(主线程启动子线程);我们每0.2分钟打打印好一份(子线程处理任务),老板一分钟之后跟我们说没时间了,让我们不管打了多少份都交到会议室去(主线程中断子线程,并且等待子线程处理完成);我们到达会议室后,老板分发文件并且开始会议(子线程处理完毕,主线程阻塞解除,继续处理后续任务)。
需要注意的是,如果线程被中断时处于等待(阻塞)状态,例如,t.join()会让main线程进入等待状态,此时,如果对main线程调用interrupt(),join()方法会立刻抛出InterruptedException。因此,目标线程只要捕获到join()方法抛出的InterruptedException,就说明有其他线程对其调用了interrupt()方法,通常情况下该线程应该立刻结束运行。
2.使用标志实现中断
另外一种中断控制方法是给线程对象定义一个public volatile boolean running字段,在外部线程中把thread.running置为false,就可以让线程结束。注意这里不仅要标注成boolean类型,还要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。
JVM会把变量的值保存在主内存中。一个线程访问变量时,实际上是将变量拷贝一份到自己的工作内存中。如果其他线程修改了自己内存中的值而还没有同步到主内存中,数据的改变就不同步了。因此需要volatile关键字来明确告知JVM,每次在访问变量时从主内存读取最新的值,并且在修改拷贝的变量后立即写回到主内存中。
五、守护线程
Java程序启动时,会先启动主线程,主线程再启动其他线程。当所有的线程都运行结束是,程序就结束了,此时JVM也会退出,整个Java进程随之终止。
有些情况下,我们需要通过一个线程来执行一些周期性的任务,这些任务通常是放在死循环中执行的,即这个进程不会自动停止,也没有一个机制来自动管理;想要维护这样的程序就比较困难。
这个时候就轮到守护线程(Daemon Thread)发挥作用了。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
创建守护线程的方法和普通线程一样,只是需要再调用start()之前调用setDaemon(true)即可。
1 | |
因此,对之前的那个在子线程中打印hello的例子,还可以这样写:
1 | |
通过将t线程标记为守护线程,就无需在主线程中对t线程执行中断操作了,只需要直接结束主线程即可;此时JVM检测到所有非守护线程都已经结束,就会自动停止守护线程,然后自己也退出运行。略有不同的是,这里主线程退出后不会触发t线程的中断异常,t线程会立即被JVM停止,因此不会输出“t线程被中断”、“t线程结束”这样的信息。
如果没有将t线程标记为守护线程,也没有在主线程中让它及时中断,就会输出这样的内容:
1 | |
可以发现此时即使主线程任务完成后停止了,但是t线程由于是死循环仍然会继续运行下去。
六、线程同步
1 | |
上面这段程序定义了两个线程对象,启动后分别对公有变量cnt自增/自减10000次,预期的结果是0,但是实际运行后,可能会得到这样的结果:
1 | |
最后得到的值其实也不确定,虽然大多数情况下是 ,但有时候会有 的误差,甚至会出现 、 这样误差很大的值。(我怀疑是a=a+1的问题,尝试替换成a++、a+=1这样的写法仍然存在这样的问题。)这是因为对于这样的自增/自减操作,实际上由三步组成:
1 | |
尽管多线程似乎是同时进行的,但是在操作系统中实际上是通过进程中断,在多个进程之间来回切换处理的。这样一来,如果两个进程同时对一个变量进行迭加操作(100+1)时,可能会出现这样的情况:
1 | |
线程1读取变量的值(100)后被操作系统中断,线程2读取变量的值(100),然后递增并写回新的值(101),此时操作系统回头处理线程1,将变量的值(100)递增并写回新值(101)。看似是两个线程都执行了+1的操作,因此会得到102,但实际上两个线程获取到的是相同的旧值,分别进行了+1的操作,而不是先后进行的。
1.synchronized同步锁
① 初识同步锁
这说明多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式(仅有一步操作,或者多个操作之间不得中断,必须将他们视为一个最小的单位“原子”)执行。我们可以考虑用一把“锁”让这些进程的最小逻辑组成一个独立的单位,操作系统必须处理完整个单元后才能够中断这个线程去处理其他的线程。
1 | |
这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),临界区内不得对线程进行中断处理(即使在临界区内中断线程,也只能等到线程触发中断异常或者临界区的逻辑执行完毕才能停止)。可见,保证一段代码的原子性,就是通过加锁和解锁实现的。Java程序使用synchronized关键字(中文释义为“同步的”)对线程的一些操作加锁:
1 | |
现在我们把之前的例子进行修改,通过“锁”来解决这个问题:
1 | |
注意synchronized (Demo.lock) { ... } 中的参数,表示用Demo.lock实例作为锁,两个线程在运行到各自的临界区时,必须先获得锁才能进入临界区内继续运行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Demo.count变量的增减与读写操作就成为了一个最小的代码单元,不能被随意中断。修改后的代码无论运行多少次,最终结果都是0。
② 同步锁的缺点及注意事项
虽然使用synchronized解决了多线程同步访问共享变量的正确性问题,但它的缺点是带来了性能的下降。synchronized代码块无法并发执行,而且加锁和解锁需要消耗一定的时间。大量使用synchronized会降低程序的执行效率。
还需要注意的是,我们可以定义多个Object的实例作为锁,在读写不同的公共变量时使用不同的锁,既可以避免同步读写造成的数据错误,又可以防止多个线程共同竞争同一个锁造成的效率降低。例如,有两个公共变量var1和var2,我们需要定义两把锁lock1和lock2,将读写var1的所有方法都使用lock1锁住,读写var2的所有方法都使用lock2锁住。
③ 不需要同步锁的情况
JVM的规范定义了几种原子操作:
- 基本类型(
long和double除外)赋值。long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作。 - 引用类型赋值,例如:
List<String> list = anotherList。
单个原子操作无需加锁,但是多个连续的原子操作(读取-修改-写入)一般需要加锁,最好连同多个变量的赋值/写入也用锁固定流程,防止出现逻辑错误。
此外,无需在读写不可变对象(String等)时加锁。
2.同步方法
我们知道锁的原理是在主类中定义一个Object lock实例,让这个实例每次只能被同一个synchronized(Demo.lock)方法获取,从而避免挂上同一把锁的这些线程操作同时进行。
但是有些时候我们只是不希望线程中的一部分操作被意外打断,或者有很多这样的原子操作需要用锁保护,这样会需要在主类中定义大量的实例同步锁,而且很多锁的利用率其实并不高,这样无形中增加了主类和线程类之间的耦合度,也让代码的维护变得更困难。
更好的方法是把synchronized逻辑封装起来,将对同一个变量的读写操作封装到一起。让我们这样修改之前的例子:
1 | |
这里不再定义两个线程类Adder和Suber,而是将它们作为方法,将主类的字段cnt作为一个属性,一起封装成一个单独的类Counter,然后在main方法中实例化两个线程对象adderThread和suberThread,分别调用counter的两个方法来实现数据的读写;由于这两个方法的同步锁参数均为this,即counter实例本身,也能很好地防止同步读写造成的数据错误。这个例子无论运行多少次,结果也是稳定的0。
实际上如果需要通过synchronized (this)锁住整个方法,也可以直接用synchronized修饰这个方法。例如上面的Counter.adder()可以写成这样:
1 | |
线程安全
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”(thread-safe)的,例如上面的Counter类就是线程安全的。之前提到Java标准库中的java.lang.StringBuffer是多线程的,这个类也是线程安全的。
还有一些不变类,例如String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。而且类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述情况以外,大多数的类例如ArrayList,都是非线程安全的类,不能在多线程中修改它们。我们默认一个类是非线程安全的。
3.死锁

有两个上锁的箱子,但是打开它们的钥匙在对方的箱子里。如果我们想要打开A箱子,就必须在B箱子中拿出A的钥匙;可是B箱子的钥匙在A箱子中。这样一来,两个箱子我们都无法解开,这就是一种“死锁”(Deadlock)现象。
死锁是指两个或多个进程(或线程)在执行过程中,因争夺资源而陷入的一种相互等待的状态。若无外力干涉,这些进程都无法向前推进。死锁通常需要满足以下四个必要条件:
- 互斥条件(Mutual Exclusion):资源一次只能被一个进程占用。
- 占有并等待(Hold and Wait):进程已经占有了至少一个资源,并等待获取其他进程占有的资源。
- 不可抢占(No Preemption):资源只能由持有它的进程自愿释放,不能被强制抢占。
- 循环等待(Circular Wait):存在一个进程等待链,每个进程都在等待下一个进程所持有的资源。
① 可重入锁
1 | |
考虑上面这样一个例子,当某个线程成功调用了add()方法时,它已经或得到了this锁,但是执行add()方法时可能还会调用dec()方法;但是这个方法同样需要该线程获取这个相同的this锁,并且在Java中也是允许这样做的。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。
因此,所以在获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。
当两个线程各自持有不同的锁,并且试图获取对方手里的锁,双方就会一直等待下去,这样就形成了死锁。没有任何机制能解除死锁,只能强制结束JVM进程。因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。
4.线程等待:使用wait和notify
考虑这样的一个需求:
- 线程1不断往队列中添加任务;
- 线程2可以从队列中获取任务。如果队列为空则等待,直到队列中至少有一个任务时再返回任务列表中的第一个任务。
1 | |
这里getTask()方法存在这样的问题:使用while死循环会导致线程开始后会一直占据synchronized锁,导致其他线程一直等待解锁后使用addTask()。执行上述代码,线程会在getTask()中因为死循环而100%占用CPU资源。
为了解决这个问题,我们可以让getTask()在条件不满足时先进入等待状态,直到条件满足线程才被唤醒并且继续执行下去:
1 | |
wait()方法调用时,会释放线程获得的锁,wait()方法返回时,线程又会重新试图获得锁。getTask()线程会在开始时获取this锁,因此可以通过this.wait();让线程先释放获取到的this锁,等条件满足时再试图获取this锁并继续执行。
在相同的锁对象上调用notify()方法,就能够唤醒对应正在wait的线程,让他们在wait()处继续执行下去。例如对上面的addTask()方法,我们可以修改成这样:
1 | |
在往队列中添加了任务后,线程立刻对this锁对象调用notify()方法,这个方法会唤醒一个正在this锁等待的线程(就是在getTask()中位于this.wait()的线程),从而使得等待线程从this.wait()方法返回。完整的代码示例如下:
1 | |
推荐调用this.notifyAll()而不是this.notify(),使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()方法内部的wait()中等待,使用notifyAll()将一次性全部唤醒。
通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。
注意到wait()方法返回时需要重新获得this锁。假设当前有3个线程被唤醒,首先要等待执行addTask()的线程结束此方法后,才能释放this锁,随后,这3个线程中只能有一个获取到this锁,剩下两个将继续等待。
还需要注意的是,这里我们使用的是while而不是if:
1 | |
while语句在回到wait()后会继续循环一次,检测到queue.isEmpty()为false才会跳出while继续执行;而if会直接在wait()返回后直接向下执行,缺少了一次可靠的检验过程。因此这样写会有两个问题:
-
虚假唤醒(Spurious Wakeups):在 Java 中,线程可能在没有被
notify()或notifyAll()调用的情况下从wait()中醒来,这就是虚假唤醒。如果使用if判断,线程会直接从wait()处继续执行,实际可能队列为空,导致queue.remove()抛出NoSuchElementException。 -
多个消费者线程场景:多个线程同时等待时,当一个任务被添加并调用
notifyAll()时,所有等待线程都会被唤醒。如果使用if判断,多个线程可能都通过空队列检查,导致只有一个线程能正确获取任务,其他线程会在空队列上调用remove()而抛出异常。
必须使用while循环来检查队列是否为空,这是处理等待 / 通知机制的标准做法。
5.线程超时:使用ReenTrantLock
synchronized关键字用于加锁,但这种锁一是很重型,二是获取时必须一直等待,没有额外的尝试机制。如果我们想实现更高级的功能,比如说超时的处理,过了很长时间都获取不到锁就退出线程防止死锁,使用synchronized就比较难实现了。
① ReenTrantLock
java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁:
1 | |
ReentrantLock是Java代码实现的锁,我们必须先获取锁,然后在finally中正确释放锁。
和synchronized不同的是,ReentrantLock可以尝试获取锁:
1 | |
上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。使用这样的锁比直接使用synchronized更安全,线程在tryLock()失败的时候就不会一直等待导致死锁了。
② 使用Condition
使用ReentrantLock后,我们怎么编写wait和notify的功能呢?
答案是使用Condition对象来实现wait和notify的功能。Condition提供的await()、signal()、signalAll()原理和synchronized锁对象的wait()、notify()、notifyAll()是一致的,并且其行为也是一样的;这里就不再赘述了。
6.读写锁:使用ReadWriteLock
使用ReentrantLock,我们保证了只有一个线程可以执行临界区代码。但是有些情况下,我们希望允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待,直到写入完成之后再继续操作,即如下的关系:

使用ReadWriteLock可以解决这个问题,它保证了:
- 只允许一个线程写入(其他线程既不能写入也不能读取);
- 没有写入时,多个线程允许同时读(提高性能)。
1 | |
把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。
使用ReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。
例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReadWriteLock。
7.乐观锁:使用StampedLock
深入分析ReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这其实也是不必要的,因为读取操作并不会对源数据造成影响。
为了进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。它在读的过程中也允许获取写锁后写入。这种读锁是一种乐观锁,即我们乐观地估计读的过程中大概率不会有写入。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
1 | |
8.数量锁:使用Semaphore
前面这些锁的目的是保护一种受限资源,保证同一时刻只有一个线程能访问(ReentrantLock),或者只有一个线程能写入(ReadWriteLock)。有时候需要限制并发的规模,如同时访问数据库的线程数、同时下载某个资源的线程数等。
这种限制数量的锁,如果用Lock数组来实现就会很难以管理。这种情况下,我们可以使用Semaphore:
1 | |
使用Semaphore先调用acquire()获取,然后通过try ... finally保证在finally中释放。
调用acquire()可能会进入等待,直到满足条件为止。也可以使用tryAcquire()指定等待时间:
1 | |
Semaphore本质上就是一个信号计数器,用于限制同一时间的最大访问数量。
七、线程池
1.引入
Java语言虽然内置了多线程支持,启动一个新线程非常方便,但是,创建线程需要操作系统资源(线程资源,栈空间等),频繁创建和销毁大量线程需要消耗大量时间。我们能否像对数组的处理一样,先创建一个变量空间的序列,然后再按需使用呢?
我们可以把很多小任务让线程池中的一组线程来执行,而不是一个任务对应一个新线程(类似于从专人专岗变成轮岗制)。简而言之,线程池中维护了多个小的线程,没有任务的时候这些线程都处于等待状态。有新任务时,这些线程就会处理任务,每个线程对应一个小任务;如果没有空闲的线程,就让剩下的任务先等待一段时间,或者添加新的线程。
我们用送外卖的场景,就能简单看懂两种任务执行模式的区别:
第一种是 “一个任务对应一个新线程”—— 商家只和外卖平台临时合作:每次有顾客下单(来了新任务),商家都要通过平台找一位临时骑手(创建新线程);骑手送完这单外卖(任务执行完),就和商家取消合作,转而去送其他商家的订单(线程销毁,不再为当前商家服务)。
这种模式的问题很明显:每次找临时骑手都要等平台匹配(线程创建有开销),遇到用餐高峰(任务量大),频繁找骑手会耽误送餐时间;而且骑手送完就接下一单,没办法固定为这家店服务(无线程复用),效率很低。
第二种是 “线程池” 模式 —— 商家自己雇佣专属骑手:商家提前招几名骑手(线程池里的核心线程),平时没订单时,骑手就在店里待命(线程空闲等待);一旦有顾客下单(来了新任务),待命的骑手就直接去配送(线程执行任务);送完回来后,继续在店里等下一单(线程不销毁,复用等待新任务)。
如果遇到突然爆单(任务量超过现有骑手承载),商家还能临时加招几名兼职骑手(线程池新增临时线程),兼职送完高峰订单,后续没单时再结束合作(临时线程销毁);要是兼职也不够,新订单就先在系统里排着(任务进入队列等待),等有骑手空闲了再配送。
对比就能发现:线程池就像商家的 “专属骑手团队”,通过 “固定骑手待命 + 按需加兼职 + 订单排队”,避免了每次找临时骑手的麻烦(减少线程创建销毁开销),还能反复用同一批骑手送单(线程复用),比 “每次找临时骑手” 的模式高效得多。
2.ExecutorService接口
Java标准库提供了ExecutorService接口表示线程池,它的典型用法如下:
1 | |
Java标准库提供的几个常用实现类有:
- FixedThreadPool:线程数固定的线程池;
- CachedThreadPool:线程数根据任务动态调整的线程池;
- SingleThreadExecutor:仅单线程执行的线程池。
我们以FixedThreadPool为例:
1 | |
这个例子创建了一个准备了4个线程的线程池,并且让它处理6个任务。可以看到先处理了其中4个,剩下两个任务等待线程池有空闲线程之后再执行。
程序结束时,需要使用shutdown()方法关闭线程池,它会等待正在执行的任务先完成再关闭。如果使用shutdownNow()方法,会立刻停止正在执行的任务,awaitTermination()则会等待指定的时间让线程池关闭。
想要让线程池根据任务的数量动态调节线程的数量,可以使用CachedThreadPool。如果在动态调整的同时还希望限制线程池规模的上下限,可以参考这样的写法:
1 | |
我自以为一些比较繁琐深入的解决方案是按需使用的,现在留个印象就行,可以等到有需要的时候再查询学习而不是全部死记下来;所以对于参考资料中一些比较深入繁琐的内容,我的笔记中没有涉及。
3.ScheduledThreadPool
有时候我们需要周期性地执行一些操作,如每隔一段时间刷新数据表以始终展示最新的数据,这时我们可以使用ScheduledThreadPool让任务定期反复执行:
| 方法 | 描述 |
|---|---|
ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);<br> |
创建一个ScheduledThreadPool |
ses.schedule(new Task("one-time"), 1, TimeUnit.SECONDS); |
在指定延迟后只执行一次 |
ses.scheduleAtFixedRate(new Task("fixed-rate"), 2, 3, TimeUnit.SECONDS); |
让任务以固定的每3秒执行(线程启动 -> 等待3s) |
ses.scheduleWithFixedDelay(new Task("fixed-delay"), 2, 3, TimeUnit.SECONDS); |
让任务以固定的3秒为间隔执行(执行完毕 -> 等待3s) |
注意FixedRate和FixedDelay的区别。FixedRate是指任务总是以固定时间间隔触发,不管任务执行多长时间;而FixedDelay是指,上一次任务执行完毕后,等待固定的时间间隔,再执行下一次任务。
4.Future的使用
创建线程时,我们通常会实现一个Runnable接口:
1 | |
但是这样的写法没有返回值,无法将result传输出去。使用全局变量托管会比较麻烦。好消息是Java标准库提供了Callable接口,比runnable多了一个返回值:
1 | |
ExecutorService.submit()方法返回了一个Future类型,其实例代表一个未来能获取结果的对象:
1 | |
我们提交一个Callable任务后,我们会同时获得一个Future对象。在主线程的某个时刻调用Future对象的get()方法,就可以获得异步执行的结果。在调用get()时,如果异步任务已经完成,我们就直接获得结果。如果异步任务还没有完成,那么get()会阻塞,直到任务完成后才返回结果。
一个Future<V>接口表示一个未来可能会返回的结果,它定义的方法有:
get():获取结果(可能会等待)get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;cancel(boolean mayInterruptIfRunning):取消当前任务;isDone():判断任务是否已完成。
5.使用Fork/Join
Java 7开始引入了一种新的Fork/Join线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行。
例如求一个长度为100的int数组中各元素之和,可以将其拆分成5个长度为20的小数组分别求和之和再累加到一起,就会快很多。
1 | |
6.使用ThreadLocal
Thread对象代表一个线程,我们可以在代码中调用Thread.currentThread()获取当前线程。
1 | |
对于多任务,Java标准库提供的线程池可以方便地执行这些任务,同时复用线程。Web应用程序就是典型的多任务应用,每个用户请求页面时,我们都会创建一个任务交给线程池去执行:
1 | |
如果想要在方法中使用user,需要在使用到的每个方法的形参列表中添加user,比较麻烦。Java标准库提供了一个特殊的ThreadLocal,它可以在一个线程中传递同一个对象。
ThreadLocal的初始化及使用方法如下所示:
1 | |
通过设置一个User实例关联到ThreadLocal中,在移除之前,所有方法都可以随时获取到该User实例:
1 | |
普通的方法调用一定是同一个线程执行的,所以,step1()、step2()以及log()方法内,threadLocalUser.get()获取的User对象是同一个实例。
特别注意ThreadLocal一定要在finally中清除,防止线程回到线程池中仍然带有之前的状态,可能会干扰后面的逻辑:
1 | |
八、虚拟线程
虚拟线程(Virtual Thread)是Java 19引入的一种轻量级线程,它在很多其他语言中被称为协程、纤程、绿色线程、用户态线程等。
1.IO密集型任务
由于线程由操作系统创建并调度,一个操作系统同时能够调度的线程数量有限(几百到几千之间),而且线程间来回切换很消耗CPU的时间,导致执行效率下降。所以线程作为一种重量级的资源,不应该频繁地创建和销毁。
在服务器端,对用户请求,通常都实现为一个线程处理一个请求。由于用户的请求数往往远超操作系统能同时调度的线程数量,所以通常使用线程池来尽量减少频繁创建和销毁线程的成本。使用多线程来处理IO请求是很低效的。传输数据时大多数时间都在等待,直到返回数据。
为了处理这些IO密集型任务(大多数时间都在等待,真正执行操作的时间占比较少),Java 19开始引入了虚拟线程。虚拟线程的接口和普通线程相同,但是由普通线程进行调度,一个普通线程可以调度成百上千个虚拟线程;本质上是当虚拟线程等待数据时就将其挂起,接收到数据之后再进行处理,通过多个线程的交替处理,实现了类似“多线程”的效果。
2.虚拟线程的使用
虚拟线程的接口和普通线程一样,唯一区别在于创建虚拟线程只能通过特定方法。
方法一:直接创建虚拟线程并运行:
1 | |
方法二:创建虚拟线程但不自动运行,而是手动调用start()开始运行:
1 | |
方法三:通过虚拟线程的ThreadFactory创建虚拟线程,然后手动调用start()开始运行:
1 | |
直接调用start()实际上是由ForkJoinPool的线程来调度的。我们也可以自己创建调度线程,然后运行虚拟线程:
1 | |
由于虚拟线程属于非常轻量级的资源,因此,用时创建,用完即扔,不需要池化虚拟线程。
最后注意,虚拟线程在Java 21正式发布,在Java 19/20是预览功能,默认关闭,需要添加参数--enable-preview启用:
1 | |
参考资料
- 廖雪峰的官方网站.Java教程[EB/OL].(2025-06-07)[2025-08-21]. https://liaoxuefeng.com/books/java/introduction/index.html ↩