admin管理员组

文章数量:1031971

Java 中的常见并发陷阱

1. 简介

在本教程中,我们将看到 Java 中一些最常见的并发问题。我们还将学习如何避免它们及其主要原因。

2. 使用线程安全对象

2.1. 共享对象

线程主要通过共享对相同对象的访问来进行通信。因此,在对象更改时从对象读取可能会产生意外的结果。此外,同时更改对象可能会使其处于损坏或不一致的状态。

我们可以避免此类并发问题并构建可靠代码的主要方法是使用不可变对象。这是因为它们的状态不能通过多个线程的干扰来修改。

但是,我们不能总是使用不可变的对象。在这些情况下,我们必须找到使我们的可变对象线程安全的方法。

2.2. 使集合线程安全

与任何其他对象一样,集合在内部维护状态。这可以通过多个线程同时更改集合来更改。因此,我们可以在多线程环境中安全地使用集合的一种方法是同步它们:

代码语言:javascript代码运行次数:0运行复制
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
List<Integer> list = Collections.synchronizedList(new ArrayList<>());Copy

通常,同步有助于我们实现相互排斥。更具体地说,这些集合一次只能由一个线程访问。因此,我们可以避免使集合处于不一致的状态。

2.3. 多线程并发集合

现在,让我们考虑一个场景,即我们需要的读取次数多于写入次数。通过使用同步集合,我们的应用程序可能会遭受重大的性能后果。如果两个线程想要同时读取集合,则一个线程必须等到另一个线程完成。

出于这个原因,Java提供了并发集合,如CopyOnWriteArrayListConcurrentHashMap,可以由多个线程同时访问:

代码语言:javascript代码运行次数:0运行复制
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
Map<String, String> map = new ConcurrentHashMap<>();Copy

CopyOnWriteArrayList通过为可变操作(如添加或删除)创建基础数组的单独副本来实现线程安全。尽管它的写入操作性能比Collections.syncdList 差,但当我们需要的读取次数明显多于写入次数时,它为我们提供了更好的性能。

ConcurrentHashMap基本上是线程安全的,并且比围绕非线程安全MapCollections.syncdMap包装器性能更高。它实际上是线程安全映射的线程安全映射,允许在其子映射中同时发生不同的活动。

2.4. 使用非线程安全类型

我们经常使用像SimpleDateFormat这样的内置对象来解析和格式化日期对象。在执行其操作时改变其内部状态。

我们需要非常小心它们,因为它们不是线程安全的。由于争用条件等原因,它们的状态在多线程应用程序中可能会变得不一致。

那么,我们如何才能安全地使用SimpleDateFormat呢?我们有几个选择:

  • 每次使用SimpleDateFormat时创建一个新实例
  • 限制使用ThreadLocal<SimpleDateFormat>_object 创建的对象数。它保证每个线程都有自己的_SimpleDateFormat 实例
  • 使用同步关键字或锁同步多个线程的并发访问

SimpleDateFormat只是其中一个例子。我们可以将这些技术用于任何非线程安全类型。

3. 争用条件

当两个或多个线程访问共享数据并尝试同时更改共享数据时,会发生争用条件。因此,争用条件可能会导致运行时错误或意外结果。

3.1. 争用条件示例

让我们考虑以下代码:

代码语言:javascript代码运行次数:0运行复制
class Counter {
    private int counter = 0;

    public void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}Copy

Counter类的设计使得每次调用增量方法都会向计数器添加 1。但是,如果从多个线程引用Counter对象,则线程之间的干扰可能会阻止这种情况按预期发生。

我们可以将反++语句分解为 3 个步骤:

  • 检索计数器的当前值
  • 将检索到的值递增 1
  • 将递增的值存储回计数器

现在,假设两个线程,thread1thread2,同时调用增量方法。它们的交错操作可能遵循以下顺序:

  • 线程 1读取计数器的当前值;0
  • 线程 2读取计数器的当前值;0
  • thread1递增检索到的值;结果是 1
  • thread2递增检索到的值;结果是 1
  • thread1将结果存储在计数器中;结果现在为 1
  • thread2将结果存储在计数器中;结果现在为 1

我们预计计数器的值为 2,但它是 1。

3.2. 基于同步的解决方案

我们可以通过同步关键代码来修复不一致:

代码语言:javascript代码运行次数:0运行复制
class SynchronizedCounter {
    private int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public synchronized int getValue() {
        return counter;
    }
}Copy

在任何时候只允许一个线程使用对象的同步方法,因此这会强制计数器的读取和写入一致性。

3.3. 内置解决方案

我们可以用内置的AtomicInteger对象替换上面的代码。此类提供了用于递增整数的原子方法,并且是比编写我们自己的代码更好的解决方案。因此,我们可以直接调用它的方法,而无需同步:

代码语言:javascript代码运行次数:0运行复制
AtomicInteger atomicInteger = new AtomicInteger(3);
atomicInteger.incrementAndGet();Copy

在这种情况下,SDK为我们解决了问题。否则,我们也可以编写自己的代码,将关键部分封装在自定义线程安全类中。这种方法有助于我们最大限度地降低复杂性并最大限度地提高代码的可重用性。

4. 集合的争用条件

4.1. 问题

我们可能陷入的另一个陷阱是认为同步集合为我们提供的保护比实际更多。

让我们检查下面的代码:

代码语言:javascript代码运行次数:0运行复制
List<String> list = Collections.synchronizedList(new ArrayList<>());
if(!list.contains("foo")) {
    list.add("foo");
}Copy

列表的每个操作都是同步的,但多个方法调用的任何组合都不会同步。更具体地说,在这两个操作之间,另一个线程可以修改我们的集合,从而导致不希望的结果。

例如,两个线程可以同时输入if块,然后更新列表,每个线程将foo值添加到列表中。

4.2. 列表的解决方案

我们可以使用同步保护代码不被多个线程一次访问:

代码语言:javascript代码运行次数:0运行复制
synchronized (list) {
    if (!list.contains("foo")) {
        list.add("foo");
    }
}Copy

我们没有将sync关键字添加到函数中,而是创建了一个关于列表的关键部分该部分一次只允许一个线程执行此操作。

我们应该注意,我们可以在列表对象上的其他操作上使用syncd(list),以保证一次只有一个线程可以对该对象执行任何操作。

4.3.并发哈希图的内置解决方案

现在,让我们考虑出于同样的原因使用Map,即仅在不存在时才添加条目。

ConcurrentHashMap为此类问题提供了更好的解决方案。我们可以使用它的原子putIfAbsent方法:

代码语言:javascript代码运行次数:0运行复制
Map<String, String> map = new ConcurrentHashMap<>();
map.putIfAbsent("foo", "bar");Copy

或者,如果我们想计算值,它的原子计算IfAbsent方法

代码语言:javascript代码运行次数:0运行复制
mapputeIfAbsent("foo", key -> key + "bar");Copy

我们应该注意,这些方法是Map接口的一部分,它们提供了一种方便的方法来避免围绕插入编写条件逻辑。在尝试进行原子多线程调用时,它们确实可以帮助我们。

5. 内存一致性问题

当多个线程对应为相同数据的内容具有不一致的视图时,会出现内存一致性问题。

除了主内存之外,大多数现代计算机体系结构都使用缓存层次结构(L1、L2 和 L3 缓存)来提高整体性能。因此,任何线程都可以缓存变量,因为与主内存相比,它提供了更快的访问。

5.1. 问题

让我们回顾一下我们的反例

代码语言:javascript代码运行次数:0运行复制
class Counter {
    private int counter = 0;

    public void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}Copy

让我们考虑以下场景:thread1递增计数器,然后thread2读取其值。可能会发生以下事件序列:

  • thread1从自己的缓存中读取计数器值;计数器为 0
  • thread1递增计数器并将其写回自己的缓存;计数器为 1
  • thread2从自己的缓存中读取计数器值;计数器为 0

当然,预期的事件序列也可能发生,thread2将读取正确的值 (1),但不能保证一个线程所做的更改每次都对其他线程可见。

5.2. 解决方案

为了避免内存一致性错误,我们需要建立先发生前关系。这种关系只是保证一个特定语句的内存更新对另一个特定语句可见。

有几种策略可以创建先发生前关系。其中之一是同步,我们已经看过了。

同步可确保互斥和内存一致性。但是,这会带来性能成本。

我们还可以通过使用volatile关键字来避免内存一致性问题。简而言之,对可变变量的每次更改始终对其他线程可见。

让我们使用易失性重写我们的计数器示例:

代码语言:javascript代码运行次数:0运行复制
class SyncronizedCounter {
    private volatile int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}Copy

我们应该注意,我们仍然需要同步增量操作,因为易失性并不能确保我们相互排斥。使用简单的原子变量访问比通过同步代码访问这些变量更有效。

5.3. 非原子长整型双精度

因此,如果我们在没有正确同步的情况下读取变量,我们可能会看到一个过时的值。F或值和双精度值,令人惊讶的是,除了过时的值之外,甚至可以看到完全随机的值。

根据JLS-17,JVM 可以将 64 位操作视为两个独立的 32 位操作。因此,在读取值或双精度值时,可以读取更新的 32 位以及过时的 32 位。因此,我们可能会在并发上下文中观察到随机的长整型双精度值。

另一方面,易失性长整型双精度值的写入和读取始终是原子的。

6. 滥用同步

同步机制是实现线程安全的强大工具。它依赖于使用内在和外在锁。让我们还记住这样一个事实,即每个对象都有不同的锁,并且一次只有一个线程可以获取锁。

但是,如果我们不注意并仔细为关键代码选择正确的锁,则可能会发生意外行为。

6.1.在this引用上同步

方法级同步是许多并发问题的解决方案。但是,如果过度使用,也可能导致其他并发问题。此同步方法依赖于引用作为锁,也称为内部锁。

我们可以在以下示例中看到如何将方法级同步转换为块级同步,并将this引用作为锁。

这些方法是等效的:

代码语言:javascript代码运行次数:0运行复制
public synchronized void foo() {
    //...
}Copy
代码语言:javascript代码运行次数:0运行复制
public void foo() {
    synchronized(this) {
      //...
    }
}Copy

当线程调用此类方法时,其他线程无法同时访问该对象。这可能会降低并发性能,因为所有内容最终都会以单线程方式运行。当对象的读取频率高于更新频率时,此方法尤其糟糕。

此外,我们代码的客户端也可能获取锁。在最坏的情况下,此操作可能会导致死锁。

6.2. 死锁

死锁描述了两个或多个线程相互阻塞的情况,每个线程都在等待获取由其他线程持有的资源。

让我们考虑一下这个例子:

代码语言:javascript代码运行次数:0运行复制
public class DeadlockExample {

    public static Object lock1 = new Object();
    public static Object lock2 = new Object();

    public static void main(String args[]) {
        Thread threadA = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("ThreadA: Holding lock 1...");
                sleep();
                System.out.println("ThreadA: Waiting for lock 2...");

                synchronized (lock2) {
                    System.out.println("ThreadA: Holding lock 1 & 2...");
                }
            }
        });
        Thread threadB = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("ThreadB: Holding lock 2...");
                sleep();
                System.out.println("ThreadB: Waiting for lock 1...");

                synchronized (lock1) {
                    System.out.println("ThreadB: Holding lock 1 & 2...");
                }
            }
        });
        threadA.start();
        threadB.start();
    }
}Copy

在上面的代码中,我们可以清楚地看到第一个线程 A获取lock1线程 B获取lock2。然后,线程_A 尝试获取已被线程 B 获取的锁 2,线程_B尝试获取已被线程 A 获取的锁 1。因此,他们都不会继续,这意味着他们陷入了僵局。

我们可以通过更改其中一个线程中的锁顺序来轻松解决此问题。

我们应该注意到,这只是一个例子,还有许多其他例子可能导致僵局。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。 原始发表:2023-02-22,如有侵权请联系 cloudcommunity@tencent 删除并发教程同步线程java

Java 中的常见并发陷阱

1. 简介

在本教程中,我们将看到 Java 中一些最常见的并发问题。我们还将学习如何避免它们及其主要原因。

2. 使用线程安全对象

2.1. 共享对象

线程主要通过共享对相同对象的访问来进行通信。因此,在对象更改时从对象读取可能会产生意外的结果。此外,同时更改对象可能会使其处于损坏或不一致的状态。

我们可以避免此类并发问题并构建可靠代码的主要方法是使用不可变对象。这是因为它们的状态不能通过多个线程的干扰来修改。

但是,我们不能总是使用不可变的对象。在这些情况下,我们必须找到使我们的可变对象线程安全的方法。

2.2. 使集合线程安全

与任何其他对象一样,集合在内部维护状态。这可以通过多个线程同时更改集合来更改。因此,我们可以在多线程环境中安全地使用集合的一种方法是同步它们:

代码语言:javascript代码运行次数:0运行复制
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
List<Integer> list = Collections.synchronizedList(new ArrayList<>());Copy

通常,同步有助于我们实现相互排斥。更具体地说,这些集合一次只能由一个线程访问。因此,我们可以避免使集合处于不一致的状态。

2.3. 多线程并发集合

现在,让我们考虑一个场景,即我们需要的读取次数多于写入次数。通过使用同步集合,我们的应用程序可能会遭受重大的性能后果。如果两个线程想要同时读取集合,则一个线程必须等到另一个线程完成。

出于这个原因,Java提供了并发集合,如CopyOnWriteArrayListConcurrentHashMap,可以由多个线程同时访问:

代码语言:javascript代码运行次数:0运行复制
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
Map<String, String> map = new ConcurrentHashMap<>();Copy

CopyOnWriteArrayList通过为可变操作(如添加或删除)创建基础数组的单独副本来实现线程安全。尽管它的写入操作性能比Collections.syncdList 差,但当我们需要的读取次数明显多于写入次数时,它为我们提供了更好的性能。

ConcurrentHashMap基本上是线程安全的,并且比围绕非线程安全MapCollections.syncdMap包装器性能更高。它实际上是线程安全映射的线程安全映射,允许在其子映射中同时发生不同的活动。

2.4. 使用非线程安全类型

我们经常使用像SimpleDateFormat这样的内置对象来解析和格式化日期对象。在执行其操作时改变其内部状态。

我们需要非常小心它们,因为它们不是线程安全的。由于争用条件等原因,它们的状态在多线程应用程序中可能会变得不一致。

那么,我们如何才能安全地使用SimpleDateFormat呢?我们有几个选择:

  • 每次使用SimpleDateFormat时创建一个新实例
  • 限制使用ThreadLocal<SimpleDateFormat>_object 创建的对象数。它保证每个线程都有自己的_SimpleDateFormat 实例
  • 使用同步关键字或锁同步多个线程的并发访问

SimpleDateFormat只是其中一个例子。我们可以将这些技术用于任何非线程安全类型。

3. 争用条件

当两个或多个线程访问共享数据并尝试同时更改共享数据时,会发生争用条件。因此,争用条件可能会导致运行时错误或意外结果。

3.1. 争用条件示例

让我们考虑以下代码:

代码语言:javascript代码运行次数:0运行复制
class Counter {
    private int counter = 0;

    public void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}Copy

Counter类的设计使得每次调用增量方法都会向计数器添加 1。但是,如果从多个线程引用Counter对象,则线程之间的干扰可能会阻止这种情况按预期发生。

我们可以将反++语句分解为 3 个步骤:

  • 检索计数器的当前值
  • 将检索到的值递增 1
  • 将递增的值存储回计数器

现在,假设两个线程,thread1thread2,同时调用增量方法。它们的交错操作可能遵循以下顺序:

  • 线程 1读取计数器的当前值;0
  • 线程 2读取计数器的当前值;0
  • thread1递增检索到的值;结果是 1
  • thread2递增检索到的值;结果是 1
  • thread1将结果存储在计数器中;结果现在为 1
  • thread2将结果存储在计数器中;结果现在为 1

我们预计计数器的值为 2,但它是 1。

3.2. 基于同步的解决方案

我们可以通过同步关键代码来修复不一致:

代码语言:javascript代码运行次数:0运行复制
class SynchronizedCounter {
    private int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public synchronized int getValue() {
        return counter;
    }
}Copy

在任何时候只允许一个线程使用对象的同步方法,因此这会强制计数器的读取和写入一致性。

3.3. 内置解决方案

我们可以用内置的AtomicInteger对象替换上面的代码。此类提供了用于递增整数的原子方法,并且是比编写我们自己的代码更好的解决方案。因此,我们可以直接调用它的方法,而无需同步:

代码语言:javascript代码运行次数:0运行复制
AtomicInteger atomicInteger = new AtomicInteger(3);
atomicInteger.incrementAndGet();Copy

在这种情况下,SDK为我们解决了问题。否则,我们也可以编写自己的代码,将关键部分封装在自定义线程安全类中。这种方法有助于我们最大限度地降低复杂性并最大限度地提高代码的可重用性。

4. 集合的争用条件

4.1. 问题

我们可能陷入的另一个陷阱是认为同步集合为我们提供的保护比实际更多。

让我们检查下面的代码:

代码语言:javascript代码运行次数:0运行复制
List<String> list = Collections.synchronizedList(new ArrayList<>());
if(!list.contains("foo")) {
    list.add("foo");
}Copy

列表的每个操作都是同步的,但多个方法调用的任何组合都不会同步。更具体地说,在这两个操作之间,另一个线程可以修改我们的集合,从而导致不希望的结果。

例如,两个线程可以同时输入if块,然后更新列表,每个线程将foo值添加到列表中。

4.2. 列表的解决方案

我们可以使用同步保护代码不被多个线程一次访问:

代码语言:javascript代码运行次数:0运行复制
synchronized (list) {
    if (!list.contains("foo")) {
        list.add("foo");
    }
}Copy

我们没有将sync关键字添加到函数中,而是创建了一个关于列表的关键部分该部分一次只允许一个线程执行此操作。

我们应该注意,我们可以在列表对象上的其他操作上使用syncd(list),以保证一次只有一个线程可以对该对象执行任何操作。

4.3.并发哈希图的内置解决方案

现在,让我们考虑出于同样的原因使用Map,即仅在不存在时才添加条目。

ConcurrentHashMap为此类问题提供了更好的解决方案。我们可以使用它的原子putIfAbsent方法:

代码语言:javascript代码运行次数:0运行复制
Map<String, String> map = new ConcurrentHashMap<>();
map.putIfAbsent("foo", "bar");Copy

或者,如果我们想计算值,它的原子计算IfAbsent方法

代码语言:javascript代码运行次数:0运行复制
mapputeIfAbsent("foo", key -> key + "bar");Copy

我们应该注意,这些方法是Map接口的一部分,它们提供了一种方便的方法来避免围绕插入编写条件逻辑。在尝试进行原子多线程调用时,它们确实可以帮助我们。

5. 内存一致性问题

当多个线程对应为相同数据的内容具有不一致的视图时,会出现内存一致性问题。

除了主内存之外,大多数现代计算机体系结构都使用缓存层次结构(L1、L2 和 L3 缓存)来提高整体性能。因此,任何线程都可以缓存变量,因为与主内存相比,它提供了更快的访问。

5.1. 问题

让我们回顾一下我们的反例

代码语言:javascript代码运行次数:0运行复制
class Counter {
    private int counter = 0;

    public void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}Copy

让我们考虑以下场景:thread1递增计数器,然后thread2读取其值。可能会发生以下事件序列:

  • thread1从自己的缓存中读取计数器值;计数器为 0
  • thread1递增计数器并将其写回自己的缓存;计数器为 1
  • thread2从自己的缓存中读取计数器值;计数器为 0

当然,预期的事件序列也可能发生,thread2将读取正确的值 (1),但不能保证一个线程所做的更改每次都对其他线程可见。

5.2. 解决方案

为了避免内存一致性错误,我们需要建立先发生前关系。这种关系只是保证一个特定语句的内存更新对另一个特定语句可见。

有几种策略可以创建先发生前关系。其中之一是同步,我们已经看过了。

同步可确保互斥和内存一致性。但是,这会带来性能成本。

我们还可以通过使用volatile关键字来避免内存一致性问题。简而言之,对可变变量的每次更改始终对其他线程可见。

让我们使用易失性重写我们的计数器示例:

代码语言:javascript代码运行次数:0运行复制
class SyncronizedCounter {
    private volatile int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}Copy

我们应该注意,我们仍然需要同步增量操作,因为易失性并不能确保我们相互排斥。使用简单的原子变量访问比通过同步代码访问这些变量更有效。

5.3. 非原子长整型双精度

因此,如果我们在没有正确同步的情况下读取变量,我们可能会看到一个过时的值。F或值和双精度值,令人惊讶的是,除了过时的值之外,甚至可以看到完全随机的值。

根据JLS-17,JVM 可以将 64 位操作视为两个独立的 32 位操作。因此,在读取值或双精度值时,可以读取更新的 32 位以及过时的 32 位。因此,我们可能会在并发上下文中观察到随机的长整型双精度值。

另一方面,易失性长整型双精度值的写入和读取始终是原子的。

6. 滥用同步

同步机制是实现线程安全的强大工具。它依赖于使用内在和外在锁。让我们还记住这样一个事实,即每个对象都有不同的锁,并且一次只有一个线程可以获取锁。

但是,如果我们不注意并仔细为关键代码选择正确的锁,则可能会发生意外行为。

6.1.在this引用上同步

方法级同步是许多并发问题的解决方案。但是,如果过度使用,也可能导致其他并发问题。此同步方法依赖于引用作为锁,也称为内部锁。

我们可以在以下示例中看到如何将方法级同步转换为块级同步,并将this引用作为锁。

这些方法是等效的:

代码语言:javascript代码运行次数:0运行复制
public synchronized void foo() {
    //...
}Copy
代码语言:javascript代码运行次数:0运行复制
public void foo() {
    synchronized(this) {
      //...
    }
}Copy

当线程调用此类方法时,其他线程无法同时访问该对象。这可能会降低并发性能,因为所有内容最终都会以单线程方式运行。当对象的读取频率高于更新频率时,此方法尤其糟糕。

此外,我们代码的客户端也可能获取锁。在最坏的情况下,此操作可能会导致死锁。

6.2. 死锁

死锁描述了两个或多个线程相互阻塞的情况,每个线程都在等待获取由其他线程持有的资源。

让我们考虑一下这个例子:

代码语言:javascript代码运行次数:0运行复制
public class DeadlockExample {

    public static Object lock1 = new Object();
    public static Object lock2 = new Object();

    public static void main(String args[]) {
        Thread threadA = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("ThreadA: Holding lock 1...");
                sleep();
                System.out.println("ThreadA: Waiting for lock 2...");

                synchronized (lock2) {
                    System.out.println("ThreadA: Holding lock 1 & 2...");
                }
            }
        });
        Thread threadB = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("ThreadB: Holding lock 2...");
                sleep();
                System.out.println("ThreadB: Waiting for lock 1...");

                synchronized (lock1) {
                    System.out.println("ThreadB: Holding lock 1 & 2...");
                }
            }
        });
        threadA.start();
        threadB.start();
    }
}Copy

在上面的代码中,我们可以清楚地看到第一个线程 A获取lock1线程 B获取lock2。然后,线程_A 尝试获取已被线程 B 获取的锁 2,线程_B尝试获取已被线程 A 获取的锁 1。因此,他们都不会继续,这意味着他们陷入了僵局。

我们可以通过更改其中一个线程中的锁顺序来轻松解决此问题。

我们应该注意到,这只是一个例子,还有许多其他例子可能导致僵局。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。 原始发表:2023-02-22,如有侵权请联系 cloudcommunity@tencent 删除并发教程同步线程java

本文标签: Java 中的常见并发陷阱