在 Java 中使用互斥对象Mutex

249 阅读5分钟

1. 概述

大家好,我是老谭说架构,今天我们将了解在 Java 中 实现互斥锁的不同方法。在 Java 中,互斥对象(Mutex)通常用 java.util.concurrent.locks.ReentrantLock 类来实现。这是一个可重入的互斥锁,允许同一线程多次获取该锁。

  1. 创建 ReentrantLock 对象:  在 criticalSection() 方法中,创建一个 ReentrantLock 对象,用于控制对临界区的访问。
  2. 获取锁:  使用 lock.lock() 获取锁。如果其他线程已经获得了锁,当前线程将被阻塞,直到锁被释放。
  3. 临界区代码:  在 try 块中包含需要保护的共享资源的代码,这称为临界区。
  4. 释放锁:  无论 try 块中的代码是否正常执行,都应该使用 lock.unlock() 释放锁,以确保其他线程能够获取锁。
  5. finally 块:  使用 finally 块确保锁在任何情况下都会被释放,即使 try 块中发生了异常。

ReentrantLock 的优点:

  • 可重入:  同一线程可以多次获取锁,而不会出现死锁。
  • 公平性:  可以选择公平锁,保证线程获取锁的顺序。
  • 条件变量:  提供 newCondition() 方法,允许线程在等待条件满足时进入休眠状态。

其他互斥机制:

  • synchronized 块:  Java 提供了一种更简单的同步机制,使用 synchronized 关键字可以将代码块标记为同步。

2. 互斥锁

在多线程应用程序中,两个或多个线程可能需要同时访问共享资源,从而导致意外行为。此类共享资源的示例包括数据结构、输入输出设备、文件和网络连接。

我们将这种情况称为竞争条件。程序中访问共享资源的部分称为临界区因此,为了避免竞争条件,我们需要同步对临界区的访问。

互斥锁(或互斥)是最简单的同步器类型*-*它确保一次只有一个线程可以执行计算机程序的关键部分

要访问临界区,一个线程需要获取互斥锁,然后访问临界区,最后释放互斥锁。与此同时,所有其他线程都会阻塞,直到互斥锁释放。 一旦一个线程退出临界区,另一个线程就可以进入临界区。

3.为什么要使用Mutex?

首先我们来举一个SequenceGeneraror类的例子,它通过每次将currentValue加一来生成下一个序列:

public class SequenceGenerator {
    
    private int currentValue = 0;

    public int getNextSequence() {
        currentValue = currentValue + 1;
        return currentValue;
    }

}

现在,让我们创建一个测试用例来查看当多个线程同时尝试访问该方法时该方法的行为:

@Test
public void givenUnsafeSequenceGenerator_whenRaceCondition_thenUnexpectedBehavior() throws Exception {
    int count = 1000;
    Set<Integer> uniqueSequences = getUniqueSequences(new SequenceGenerator(), count);
    Assert.assertEquals(count, uniqueSequences.size());
}

private Set<Integer> getUniqueSequences(SequenceGenerator generator, int count) throws Exception {
    ExecutorService executor = Executors.newFixedThreadPool(3);
    Set<Integer> uniqueSequences = new LinkedHashSet<>();
    List<Future<Integer>> futures = new ArrayList<>();

    for (int i = 0; i < count; i++) {
        futures.add(executor.submit(generator::getNextSequence));
    }

    for (Future<Integer> future : futures) {
        uniqueSequences.add(future.get());
    }

    executor.awaitTermination(1, TimeUnit.SECONDS);
    executor.shutdown();

    return uniqueSequences;
}

一旦我们执行这个测试用例,我们就会发现它大多数时候都会失败,原因类似于:

java.lang.AssertionError: expected:<1000> but was:<989>
  at org.junit.Assert.fail(Assert.java:88)
  at org.junit.Assert.failNotEquals(Assert.java:834)
  at org.junit.Assert.assertEquals(Assert.java:645)

uniqueSequences大小应该等于我们在测试用例中执行getNextSequence方法的次数。然而,由于竞争条件,情况并非如此。显然,我们不希望出现这种行为。

因此,为了避免这种竞争条件,我们需要确保一次只有一个线程可以执行getNextSequence方法。在这种情况下,我们可以使用互斥锁来同步线程。

在 Java 中,我们可以通过多种方式实现互斥锁。接下来,我们将了解为SequenceGenerator类实现互斥锁的不同方法。

4.使用synchronized关键字

首先,我们来讨论一下[synchronized关键字],这是在Java中实现互斥锁的最简单方法。

Java 中的每个对象都关联有一个固有锁。synchronized方法  synchronized 块使用此固有锁限制每次只有一个线程可以访问临界区。

因此,当线程调用同步方法或进入同步块时,它会自动获取锁。当方法或块完成或从中抛出异常时,锁就会释放。

让我们将 getNextSequence改为具有互斥锁,只需添加synchronized关键字即可:

public class SequenceGeneratorUsingSynchronizedMethod extends SequenceGenerator {
    
    @Override
    public synchronized int getNextSequence() {
        return super.getNextSequence();
    }

}

同步块与同步方法类似,但对临界区和可用于锁定的对象有更多的控制。

那么,现在让我们看看如何使用同步块来同步自定义互斥对象

public class SequenceGeneratorUsingSynchronizedBlock extends SequenceGenerator {
    
    private Object mutex = new Object();

    @Override
    public int getNextSequence() {
        synchronized (mutex) {
            return super.getNextSequence();
        }
    }

}

5.使用ReentrantLock

[ReentrantLock] 类是在 Java 1.5 中引入的。它比synchronized关键字方法提供了更多的灵活性和控制力。

让我们看看如何使用ReentrantLock实现互斥:

public class SequenceGeneratorUsingReentrantLock extends SequenceGenerator {
    
    private ReentrantLock mutex = new ReentrantLock();

    @Override
    public int getNextSequence() {
        try {
            mutex.lock();
            return super.getNextSequence();
        } finally {
            mutex.unlock();
        }
    }
}

6. 使用信号量

ReentrantLock类似,Semaphore 类也是在 Java 1.5 中引入的。

互斥锁只能有一个线程访问临界区,而信号量允许固定数量的线程访问临界区。因此,我们也可以通过将信号量 **中允许的线程数设置为 1 来****实现互斥锁。

现在让我们使用Semaphore创建另一个线程安全版本的SequenceGenerator:**

public class SequenceGeneratorUsingSemaphore extends SequenceGenerator {
    
    private Semaphore mutex = new Semaphore(1);

    @Override
    public int getNextSequence() {
        try {
            mutex.acquire();
            return super.getNextSequence();
        } catch (InterruptedException e) {
            // exception handling code
        } finally {
            mutex.release();
        }
    }
}

7.使用Guava的Monitor

到目前为止,我们已经了解了使用 Java 提供的功能实现互斥的选项。

但是, Google 的 Guava 库中的Monitor类是 ReentrantLock类的更好替代品。根据其文档,使用Monitor 的代码比使用 ReentrantLock 的代码更易读且更不容易出错。

首先,我们将为Guava添加 Maven 依赖项:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.0.1-jre</version>
</dependency>复制

现在,我们将使用Monitor类编写 SequenceGenerator的另一个子类:**

public class SequenceGeneratorUsingMonitor extends SequenceGenerator {
    
    private Monitor mutex = new Monitor();

    @Override
    public int getNextSequence() {
        mutex.enter();
        try {
            return super.getNextSequence();
        } finally {
            mutex.leave();
        }
    }

}

8. 结论

在本文内容中,我们研究了互斥锁的概念。此外,我们还了解了在 Java 中实现互斥锁的不同方法。

选择互斥机制:

  • 如果需要更精细的控制和功能,例如公平性或条件变量,则使用 ReentrantLock。
  • 如果需要简单的同步机制,则使用 synchronized 块。

注意事项:

  • 确保在任何情况下都释放锁,以避免死锁。
  • 在使用互斥机制时,要小心处理异常,确保锁被正确释放。
  • 避免在临界区中进行长时间操作,以免阻塞其他线程。