Java 中文官方教程 2022 版(九)(2)https://developer.aliyun.com/article/1486343
中断状态标志
中断机制是通过一个称为中断状态的内部标志实现的。调用Thread.interrupt
会设置这个标志。当线程通过调用静态方法Thread.interrupted
检查中断时,中断状态会被清除。非静态的isInterrupted
方法用于一个线程查询另一个线程的中断状态,不会改变中断状态标志。
按照惯例,任何通过抛出InterruptedException
退出的方法在这样做时会清除中断状态。然而,另一个线程调用interrupt
可能会立即再次设置中断状态。
加入
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/join.html
join
方法允许一个线程等待另一个线程的完成。如果t
是一个当前正在执行的线程的Thread
对象,
t.join();
会导致当前线程暂停执行,直到t
的线程终止。join
的重载允许程序员指定等待时间。然而,与sleep
一样,join
依赖于操作系统的时间控制,因此你不应该假设join
会等待与你指定的时间完全相同。
像sleep
一样,join
在收到InterruptedException
时会退出。
简单线程示例
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/simple.html
以下示例汇集了本节中的一些概念。SimpleThreads
包含两个线程。第一个是每个 Java 应用程序都有的主线程。主线程从 Runnable
对象 MessageLoop
创建一个新线程,并等待其完成。如果 MessageLoop
线程花费太长时间才能完成,主线程会中断它。
MessageLoop
线程会打印一系列消息。如果在打印完所有消息之前被中断,MessageLoop
线程会打印一条消息然后退出。
public class SimpleThreads { // Display a message, preceded by // the name of the current thread static void threadMessage(String message) { String threadName = Thread.currentThread().getName(); System.out.format("%s: %s%n", threadName, message); } private static class MessageLoop implements Runnable { public void run() { String importantInfo[] = { "Mares eat oats", "Does eat oats", "Little lambs eat ivy", "A kid will eat ivy too" }; try { for (int i = 0; i < importantInfo.length; i++) { // Pause for 4 seconds Thread.sleep(4000); // Print a message threadMessage(importantInfo[i]); } } catch (InterruptedException e) { threadMessage("I wasn't done!"); } } } public static void main(String args[]) throws InterruptedException { // Delay, in milliseconds before // we interrupt MessageLoop // thread (default one hour). long patience = 1000 * 60 * 60; // If command line argument // present, gives patience // in seconds. if (args.length > 0) { try { patience = Long.parseLong(args[0]) * 1000; } catch (NumberFormatException e) { System.err.println("Argument must be an integer."); System.exit(1); } } threadMessage("Starting MessageLoop thread"); long startTime = System.currentTimeMillis(); Thread t = new Thread(new MessageLoop()); t.start(); threadMessage("Waiting for MessageLoop thread to finish"); // loop until MessageLoop // thread exits while (t.isAlive()) { threadMessage("Still waiting..."); // Wait maximum of 1 second // for MessageLoop thread // to finish. t.join(1000); if (((System.currentTimeMillis() - startTime) > patience) && t.isAlive()) { threadMessage("Tired of waiting!"); t.interrupt(); // Shouldn't be long now // -- wait indefinitely t.join(); } } threadMessage("Finally!"); } }
同步
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/sync.html
线程主要通过共享对字段和对象引用字段引用的访问来进行通信。这种形式的通信非常高效,但可能导致两种错误:线程干扰和内存一致性错误。防止这些错误所需的工具是同步。
然而,同步可能引入线程争用,当两个或更多线程尝试同时访问同一资源并导致 Java 运行时执行一个或多个线程更慢,甚至暂停它们的执行时发生。饥饿和活锁是线程争用的形式。有关更多信息,请参阅 Liveness 部分。
本节涵盖以下主题:
- 线程干扰描述了当多个线程访问共享数据时引入错误的情况。
- 内存一致性错误描述了由共享内存不一致视图引起的错误。
- 同步方法描述了一种简单的习语,可以有效地防止线程干扰和内存一致性错误。
- 隐式锁和同步描述了一种更通用的同步习语,并描述了同步是基于隐式锁的。
- 原子访问讨论了无法被其他线程干扰的操作的一般概念。
线程干扰
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/interfere.html
考虑一个简单的名为 Counter
的类
class Counter { private int c = 0; public void increment() { c++; } public void decrement() { c--; } public int value() { return c; } }
Counter
被设计成每次调用 increment
都会将 c
加 1,每次调用 decrement
都会从 c
减 1。然而,如果从多个线程引用 Counter
对象,线程之间的干扰可能会阻止预期的操作发生。
当两个操作在不同线程中运行,但作用于相同数据时,干扰就会发生。这意味着这两个操作由多个步骤组成,步骤序列会交叉。
对于 Counter
实例的操作似乎不可能交错,因为对 c
的操作都是单个简单语句。然而,即使是简单语句也可以被虚拟机翻译为多个步骤。我们不会检查虚拟机执行的具体步骤 — 知道单个表达式 c++
可以分解为三个步骤就足够了:
- 检索当前值
c
。 - 递增检索到的值 1。
- 将递增后的值存储回
c
。
表达式 c--
可以以相同方式分解,只是第二步是减少而不是增加。
假设线程 A 大约在同一时间调用 increment
,而线程 B 调用 decrement
。如果 c
的初始值为 0
,它们交错的操作可能会按照这个顺序进行:
- 线程 A:检索 c。
- 线程 B:检索 c。
- 线程 A:递增检索到的值;结果为 1。
- 线程 B:减少检索到的值;结果为 -1。
- 线程 A:将结果存储在 c 中;c 现在为 1。
- 线程 B:将结果存储在 c 中;c 现在为 -1。
线程 A 的结果丢失,被线程 B 覆盖。这种特定的交错只是一种可能性。在不同情况下,可能会丢失线程 B 的结果,或者根本没有错误。由于它们是不可预测的,线程干扰 bug 可能很难检测和修复。
内存一致性错误
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/memconsist.html
内存一致性错误发生在不同线程对应该是相同数据的不一致视图时。内存一致性错误的原因复杂,超出了本教程的范围。幸运的是,程序员不需要详细了解这些原因。所需的只是避免它们的策略。
避免内存一致性错误的关键在于理解happens-before关系。这种关系简单地保证了一个特定语句的内存写入对另一个特定语句是可见的。为了看到这一点,考虑以下示例。假设定义并初始化了一个简单的int
字段:
int counter = 0;
counter
字段在两个线程 A 和 B 之间共享。假设线程 A 增加counter
:
counter++;
然后,不久之后,线程 B 打印出counter
:
System.out.println(counter);
如果这两个语句在同一个线程中执行,可以安全地假设打印出的值为"1"。但如果这两个语句在不同的线程中执行,打印出的值可能是"0",因为不能保证线程 A 对counter
的更改对线程 B 可见,除非程序员在这两个语句之间建立了一个 happens-before 关系。
有几种动作会创建先于关系。其中之一是同步,我们将在接下来的部分中看到。
我们已经看到了两个创建先于关系的动作。
- 当一个语句调用
Thread.start
时,与该语句具有先于关系的每个语句也与新线程执行的每个语句具有先于关系。导致创建新线程的代码的效果对新线程可见。 - 当一个线程终止并导致另一个线程中的
Thread.join
返回时,那么终止线程执行的所有语句与成功加入后面的所有语句之间存在先于关系。线程中代码的效果现在对执行加入的线程可见。
有关创建先于关系的动作列表,请参考Java java.util.concurrent
包的摘要页面。。
同步方法
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html
Java 编程语言提供了两种基本的同步习语:同步方法和同步语句。其中更复杂的同步语句将在下一节中描述。本节讨论的是同步方法。
要使方法同步,只需在其声明中添加synchronized
关键字:
public class SynchronizedCounter { private int c = 0; public synchronized void increment() { c++; } public synchronized void decrement() { c--; } public synchronized int value() { return c; } }
如果count
是SynchronizedCounter
的一个实例,则使这些方法同步会产生两个效果:
- 首先,不可能让同一对象上的两次同步方法调用交错。当一个线程正在为对象执行同步方法时,所有调用同一对象的同步方法的其他线程都会被阻塞(暂停执行),直到第一个线程完成对象的操作。
- 其次,当一个同步方法退出时,它会自动与同一对象的任何后续同步方法的调用建立 happens-before 关系。这确保了对对象状态的更改对所有线程都是可见的。
请注意,构造函数不能被同步 — 使用synchronized
关键字与构造函数是语法错误。同步构造函数没有意义,因为只有创建对象的线程在构造对象时应该访问它。
警告: 在构建一个将在多个线程之间共享的对象时,一定要非常小心,确保对象的引用不会过早“泄漏”。例如,假设你想要维护一个名为instances
的List
,其中包含类的每个实例。你可能会诱惑地在构造函数中添加以下行:
instances.add(this);
但是其他线程可以使用instances
来访问对象,而在对象构造完成之前。
同步方法为防止线程干扰和内存一致性错误提供了一种简单的策略:如果一个对象对多个线程可见,那么对该对象的变量的所有读取或写入都通过synchronized
方法进行。 (一个重要的例外:final
字段,在对象构造后无法修改,可以通过非同步方法安全地读取,一旦对象构造完成)这种策略是有效的,但在后面的课程中我们将看到它可能会出现 liveness 问题。
内在锁和同步
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/locksync.html
同步建立在一个称为内在锁或监视器锁的内部实体周围。内在锁在同步的两个方面发挥作用:强制对对象状态的独占访问和建立对可见性至关重要的 happens-before 关系。
每个对象都有一个与之关联的内在锁。按照惯例,需要独占和一致访问对象字段的线程在访问这些字段之前必须获取对象的内在锁,然后在完成后释放内在锁。线程在获取锁和释放锁之间被认为拥有内在锁。只要一个线程拥有内在锁,其他线程就无法获取相同的锁。当另一个线程尝试获取锁时,它将被阻塞。
当一个线程释放一个内在锁时,该操作与后续获取相同锁的任何操作建立 happens-before 关系。
同步方法中的锁
当一个线程调用一个同步方法时,它会自动获取该方法对象的内在锁,并在方法返回时释放它。即使返回是由未捕获的异常引起的,锁也会被释放。
你可能会想知道当调用静态同步方法时会发生什么,因为静态方法与类相关联,而不是对象。在这种情况下,线程会获取与类相关联的Class
对象的内在锁。因此,对类的静态字段的访问受到一个与类的任何实例的锁不同的锁的控制。
同步语句
创建同步代码的另一种方法是使用synchronized 语句。与同步方法不同,同步语句必须指定提供内在锁的对象:
public void addName(String name) { synchronized(this) { lastName = name; nameCount++; } nameList.add(name); }
在这个例子中,addName
方法需要同步对lastName
和nameCount
的更改,但也需要避免同步调用其他对象的方法。(从同步代码调用其他对象的方法可能会导致在 Liveness 部分描述的问题。)如果没有同步语句,就必须有一个单独的、非同步的方法,唯一目的是调用nameList.add
。
同步语句也有助于通过细粒度同步提高并发性。例如,假设类MsLunch
有两个实例字段,c1
和c2
,它们永远不会同时使用。所有这些字段的更新必须同步,但没有理由阻止对 c1 的更新与对 c2 的更新交错—这样做会通过创建两个仅用于提供锁的对象来减少并发性。
public class MsLunch { private long c1 = 0; private long c2 = 0; private Object lock1 = new Object(); private Object lock2 = new Object(); public void inc1() { synchronized(lock1) { c1++; } } public void inc2() { synchronized(lock2) { c2++; } } }
使用这种习语要非常小心。你必须绝对确定交错访问受影响字段是安全的。
可重入同步
请记住,一个线程不能获取另一个线程拥有的锁。但一个线程可以获取它已经拥有的锁。允许一个线程多次获取相同的锁使可重入同步成为可能。这描述了一种情况,即同步代码直接或间接地调用一个也包含同步代码的方法,并且两组代码使用相同的锁。如果没有可重入同步,同步代码将不得不采取许多额外的预防措施,以避免一个线程导致自己被阻塞。
原子访问
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/atomic.html
在编程中,原子操作是一种有效地一次性完成的操作。原子操作不能在中途停止:它要么完全发生,要么根本不发生。在原子操作完成之前,不会看到任何副作用。
我们已经看到增量表达式,比如c++
,并不描述原子操作。即使是非常简单的表达式也可以定义可以分解为其他操作的复杂操作。然而,有一些操作是可以指定为原子操作的:
- 对于引用变量和大多数基本变量(除了
long
和double
之外的所有类型),读取和写入是原子的。 - 对于所有声明为
volatile
的变量,读取和写入都是原子的(包括long
和double
变量)。
原子操作不能交错,因此可以在不担心线程干扰的情况下使用它们。然而,这并不消除同步原子操作的所有需求,因为内存一致性错误仍然可能发生。使用volatile
变量可以减少内存一致性错误的风险,因为对volatile
变量的任何写入都会与随后对该变量的读取建立 happens-before 关系。这意味着对volatile
变量的更改始终对其他线程可见。更重要的是,这也意味着当线程读取volatile
变量时,它不仅看到volatile
的最新更改,还看到导致更改的代码的副作用。
使用简单的原子变量访问比通过同步代码访问这些变量更有效,但需要程序员更加小心,以避免内存一致性错误。额外的努力是否值得取决于应用程序的大小和复杂性。
java.util.concurrent
包中的一些类提供了不依赖于同步的原子方法。我们将在高级并发对象部分讨论它们。
活跃性
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/liveness.html
并发应用程序按时执行的能力被称为其liveness。本节描述了最常见的活跃性问题,死锁,并简要描述了另外两种活跃性问题,饥饿和活锁。
死锁
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/deadlock.html
死锁 描述了两个或更多线程永远被阻塞,彼此等待的情况。这里有一个例子。
阿方索和加斯顿是朋友,也是极信奉礼貌的人。一个严格的礼貌规则是,当你向朋友鞠躬时,你必须保持鞠躬的姿势,直到你的朋友有机会回礼。不幸的是,这个规则没有考虑到两个朋友可能同时向对方鞠躬的可能性。这个示例应用程序,死锁
,模拟了这种可能性:
public class Deadlock { static class Friend { private final String name; public Friend(String name) { this.name = name; } public String getName() { return this.name; } public synchronized void bow(Friend bower) { System.out.format("%s: %s" + " has bowed to me!%n", this.name, bower.getName()); bower.bowBack(this); } public synchronized void bowBack(Friend bower) { System.out.format("%s: %s" + " has bowed back to me!%n", this.name, bower.getName()); } } public static void main(String[] args) { final Friend alphonse = new Friend("Alphonse"); final Friend gaston = new Friend("Gaston"); new Thread(new Runnable() { public void run() { alphonse.bow(gaston); } }).start(); new Thread(new Runnable() { public void run() { gaston.bow(alphonse); } }).start(); } }
当 死锁
运行时,当它们尝试调用 bowBack
时,两个线程都很可能被阻塞。由于每个线程都在等待另一个线程退出 bow
,因此这两个阻塞永远不会结束。
饥饿和活锁
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/starvelive.html
饥饿和活锁问题比死锁问题要少见得多,但仍然是每个并发软件设计者可能会遇到的问题。
饥饿
饥饿描述了一个线程无法定期访问共享资源并且无法取得进展的情况。这种情况发生在"贪婪"线程长时间地使共享资源不可用时。例如,假设一个对象提供了一个经常需要很长时间才能返回的同步方法。如果一个线程频繁调用这个方法,其他也需要频繁同步访问同一对象的线程将经常被阻塞。
活锁
一个线程经常是作为对另一个线程动作的响应。如果另一个线程的动作也是对另一个线程动作的响应,那么可能会发生livelock。与死锁类似,活锁的线程无法取得进一步的进展。然而,这些线程并没有被阻塞 — 它们只是忙于相互响应而无法恢复工作。这就好比两个人试图在走廊里互相让对方通过:阿方斯向左移动让加斯通通过,而加斯通向右移动让阿方斯通过。看到他们仍然互相阻挡,阿方斯向右移动,而加斯通向左移动。他们仍然互相阻挡,所以…
保护块
原文:
docs.oracle.com/javase/tutorial/essential/concurrency/guardmeth.html
线程经常需要协调它们的动作。最常见的协调习语是保护块。这样的块开始于轮询一个条件,该条件必须在块可以继续之前为真。为了正确执行此操作,需要遵循一些步骤。
假设,例如guardedJoy
是一个方法,必须在另一个线程设置共享变量joy
之前才能继续。这样的方法理论上可以简单地循环,直到条件满足,但是该循环是浪费的,因为它在等待时持续执行。
public void guardedJoy() { // Simple loop guard. Wastes // processor time. Don't do this! while(!joy) {} System.out.println("Joy has been achieved!"); }
一个更有效的保护块调用Object.wait
来挂起当前线程。调用wait
不会返回,直到另一个线程发出通知,表明可能发生了某个特殊事件,尽管不一定是该线程正在等待的事件:
public synchronized void guardedJoy() { // This guard only loops once for each special event, which may not // be the event we're waiting for. while(!joy) { try { wait(); } catch (InterruptedException e) {} } System.out.println("Joy and efficiency have been achieved!"); }
**注意:**始终在测试等待的条件的循环中调用wait
。不要假设中断是为了您正在等待的特定条件,或者该条件仍然为真。
像许多暂停执行的方法一样,wait
可能会抛出InterruptedException
。在这个例子中,我们可以忽略这个异常,我们只关心joy
的值。
为什么这个guardedJoy
的版本是同步的?假设d
是我们用来调用wait
的对象。当一个线程调用d.wait
时,它必须拥有d
的内在锁,否则会抛出错误。在同步方法中调用wait
是获取内在锁的简单方法。
当调用wait
时,线程释放锁并暂停执行。在将来的某个时间,另一个线程将获得相同的锁并调用Object.notifyAll
,通知所有等待该锁的线程发生了重要事件:
public synchronized notifyJoy() { joy = true; notifyAll(); }
第二个线程释放锁后一段时间,第一个线程重新获取锁,并通过从wait
调用返回来恢复执行。
**注意:**还有第二种通知方法,notify
,它唤醒单个线程。因为notify
不允许您指定被唤醒的线程,所以它只在大规模并行应用程序中有用,即具有大量线程的程序,所有线程都在做类似的工作。在这种应用程序中,您不关心哪个线程被唤醒。
让我们使用保护块来创建一个生产者-消费者应用程序。这种应用程序在两个线程之间共享数据:生产者创建数据,消费者对其进行处理。这两个线程使用共享对象进行通信。协调是必不可少的:消费者线程在生产者线程交付数据之前不得尝试检索数据,生产者线程在消费者尚未检索旧数据之前不得尝试交付新数据。
在这个例子中,数据是一系列文本消息,通过一个类型为Drop
的对象共享:
public class Drop { // Message sent from producer // to consumer. private String message; // True if consumer should wait // for producer to send message, // false if producer should wait for // consumer to retrieve message. private boolean empty = true; public synchronized String take() { // Wait until message is // available. while (empty) { try { wait(); } catch (InterruptedException e) {} } // Toggle status. empty = true; // Notify producer that // status has changed. notifyAll(); return message; } public synchronized void put(String message) { // Wait until message has // been retrieved. while (!empty) { try { wait(); } catch (InterruptedException e) {} } // Toggle status. empty = false; // Store message. this.message = message; // Notify consumer that status // has changed. notifyAll(); } }
Java 中文官方教程 2022 版(九)(4)https://developer.aliyun.com/article/1486345