fundamentals\java\Concurrency2


原文链接: Concurrency Fundamentals: Deadlocks and Object Monitors

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.");
                    }
                }
            }
        }
    }
}

上面的代码可启动了两个线程,并试图锁定两个静态资源。但是要行成一个死锁,两个线程的锁定顺序必须不同,因此我们使用随机数来选择线程首先要锁定的资源。如果布尔变量b为真,则首先锁定资源1,然后线程尝试获取资源2的锁。如果b为false,则线程首先锁定resource2,然后尝试锁定resource1。这个程序不需要运行很长时间,就会出现第一个死锁,也就是说,如果我们不终止它,程序将永远挂起:

[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.

在此次执行中,线程1持有资源2的锁并等待资源1的锁,而线程2持有资源1的锁并等待资源2。
如果我们将上面示例代码中的布尔变量b永远设置为true,我们将不会遇到任何死锁,因为线程1和线程2请求锁的顺序总是相同的。因此,两个线程中的一个首先获取锁,然后请求第二个锁,因为其他线程等待第一个锁,所以第二个锁仍然可用。
通常,发生死锁有以下要求:

  1. 独占资源:有一个资源在任何时间点只能由一个线程访问。
  2. 资源持有:在持有(锁定)了一个资源时,线程会尝试获取其他独占资源上的另一个锁。
  3. 非抢占:一个持有锁的线程,没有机制在一定时间段后释放资源。
  4. 循环等待:在运行时,在一系列线程中,其中两个(或多个)线程各自等待另一个线程释放已锁定的资源。

尽管需求列表看起来很长,但复杂的多线程应用程序死锁并不少见。但是,如果您能够解决上面列出的某个要求,则可以尝试避免死锁:

  1. 避免独占资源:一般来说这是不能避免的需求,因为资源必须独占式使用。但情况并非总是如此。当使用DBMS系统时,一个可能的解决方案不是对必须更新的表行使用悲观锁,而是使用一种称为乐观锁的技术。
  2. 避免资源持有:一个可能的解决方案是在算法开始时锁定所有必要的资源,如果无法获取所有锁,则释放所有资源。当然,这并不总是可能的,也许要锁定的资源无法预先知晓,或者它在浪费资源。
  3. 设置抢占机制:如果不能立即获得锁,则可以通过引入超时来避免可能的死锁。例如,SDK类ReentrantLock能指定超时时间。
  4. 避免循环等待:正如我们从上面的示例代码中看到的,如果所有线程加锁的顺序不存在差异,则不会出现死锁。如果能够将所有锁代码放入一个方法中,所有线程都必须通过该方法,那么可以轻松地控制这一点。

在更复杂的应用程序中,您甚至可以考虑实现死锁检测系统。在这里,您必须实现某种线程监视,其中每个线程都报告成功获取锁和尝试获取锁。如果线程和锁被建模为有向图,则可以检测两个不同的线程何时持有资源,同时请求另一个被阻塞的资源。如果您能够强制阻塞线程释放获得的资源,那么您就能够自动解决死锁情况。

1.2 线程饥饿

线程调度程序决定下一步应该执行哪个处于可运行状态的线程。该决定基于线程的优先级;因此优先级较低的线程比优先级较高的线程获得更少的CPU时间。听起来合理的功能在使用不当时也会导致问题。如果大多数具有高优先级的线程被执行,那么具有较低优先级的线程会处于“饥饿”状态,因为它们不能获得足够的CPU时间来。因此,仅在理由充分时才建议设置线程的优先级。
线程“饥饿”的一个典型用例是finalize()方法。Java语言的finalize()方法中的代码将在对象被垃圾收集时执行。但是,当您查看finalize()线程的优先级时,您可能会发现它不是以最高优先级运行的。因此,如果对象的finalize()中的代码相比方法主体来说费时太长,则有可能导致finalize()线程处于“饥饿”状态。
线程在执行时间上的另一个问题是,它没有定义线程执行同步块的顺序。当许多线程并发的访问封装在sync关键字内的代码时,可能会发生某些线程必须比其他线程等待更长的时间才能执行的情况。理论上,他们可能永远不会被执行。
后一个问题的解决方案是所谓的“平凡”锁。fair锁在选择下一个要执行的线程时,会考虑线程的等待时间。JavaSDK提供了一个fair锁的示例实现:

Java.util.competition.locks.reentrantlock.如果使用了bool=true的构造器,ReentrantLock保证等待时间最长的线程将被执行,但同时也引入了一个问题,即线程优先级没有被考虑在内,因此具有较低优先级的线程(通常在该处于等待状态)可能会更频繁地执行。最后,当然ReentrantLock类只会计算,当前正在等待的线程,也就是说,频繁执行的线程更多的得到锁。如果线程优先级太低,可能不会频繁的得到锁。

2. 使用wait()和notify()实现对象锁

在多线程编程中,一个常见的任务是让一些工作线程等待生产者生产产品————生产消费模式。但是我们知道,无论是让线程在一个循环中不停的等待或是循环的检查某个状态的值都是对CPU时间资源的一种浪费。而当我们希望消费者在生产者提交请求后立即开始执行,thread.sleep()方法也没有多大价值。
因此Java编程语言有另一种机制,可以应对这种场景:Wait()和Notify()。每个对象都从java.lang.object类继承wait()方法,这个方法可用于暂停当前线程执行,并等待其他线程使用notify()方法唤醒。为了正确工作,线程获得一个锁开始执行synchronized关键字内的代码,执行完成后调用wait()时,锁被释放,与其他线程一起等待,直到拥有锁的另一个线程在同一对象实例上调用notify()方法。
在多线程应用程序中,可能有多个线程在等待某个对象被唤醒。因此,唤醒线程有两种不同的方法:notify()和notifyall()。notify只唤醒其中一个等待的线程,但是notifyAll方法会唤醒所有等待的线程。但请注意,与synchronized关键字类似,没有办法指定调用notify时接下来唤醒哪个线程。在一个简单的生产——消费者的例子中,这并不重要,因为我们对具体的哪个线程被唤醒不感兴趣。
下面的代码演示了如何使用wait()和notify()机制让消费者线程等待从某个生产者线程推送到队列中的新工作:

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()方法启动五个消费者线程和一个生产者线程,然后等待它们完成。然后,生产者线程将一个新值插入队列,然后通知所有等待线程。消费者线程获取队列锁执行完之后进入休眠状态,以便稍后在队列再次填充时被唤醒。当生产者线程完成其工作后,它通知所有使用者线程唤醒。如果我们不执行最后一步,消费者线程将永远等待下一个通知,因为我们没有为等待指定任何超时。相反,我们可以使用wait(时长)在经过一段时间后将唤醒。

2.1.使用wait()和notify()以及内嵌的synchronized代码块

正如在上一节中提到的,在对象锁上调用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关键字添加到了方法中,然后又在队列对象中进行wait()同步,以便在等待队列中的下一个值时使当前线程进入睡眠状态。然后,当前线程将会释放队列对象上的锁,但不会释对象锁。putint()方法通知休眠线程已添加新值。但不小心我们也添加了关键字synchronized到这个方法中。当第一个线程睡着时,它仍然持有方法上的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()返回空值时将引发IllegalstateException。当队列中没有值时,就会抛出异常。
当运行此示例时,您将看到很快就会抛出IllegalstateException。尽管我们在队列对象上进行了正确的同步操作,但依旧会引发异常。这里的原因是我们有两个单独的同步代码块。假设我们有两个线程到达了第一个同步块。第一个线程进入块并进入休眠状态,因为队列是空的。第二个线程也是如此。现在,当两个线程都唤醒时(由一个线程操作队列对象调用notifyall()时),它们都会在队列中看到一个值(生产者添加的值)。然后两个线程都到达第二个屏障。在这里,第一个线程进入并轮询队列中的值。当第二个线程进入时,队列已经是空的。因此,poll()调用返回空值,并抛出异常。
为避免出现上述情况,您必须将依赖对象锁的所有操作放在同一个同步块中:

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()的调用之间从队列中删除元素。

3.多线程设计方法

正如我们在最后几节中看到的,实现多线程应用程序有时比说起来要复杂得多。因此,在启动项目时,有一个清晰的设计是很重要的。

3.1. 不可变对象

多线程设计中一个被认为非常重要的概念是不可变性。如果在不同线程之间共享对象实例,那您必须注意两个线程会不会同时修改同一对象。而不可修改的对象由于其无法更改性(当您想要修改数据时,总是必须构造一个新的实例),使之能轻易的应对多线程同时修改共享对象实例的问题。基本类java.lang.string是一个不可变类的一个例子。每次要更改字符串时,都会得到一个新实例:

String str = "abc";
String substr = str.substring(1);

虽然对象创建不时不需要成本,但这些成本常常被高估了。试想,如果使用不可变对象使得设计变得简单,比之于不使用不可变对象,但是与之俱来的时并发错误风险(在项目中可能很晚才观察到这些错误),您必须进行权衡之中的利弊了。
在下面的内容中,是一组规则,当您想使类不可变时,可以应用以下规则:

  1. 所有字段都应该是final 的和private的。
  2. 不应该有setter方法。
  3. 类本身应该声明为final,以防止子类违反不可变原则。
  4. 如果字段不是基元类型,而是对其他对象的引用:
    1. 这些引用的对象不应该有直接访问的getter方法。
    2. 这些引用的对象不能被更改(或者至少更改这些引用对于对象的客户端是不可见的)。

以下类的实例表示一个消息:包含主题、消息正文和几个键/值对:

public final class ImmutableMessage {
    private final String subject;
    private final String message;
    private final Map<String,String> header;
 
    public ImmutableMessage(Map<String,String> header, String subject, String message) {
        this.header = new HashMap<String,String>(header);
        this.subject = subject;
        this.message = message;
    }
 
    public String getSubject() {
        return subject;
    }
 
    public String getMessage() {
        return message;
    }
 
    public String getHeader(String key) {
        return this.header.get(key);
    }
 
    public Map<String,String> getHeaders() {
        return Collections.unmodifiableMap(this.header);
    }
}

本类是不可变的,因为它的所有字段都是最终字段和私有字段。没有任何方法能够在实例构造后修改其状态。返回对subject和message的引用是安全的,因为字符串本身是不可变的类。例如,获得消息引用的调用者不能直接修改它。对于Headers的Map,我们必须更加注意。只要返回对映射的引用,调用方就可以更改其内容。因此,我们必须返回通过调用collections.unmodifiableMap()获得的不可修改的映射。这将返回映射上的一个视图,该视图允许调用方读取值(这些值又是字符串),但不允许修改。尝试修改映射实例时,将引发UnsupportedOperationException。在本例中,返回特定键的值也是安全的,就像在getheader(String key)中一样,因为返回的字符串再次是不可变的。如果映射包含本身不可变的对象,则此操作将不具有线程安全性。

3.2. API设计技巧

在设计类的公共方法时,即该类的API,您也可以尝试将其设计为多线程使用。您可能想通过状态来控制方法的执行。解决这种情况的一个简单的解决方案是有一个private标志,它检查我们处于哪个状态并在非法状态抛出异常,例如当调用特定方法时出现非法状态异常:

public class Job {
    private boolean running = false;
    private final String filename;
 
    public Job(String filename) {
        this.filename = filename;
    }
 
    public synchronized void start() {
        if(running) {
            throw new IllegalStateException("...");
        }
        ...
    }
 
    public synchronized List getResults() {
        if(!running) {
            throw new IllegalStateException("...");
        }
        ...
    }
}

上面的模式通常也被称为“冒泡模式”,因为方法一旦在错误的状态下执行就会冒泡。但是,您可以使用静态工厂方法设计相同的功能,而不必在每个方法中检入对象的状态:

public class Job {
    private final String filename;
 
    private Job(String filename) {
        this.filename = filename;
    }
 
    public static Job createAndStart(String filename) {
        Job job = new Job(filename);
        job.start();
        return job;
    }
 
    private void start() {
        ...
    }
 
    public synchronized List getResults() {
        ...
    }
}

静态工厂方法使用私有构造函数创建作业的新实例,并已对该实例调用start()。作业的返回引用已处于要使用的正确状态,因此getResults()方法只需要同步,但不必检查对象的状态。

3.3. 线程本地存储

到目前为止,我们已经看到线程共享相同的内存。在性能方面,这是一种在线程之间共享数据的好方法。如果我们使用多个进程来并行执行代码,那么我们将有更多繁重的数据交换方法,如远程过程调用,文件系统或网络级别的同步。如果没有正确同步,那么不同线程之间的共享内存很难满足要求。
Java中提供线程专有的内存JavaLang.threadLocal 类:

public class ThreadLocalExample implements Runnable {
    private static final ThreadLocal threadLocal = new ThreadLocal();
    private final int value;
 
    public ThreadLocalExample(int value) {
        this.value = value;
    }
 
    @Override
    public void run() {
        threadLocal.set(value);
        Integer integer = threadLocal.get();
        System.out.println("[" + Thread.currentThread().getName() + "]: " + integer);
    }
 
    public static void main(String[] args) throws InterruptedException {
        Thread threads[] = new Thread[5];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new ThreadLocalExample(i), "thread-" + i);
            threads[i].start();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i].join();
        }
    }
}

您可能会奇怪,虽然变量threadlocal声明为静态,但每个线程都会准确地输出它通过构造函数获得的值。threadlocal的内部实现确保每次调用set()时,给定的值都存储在只有当前线程可以访问的内存区域中。因此,当您以后调用get()时,您将检索以前设置的值,尽管在此期间其他线程可能调用了set()。
JavaEE世界中的应用服务器在使用许多并行线程时会大量使用TyLeadLocal特性,但每个线程都有其自身的事务或安全上下文。由于您不想在每个方法调用中传递这些对象,所以只需将其存储在线程自己的内存中,并在以后需要时访问它。

发布了26 篇原创文章 · 获赞 4 · 访问量 2539

猜你喜欢

转载自blog.csdn.net/u012296499/article/details/99210587
今日推荐