Java中的常见并发陷阱

73 篇文章 0 订阅
27 篇文章 0 订阅

优锐课java学习分享笔记

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

2.使用线程安全对象

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

我们避免此类并发问题并构建可靠代码的主要方法是使用不可变对象。 这是因为它们的状态无法通过多线程的干扰进行修改。
但是,我们不能总是使用不可变的对象。 在这些情况下,我们必须找到使可变对象成为线程安全的方法。

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

1
2	Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
List<Integer> list = Collections.synchronizedList(new ArrayList<>());

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

2.3。 专家多线程集合
现在让我们考虑一个场景,我们需要更多的读取而不是写入。 通过使用同步集合,我们的应用程序可能会遭受重大的性能后果。 如果两个线程要同时读取集合,则一个线程必须等待另一个线程完成。

因此,Java提供并发集合,例如CopyOnWriteArrayList和ConcurrentHashMap,可以由多个线程同时访问:

1
2	CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
Map<String, String> map = new ConcurrentHashMap<>();

CopyOnWriteArrayList通过为诸如添加或删除之类的可变操作创建基础数组的单独副本来实现线程安全性。 尽管它的写操作性能比

Collections.synchronizedList差,但当我们需要的读操作比写操作多时,它为我们提供了更好的性能。

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

2.4. 使用非线程安全类型
我们经常使用诸如SimpleDateFormat之类的内置对象来解析和格式化日期对象。 SimpleDateFormat类在执行操作时会更改其内部状态。

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

那么,我们如何安全地使用SimpleDateFormat? 我们有几种选择:

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

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

3.比赛条件
当两个或多个线程访问共享数据并且它们试图同时更改它们时,就会发生竞争状态。 因此,竞争条件可能导致运行时错误或意外结果。

3.1。 比赛条件示例
让我们考虑以下代码:

	class Counter {
    private int counter = 0;
 
    public void increment() {
        counter++;
    }
 
    public int getValue() {
        return counter;
    }
}

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

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

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

现在,让我们假设两个线程,thread1和thread2,同时调用了增量方法。 他们交错的动作可能遵循以下顺序:

thread1读取计数器的当前值; 0
thread2读取计数器的当前值; 0
thread1增加检索到的值; 结果是1
thread2增加检索到的值; 结果是1
thread1将结果存储在计数器中; 现在的结果是1
thread2将结果存储在计数器中; 现在的结果是1

3.2. 基于同步的解决方案

我们可以通过同步关键代码来解决不一致问题:

class SynchronizedCounter {
    private int counter = 0;
 
    public synchronized void increment() {
        counter++;
    }
 
    public synchronized int getValue() {
        return counter;
    }
}

一次仅允许一个线程使用对象的同步方法,因此这会强制计数器的读写一致性。

3.3。 内置解决方案
我们可以将上述代码替换为内置的AtomicInteger对象。 此类提供除其他外的原子方法,用于增加整数,是比编写自己的代码更好的解决方案。 因此,我们可以直接调用其方法而无需同步:

AtomicInteger atomicInteger = new AtomicInteger(3);
atomicInteger.incrementAndGet();

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

4.收藏品的比赛条件
4.1. 问题

我们可以陷入的另一个陷阱是,认为同步收集比实际提供的保护更多。
让我们检查下面的代码:

	List<String> list = Collections.synchronizedList(new ArrayList<>());
if(!list.contains("foo")) {
    list.add("foo");
}

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

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

4.2。 列表解决方案

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

synchronized (list) {
    if (!list.contains("foo")) {
        list.add("foo");
    }
}

我们没有在功能中添加同步关键字,而是创建了一个与列表有关的关键部分,该部分一次只允许一个线程执行此操作。

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

4.3。 内置解决方案

对于ConcurrentHashMap
现在,出于相同的原因,考虑使用地图,即仅在不存在时才添加条目。
ConcurrentHashMap为此类问题提供了更好的解决方案。 我们可以使用其原子的ifIfAbsent方法:for ConcurrentHashMap

	Map<String, String> map = new ConcurrentHashMap<>();
map.putIfAbsent("foo", "bar");

或者,如果我们想计算该值,则使用其原子的computeIfAbsent方法:

1	map.computeIfAbsent("foo", key -> key + "bar");

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

5.内存一致性问题
当多个线程对应为相同数据的视图不一致时,将发生内存一致性问题。
根据Java内存模型,除主内存(RAM)外,每个CPU都有自己的缓存。 因此,任何线程都可以缓存变量,因为与主内存相比,它提供了更快的访问。

5.1。 问题
让我们回想一下我们的Counter示例:

	class Counter {
    private int counter = 0;
 
    public void increment() {
        counter++;
    }
 
    public int getValue() {
        return counter;
    }
}`在这里插入代码片`

让我们考虑以下情形:线程1递增计数器,然后线程2读取其值。 可能会发生以下事件序列:

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

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

5.2。 解决方案
为了避免内存一致性错误,我们需要建立事前发生的关系。 这种关系只是对一个特定语句的内存更新对另一特定语句可见的保证。
有几种策略可以创建事前发生的关系。 其中之一是同步,我们已经介绍过了。

同步可确保互斥和内存一致性。 但是,这会带来性能成本。
我们还可以通过使用volatile关键字来避免内存一致性问题。 简而言之,对volatile变量的任何更改始终对其他线程可见。
让我们使用volatile重写我们的Counter示例:

class SyncronizedCounter {
    private volatile int counter = 0;
 
    public synchronized void increment() {
        counter++;
    }
 
    public int getValue() {
        return counter;
    }
}

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

6.滥用同步
同步机制是实现线程安全的强大工具。 它依赖于内部和外部锁的使用。 我们还记得以下事实:每个对象都有一个不同的锁,一次只能有一个线程获得一个锁。
但是,如果我们不注意并为关键代码仔细选择正确的锁,则可能会发生意外行为。

6.1。 同步此参考
方法级同步是许多并发问题的解决方案。 但是,如果使用过多,它也可能导致其他并发问题。 这种同步方法依赖于此引用作为锁,也称为内在锁。
我们可以在以下示例中看到如何将这个引用作为锁将方法级同步转换为块级同步。
这些方法是等效的:

public synchronized void foo() {
    //...
}
	public void foo() {
    synchronized(this) {
      //...
    }
}

当线程调用这种方法时,其他线程无法同时访问该对象。 由于所有操作最终都以单线程运行,因此这可能会降低并发性能。 当读取的对象多于更新的对象时,此方法特别糟糕。
此外,我们代码的客户也可能会获得此锁。 在最坏的情况下,此操作可能导致死锁。

6.2。 僵局
死锁描述了两个或多个线程相互阻塞,每个线程都等待获取某个其他线程拥有的资源的情况。
让我们考虑示例:

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();
    }
}

在上面的代码中,我们可以清楚地看到第一个线程A获取lock1和线程B获取lock2。 然后,线程A尝试获取已由线程B获取的lock2,并且线程B尝试获取已由线程A获取的lock1。 因此,他们两个都不会继续前进,这意味着他们陷入了僵局。

我们可以通过更改其中一个线程的锁定顺序来轻松解决此问题。
我们应该注意,这只是一个例子,还有许多其他例子可能导致僵局。

7.结论
在本文中,我们探讨了在多线程应用程序中可能遇到的并发问题的几个示例。
首先,我们了解到我们应该选择不可变或线程安全的对象或操作。
然后,我们看到了一些竞争条件的示例,以及如何使用同步机制避免它们。 此外,我们了解了与内存相关的竞争条件以及如何避免它们。
尽管同步机制可以帮助我们避免许多并发问题,但是我们可以轻松地滥用它并创建其他问题。 因此,我们研究了这种机制使用不当时可能会遇到的几个问题。

喜欢这篇文章的可以点个赞,欢迎大家留言评论,记得关注我,每天持续更新技术干货、职场趣事、海量面试资料等等
如果你对java技术很感兴趣也可以+ qq群:907135806 交流学习,共同学习进步。
不要再用"没有时间“来掩饰自己思想上的懒惰!趁年轻,使劲拼,给未来的自己一个交代

文章写道这里,欢迎完善交流。最后奉上近期整理出来的一套完整的java架构思维导图,分享给大家对照知识点参考学习。有更多JVM、Mysql、Tomcat、Spring Boot、Spring Cloud、Zookeeper、Kafka、RabbitMQ、RockerMQ、Redis、ELK、Git等Java干货加vx:ddmsiqi 领取啦

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值