多线程介绍
当我们编写 Java 应用程序时,通常会面临需要同时处理多个任务的情况。这可能涉及到从网络下载数据、计算密集型操作、响应用户交互或执行其他需要同时进行的任务。在这些情况下,多线程编程可以成为强大的工具,它允许我们更有效地利用计算资源,同时确保应用程序的流畅运行。
下面介绍 Java 多线程编程的基础知识
,从线程的创建、使用、生命周期以及线程安全产生的原因,助力你更好地理解和使用线程。
一、线程创建与启动
线程是轻量级的,与进程相比,线程消耗的资源较少,因为它们共享相同的进程内存空间。在 Java 的线程模型中,是允许多个线程在同一个程序中执行不同的任务的,线程的存在大大提高了程序的性能和响应能力。
1. 继承Thread类(实现Runnable接口)
在 Java 中,线程可以使用java.lang.Thread
类来创建和管理线程,最常见的写法例如:
public class ThreadRunnableTest {public static void main(String[] args) {Thread thread = new Thread(new Task());thread.setName("测试线程");thread.start();}static class Task implements Runnable {@Overridepublic void run() {System.out.println("线程运行,线程名称:" + Thread.currentThread().getName());}}
}
上述写法是创建一个普通的线程,当调用 start
方法之后,主线程就会开启一条子线程去执行任务,同时主线程继续按照顺序向下执行,此时主线程与子线程会处于同时执行的状态,但是这种方式是没有返回值的。
2. 实现Callable接口
子线程是可以有返回值的,在 Java 中同样提供了一种可以存在返回值的线程语义,实现Callable接口,重写call方法它的基础使用如下:
public class ThreadCallableTest {public static void main(String[] args) {//构建一个具有返回结果的任务对象 包装实际的任务对象FutureTask<String> stringFutureTask = new FutureTask<>(new TaskReturn());Thread thread = new Thread(stringFutureTask);thread.setName("测试线程");thread.start();try {System.out.println(stringFutureTask.get());} catch (InterruptedException | ExecutionException e) {e.printStackTrace();}}private static class TaskReturn implements Callable<String> {@Overridepublic String call() throws Exception {return String.format("我被线程【%s】执行了", Thread.currentThread().getName());}}
}
上述代码我们创建了一个具有返回值的线程任务,可以看到,我们在定义任务的时候规定了一个泛型,这个泛型就是这个任务最终的返回结果的类型。与常规 Runnable 线程不同的是,Callable 无法直接传递到 Thread 中,需要使用 FutureTask 来包装 Callable 对象, FutureTask 的 get 方法可以获取 Callable 异步任务的执行结果。
3. 主线程和子线程
点击运行之后,会存在一条线程运行 main 方法,我们称运行 Main 方法的线程为主线程
,从 main 方法中创建的线程称为子线程
。
主线程与子线程两条线程是一个并行的关系:
二、线程的主要参数与 API
1. 优先级
在 Java 中,线程的优先级用于指定线程相对于其他线程的执行优先级。
线程的优先级是一个整数值,通常在 1 到 10 之间,其中 1 表示最低优先级,10 表示最高优先级。线程的优先级可以影响线程调度器的决策,但并不保证线程一定按照优先级顺序执行,因为线程调度取决于底层操作系统和 Java 虚拟机的实现。
线程优先级的作用包括如下:
- 控制执行顺序: 优先级高的线程可能会比优先级低的线程更容易的获取执行资源,但这并不是绝对的,因为线程调度仍受操作系统和虚拟机的影响。
- 资源分配: 在多核处理器上,高优先级的线程可能更容易获得 CPU 时间片,因此可以更频繁地执行。
- 应用需求: 线程的优先级可以用于满足应用程序的特定需求,例如,确保某些任务的实时性。
要设置线程的优先级,可以使用 Thread 类的setPriority()
方法。
// 设置线程的优先级为最高
thread.setPriority(Thread.MAX_PRIORITY);
线程的优先级在一些情况下可能不会按预期工作,因为它依赖于底层操作系统的支持。此外,在一些多线程编程场景中,过度依赖线程优先级可能导致不可预测的结果,因此应该小心使用。在编写多线程应用程序时,最好使用其他机制来控制线程的行为,如锁、条件变量和线程池等,以确保线程能够按照预期的方式协作和同步。
线程优先级可能产生的问题。
- 优先级反转: 由于操作系统并不会严格地按照代码定义的线程优先级来分配资源,只不过说高优先级的线程获取到执行资源的可能性更高一些,假设当一个低优先级线程持有锁后,长时间不释放锁,这就会导致高优先级线程在等待期间被阻塞。简单来说就是,低优先级线程可能会持有高优先级线程需要的资源,从而延迟了高优先级线程的执行。
- 饥饿(Starvation): 由于优先级的原因,高优先级线程获取系统执行资源的可能性会更大一些,所以在极端情况下会出现低优先级的线程一直都获取不到执行资源,从而导致低优先级的线程无法工作!
- 操作系统差异: 不同的操作系统和 Java 虚拟机实现可能对线程优先级的处理方式有所不同,因此线程在不同平台上的表现也可能不同。
- 优先级饱和: 当线程数目过多时,无论其优先级如何,都可能导致竞争激烈,线程调度变得复杂,无法轻易预测线程的执行次序。
2. 线程名称
无论使用哪种开发框架编写代码,都会涉及大量线程的创建。如果这些线程没有清晰的标识表示它们正在处理哪个任务,开发人员将面临更大的挑战。这就是线程名称非常重要的原因。无论是在排查问题,还是解决由于某些原因引起的死锁问题时,线程名称都提供了宝贵的线索。
如何设置和获取线程的名称呢?
//设置线程的名称
thread.setName("测试线程");
//获取当前线程的名字
Thread.currentThread().getName()
线程名字,特别是在调试系统因为某些原因变得很慢,或者因为某些原因造成死锁这类问题中“屡建奇功”,使用一些 JVM 工具可以很容易监控到线程的存在,比如下图就是我使用 jconsole 监控到的线程的存在:
Mac 打开jconsole
/usr/libexec/java_home -V
切到bin目录,运行jconsole
3. 守护线程
在 Java 中,守护线程(Daemon Thread)是一种特殊类型的线程,其作用是为其他线程提供服务和支持。与普通线程(用户线程)不同,守护线程的生命周期会随着程序的主线程(或最后一个用户线程)的结束而终止。这意味着当只剩下守护线程在运行时,Java 虚拟机会自动退出。
守护线程通常用于执行后台任务,如垃圾回收、定时任务、监控、日志记录等,它们在后台默默地执行,不会干扰或影响程序的正常执行。一旦所有用户线程完成了它们的任务并退出,Java 虚拟机就会自动关闭,而不管守护线程是否完成了它们的工作。
假设我们存在下述代码:
public class ThreadRunnableTest {public static void main(String[] args) {Thread thread = new Thread(new Task());thread.setName("测试线程");thread.start();}private static class Task implements Runnable {@Overridepublic void run() {try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("线程运行,线程名称:" + Thread.currentThread().getName());}}
}
可以看到,我们的代码同上文的代码没有做什么特别大的改变,只是多增加了一个 10 秒的睡眠,此时运行程序,JVM 会等待子线程 10 秒睡眠完成之后才会正式地将主线程正常结束,这一类的线程叫做工作线程。
那么,我们在代码中使用 thread.setDaemon(true);
来将一个工作线程变为守护线程:
public class DaemonThreadTest {public static void main(String[] args) {Thread thread = new Thread(new Task());thread.setName("测试守护线程");// 设置为守护线程thread.setDaemon(true);thread.start();}private static class Task implements Runnable{@Overridepublic void run() {try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("线程运行,线程名称:" + Thread.currentThread().getName());}}
}
运行代码后,主线程运行完毕之后,项目会直接结束,并不会走到输出,也不会有异常,会直接结束运行!这就是守护线程与工作线程最本质的区别:
- 守护线程: 只要主线程执行完毕,无论守护线程执行与否,都会停止服务。
- 工作线程: JVM 会等待工作线程全部结束之后才会停止主线程服务。
在日常开发中,我们必须谨慎考虑守护线程的使用。这是因为守护线程的生命周期与主线程密切相关,一旦主线程结束,守护线程可能来不及执行资源回收等必要的操作(比如关闭 JDBC 连接或文件流连接),这可能导致一些令人困惑的问题出现。
因此,我们需要慎重选择守护线程的任务,确保它们的工作不会影响到程序的正常运行,特别是在主线程结束时。
4. 停止线程
如何停止线程似乎是一个老生常谈的问题,现阶段来说也没有一个很好的方案很完美地停止线程。
JDK 官方提供的 thread.stop();
方法可以直接将线程强行终止,且不会存在任何的异常信息!但是无论是 JDK 官方,还是网上的一些文章都告诉我们这种方式不推荐,确实,这种方式会导致资源不释放!
另一种方法是使用 JDK 官方提供的interrupt
方法来请求线程停止。
interrupt
方法会导致正在等待的线程触发InterruptedException
异常,从而可以捕获这个异常以实现线程的停止。然而,这种方式只在存在等待条件(如sleep
、wait
等)的情况下才能生效。如果代码中没有这些等待条件,或者线程已经执行完它们,那么interrupt
方法可能无法停止线程的任务。不过,它在处理子线程作为循环任务的情况下非常有用,我们可以通过发出停止信号并在循环体内检测该信号来终止循环,从而结束子线程的任务。
public class StopThreadTest {public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(new Task());thread.setName("测试线程");thread.start();TimeUnit.SECONDS.sleep(1);//发出一个停止信号thread.interrupt();}private static class Task implements Runnable {@Overridepublic void run() {//验证停止信号是否已经停止while (!Thread.currentThread().isInterrupted()) {System.out.println("我执行了");}}}
}
关于线程的停止,存在许多不同的观点和方法。在我实际的生产经验中,最常见的线程停止方式是基于interrupt
发出的停止信号,以完成子线程的终止功能。此外,在一次面试中,我听到过这样一种停止线程的方法,即在代码逻辑中设置检查点,检查这个检查点是否接收到interrupt
发出的终止信号,从而实现线程的停止功能。然而,这种方法通常只是暂时解决问题,没有根本性的解决办法。
在实际的开发环境中,我们需要根据自身的开发条件和需求来选择适合的线程停止方式。每种方法都有其适用的场景,因此需要根据具体情况来做出决策。最终,确保线程能够在可控和可维护的条件下停止是至关重要的。
三、线程生命周期和状态
线程的生命周期和状态是指线程从创建到终止所经历的各种状态和阶段。Java 线程的生命周期主要包括以下状态:
- 新建状态(New): 当创建一个线程对象时,线程处于新建状态。此时线程对象已经被创建,但尚未启动。
- 就绪状态(Runnable): 在就绪状态中,线程已经准备好运行,但它还未获得 CPU 时间片以执行。线程可能在就绪队列中等待 CPU 资源。
- 运行状态(Running): 当线程获得 CPU 时间片并且开始执行时,它处于运行状态。线程会执行它的任务代码, 这个状态是Runnable的一个子状态,实际的定义中并不存在这个状态,这里列举出来只是为了方便读者理解。
- 阻塞状态(Blocked): 在阻塞状态中,线程被阻止执行,通常是因为它在等待某个条件的满足,如等待 I/O 操作完成或等待锁的释放。
- 等待状态(Waiting): 在等待状态中,线程被要求等待,直到其他线程通知它继续执行。线程进入等待状态可以通过调用
wait()
方法、join()
方法或类似的方法。 - 定时等待状态(Timed Waiting): 与等待状态类似,线程进入定时等待状态是为了等待一段时间,直到时间到或者其他线程通知它继续执行。线程进入定时等待状态可以通过调用
sleep()
方法或指定超时的wait()
方法。 - 终止状态(Terminated): 线程处于终止状态表示它的生命周期已经结束,不再可执行。线程可以通过正常执行完任务或者因异常而终止。
线程可以在不同状态之间转换,例如,一个新建状态的线程可以转换为就绪状态,然后再转换为运行状态。运行状态的线程也可以进入阻塞、等待、定时等待状态,然后最终终止。
理解线程的生命周期和状态对于有效地管理多线程程序非常重要,因为它有助于掌握线程的行为、同步和调度。可以使用 Java 的 Thread 类和相关的工具来监控和管理线程的状态,以确保线程在程序中按照预期的方式运行。有关线程的状态的定义可以在 java.lang.Thread.State
看到。
四、竞态条件和临界区
在并发编程中,我们听到最多的问题就是:并发安全。
什么是并发安全问题呢?并发安全是如何产生的呢?
我们先听一个故事:
在一个宁静的小镇上,有一座古老的图书馆,馆内藏书丰富,是镇上居民学习和休闲的好去处。图书馆的管理员是一位名叫李华的年轻人,他非常认真负责,总是尽心尽力地管理好每一本书。
问题的出现
随着图书馆的知名度越来越高,每天来借书的人越来越多。为了提高效率,李华请来了两位助手——小王和小张,帮助他管理图书的借阅和归还。每位助手负责一部分工作。
然而,问题很快出现了。图书馆有一本非常受欢迎的小说《奇幻之旅》,几乎每天都有人来借阅。小王和小张在处理这本书的借阅和归还时,经常会出现冲突。
有一天,两位读者同时来到图书馆,要求借阅《奇幻之旅》。小王和小张分别接待了他们。小王查看了图书管理系统,发现书还在馆内,便告诉读者A可以借阅。与此同时,小张也查看了系统,发现书还在馆内,便告诉读者B也可以借阅。结果,两位读者都拿到了同一本书,导致了混乱。
竞态条件
这个故事中的问题实际上是典型的竞态条件。小王和小张同时访问了图书管理系统中的同一本书的状态,但由于没有协调机制,导致了数据的不一致。关键在于“多人、同时、顺序敏感”这几个因素。
解决问题
李华意识到问题的关键在于两位助手同时操作同一本书的借阅状态,导致了冲突。于是,他决定采取措施来解决这个竞态条件。他想出了一个简单而有效的办法:
- 引入一把锁:李华在图书管理系统中增加了一个锁机制,每次只允许一个助手操作某本书的状态。
- 制定规则:李华规定,当助手需要处理某本书的借阅或归还时,必须先获取该书的锁。其他助手必须等待,直到锁被释放。
实施新规则 - 获取锁:当小王或小张需要处理某本书的借阅或归还时,他们必须先在系统中获取该书的锁。如果锁已经被另一名助手占用,他们必须等待。
- 操作临界区:获取锁的助手可以安全地更新图书的状态,确保数据的一致性。
- 释放锁:完成操作后,助手必须释放锁,以便其他助手可以继续操作。
- 结果
新规则实施后,小王和小张再也没有同时操作同一本书的状态的问题了。每次只有一位助手能够处理某本书的借阅或归还,确保了系统的数据始终准确无误。读者们对图书馆的服务赞不绝口,图书馆的管理变得更加高效有序。
结论
通过引入锁机制,李华成功解决了竞态条件的问题。这个简单的解决方案不仅提高了工作效率,还确保了数据的一致性和准确性。在并发编程中,类似的锁机制也被广泛应用于防止多个线程同时访问临界区,从而避免数据混乱和错误。
这个规则保证了在竞态条件下,临界区能够被正确、有顺序地操作,而这个规则我们在并发编程中一般称之为 锁
!
五、总结
线程作为能够将服务器资源利用率发挥到极致的关键元素,对于现代 Java 开发而言,深入了解和掌握线程是不可或缺的技能之一。
在本章节中,我们重点介绍了线程的基本使用方式、常用参数以及常用方法,并透过案例讨论了多线程可能导致的并发安全问题以及产生这些问题的原因。这一深入探讨为后续学习奠定了基础。虽然线程的使用可以提升服务器资源利用率,但若使用不当、控制不得当,就可能导致系统出现严重问题。
在下一节中,我们将详细探讨如何合理、高效、安全地使用线程,以确保系统的稳定性和性能表现。这将为你提供更全面的视角,使你能够更加精准地运用线程,充分发挥其优势,同时避免潜在的问题。