线程间的协作
竞争条件和临界区
一个竞争条件 是一个可能在临界区内出现的特殊情况。临界区 是被多个线程执行的代码区域,并且由于临界区的同步执行会导致线程执行顺序会出现差别。
当多个线程执行临界区产生的结果因为线程执行的顺序而最终不同时,临界区被称为包含竞争条件。竞争条件一词源于线程正在竞争通过临界区的比喻,并且该竞争的结果影响执行临界区的结果。
这可能听起来有点复杂,因此我将在以下章节中详细介绍竞争条件和临界区。
临界区
在同一个应用程序中运行多个线程不会导致问题,但是当多个线程访问相同的资源时会出现问题。例如,相同的内存(变量,数组或对象),系统(数据库,Web服务等)或文件。
实际上,只有一个或多个线程写入这些资源时才会出现问题。只要资源不改变,让多个线程读取相同的资源是安全的。
这是一个临界区Java代码示例,如果同时由多个线程执行,则可能会失败
1 | public class Counter { |
想象一下,如果两个线程A和B正在同一个 Counter
类实例上执行add方法,我们无法知道操作系统何时在两个线程之间切换。 add()
方法中的代码不是被Java虚拟机作为单个原子指令执行的,而是将其作为一组较小的指令执行,类似于:
- 从内存中读取this.count到寄存器中。
- 添加值到寄存器。
- 将寄存器写入存储器。
观察以下线程A和B的混合执行会发生什么:
1 | this.count = 0; |
两个线程想要将值2和3添加到counter。因此,在两个线程完成执行后,该值应该为5。但是,由于两个线程的执行是交错的,因此结果会不同。
在上面列出的执行序列示例中,两个线程都从内存中读取值0。然后,他们分别将他们自己的值2和3添加到其中,然后将结果写回内存。 this.count
中存储的值将是最后一个线程写入其中的值而不是5。在上面的例子中它是线程A,但如果执行顺序改变它也可以是线程B.
临界区中的竞争条件
前面示例中 add()
方法中的代码包含一个临界区。当多个线程执行此临界区时,会出现竞争条件。
更正式地说,遇到两个线程竞争相同资源的情况,其中访问资源的顺序是重要的,称为竞争条件。导致竞争条件的代码部分称为临界区。
防止竞争条件
为了防止竞争条件发生,您必须确保临界区作为原子指令执行。这意味着一旦一个线程执行它,在第一个线程离开临界区之前,没有其他线程可以执行它。
通过在临界区使用适当的线程同步机制可以避免竞争条件,可以使用同步的Java代码块来实现线程同步。线程间的同步也可以使用其他同步结构来实现,例如锁或原子变量,如java.util.concurrent.atomic.AtomicInteger。
临界区吞吐量
对于较小的临界区,将整个临界区包含在同步块可以起作用。但是,对于较大的临界区,将它分解为几个较小的临界区可能是有益的,这将允许多个线程同时执行多个较小的临界区,可以减少对共享资源的竞争,从而增加总临界区的吞吐量。
这是一个非常简单的Java代码示例,用于表达我的意思:
1 | public class TwoSums { |
注意该add()
方法将值添加到两个不同的sum成员变量。为了防止竞争条件,求和在Java同步块内执行。使用此实现,只有一个线程可以执行求和。
但是,由于两个sum变量彼此独立,因此可以将它们的求和分成两个独立的同步块,如下所示:
1 | public class TwoSums { |
现在两个线程可以同时执行该add()
方法,第一个同步块内有一个线程,第二个同步块内有另一个线程。两个同步块在不同对象上同步,因此两个不同的线程可以独立的分别执行这两个块。这样线程执行该add()
方法就可以彼此等待更少的时间。
当然,这个例子非常简单。在现实生活中的共享资源中,临界区的分解可能要复杂得多,并且需要对执行顺序可能性进行更多分析。
线程安全和共享资源
可以被多个线程同时安全调用的代码称为线程安全。如果一段代码是线程安全的,那么它不包含竞争条件,仅当多个线程更新共享资源时才会出现竞争条件。因此,了解Java线程在执行时需要共享的资源非常重要。
局部变量
局部变量存储在每个线程自己的堆栈中,这意味着线程之间永远不会共享局部变量,这也意味着所有原始类型局部变量(primitive variable,例如int,long等)都是线程安全的。以下是线程安全局部原始类型变量的示例:
1 | public void someMethod(){ |
局部对象引用
局部对象引用和原始类型变量有点不同,引用自己本身不共享。但是,引用的对象不存储在每个线程的本地堆栈中,所有对象都存储在共享堆中。
如果一个方法创建的对象永远不会离开创建它的方法,那么它是线程安全的。事实上,您也可以将其传递给其他方法和对象,只要这些方法或对象都不会使此对象能够被其他线程使用。
以下是线程安全局部对象的示例:
1 | public void someMethod(){ |
此示例中LocalObject
的实例不从方法返回,也不会传递给可从someMethod()
方法外部访问的任何其他对象。执行someMethod()
方法的每个线程将创建自己的LocalObject
实例并将其分配给localObject
引用。因此,这里LocalObject
的使用是线程安全的。
实际上,整个方法someMethod()
都是线程安全的。即使LocalObject
实例作为参数传递给同一个类或其他类中的其他方法,它的使用也是线程安全的。
当然,唯一的例外是,如果一个方法使用LocalObject
作为调用参数,并且以允许其他线程访问的方式存储这个LocalObject
实例。
下面这个示例展示了上面描述的例外情况:
1 | import org.junit.Test; |
sharedLocalObject
即是可从someMethod
方法外部访问的对象,此对象可以被其他线程访问(因为此对象是类的成员变量,而线程和嵌套子类一样,可以在run()中访问此对象)。这个示例解释了上面这句比较抽象的话:
此示例中
LocalObject
的实例不从方法返回,也不会传递给可从someMethod()
方法外部访问的任何其他对象。
对象成员变量
对象成员变量(字段)与对象一起存储在堆上。因此,如果两个线程在同一对象实例上调用方法,并且此方法更新成员变量,则该方法不是线程安全的。以下是非线程安全方法的示例:
1 | public class NotThreadSafe{ |
如果两个线程在同一个NotThreadSafe实例上同时调用add()
方法,则会导致竞争条件。例如:
1 | NotThreadSafe sharedInstance = new NotThreadSafe(); |
注意两个MyRunnable
实例共享同一个NotThreadSafe
实例。因此,当他们在NotThreadSafe
实例上调用add()
方法时,会导致竞争条件。
但是,如果两个线程在不同的实例上同时调用add()
方法 ,那么它不会导致竞争条件。以下是之前的示例,但略有修改:
1 | new Thread(new MyRunnable(new NotThreadSafe())).start(); |
现在两个线程各有自己的NotThreadSafe
实例,因此它们对add方法的调用不会彼此干扰,代码不再具有竞争条件。因此,即使对象不是线程安全的,它仍然可以以不会导致竞争条件的方式使用。
线程控制逃逸规则
在尝试确定您的代码对某个资源的访问是否是线程安全时,您可以使用线程控制逃逸规则:
1 | If a resource is created, used and disposed within |
资源可以是任何共享资源,如对象,数组,文件,数据库连接,套接字等。在Java中,您并不需要显式地回收对象,因此“回收”意味着丢失对象的引用(引用另一个对象)或将引用置为null。
即使对象的使用是线程安全的,但是如果该对象指向共享资源(如文件或数据库),则整个应用程序可能不是线程安全的。例如,如果线程1和线程2各自创建自己的数据库连接,连接1和连接2,则每个连接本身的使用是线程安全的。但是连接指向的数据库的使用可能不是线程安全的。例如,如果两个线程都执行如下代码:
1 | check if record X exists |
如果两个线程同时执行此操作,并且它们正在检查的记录X恰好是相同的记录,则存在两个线程最终都插入它的风险。这是一个示例:
1 | Thread 1 checks if record X exists. Result = no |
对于在文件或其他共享资源上进行操作的线程也可能发生这种情况。因此,区分由线程控制的对象是资源还是仅仅引用这个资源(如数据库连接所做的)是很重要的。
线程安全和不变性
仅当多个线程正在访问同一资源,并且一个或多个线程写入资源时,才会出现竞争条件。如果多个线程读取相同的资源, 竞争条件不会发生。
我们可以确保线程之间共享的对象永远不会被任何线程更新,方法是使共享对象不可变,从而保证线程安全。这是一个例子:
1 | public class ImmutableValue{ |
注意ImmutableValue
实例的值是在构造函数中传递的,另请注意没有setter方法。一旦ImmutableValue
实例被创建,你将不能改变它的值,它是不可变的。但是,您可以使用getValue()
方法读取它。
如果需要对ImmutableValue
实例执行操作,可以通过返回带有该操作产生的值的新实例来执行此操作。以下是添加操作的示例:
1 | public class ImmutableValue{ |
注意add()
方法返回了一个带有add操作结果的新ImmutableValue
实例,而不是将值添加到自身。
引用不是线程安全的!
要记住,即使对象是不可变的并且因此线程安全,该对象的引用也可能不是线程安全的。看看这个例子:
1 | public class Calculator{ |
Calculator
类持有一个对ImmutableValue
实例的引用。注意可以通过setValue()
和add()
方法更改该引用。因此,即使Calculator
类在内部使用不可变对象,它本身也不是不可变的,因此不是线程安全的,这和前面的累加器实现一样,add()和setValue()方法都不是原子操作,而是几个操作组成。换句话说:ImmutableValue
是线程安全的,但使用它不是。在尝试通过不变性实现线程安全时,请记住这一点。
为了使Calculator
类线程安全,你可以使用synchronized关键词声明getValue()
, setValue()
和add()
方法 ,那将可以做到。
线程通信
在前面已经对 wait() , notify() 和 notifyAll() 进行了讲解,并得出了等待/通知机制的基本范式,接下来就对如何得到此范式做一个分析。
线程信令的目的是使线程能够相互发送信号。另外,线程信令使线程能够等待来自其他线程的信号。例如,线程B可能等待来自线程A的信号,指示数据已准备好被处理
通过共享对象发送信号
线程相互发送信号的一种简单方法是在某个共享对象变量中设置信号值。线程A可以从同步块内部将布尔成员变量hasDataToProcess
设置为true,然后线程B可以读取同步块内的hasDataToProcess
成员变量。下面是一个可以保存这种信号的对象的简单示例,并提供了设置和检查它的方法:
1 | public class MySignal{ |
线程A和B必须都要有对同一个MySignal
实例的引用才能使信号工作。如果线程A和B具有对不同MySignal
实例的引用,则它们将不会检测彼此的信号。要处理的数据可以位于与MySignal
实例分开的共享缓冲区中。
忙等待
处理数据的线程B正在等待可用于处理的数据。换句话说,它正在等待来自线程A的信号,线程A能够让hasDataToProcess()
方法返回true。这是线程B在等待此信号时运行的循环:
1 | protected MySignal sharedSignal = ... |
注意while循环如何一直执行,直到hasDataToProcess()
返回true,这称为忙等待,等待时线程正忙。
线程等待
等待线程忙等待运行时不能有效地利用计算机的CPU,除非平均等待时间非常短。否则,如果等待的线程能以某种方式睡眠或变为非活动状态,直到它收到它正在等待的信号,那将更加智能。
Java有一个内置的等待机制,可以让线程在等待信号时变为非活动状态。java.lang.Object类定义了三个方法,wait()
,notify()
和notifyAll()
,以方便这一点。
在任何对象上调用wait()
的线程将变为非活动状态,直到另一个线程在该对象上调用notify()
。为了调用wait()
或通知调用线程必须首先获取该对象的锁。换句话说,调用线程必须从同步块内部调用wait()
或notify()
。这是一个名为MyWaitNotify的MySignal的修改版本,它使用wait()
和notify()
。
1 | public class MonitorObject { |
等待线程将调用 doWait() ,通知线程将调用 doNotify() 。当一个线程调用一个对象上的 notify() 时,一个在该对象等待的线程被唤醒并被允许执行。还有一个 notifyAll() 方法将唤醒等待给定对象的所有线程。
正如您所看到的,等待和通知线程都在同步块内调用 wait() 和 notify() 。这是强制性的!线程在没有持有调用该方法的对象的锁时,不能调用 *wait()*, notify() 或 notifyAll() 。如果调用的话,则抛出IllegalMonitorStateException
。
但是,这怎么做到呢?只要在同步块内执行,等待线程不会一直持有监视器对象(myMonitorObject)的锁吗?等待线程是否会阻止通知线程进入 doNotify() 中的同步块?答案是不。一旦线程调用 wait() ,它就会释放它在监视器对象上持有的锁。这允许其他线程也调用 wait() 或 notify() ,因为必须从synchronized块内调用这些方法。
一旦线程被唤醒,它不能立刻退出 wait() 调用,直到调用 notify() 的线程离开其synchronized块。换句话说:被唤醒的线程必须重新获取监视器对象上的锁才能退出 wait() 调用,因为等待调用嵌套在同步块中。如果使用 notifyAll() 唤醒多个线程,则一次只有一个被唤醒的线程可以退出 wait() 方法,因为每个线程必须在退出 wait() 之前依次获取监视器对象上的锁。
信号丢失
当在调用 notify() 和 notifyAll() 方法时如果没有线程在等待,notify() 和 notifyAll() 方法不会保持对等待线程的方法调用。然后,通知信号就丢失了。因此,如果线程在被通知的线程调用 wait() 之前调用 notify() ,则等待线程将丢失该信号。这可能是也可能不是问题,但在某些情况下,这可能导致等待线程永远等待,永不醒来,因为错过了唤醒信号。
为避免丢失信号,应将它们存储在信号类中。在MyWaitNotify
示例中,通知信号应存储在MyWaitNotify
实例内的成员变量中。以下是MyWaitNotify
的修改版本:
1 | public class MyWaitNotify2 { |
注意 doNotify() 方法现在在调用 notify() 之前将wasSignalled
变量设置为true。另外,注意 doWait() 方法现在在调用 wait() 之前检查wasSignalled
变量。事实上,如果在 doWait() 调用之前和期间没有收到信号,它只调用 wait() 。
意外唤醒
由于莫名其妙的原因,即使没有调用 notify() 和 notifyAll() ,也可以唤醒线程,这被称为虚假唤醒,线程没有任何理由的醒来。
如果在MyWaitNofity2
类的 doWait() 方法中发生虚假唤醒,则等待线程在没有收到正确的信号时也可以继续处理,这可能会导致应用程序出现严重问题。
为防止虚假唤醒,在while循环内而不是if语句内部检查信号成员变量。这样的while循环也称为自旋锁。被唤醒的线程自旋,直到自旋锁(while循环)中的条件变为false。以下是MyWaitNotify2
的修改版本,如下:
1 | public class MyWaitNotify3 { |
注意 wait() 调用现在嵌套在while循环而不是if语句中。如果等待的线程在没有收到信号的情况下唤醒,则wasSignalled
值仍将为false,并且while循环将再次执行,导致唤醒的线程返回等待。
多个线程等待同一信号
如果你有多个线程在等待,那么while循环也是一个不错的解决方案,它们是被用notifyAll()
唤醒的,但是只允许其中一个线程继续执行。一次只有一个线程能够获取监视器对象的锁,这意味着只有一个线程可以退出 wait() 调用并清除wasSignalled
标志。一旦该线程退出 doWait() 方法中的synchronized块,其他线程也可以获取锁然后退出 wait() 调用并检查while循环内的wasSignalled
成员变量。但是,这个标志在第一个线程唤醒时被清除,因此其余的唤醒线程返回等待,直到下一个信号到达。
不要在常量String或全局对象上调用wait()
本文的早期版本有一个
MyWaitNotify
示例类,它使用常量字符串(””)作为监视对象。以下是该示例的样子:
1 | public class MyWaitNotify{ |
在空字符串或任何其他常量字符串上调用 wait() 和 notify() 的问题是,JVM / Compiler在内部将常量字符串转换为同一对象。这意味着,即使您有两个不同的MyWaitNotify
实例,它们也引用相同的空字符串实例。这也意味着在第一个MyWaitNotify
实例上调用 doWait() 的线程可能会被第二个MyWaitNotify
实例上的 doNotify() 调用唤醒。
情况如下图所示:
请记住,即使4个线程在同一个共享字符串实例上调用 wait() 和 notify() ,来自doWait()和doNotify()调用的信号也会分别存储在两个MyWaitNotify
实例中。MyWaitNotify 1
上的 doNotify() 调用可能会唤醒在MyWaitNotify 2
中等待的线程,但该信号将仅存储在MyWaitNotify 1
中。
这可能不是一个大问题。毕竟,如果在第二个 MyWaitNotify
实例上调用 doNotify() ,那么真正发生的是线程A和B被错误唤醒。这个唤醒的线程(A或B)将在while循环中检查其信号,然后返回等待,因为在第一个MyWaitNotify
实例上没有调用 doNotify() ,所以信号没有被改变,设置为true。这种情况等于主动的虚假唤醒。线程A或B在没有发出信号的情况下唤醒。但是代码可以处理这个问题,所以线程会回来等待。
问题是,由于 doNotify() 调用只调用 notify() 而不调用 notifyAll() ,因此即使4个线程在同一个字符串实例(空字符串)上等待,也只会唤醒一个线程。因此,一个信号真正用于C或D时,但是线程A或B中的一个被唤醒,则被唤醒线程(A或B)将检查其信号,看到没有接收到信号,然后返回等待。C或D不会醒来检查他们实际收到的信号,因此信号丢失了。这种情况等同于前面描述的信号丢失问题,C和D被发送了一个信号但没有响应它。
如果 doNotify() 方法调用了 notifyAll() 而不是 notify() ,则所有等待的线程都被唤醒并依次检查信号。线程A和B将返回等待,但C或D中的一个会发现该信号并离开 doWait() 方法调用。C和D中的另一个将返回等待,因为发现信号的线程在离开 doWait() 时清除了信号。
你可能会被诱惑然后总是调用 notifyAll() 而不是 notify() ,但这是一个糟糕的主意。当只有其中一个线程能够响应信号时,没有理由唤醒所有等待的线程。
所以:不要对 wait() / notify() 机制使用全局对象,字符串常量等。例如,每个MyWaitNotify3
(前面部分的示例)实例都有自己的MonitorObject
实例,而不是使用空字符串进行 wait() / notify() 调用。
下面是一个说明上面问题的示例:
1 | import org.junit.Test; |
输出结果如下:
Thread-4 notify
Thread-1 is notified
可能需要多次运行才会出现线程1或线程2被通知的情况。
经过此章内容的讲解,相信对等待/通知的范式是如何形成的,有了一个充分的认知。