JavaRush /Java 博客 /Random-ZH /并发基础知识:死锁和对象监视器(第 1、2 节)(文章翻译)
Snusmum
第 34 级
Хабаровск

并发基础知识:死锁和对象监视器(第 1、2 节)(文章翻译)

已在 Random-ZH 群组中发布
来源文章:http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html 作者:Martin Mois 本文是我们的Java 并发基础课程 的​​一部分。 在本课程中,您将深入研究并行性的魔力。您将学习并行性和并行代码的基础知识,并熟悉原子性、同步和线程安全等概念。看看这里吧

内容

1.活跃性  1.1死锁  1.2饥饿 2.使用 wait() 和 notify() 进行对象监控  2.1  使用wait() 和 notify() 嵌套同步块  2.2同步块中的条件 3.多线程设计3.1不可变对象  3.2 API 设计  3.3本地线程存储
1. 活力
在开发使用并行性来实现其目标的应用程序时,您可能会遇到不同线程相互阻塞的情况。如果在这种情况下应用程序的运行速度比预期慢,我们会说它没有按预期运行。在本节中,我们将仔细研究可能威胁多线程应用程序生存能力的问题。
1.1 相互阻塞
死锁这个术语在软件开发人员中是众所周知的,甚至大多数普通用户也会时不时地使用它,尽管并不总是在正确的意义上。严格来说,该术语意味着两个(或多个)线程中的每一个都在等待另一个线程释放其锁定的资源,而第一个线程本身已锁定第二个线程正在等待访问的资源:为了更好地理解问题,看 Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A 下面的代码: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } 从上面的代码可以看到,两个线程启动并尝试锁定两个静态资源。但对于死锁,我们需要两个线程有​​不同的顺序,因此我们使用 Random 对象的实例来选择线程首先要锁定的资源。如果布尔变量b为true,则资源1首先被锁定,然后线程尝试获取资源2的锁。如果 b 为 false,则线程锁定资源 2,然后尝试获取资源 1。这个程序不需要运行很长时间就能实现第一个死锁,即 如果我们不中断它,程序将永远挂起: [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. 在此运行中,tread-1 已获取资源2 锁并正在等待资源1 的锁,而tread-2 拥有资源1 锁并正在等待资源2。如果我们将上面代码中的布尔变量 b 的值设置为 true,我们将无法观察到任何死锁,因为线程 1 和线程 2 请求锁的顺序始终相同。在这种情况下,两个线程中的一个将首先获得锁,然后请求第二个锁,第二个线程仍然可用,因为另一个线程正在等待第一个锁。一般来说,我们可以区分出以下几种发生死锁的必要条件: - 共享执行:存在一种资源,任何时候只能被一个线程访问。- 资源持有:在获取一个资源时,线程尝试获取某个唯一资源上的另一个锁。- 无抢占:如果一个线程持有锁一段时间,则没有释放资源的机制。- 循环等待:在执行过程中,会发生线程集合,其中两个(或多个)线程相互等待以释放已锁定的资源。尽管条件列表看起来很长,但对于运行良好的多线程应用程序来说,出现死锁问题的情况并不罕见。但是,如果您可以删除上述条件之一,则可以阻止它们: - 共享执行:当资源必须仅由一个人使用时,此条件通常无法删除。但这不一定是原因。 当使用 DBMS 系统时,一种可能的解决方案是使用一种称为乐观锁定的技术,而不是对需要更新的某些表行使用悲观锁。- 避免在等待另一个独占资源时持有一个资源的方法是在算法开始时锁定所有必需的资源,如果不可能一次锁定所有资源,则将它们全部释放。当然,这并不总是可能的;也许需要锁定的资源是事先未知的,或者这种方法只会导致资源浪费。- 如果不能立即获取锁,绕过可能的死锁的一种方法是引入超时。例如, ReentrantLock类SDK 提供了设置锁定到期日期的功能。- 正如我们从上面的示例中看到的,如果不同线程之间的请求顺序没有差异,则不会发生死锁。如果您可以将所有阻塞代码放入所有线程都必须执行的一个方法中,那么这很容易控制。在更高级的应用程序中,您甚至可以考虑实现死锁检测系统。在这里,您将需要实现某种类似的线程监视,其中每个线程报告它已成功获取锁并正在尝试获取锁。如果线程和锁被建模为有向图,您可以检测两个不同的线程何时持有资源,同时尝试访问其他锁定的资源。如果您可以强制阻塞线程释放所需的资源,则可以自动解决死锁情况。
1.2 禁食
调度程序决定接下来应该执行哪个 处于 RUNNABLE 状态的线程。该决定基于线程优先级;因此,与优先级较高的线程相比,优先级较低的线程获得的 CPU 时间较少。看似合理的解决方案如果被滥用也可能会导致问题。如果高优先级线程大部分时间都在执行,那么低优先级线程似乎就会挨饿,因为它们没有足够的时间来正常工作。因此,建议仅在有令人信服的理由时才设置线程优先级。例如,finalize() 方法给出了一个不明显的线程饥饿示例。它为 Java 语言提供了一种在对象被垃圾收集之前执行代码的方法。但是如果您查看终结线程的优先级,您会发现它并不是以最高优先级运行。因此,当对象的 Finalize() 方法相对于代码的其余部分花费太多时间时,就会发生线程饥饿。执行时间的另一个问题是由于没有定义线程遍历同步块的顺序。当许多并行线程正在遍历同步块中的某些代码时,可能会发生某些线程在进入同步块之前必须比其他线程等待更长的时间。从理论上讲,他们可能永远无法到达那里。这个问题的解决方案就是所谓的“公平”阻塞。公平锁在确定接下来要传递给谁时会考虑线程等待时间。Java SDK 中提供了公平锁定的示例实现:java.util.concurrent.locks.ReentrantLock。如果使用构造函数并将布尔标志设置为 true,则 ReentrantLock 会授予对等待时间最长的线程的访问权限。这保证了不存在饥饿,但同时也导致了忽视优先事项的问题。因此,经常在此屏障等待的优先级较低的进程可能会更频繁地运行。最后但并非最不重要的一点是,ReentrantLock 类只能考虑正在等待锁的线程,即 经常启动并到达屏障的线程。如果线程的优先级太低,那么这种情况不会经常发生,因此高优先级线程仍然会更频繁地传递锁。
2.对象监听与wait()和notify()一起使用
在多线程计算中,常见的情况是让一些工作线程等待其生产者为它们创建一些工作。但是,正如我们所知,就 CPU 时间而言,在检查某个值时主动循环等待并不是一个好的选择。如果我们想在到达后立即开始工作,那么在这种情况下使用 Thread.sleep() 方法也不是特别合适。为此,Java 编程语言有另一种结构可以用于该方案:wait() 和notify()。wait() 方法由 java.lang.Object 类的所有对象继承,可用于挂起当前线程并等待,直到另一个线程使用 notify() 方法唤醒我们。为了正确工作,调用 wait() 方法的线程必须持有之前使用 synchronized 关键字获取的锁。当调用 wait() 时,锁被释放,并且线程等待,直到现在持有锁的另一个线程在同一对象实例上调用 notify()。在多线程应用程序中,自然可能有多个线程等待某个对象的通知。因此,唤醒线程有两种不同的方法:notify()和notifyAll()。第一个方法唤醒其中一个等待线程,而notifyAll() 方法则唤醒所有等待线程。但要注意的是,与synchronized关键字一样,没有规则确定调用notify()时接下来会唤醒哪个线程。在一个具有生产者和消费者的简单示例中,这并不重要,因为我们不关心哪个线程被唤醒。以下代码显示了如何使用 wait() 和 notification() 机制让消费者线程等待生产者线程将新工作添加到队列中: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } main() 方法启动 5 个消费者线程和 1 个生产者线程,然后等待它们完成。然后,生产者线程将新值添加到队列中,并通知所有等待线程发生了某些情况。消费者获得队列锁(即,一个随机消费者),然后进入睡眠状态,稍后当队列再次满时被提升。当生产者完成工作后,它会通知所有消费者唤醒他们。如果我们没有执行最后一步,消费者线程将永远等待下一个通知,因为我们没有设置等待超时。相反,我们可以使用 wait(long timeout) 方法至少在经过一段时间后被唤醒。
2.1 使用wait()和notify()嵌套同步块
如上一节所述,在对象的监视器上调用 wait() 仅释放该监视器上的锁。同一线程持有的其他锁不会被释放。很容易理解,在日常工作中,可能会出现调用 wait() 的线程进一步持有锁的情况。如果其他线程也在等待这些锁,则可能会出现死锁情况。让我们看看以下示例中的锁定: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } 正如我们 之前了解到的,将synchronized 添加到方法签名相当于创建一个synchronized(this){} 块。在上面的示例中,我们不小心将synchronized关键字添加到方法中,然后将队列与队列对象的监视器同步,以使该线程在等待队列中的下一个值时进入睡眠状态。然后,当前线程释放队列上的锁,但不释放队列上的锁。putInt() 方法通知休眠线程已添加新值。但偶然我们也将synchronized关键字添加到这个方法中。现在第二个线程已经进入睡眠状态,它仍然持有锁。因此,当第二个线程持有锁时,第一个线程无法进入 putInt() 方法。结果,我们陷入了僵局,程序被冻结。如果运行上面的代码,它将在程序开始运行后立即发生。在日常生活中,这种情况可能并不那么明显。线程持有的锁可能取决于运行时遇到的参数和条件,并且导致问题的同步块在代码中可能与我们放置 wait() 调用的位置不那么接近。这使得发现此类问题变得困难,特别是因为它们可能会随着时间的推移或在高负载下发生。
2.2 同步块中的条件
通常,在对同步对象执行任何操作之前,您需要检查是否满足某些条件。例如,当您有一个队列时,您希望等待它填满。因此,您可以编写一个方法来检查队列是否已满。如果它仍然是空的,那么你让当前线程进入睡眠状态,直到它被唤醒: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } 上面的代码在调用 wait() 之前与队列同步,然后在 while 循环中等待,直到队列中至少出现一个元素。第二个同步块再次使用队列作为对象监视器。它调用队列的 poll() 方法来获取值。出于演示目的,当 poll 返回 null 时,将引发 IllegalStateException。当队列没有要获取的元素时会发生这种情况。当您运行此示例时,您将看到经常抛出 IllegalStateException。尽管我们使用队列监视器正确同步,但还是抛出了异常。原因是我们有两个不同的同步块。想象一下,我们有两个线程已到达第一个同步块。第一个线程进入块并进入睡眠状态,因为队列为空。第二个线程也是如此。现在两个线程都已唤醒(感谢另一个线程为监视器调用的notifyAll() 调用),它们都看到了生产者添加到队列中的值(项目)。随后两人就来到了第二道关卡前。这里第一个线程进入队列并从队列中检索值。当第二个线程进入时,队列已经空了。因此,它接收 null 作为从队列返回的值并引发异常。为了防止这种情况,您需要在同一个同步块中执行依赖于监视器状态的所有操作: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } 这里我们在与 isEmpty() 方法相同的同步块中执行 poll() 方法。由于使用了同步块,我们可以确定在给定时间只有一个线程正在执行该监视器的方法。因此,在调用 isEmpty() 和 poll() 之间,没有其他线程可以从队列中删除元素。 这里继续翻译。
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION