01 多线程初阶:解谜多线程世界

在日常开发中,你是否曾遇到这样的情景:你的应用程序需要执行多个任务,但你希望它们能够同时运行,以提高性能和响应性。这正是多线程编程的核心概念所涵盖的问题。

当我们编写 Java 应用程序时,通常会面临需要同时处理多个任务的情况。这可能涉及到从网络下载数据、计算密集型操作、响应用户交互或执行其他需要同时进行的任务。在这些情况下,多线程编程可以成为强大的工具,它允许我们更有效地利用计算资源,同时确保应用程序的流畅运行

在本文中,我们将开始初步研究 Java 多线程编程的基础知识,从线程的创建、使用、生命周期以及线程安全产生的原因,助力你更好地理解和使用线程。

一、线程创建与启动

线程是轻量级的,与进程相比,线程消耗的资源较少,因为它们共享相同的进程内存空间。在 Java 的线程模型中,是允许多个线程在同一个程序中执行不同的任务的,线程的存在大大提高了程序的性能和响应能力。

在 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 {
        @Override
        public void run() {
            System.out.println("线程运行,线程名称为:" + Thread.currentThread().getName());
        }
    }
}

上述写法是创建一个普通的线程,当调用 start 方法之后,主线程就会开启一条子线程去执行任务,同时主线程继续按照顺序向下执行,此时主线程与子线程会处于同时执行的状态,那么子线程是不是可以有返回值呢?

在 Java 中同样提供了一种可以存在返回值的线程语义,它的基础使用如下:

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> {
        @Override
        public String call() throws Exception {
            return String.format("我被线程【%s】执行了", Thread.currentThread().getName());
        }
    }
}

上述代码我们创建了一个具有返回值的线程任务,可以看到,我们在定义任务的时候规定了一个泛型,这个泛型就是这个任务最终的返回结果的类型。与常规 Runnable 线程不同的是,Callable 无法直接传递到 Thread 中,于是我们使用了 FutureTask 来包装 Callable 对象, FutureTask 的 get 方法可以获取 Callable 异步任务的执行结果。

说到这,我们就基本上掌握了一个线程基本的定义方式。在上文我们提到了主线程和子线程,简单概括一下:通常来说,点击运行之后,会存在一条线程运行 main 方法,我们称运行 Main 方法的线程为主线程,从 main 方法中创建的线程称为子线程

我们可以简单将主线程与子线程认为是如下的关系,两条线程是一个并行的关系:

子线程演示.png

二、线程的主要参数与 API

通过上文,了解了线程的基本用法之后,我们需要对线程的一些重要参数和方法做一个详细的说明!我们主要会从线程的优先级、线程名称、什么是守护线程、如何停止线程四个方面做一个重点的介绍。

1. 优先级

在 Java 中,线程的优先级用于指定线程相对于其他线程的执行优先级。

线程的优先级是一个整数值,通常在 1 到 10 之间,其中 1 表示最低优先级,10 表示最高优先级。线程的优先级可以影响线程调度器的决策,但并不保证线程一定按照优先级顺序执行,因为线程调度取决于底层操作系统和 Java 虚拟机的实现。

线程优先级的作用包括如下:

  • 控制执行顺序: 优先级高的线程可能会比优先级低的线程更容易的获取执行资源,但这并不是绝对的,因为线程调度仍受操作系统和虚拟机的影响。
  • 资源分配: 在多核处理器上,高优先级的线程可能更容易获得 CPU 时间片,因此可以更频繁地执行。
  • 应用需求: 线程的优先级可以用于满足应用程序的特定需求,例如,确保某些任务的实时性。

要设置线程的优先级,可以使用 Thread 类的setPriority()方法。例如:

// 设置线程的优先级为最高
thread.setPriority(Thread.MAX_PRIORITY);

注意,线程的优先级在一些情况下可能不会按预期工作,因为它依赖于底层操作系统的支持。此外,在一些多线程编程场景中,过度依赖线程优先级可能导致不可预测的结果,因此应该小心使用。在编写多线程应用程序时,最好使用其他机制来控制线程的行为,如锁、条件变量和线程池等,以确保线程能够按照预期的方式协作和同步,这里暂时了解即可。

以下是一些线程优先级可能产生的问题。

  • 优先级反转: 由于操作系统并不会严格地按照代码定义的线程优先级来分配资源,只不过说高优先级的线程获取到执行资源的可能性更高一些,假设当一个低优先级线程持有锁后,长时间不释放锁,这就会导致高优先级线程在等待期间被阻塞。简单来说就是,低优先级线程可能会持有高优先级线程需要的资源,从而延迟了高优先级线程的执行。
  • 饥饿(Starvation): 由于优先级的原因,高优先级线程获取系统执行资源的可能性会更大一些,所以在极端情况下会出现低优先级的线程一直都获取不到执行资源,从而导致低优先级的线程无法工作!
  • 操作系统差异: 不同的操作系统和 Java 虚拟机实现可能对线程优先级的处理方式有所不同,因此线程在不同平台上的表现也可能不同。
  • 优先级饱和: 当线程数目过多时,无论其优先级如何,都可能导致竞争激烈,线程调度变得复杂,无法轻易预测线程的执行次序。

2. 线程名称

多线程编程不仅在开发过程中难以理解,而且更让人困扰的是,一旦多线程任务出现问题,调试变得异常复杂。我相信那些有多线程编程经验的同学都会了解,多线程任务的问题通常不是必现的 bug,而是在特定情况下或者当并发数量达到一定规模时才会显现。

在传统的系统中,无论使用哪种开发框架编写代码,都会涉及大量线程的创建。如果这些线程没有清晰的标识表示它们正在处理哪个任务,开发人员将面临更大的挑战。这就是线程名称非常重要的原因。无论是在排查问题,还是解决由于某些原因引起的死锁问题时,线程名称都提供了宝贵的线索。

那如何设置和获取线程的名称呢?

//设置线程的名称
thread.setName("测试线程");
//获取当前线程的名字
Thread.currentThread().getName()

线程名字,特别是在调试系统因为某些原因变得很慢,或者因为某些原因造成死锁这类问题中“屡建奇功”,我们使用一些 JVM 工具可以很容易监控到线程的存在,比如下图就是我使用 jconsole 监控到的线程的存在:

jvm图片.jpg

可以看到,我们上面实例代码创建的线程可以很容易地被监控到。

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 {
        @Override
        public 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{
        @Override
        public 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异常,从而可以捕获这个异常以实现线程的停止。然而,这种方式只在存在等待条件(如sleepwait等)的情况下才能生效。如果代码中没有这些等待条件,或者线程已经执行完它们,那么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 {
        @Override
        public void run() {
            //验证停止信号是否已经停止
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("我执行了");
            }
        }
    }
}

关于线程的停止,存在许多不同的观点和方法。在我实际的生产经验中,最常见的线程停止方式是基于interrupt发出的停止信号,以完成子线程的终止功能。此外,在一次面试中,我听到过这样一种停止线程的方法,即在代码逻辑中设置检查点,检查这个检查点是否接收到interrupt发出的终止信号,从而实现线程的停止功能。然而,这种方法通常只是暂时解决问题,没有根本性的解决办法。

在实际的开发环境中,我们需要根据自身的开发条件和需求来选择适合的线程停止方式。每种方法都有其适用的场景,因此需要根据具体情况来做出决策。最终,确保线程能够在可控和可维护的条件下停止是至关重要的。

三、线程生命周期和状态

线程的生命周期和状态是指线程从创建到终止所经历的各种状态和阶段。Java 线程的生命周期主要包括以下状态:

  1. 新建状态(New): 当创建一个线程对象时,线程处于新建状态。此时线程对象已经被创建,但尚未启动。
  2. 就绪状态(Runnable): 在就绪状态中,线程已经准备好运行,但它还未获得 CPU 时间片以执行。线程可能在就绪队列中等待 CPU 资源。
  3. 运行状态(Running): 当线程获得 CPU 时间片并且开始执行时,它处于运行状态。线程会执行它的任务代码。
  4. 阻塞状态(Blocked): 在阻塞状态中,线程被阻止执行,通常是因为它在等待某个条件的满足,如等待 I/O 操作完成或等待锁的释放。
  5. 等待状态(Waiting): 在等待状态中,线程被要求等待,直到其他线程通知它继续执行。线程进入等待状态可以通过调用wait()方法、join()方法或类似的方法。
  6. 定时等待状态(Timed Waiting): 与等待状态类似,线程进入定时等待状态是为了等待一段时间,直到时间到或者其他线程通知它继续执行。线程进入定时等待状态可以通过调用sleep()方法或指定超时的wait()方法。
  7. 终止状态(Terminated): 线程处于终止状态表示它的生命周期已经结束,不再可执行。线程可以通过正常执行完任务或者因异常而终止。

线程可以在不同状态之间转换,例如,一个新建状态的线程可以转换为就绪状态,然后再转换为运行状态。运行状态的线程也可以进入阻塞、等待、定时等待状态,然后最终终止。

理解线程的生命周期和状态对于有效地管理多线程程序非常重要,因为它有助于掌握线程的行为、同步和调度。可以使用 Java 的 Thread 类和相关的工具来监控和管理线程的状态,以确保线程在程序中按照预期的方式运行。有关线程的状态的定义可以在 java.lang.Thread.State 看到。

四、竞态条件和临界区

在并发编程中,我们听到最多的问题就是:并发安全

什么是并发安全问题呢?并发安全是如何产生的呢?

我们先听一个故事:

在一个小镇的面包店里,有一位名叫小明的面包师傅,有天小明发明了一种新的糕点,他称之为“竞态蛋糕”。这个蛋糕很特殊,顾客购买后需要立即品尝才能尝出它的美味。由于蛋糕十分好吃,小明的面包店非常受欢迎,导致每天都有很多顾客涌入。为了更快地制作面包,小明雇佣了两名助手——小红和小绿——来帮助他制作竞态蛋糕。每位助手都负责一半的制作过程。

竞态蛋糕的制作需要经过以下步骤:

  1. 制作混合蛋糕面糊。
  2. 烤制蛋糕。
  3. 添加奶油和装饰。

问题出现在第三个步骤,添加奶油和装饰。小红和小绿经常同时完成前两个步骤,然后争相来完成第三个步骤。这导致了问题的发生。

有一天,两位助手都在准备为一位顾客制作蛋糕,但是当进行到最后一步时出现了问题。他们同时试图向蛋糕上添加奶油和装饰,但由于操作冲突,最终的蛋糕变得一团糟。

这个小故事传达了一个重要观点,即竞态条件的产生通常发生在多个人同时尝试操作某个需要一定顺序才能完成的物品时。关键要注意“多人同时顺序敏感”这几个因素。

在上述例子中,小红和小绿就好像是两个线程,而蛋糕则代表了临界区。当多个线程同时以不同的顺序访问临界区时,此时就产生了 态条件,如果临界区没有做特殊处理,数据可能会变得混乱不堪。

那么,我们应该如何解决这个问题呢?故事还没完,我们将故事继续:

小明意识到了问题,并决定解决这个竞态条件。他引入了一个规则,同时只有一位助手能够操作最后一步,而另一位助手必须等待。这个简单的解决方案确保了竞态条件不再发生,而竞态蛋糕变得一如既往地美味。

这个规则保证了在竞态条件下,临界区能够被正确、有顺序地操作,而这个规则我们在并发编程中一般称之为

五、总结

线程作为能够将服务器资源利用率发挥到极致的关键元素,对于现代 Java 开发而言,深入了解和掌握线程是不可或缺的技能之一。

在本章节中,我们重点介绍了线程的基本使用方式、常用参数以及常用方法,并透过案例讨论了多线程可能导致的并发安全问题以及产生这些问题的原因。这一深入探讨为后续学习奠定了基础。虽然线程的使用可以提升服务器资源利用率,但若使用不当、控制不得当,就可能导致系统出现严重问题。

在下一节中,我们将详细探讨如何合理、高效、安全地使用线程,以确保系统的稳定性和性能表现。这将为你提供更全面的视角,使你能够更加精准地运用线程,充分发挥其优势,同时避免潜在的问题。