为什么等待(100)会导致同步方法在多线程中失败



我引用的是Baeldung.com。不幸的是,这篇文章没有解释为什么这不是一个线程安全的代码。第条

我的目标是了解如何使用synchronized关键字创建线程安全方法。

我的实际结果是:计数值为1。

package NotSoThreadSafe;
public class CounterNotSoThreadSafe {
private int count = 0;
public int getCount() { return count; }
// synchronized specifies that the method can only be accessed by 1 thread at a time.
public synchronized void increment() throws InterruptedException { int temp = count; wait(100); count = temp + 1; }
}

我的预期结果是:计数值应该是10,因为:

  1. 我在一个池中创建了10个线程
  2. 我执行了10次Counter.increment()
  3. 我确保我只在CountDownLatch达到0之后进行测试
  4. 因此,它应该是10。但是,如果使用Object.wait(100)释放synchronized的lock,则该方法将变得不安全
package NotSoThreadSafe;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CounterNotSoThreadSafeTest {
@Test
void incrementConcurrency() throws InterruptedException {
int numberOfThreads = 10;
ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
CounterNotSoThreadSafe counter = new CounterNotSoThreadSafe();
for (int i = 0; i < numberOfThreads; i++) {
service.execute(() -> {
try { counter.increment(); } catch (InterruptedException e) { e.printStackTrace(); }
latch.countDown();
});
}
latch.await();
assertEquals(numberOfThreads, counter.getCount());
}
}

此代码同时存在经典的并发问题:竞争条件(语义问题(和数据竞争(内存模型相关问题(。

  1. Object.wait()释放对象的监视器,另一个线程可以在当前线程等待时进入同步块/方法。显然,作者的意图是使该方法原子化,但Object.wait()打破了原子性。因此,如果我们从10个线程同时调用.increment(),并且每个线程调用方法100_000次,那么我们得到count<10*100_000几乎总是这样,但这不是我们想要的。这是一个竞争条件,一个逻辑/语义问题。我们可以改写代码。。。由于我们释放了监视器(这相当于从同步块中退出(,代码的工作方式如下(类似于两个分离的同步部分(:
public void increment() { 
int temp = incrementPart1(); 
incrementPart2(temp); 
}

private synchronized int incrementPart1() {
int temp = count; 
return temp; 
}

private synchronized void incrementPart2(int temp) {
count = temp + 1; 
}

因此,我们的increment不是原子地递增计数器。现在,让我们假设第一个线程调用incrementPart1,然后第二个线程调用增量Part1,第二个调用增量Part2,最后第一个调用增量Part 2。我们对increment()进行了两次调用,但结果是1,而不是2。

  1. 另一个问题是数据竞赛。Java语言规范(JLS(中描述了Java内存模型(JMM(。JMM在易失性内存写入/读取、对象监视器的操作等操作之间引入了先发生(HB(顺序。https://docs.oracle.com/javase/specs/jls/se11/html/jls-17.html#jls-17.4.5 HB保证一个线程写入的值将被另一个线程看到。如何获得这些担保的规则也被称为安全出版规则。最常见/最有用的是:
  • 通过volatile字段发布值/引用(https://docs.oracle.com/javase/specs/jls/se11/html/jls-17.html#jls-17.4.5(,或者作为该规则的结果,通过AtomicX类

  • 通过正确锁定的字段发布值/引用(https://docs.oracle.com/javase/specs/jls/se11/html/jls-17.html#jls-17.4.5(

  • 使用静态初始值设定项来初始化存储(http://docs.oracle.com/javase/specs/jls/se11/html/jls-12.html#jls-12.4(

  • 将值/引用初始化为最终字段,从而导致冻结操作(https://docs.oracle.com/javase/specs/jls/se11/html/jls-17.html#jls-17.5(.

因此,为了使计数器正确可见(正如JMM所定义的(,我们必须使其具有挥发性

private volatile int count = 0;

或者在同一对象监视器的同步上进行读取

public synchronized int getCount() { return count; }

我想说的是,在实践中,在英特尔处理器上,由于实现了TSO(Total Store Ordering,总存储排序(,您只需简单的普通读取,就可以读取正确的值,而无需付出任何额外的努力。但在更宽松的体系结构上,比如ARM,你会遇到问题。正式遵循JMM,以确保您的代码真正是线程安全的,并且不包含任何数据竞赛。

为什么int temp = count; wait(100); count = temp + 1;不是线程安全的?一种可能的流程:

  • 第一个线程读取count(0(,将其保存在temp中以备以后使用,然后等待,允许第二个线程运行(释放锁(
  • 第二个线程读取保存在temp中的count(也是0(并等待,最终允许第一个线程继续
  • 第一线程从CCD_ 15递增值并保存在CCD_ 16(1(中
  • 但第二个线程仍然在temp中保持count(0(的旧值——最终它将运行并将temp+1(1(存储到count中,而不递增其新值

非常简化,只考虑2个线程

简而言之:wait()释放锁,允许其他(同步的(方法运行。

最新更新