天天看点

了解 java 线程

作者:粥屋与包屋

什么是线程?

thread 是运行进程中独立的操作序列。任何进程都可以有多个并发运行的线程,使你的应用程序能够并行解决多个任务。线程是语言处理并发性的重要组成部分。

我喜欢将多线程应用程序可视化为一组序列时间线,如图 1 所示。注意,应用程序以一个线程(主线程)开始。该线程启动其他线程,这些线程可以启动其他线程,依此类推。记住,每个线程都是独立于其他线程的。例如,主线程可以在应用程序本身之前就结束执行。当所有线程停止时,进程停止。

了解 java 线程

图 1

一个给定的线程上的指令总是以定义的顺序进行。如果指令A在同一线程的指令B之前,你总是知道A会在B之前发生。但是,由于两个线程是相互独立的,你不能对两个指令A和B说同样的话,每个指令都在一个单独的线程上。在这种情况下,要么A可以在B之前执行,要么反之亦然(图 2)。有时我们会说一种情况比另一种情况更有可能,但我们无法知道一个流程会如何持续执行。

了解 java 线程

图 2

在很多情况下,你会看到线程执行被工具直观地表示为序列时间线。图 3 显示了 VisualVM(我们在后续文章中使用的 profiler 工具)以序列时间线的方式呈现线程执行。

了解 java 线程

图 3

线程的生命周期

可视化线程执行之后,理解线程执行的另一个重要方面是了解线程生命周期。在整个执行过程中,线程会经历多个状态(图 4)。当使用 profiler 或 thread dump 时,我们经常会引用线程的状态,这在试图弄清楚执行时很重要。了解线程如何从一种状态转换到另一种状态,以及线程在每种状态下的行为对于跟踪和研究应用程序的行为至关重要。

图 4 直观地展示了线程状态以及线程如何从一种状态转换到另一种状态。我们可以确定Java线程的以下主要状态:

  • New — 线程在实例化之后(启动之前)就处于这种状态。在这种状态下,线程是一个简单的 Java 对象。应用程序还不能执行它定义的指令。
  • Runnable — 线程在 start() 方法被调用后就处于这个状态。在这种状态下,JVM 可以执行线程定义的指令。在这种状态下,JVM 会逐步地在两个子状态之间移动线程:
    • Ready — 线程不会执行,但 JVM 可以随时让它执行。
    • Running — 线程正在执行。CPU 当前执行它定义的指令。
  • Blocked — 线程启动了,但暂时脱离了可运行状态,因此 JVM 无法执行其指令。这种状态可以让JVM 暂时“隐藏”线程,让 JVM 无法执行线程,从而帮助我们控制线程的执行。阻塞时,线程可能处于下列子状态之一:
    • Monitored — 线程被同步块(控制对同步块访问的对象)的监视器暂停,并等待被释放以执行该块。
    • Waiting — 在执行过程中,调用了监视器的 wait() 方法,这将导致当前线程暂停。线程将保持阻塞,直到调用 notify() 或 notifyAll() 方法允许 JVM 释放正在执行的线程。
    • Sleeping — 调用 Thread 类中的 sleep() 方法,暂停当前线程一段指定的时间。时间作为参数传递给 sleep() 方法。这段时间过后,线程就可以运行了。
    • Parked — 几乎和等待一样,线程会在有人调用 park() 方法后显示为停止,这会阻塞当前线程,直到调用 unpark() 方法。
  • Dead — 一个线程在完成它的指令集后就会死亡或终止,一个错误或异常使它暂停,或者它被另一个线程中断。线程一旦死亡,就不能重新启动。
了解 java 线程

图 4

图 4 还显示了线程状态之间可能的转换:

  • 一旦有人调用线程的 start() 方法,线程就会从 new 变为runnable。
  • 一旦线程处于 runnable 状态,它就会在就绪状态和运行状态之间摇摆不定。JVM 决定执行哪个线程以及何时执行。
  • 有时,线程会阻塞。它可以通过几种方式进入阻塞状态:
    • 调用 Thread 类中的 sleep() 方法,使当前线程处于临时阻塞状态。
    • 有人调用了 join() 方法,导致当前线程等待另一个线程。
    • 有人调用了监视器的 wait() 方法,暂停当前线程的执行,直到调用 notify() 或 notifyAll() 方法。
    • 同步块的监视器暂停一个线程的执行,直到另一个活动线程完成同步块的执行。
  • 线程在结束执行或被另一个线程中断时可能会进入死亡(终止)状态。JVM 认为从阻塞状态到死亡状态的转换是不可接受的。如果一个被阻塞的线程被另一个线程中断,则转换过程会抛出 InterruptedException 异常。

同步线程

开发人员使用这些方法在多线程架构中控制线程。不正确的同步也是许多问题的根本原因,您必须调查和解决这些问题。我们将概述同步线程最常用的方法。

1 Synchronized 块

同步线程的最简单方法是使用同步代码块,这通常是Java开发人员学习同步线程的第一个概念。其目的是通过同步代码在同一时间只允许一个线程——禁止对给定的代码段进行并发执行。有两种选择:

  • 块同步— 在给定的代码块上应用 synchronized 修饰符
  • 方法同步— 在方法上应用 synchronized 修饰符

下面的代码片段是一个同步块的例子:

synchronized (a) { // 括号之间的对象是同步块的监视器。
    // 同步指令块定义在花括号之间。
    // do something
}           

下面的代码片段展示了方法同步:

// 应用于方法的 synchronized 修饰符
synchronized void m() {
    // 花括号之间定义方法的整个代码块是同步的。
    // do something
}           

使用 synchronized 关键字的两种方式工作起来是一样的,尽管它们看起来有点不同。你会发现每个同步块有两个重要的组成部分:

  • 监视器— 管理同步指令执行的对象
  • 指令块 — 实际的指令,是同步的

方法同步似乎缺少监视器,但对于这种语法,监视器实际上是隐含的。对于非静态方法,实例 “this” 将被用作监视器,而对于静态方法,同步块将使用类的类型实例。

监视器(不能为 null)是对同步块有意义的对象。该对象决定线程是否可以进入并执行同步指令。从技术上讲,这个规则很简单:一旦线程进入同步块,它就会获得监视器上的一个锁。在拥有锁的线程释放它之前,同步块中不会接受其他线程。为了简化问题,我们假设线程只有在退出同步块时才释放锁。图 5 展示了一个可视化的例子。想象一下,两个同步的代码块位于应用程序的不同部分,但由于它们都使用相同的监视器 M1(相同的对象实例),一个线程一次只能在其中一个代码块中执行。没有一条指令A、B或C会被并发调用(至少不会从当前 synchronized 块中调用)。

了解 java 线程

图 5

然而,应用程序可能定义多个同步块。监视器链接了多个同步块,但当两个同步块使用两个不同的监视器时(图 6),这些块是不同步的。在图 6 中,第一个和第二个同步块也彼此同步,因为它们使用了相同的监视器。但这两个块并没有与第三个块同步。其结果是,定义在第三个同步块中的指令D可以与前两个同步块中的任何指令并发执行。

了解 java 线程

图 6

在使用 profiler 或 thread dump 等工具调查问题时,您需要了解线程被阻塞的方式。这些信息可以说明发生了什么、为什么或者是什么导致给定的线程没有执行。图 7 展示了 VisualVM 如何监控一个同步块阻塞了一个线程。

了解 java 线程

图 7

2 使用 wait(), notify(), 和 notifyAll()

阻塞线程的另一种方式是要求它等待一个未定义的时间。使用同步块监视器的 wait() 方法,可以指示线程无限期地等待。其他线程可以 “告诉” 等待它继续工作的线程。你可以使用监视器的 notify() 或 notifyAll() 方法来做到这一点。这些方法通常用于提高应用程序的性能,防止没有意义的线程执行。与此同时,错误地使用这些方法可能会导致死锁,或者线程无限期地等待,而从未被释放执行。

请记住,wait()、notify() 和 notifyAll() 只有在同步块中使用时才有意义。这些方法是同步块的监视器的行为,所以你不能在没有监视器的情况下使用它们。使用 wait() 方法,监视器会在未定义的时间内阻塞线程。当阻塞线程时,它也释放它获得的锁,以便其他线程可以进入该监视器同步的块。当调用 notify() 方法时,线程可以再次执行。图 8 总结了 wait() 和 notify() 方法。

了解 java 线程

图 8

图 9 展示了一个更具体的场景。在后面的文章,我们使用了一个实现生产者-消费者方式的应用程序示例,其中多个线程共享资源。生产者线程将值添加到共享资源,消费者线程使用这些值。但是,如果共享资源不再有价值,会发生什么呢?消费者不会从此时执行中受益。从技术上讲,它们仍然可以执行,但没有价值可以使用,因此允许 JVM 执行它们将导致系统中不必要的资源消耗。更好的方法是,当共享资源没有价值时,“告诉” 消费者等待,并在生产者添加新值后继续执行。

了解 java 线程

图 9

3 加入线程

一种非常常见的线程同步方法是通过使一个线程等待另一个线程完成其执行来连接线程。与等待/通知模式不同的是,线程不会等待通知。线程只是等待另一个线程完成它的执行。图 10 展示了可以从这种同步技术中获益的场景。

假设您必须基于从两个不同的独立来源检索的数据来实现一些数据处理。通常,从第一个数据源检索数据大约需要5秒,从第二个数据源获取数据大约需要8秒。如果按顺序执行操作,获取所有数据所需的时间是5 + 8 = 13秒。但你知道更好的方法。由于数据源是两个独立的数据库,如果使用两个线程,则可以同时从两个数据源获取数据。但是,您需要确保处理数据的线程在开始之前等待检索数据的两个线程完成。要实现这一点,需要让处理线程与检索数据的线程进行连接(如图 10 所示)。

了解 java 线程

图 10

在许多情况下,连接线程是一种必要的同步技术。但如果使用不当,它也会导致问题。例如,如果一个线程正在等待另一个线程,被卡住或永远不会结束,那么加入它的线程将永远不会执行。

4 在指定时间内阻塞线程

有时候线程需要等待一定的时间。在这种情况下,线程处于 “定时等待” 状态或 “睡眠” 状态。下面的操作是导致线程定时等待的最常见操作:

  • sleep() — 你总是可以在 Thread 类中使用静态的 sleep() 方法,让当前执行代码的线程等待固定的时间。
  • wait(long timeout)— 带 timeout 参数的 wait 方法可以和不带参数的 wait() 方法一样使用。但是,如果你提供了一个参数,如果没有提前通知,线程将等待给定的时间。
  • join(long timeout) — 他的操作与 join() 方法相同,但会等待最大超时时间,超时时间由参数指定。

我在应用程序中发现的一个常见的反模式是使用 sleep() 让线程等待,而不是第4章中讨论的 wait() 方法。以我们讨论的生产者-消费者架构为例。你可以使用 sleep() 而不是 wait(),但是消费者应该休眠多长时间才能确保生产者有时间运行并向共享资源添加值呢?这个问题我们没有答案。例如,让线程睡眠 100 毫秒(如图 11 所示)可能太长或太短。在大多数情况下,如果您遵循这种方法,您最终不会获得最佳性能。

了解 java 线程

图 11

5 用阻塞对象同步线程

JDK 提供了一套令人印象深刻的同步线程的工具。在这些类中,多线程架构中使用的一些最著名的类是

  • Semaphore — 用于限制执行给定代码块的线程数量的对象
  • CyclicBarrier — 一个对象,你可以使用它来确保在执行给定的代码块时,至少有给定数量的线程处于活动状态
  • Lock — 提供更广泛同步选项的对象
  • Latch — 一个对象,您可以使用它使一些线程等待,直到其他线程中的某些逻辑执行完毕

这些对象是高级实现,每个都部署了定义好的机制,以简化某些场景中的实现。在大多数情况下,这些对象会因为使用方式不当而引起麻烦,在很多情况下,开发人员会过度使用它们来编写代码。我的建议是使用你能找到的最简单的解决方案来解决问题,并且在使用任何这些对象之前,确保你正确地理解它们是如何工作的。

多线程架构中的常见问题

在研究多线程架构时,您将确定常见问题,这些问题是各种意外行为(意外输出或性能问题)的根本原因。提前理解这些问题可以帮助你更快地识别问题的来源并解决它。这些问题如下:

  • 竞态条件—两个或多个线程竞争修改共享资源。
  • 死锁—两个或多个线程在相互等待时发生阻塞。
  • Livelocks—两个或多个线程不满足停止并继续运行的条件,而没有执行任何有用的工作。
  • 饥饿—当 JVM 执行其他线程时,某个线程会持续被阻塞。线程永远不会执行它定义的指令。

1 竞态条件

当多个线程试图同时更改同一资源时,就会发生竞态条件。当这种情况发生时,我们可能会遇到意想不到的结果或异常。通常,我们使用同步技术来避免这些情况。如图 12 所示。线程 T1 和线程 T2 同时尝试改变变量 x 的值。线程 T1 尝试增加该值,而线程 T2 尝试减少该值。这种情况可能会导致重复执行应用程序的不同输出。可能有以下情况:

  • 操作执行后,x 可能是 5— 如果 T1 先改变值,而 T2 读取已经改变的变量值,或者相反,变量的值仍然是 5。
  • 操作执行后,x 可以是 4 — 如果两个线程同时读取 x 的值,但 T2 最后写了这个值,x 将是(T2 读取的值,5,减去 1)。
  • 操作执行后,x 可能是 6 — 如果两个线程同时读取 x 的值,但 T1 最后写入 x 的值,则 x 将为 6 (T1 读取的值为 5,加 1)。

这种情况通常会导致意想不到的输出。在多线程架构中,可能存在多个执行流,因此很难再现这样的场景。有时,它们只发生在特定的环境中,这使得调查变得困难。

了解 java 线程

图 12

2 死锁

死锁是指两个或多个线程暂停,然后等待彼此的某些操作来继续执行的情况(图 13)。死锁会导致应用程序(至少是应用程序的一部分)冻结,从而阻止某些功能的运行。

了解 java 线程

图 13 死锁示例在 T1 等待 T2 继续执行,T2 等待 T1 的情况下,线程处于死锁状态。

图 14 说明了代码发生死锁的方式。在这个例子中,一个线程获得了资源a上的锁,另一个线程获得了资源b上的锁,但是每个线程也需要其他线程获得的资源来继续执行。线程T1等待线程T2释放资源A,但与此同时,线程T2等待线程T1释放资源B。两个线程都不能继续,因为它们都在等待对方释放所需的资源,从而导致死锁。

了解 java 线程

图 14

图 14 中给出的例子很简单,但它只是一个说教性的例子。现实场景通常更难以调查和理解,可能涉及两个以上的线程。注意,同步块并不是线程陷入死锁的唯一方式。理解这种情况的最好方法是使用你在后续文章学到的调查技术。

3 Livelocks

Livelocks 或多或少与死锁相反。当线程处于 livelock 中时,条件总是以这样一种方式变化,即使线程应该在给定条件下停止,它们也会继续执行。线程不能停止,它们持续运行,通常会无缘无故地消耗系统资源。在应用程序执行过程中,Livelocks 会导致性能问题。

图 15 展示了一个带有序列图的 livelock。两个线程T1和T2在循环中运行。为了停止执行,T1 在最后一次迭代之前使一个条件为真。下一次 T1 回到条件时,它期望它为 true 并停止。然而,这并没有发生,因为另一个线程 T2 将其更改为 false。T2 也处于同样的情况。每个线程改变条件,这样它就可以停止,但与此同时,条件的每次改变都会导致另一个线程继续运行。

了解 java 线程

图 15

这是一个简化的场景。在现实世界中,更复杂的场景可能会引起 Livelocks,并且可能涉及两个以上的线程。

4 饥饿

另一个常见的问题是饥饿,尽管在今天的应用程序中不太可能出现。饥饿是由于某个线程不断被排除在执行之外,即使它是可运行的。线程希望执行它的指令,但是JVM不断地允许其他线程访问系统的资源。因为线程不能访问系统的资源并执行它定义的指令集,所以我们说它正在挨饿。

在早期的JVM版本中,当开发人员为给定线程设置了低得多的优先级时,就会出现这种情况。如今,JVM实现在处理这些情况时要聪明得多,因此(至少在我的经验中)饥饿场景不太可能出现。