Java并发教程–原子性和竞争条件

原子性是多线程程序中的关键概念之一。 我们说一组动作是原子的,如果它们都以不可分割的方式作为一个单一的操作执行。 认为多线程程序中的一组操作将被串行执行是理所当然的,可能会导致错误的结果。 原因是由于线程干扰,这意味着如果两个线程对同一数据执行多个步骤,则它们可能会重叠。

以下交织示例显示了两个线程执行多个操作(循环中的打印)以及它们如何重叠:


public class Interleaving {
    
    public void show() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " - Number: " + i);
        }
    }
    
    public static void main(String[] args) {
        final Interleaving main = new Interleaving();
        
        Runnable runner = new Runnable() {
            @Override
            public void run() {
                main.show();
            }
        };
        
        new Thread(runner, "Thread 1").start();
        new Thread(runner, "Thread 2").start();
    }
}

执行时,将产生不可预测的结果。 举个例子:

Thread 2 - Number: 0
Thread 2 - Number: 1
Thread 2 - Number: 2
Thread 1 - Number: 0
Thread 1 - Number: 1
Thread 1 - Number: 2
Thread 1 - Number: 3
Thread 1 - Number: 4
Thread 2 - Number: 3
Thread 2 - Number: 4

在这种情况下,不会发生任何错误,因为它们只是打印数字。 但是,当您需要在不同步的情况下共享对象的状态(其数据)时,这会导致竞争条件的存在。

比赛条件

如果由于线程交织而有可能产生不正确的结果,则您的代码将处于竞争状态。 本节描述了两种竞争条件:

  1. 先检查后行动
  2. 读-修改-写

为了消除竞争条件并增强线程安全性,我们必须通过使用同步使这些操作成为原子操作。 以下各节中的示例将显示这些竞争条件的影响。

先行动后竞赛状态

当您有一个共享字段并希望依次执行以下步骤时,会出现此竞争条件:

  1. 从字段中获取值。
  2. 根据上一次检查的结果来做一些事情。

这里的问题是,当第一个线程在上次检查后要执行操作时,另一个线程可能已经插入并更改了字段的值。 现在,第一个线程将基于不再有效的值执行操作。 通过示例更容易看到这一点。

UnsafeCheckThenAct应该一次更改字段 。 在对changeNumber方法的调用之后,应导致执行else条件:

public class UnsafeCheckThenAct {
    private int number;
    
    public void changeNumber() {
        if (number == 0) {
            System.out.println(Thread.currentThread().getName() + " | Changed");
            number = -1;
        }
        else {
            System.out.println(Thread.currentThread().getName() + " | Not changed");
        }
    }
    
    public static void main(String[] args) {
        final UnsafeCheckThenAct checkAct = new UnsafeCheckThenAct();
        
        for (int i = 0; i < 50; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    checkAct.changeNumber();
                }
            }, "T" + i).start();
        }
    }
}

但是由于此代码未同步,因此(无法保证)可能会导致对该字段进行多次修改:

T13 | Changed
T17 | Changed
T35 | Not changed
T10 | Changed
T48 | Not changed
T14 | Changed
T60 | Not changed
T6 | Changed
T5 | Changed
T63 | Not changed
T18 | Not changed

这种竞争条件的另一个示例是延迟初始化

解决此问题的一种简单方法是使用同步。

SafeCheckThenAct是线程安全的,因为它已通过同步对共享字段的所有访问来消除竞争条件。

public class SafeCheckThenAct {
    private int number;
    
    public synchronized void changeNumber() {
        if (number == 0) {
            System.out.println(Thread.currentThread().getName() + " | Changed");
            number = -1;
        }
        else {
            System.out.println(Thread.currentThread().getName() + " | Not changed");
        }
    }
    
    public static void main(String[] args) {
        final SafeCheckThenAct checkAct = new SafeCheckThenAct();
        
        for (int i = 0; i < 50; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    checkAct.changeNumber();
                }
            }, "T" + i).start();
        }
    }
}

现在,执行此代码将始终产生相同的预期结果。 只有一个线程会更改该字段:

T0 | Changed
T54 | Not changed
T53 | Not changed
T62 | Not changed
T52 | Not changed
T51 | Not changed
...

在某些情况下,还有其他机制会比同步整个方法更好,但我不会在本文中讨论它们。

读-修改-写竞争条件

在这里,当执行以下一组操作时,会出现另一种竞争条件:

  1. 从字段中获取值。
  2. 修改值。
  3. 将新值存储到该字段。

在这种情况下,还有另一种危险的可能性,那就是丢失了对该字段的某些更新。 一种可能的结果是:

Field’s value is 1.
Thread 1 gets the value from the field (1).
Thread 1 modifies the value (5).
Thread 2 reads the value from the field (1).
Thread 2 modifies the value (7).
Thread 1 stores the value to the field (5).
Thread 2 stores the value to the field (7).

如您所见,值5的更新已丢失。

让我们看一个代码示例。 UnsafeReadModifyWrite共享一个数字字段,每次都会递增:

public class UnsafeReadModifyWrite {
    private int number;
    
    public void incrementNumber() {
        number++;
    }
    
    public int getNumber() {
        return this.number;
    }
    
    public static void main(String[] args) throws InterruptedException {
        final UnsafeReadModifyWrite rmw = new UnsafeReadModifyWrite();
        
        for (int i = 0; i < 1_000; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    rmw.incrementNumber();
                }
            }, "T" + i).start();
        }
        
        Thread.sleep(6000);
        System.out.println("Final number (should be 1_000): " + rmw.getNumber());
    }
}

您能发现引起比赛状况的复合动作吗?

我敢肯定你做到了,但是为了完整起见,我还是会解释一下。 问题出在增量( number ++ )中。 这似乎是一个动作,但实际上,它是三个动作的序列(get-increment-write)。

执行此代码时,我们可能会看到丢失了一些更新:

2014-08-08 09:59:18,859|UnsafeReadModifyWrite|Final number (should be 10_000): 9996

由于无法保证线程如何交织,因此取决于您的计算机,将很难重现此更新丢失。 如果无法重现上面的示例,请尝试UnsafeReadModifyWriteWithLatch ,它使用CountDownLatch来同步线程的开始,并重复测试一百次。 您可能应该在所有结果中看到一些无效值:

Final number (should be 1_000): 1000
Final number (should be 1_000): 1000
Final number (should be 1_000): 1000
Final number (should be 1_000): 997
Final number (should be 1_000): 999
Final number (should be 1_000): 1000
Final number (should be 1_000): 1000
Final number (should be 1_000): 1000
Final number (should be 1_000): 1000
Final number (should be 1_000): 1000
Final number (should be 1_000): 1000

这个例子可以通过使所有三个动作原子化来解决。

SafeReadModifyWriteSynchronized在对共享字段的所有访问中使用同步:

public class SafeReadModifyWriteSynchronized {
    private int number;
    
    public synchronized void incrementNumber() {
        number++;
    }
    
    public synchronized int getNumber() {
        return this.number;
    }
    
    public static void main(String[] args) throws InterruptedException {
        final SafeReadModifyWriteSynchronized rmw = new SafeReadModifyWriteSynchronized();
        
        for (int i = 0; i < 1_000; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    rmw.incrementNumber();
                }
            }, "T" + i).start();
        }
        
        Thread.sleep(4000);
        System.out.println("Final number (should be 1_000): " + rmw.getNumber());
    }
}

让我们看另一个删除此竞争条件的示例。 在这种特定情况下,由于字段号与其他变量无关,因此我们可以使用原子变量。

SafeReadModifyWriteAtomic使用原子变量来存储字段的值:

public class SafeReadModifyWriteAtomic {
    private final AtomicInteger number = new AtomicInteger();
    
    public void incrementNumber() {
        number.getAndIncrement();
    }
    
    public int getNumber() {
        return this.number.get();
    }
    
    public static void main(String[] args) throws InterruptedException {
        final SafeReadModifyWriteAtomic rmw = new SafeReadModifyWriteAtomic();
        
        for (int i = 0; i < 1_000; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    rmw.incrementNumber();
                }
            }, "T" + i).start();
        }
        
        Thread.sleep(4000);
        System.out.println("Final number (should be 1_000): " + rmw.getNumber());
    }
}

以下帖子将进一步说明机制,如锁定或原子变量。

结论

这篇文章解释了在非同步多线程程序中执行复合操作时隐含的一些风险。 为了强制执行原子性并防止线程交织,必须使用某种类型的同步。

翻译自: https://www.javacodegeeks.com/2014/08/java-concurrency-tutorial-atomicity-and-race-conditions.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值