字节码指令和处理器操作之间的关系

10

Java规范保证基本变量赋值始终是原子的(除了longdouble类型)。

相反,Fetch-and-Add操作对应于著名的i++自增操作,将是非原子的,因为它导致读取-修改-写入操作。

假设这段代码:

public void assign(int b) {
    int a = b;
}

生成的字节码是:
public void assign(int);
    Code:
       0: iload_1       
       1: istore_2      
       2: return 

因此,我们可以看到这个任务由两个步骤(加载和存储)组成。
假设有以下代码:
public void assign(int b) {
        int i = b++;
}

字节码:
public void assign(int);
    Code:
       0: iload_1       
       1: iinc          1, 1    //extra step here regarding the previous sample
       4: istore_2      
       5: return 

了解到X86处理器(至少是现代处理器)可以原子性地执行增量操作,如下所述:

在计算机科学中,fetch-and-add CPU指令是一种特殊指令,可以原子性地修改内存位置的内容。它用于在多处理器系统中实现互斥和并发算法,这是信号量的一般化。

因此,第一个问题:尽管字节码需要两个步骤(加载和存储),但Java是否依赖于分配操作始终是以原子方式执行的事实,并且因此可以确保其规范中的原子永久性(对于原始分配)?

第二个问题:确认使用非常现代的X86处理器并且没有在不同架构之间共享编译代码时,根本不需要同步i ++操作(或AtomicInteger)吗?考虑它已经是原子的。


1
我的理解是,赋值的原子性仅意味着istore是原子的 - 换句话说,在a = b中,可能会读取b,然后将其变异为新值,然后将原始值分配给a。但是,原子性保证了a不会成为b所持有的2个值的混合体。 - assylias
@assylias 我也是这么想的,我同意这个观点 :) - Mik378
3个回答

5
即使i++被翻译为X86 Fetch-And-Add指令,也不会改变任何东西,因为Fetch-And-Add指令中提到的内存是CPU的本地内存寄存器,而不是设备/应用程序的通用内存。在现代CPU上,这个属性将扩展到CPU的本地内存缓存,甚至可以扩展到多核CPU使用的不同核心的各种缓存,但在多线程应用程序的情况下,绝对不能保证这种分布将扩展到线程本身使用的内存副本。
简而言之,在多线程应用程序中,如果一个变量可以由同时运行的不同线程修改,则必须使用系统提供的某些同步机制,并且您不能依赖于i++指令占用Java代码的单个行以实现原子性。

4
考虑第二个问题。
您暗示i++会转换为X86 Fetch-And-Add指令,这是不正确的。如果该代码由JVM编译和优化,则可能是正确的(必须检查JVM的源代码以确认),但该代码也可以在解释模式下运行,其中fetchadd是分离且未同步的。
出于好奇,我检查了此Java代码生成的汇编代码:
public class Main {
    volatile int a;

  static public final void main (String[] args) throws Exception {
    new Main ().run ();
  }

  private void run () {
      for (int i = 0; i < 1000000; i++) {
        increase ();
      }  
  } 

  private void increase () {
    a++;
  }
}

我使用了Java HotSpot(TM) Server VM (17.0-b12-fastdebug) for windows-x86 JRE (1.6.0_20-ea-fastdebug-b02)版本的JVM(我在我的硬盘上找到了它)。
以下是运行该程序(java -server -XX:+PrintAssembly -cp . Main)的关键输出:
首先,它被编译成这样:
00c     PUSHL  EBP
    SUB    ESP,8    # Create frame
013     MOV    EBX,[ECX + #8]   # int ! Field  VolatileMain.a
016     MEMBAR-acquire ! (empty encoding)
016     MEMBAR-release ! (empty encoding)
016     INC    EBX
017     MOV    [ECX + #8],EBX ! Field  VolatileMain.a
01a     MEMBAR-volatile (unnecessary so empty encoding)
01a     LOCK ADDL [ESP + #0], 0 ! membar_volatile
01f     ADD    ESP,8    # Destroy frame
    POPL   EBP
    TEST   PollPage,EAX ! Poll Safepoint

029     RET

然后它被内联并编译成这样:
0a8   B11: #    B11 B12 &lt;- B10 B11   Loop: B11-B11 inner stride: not constant post of N161 Freq: 0.999997
0a8     MOV    EBX,[ESI]    # int ! Field  VolatileMain.a
0aa     MEMBAR-acquire ! (empty encoding)
0aa     MEMBAR-release ! (empty encoding)
0aa     INC    EDI
0ab     INC    EBX
0ac     MOV    [ESI],EBX ! Field  VolatileMain.a
0ae     MEMBAR-volatile (unnecessary so empty encoding)
0ae     LOCK ADDL [ESP + #0], 0 ! membar_volatile
0b3     CMP    EDI,#1000000
0b9     Jl,s  B11   # Loop end  P=0.500000 C=126282.000000

如您所见,它并未使用Fetch-And-Add指令来执行a++操作。


如果规范没有保证原子操作,那么并不意味着不会使用原子操作。顺便说一句,在上面的例子中,我会假设JIT会将该方法优化为无操作。 ;) - Peter Lawrey

0
关于您的第一个问题:读取和写入是原子操作,但读/写操作不是。我找不到有关基元的具体参考,但JLS #17.7类似地提到了引用:

无论它们是实现为32位还是64位值,对引用的写入和读取始终是原子的。

因此,在您的情况下,iload和istore都是原子的,但整个(iload,istore)操作不是。

认为根本不需要同步i ++操作是错误的吗?

关于您的第二个问题,下面的代码在我的x86机器上打印982(而不是1,000),这表明一些 ++ 在翻译中丢失了==>即使在支持获取和添加指令的处理器架构上,您也需要正确同步 ++ 操作。
public class Test1 {

    private static int i = 0;

    public static void main(String args[]) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        final CountDownLatch start = new CountDownLatch(1);
        final Set<Integer> set = new ConcurrentSkipListSet<>();
        Runnable r = new Runnable() {
            @Override
            public void run() {
                try {
                    start.await();
                } catch (InterruptedException ignore) {}
                for (int j = 0; j < 100; j++) {
                    set.add(i++);
                }
            }
        };

        for (int j = 0; j < 10; j++) {
            executor.submit(r);
        }
        start.countDown();
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.SECONDS);
        System.out.println(set.size());
    }
}

1
这证实了ShyJ所声称的内容 => "你暗示i++会转换为X86 Fetch-And-Add指令,这是不正确的。" - Mik378
@Mik378 在你的第一个问题中添加了一些内容。 - assylias

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接