!=检查线程安全吗?


140

我知道诸如之类的复合操作i++不是线程安全的,因为它们涉及多个操作。

但是,检查引用本身是否是线程安全的操作?

a != a //is this thread-safe

我尝试对此进行编程,并使用多个线程,但没有失败。我想我无法在机器上模拟种族。

编辑:

public class TestThreadSafety {
    private Object a = new Object();

    public static void main(String[] args) {

        final TestThreadSafety instance = new TestThreadSafety();

        Thread testingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                long countOfIterations = 0L;
                while(true){
                    boolean flag = instance.a != instance.a;
                    if(flag)
                        System.out.println(countOfIterations + ":" + flag);

                    countOfIterations++;
                }
            }
        });

        Thread updatingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                while(true){
                    instance.a = new Object();
                }
            }
        });

        testingReferenceThread.start();
        updatingReferenceThread.start();
    }

}

这是我用来测试线程安全性的程序。

怪异的行为

当我的程序在两次迭代之间开始时,我得到输出标志值,这意味着引用!=检查在同一引用上失败。但是经过一些迭代后,输出变为恒定值false,然后长时间长时间执行该程序不会生成单个true输出。

如输出所示,经过n次(非固定)迭代后,输出似乎是恒定值,并且没有变化。

输出:

对于某些迭代:

1494:true
1495:true
1496:true
19970:true
19972:true
19974:true
//after this there is not a single instance when the condition becomes true

2
在这种情况下,“线程安全”是什么意思?您是否要保证总是返回false?
JB Nizet

@JBNizet是的。那就是我的想法。
Narendra Pathai 2013年

5
它甚至不总是在单线程上下文中返回false。可能是NaN
。.– harold

4
可能的解释:该代码是即时编译的,并且已编译的代码仅将变量加载一次。这是预期的。
Marko Topolnik

3
打印单个结果不是测试比赛的好方法。与测试相比,打印(格式化和写入结果)的成本相对较高(有时,当与终端的连接带宽或终端本身的带宽变慢时,程序最终会在写入时阻塞)。另外,IO通常包含其自身的互斥体,这会影响线程的执行顺序(请注意,您彼此之间1234:true永不粉碎的行)。竞赛测试需要更紧密的内循环。最后打印摘要(就像下面有人对单元测试框架所做的那样)。
本杰克逊

Answers:


124

在没有同步的情况下,此代码

Object a;

public boolean test() {
    return a != a;
}

可能产生true。这是字节码test()

    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    IF_ACMPEQ L1
...

如我们所见,它a两次将字段加载到本地var,这是非原子操作,如果a在两次调用之间进行了更改,则可能会产生比较false

此外,这里的内存可见性问题也很重要,不能保证a当前线程可以看到另一个线程所做的更改。


22
尽管有力的证据,但字节码实际上并不是证明。它也必须在JLS中某处……
Marko Topolnik

10
@Marko我同意您的想法,但不一定是您的结论。对我来说,上面的字节码是显而易见的/规范的实现方式!=,它涉及分别加载LHS和RHS。因此,如果LHS和RHS在语法上相同时,如果JLS 没有提及优化的任何特定内容,则将适用一般规则,这意味着加载a两次。
Andrzej Doyle

20
实际上,假设生成的字节码符合JLS,这就是证明!
proskor

6
@Adrian:首先:即使该假设无效,只要有一个编译器可以评估为“ true”,就足以证明它有时可以评估为“ true”(即使规范禁止使用该编译器)不)。其次:Java规范明确,大多数编译器都与Java紧密兼容。在这方面使用它们作为参考是有意义的。第三:您使用了“ JRE”一词,但我认为这并不意味着您认为这意味着什么。。。
ruakh

2
@AlexanderTorstling- “我不确定这是否足以排除单读优化。” 这还不够。实际上,在没有同步的情况下(以及所施加的额外的“先于”关系),优化是有效的
Stephen C

47

检查a != a线程安全吗?

如果a可能由另一个线程更新(没有适当的同步!),则为否。

我尝试对此进行编程,并使用多个线程,但没有失败。我猜不能在我的机器上模拟种族。

那没什么意思!问题是,如果JLS 允许执行a另一个线程更新的执行,则代码不是线程安全的。您不能导致竞争条件在特定机器和特定Java实现上的特定测试用例发生的事实,并不排除它在其他情况下发生的事实。

这是否意味着!= a可能返回true

是的,理论上,在某些情况下。

或者,即使同时更改,a != a也可以返回。falsea


关于“怪异行为”:

当我的程序在两次迭代之间开始时,我得到输出标志值,这意味着引用!=检查在同一引用上失败。但是经过一些迭代后,输出变为常量值false,然后长时间执行该程序不会生成单个true输出。

此“怪异”行为与以下执行方案一致:

  1. 程序被加载,JVM开始解释字节码。因为(从javap输出中可以看到)字节码执行两次加载,所以(显然)您偶尔会看到竞争条件的结果。

  2. 一段时间后,该代码由JIT编译器编译。JIT优化器注意到,同一内存插槽(a)有两个负载并在一起,并优化了第二个负载。(实际上,它有可能完全优化测试……)

  3. 现在,竞赛条件不再显示,因为不再有两个负载。

请注意,所有这些都与JLS允许Java实现执行的操作一致。


@kriss这样评论:

看起来这可能是C或C ++程序员所谓的“未定义行为”(取决于实现)。似乎在这种情况下,java中可能会有一些UB。

Java内存模型(在JLS 17.4中指定)指定了一组前提条件,在这些前提条件下,可以确保一个线程看到另一个线程写入的内存值。如果一个线程尝试读取由另一个线程写入的变量,但不满足这些前提条件,则可能存在许多可能的执行……其中某些可能不正确(从应用程序需求的角度来看)。换句话说,定义了可能的行为(即“格式正确的执行”集),但我们无法确定将发生哪些行为。

如果代码的最终效果相同,则允许编译器对负载进行合并和重新排序以及保存(以及执行其他操作):

  • 当由单个线程执行时,以及
  • 当由正确同步的不同线程执行时(根据内存模型)。

但是,如果代码未正确同步(因此“发生之前”的关系没有充分约束格式良好的执行集),则允许编译器以可能产生“错误”结果的方式对加载和存储进行重新排序。(但这只是说程序不正确。)


这是否意味着a != a可以返回true?
proskor

我的意思是也许在我的机器上,我无法模拟以上代码是非线程安全的。因此,也许背后有理论上的推理。
Narendra Pathai 2013年

@NarendraPathai-无法证明它没有理论上的原因。可能是有实际原因的...或者您只是没有幸运。
斯蒂芬·C

请使用我正在使用的程序检查我更新的答案。该检查有时会返回true,但输出中似乎有一个奇怪的行为。
Narendra Pathai 2013年

1
@NarendraPathai-见我的解释。
斯蒂芬·C

27

经test-ng验证:

public class MyTest {

  private static Integer count=1;

  @Test(threadPoolSize = 1000, invocationCount=10000)
  public void test(){
    count = new Integer(new Random().nextInt());
    Assert.assertFalse(count != count);
  }

}

我有2次万次调用失败。所以,它不是线程安全的


6
您甚至都没有检查是否相等……该Random.nextInt()部分是多余的。您也可以进行测试new Object()
Marko Topolnik

@MarkoTopolnik请使用我使用的程序检查我更新的答案。该检查有时会返回true,但输出中似乎有一个奇怪的行为。
Narendra Pathai 2013年

1
附带说明,随机对象通常是要重用的,而不是每次需要新的int时都不会创建。
西蒙·佛斯伯格

15

不它不是。为了进行比较,Java VM必须将这两个值放在堆栈上进行比较,然后运行compare指令(该指令取决于“ a”的类型)。

Java VM可以:

  1. 读两次“ a”,将每个放到堆栈上,然后比较结果
  2. 仅读取一次“ a”,将其放在堆栈上,将其复制(“ dup”指令),然后运行比较
  3. 完全消除该表达式并将其替换为 false

在第一种情况下,另一个线程可以在两次读取之间修改“ a”的值。

选择哪种策略取决于Java编译器和Java运行时(尤其是JIT编译器)。它甚至可能在程序运行时更改。

如果要确保如何访问变量,则必须使其volatile(所谓的“半内存屏障”)或添加完整的内存屏障(synchronized)。您还可以使用某些hgiher级别的API(例如AtomicInteger,Juned Ahasan提到的)。

有关线程安全的详细信息,请阅读JSR 133Java内存模型)。


声明avolatile仍然意味着两个不同的读物,并且可能在它们之间进行更改。
Holger

6

斯蒂芬·C(Stephen C)对此进行了很好的解释。为了娱乐,您可以尝试使用以下JVM参数运行相同的代码:

-XX:InlineSmallCode=0

这将阻止JIT进行优化(它在热点7服务器上进行),并且您将true永远看到(我停在2,000,000,但我想在那之后它还会继续)。

有关信息,请参见下面的JIT代码。老实说,我对流水线的理解不够流利,无法知道测试是否真正完成或这两个负载来自何处。(第26行是测试flag = a != a,第31行是的右括号while(true))。

  # {method} 'run' '()V' in 'javaapplication27/TestThreadSafety$1'
  0x00000000027dcc80: int3   
  0x00000000027dcc81: data32 data32 nop WORD PTR [rax+rax*1+0x0]
  0x00000000027dcc8c: data32 data32 xchg ax,ax
  0x00000000027dcc90: mov    DWORD PTR [rsp-0x6000],eax
  0x00000000027dcc97: push   rbp
  0x00000000027dcc98: sub    rsp,0x40
  0x00000000027dcc9c: mov    rbx,QWORD PTR [rdx+0x8]
  0x00000000027dcca0: mov    rbp,QWORD PTR [rdx+0x18]
  0x00000000027dcca4: mov    rcx,rdx
  0x00000000027dcca7: movabs r10,0x6e1a7680
  0x00000000027dccb1: call   r10
  0x00000000027dccb4: test   rbp,rbp
  0x00000000027dccb7: je     0x00000000027dccdd
  0x00000000027dccb9: mov    r10d,DWORD PTR [rbp+0x8]
  0x00000000027dccbd: cmp    r10d,0xefc158f4    ;   {oop('javaapplication27/TestThreadSafety$1')}
  0x00000000027dccc4: jne    0x00000000027dccf1
  0x00000000027dccc6: test   rbp,rbp
  0x00000000027dccc9: je     0x00000000027dcce1
  0x00000000027dcccb: cmp    r12d,DWORD PTR [rbp+0xc]
  0x00000000027dcccf: je     0x00000000027dcce1  ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd1: add    rbx,0x1            ; OopMap{rbp=Oop off=85}
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd5: test   DWORD PTR [rip+0xfffffffffdb53325],eax        # 0x0000000000330000
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
                                                ;   {poll}
  0x00000000027dccdb: jmp    0x00000000027dccd1
  0x00000000027dccdd: xor    ebp,ebp
  0x00000000027dccdf: jmp    0x00000000027dccc6
  0x00000000027dcce1: mov    edx,0xffffff86
  0x00000000027dcce6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dcceb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=112}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dccf0: int3   
  0x00000000027dccf1: mov    edx,0xffffffad
  0x00000000027dccf6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dccfb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=128}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dcd00: int3                      ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
  0x00000000027dcd01: int3   

1
这是一个很好的例子,说明当您遇到无限循环并且几乎可以吊起所有东西时,JVM实际上将产生的那种代码。实际的“循环”是从0x27dccd1到的三个指令0x27dccdfjmp循环中的in是无条件的(因为循环是无限的)。循环中仅有的另外两条指令是add rbc, 0x1-正在递增countOfIterations(尽管事实上循环永远不会退出,所以不会读取该值:如果在调试器中闯入它,可能需要它)。 。
BeeOnRope

...和看起来很奇怪的test指令,实际上仅用于内存访问(请注意,eax甚至从未在该方法中设置!):这是一个特殊页面,当JVM要触发所有线程时,该页面设置为不可读达到安全点,因此它可以执行gc或其他要求所有线程都处于已知状态的操作。
BeeOnRope

更重要的是,JVM完全将instance. a != instance.a比较提升到循环之外,并且在进入循环之前仅执行一次!它知道不需要重新加载instance或者a它们没有声明为volatile,并且没有其他代码可以在同一线程上对其进行更改,因此仅假设它们在整个循环中是相同的,这在内存允许的范围内模型。
BeeOnRope

5

不,a != a不是线程安全的。此表达式包含三个部分:load aa再次加载和perform !=。另一个线程可能获得a的父级的固有锁定并a在两次加载操作之间更改in 的值。

但是,另一个因素是是否a本地。如果a是本地的,则没有其他线程可以访问它,因此应该是线程安全的。

void method () {
    int a = 0;
    System.out.println(a != a);
}

还应始终打印false

声明a为as volatile不会解决if ais static或instance 的问题。问题不在于线程的值不同a,而是一个线程a使用不同的值加载两次。实际上,这可能会使大小写的线程安全性降低。如果a不这样做,volatilea可能会被缓存,并且另一个线程中的更改不会影响缓存的值。


您的示例使用了synchronized错误的代码:要保证该代码可以打印false,所有设置的 方法a也必须是synchronized
ruakh

为什么这样?如果该方法已同步,a则在执行该方法时,任何其他线程将如何获得对父对象的固有锁定,这是设置该值所必需的a
DoubleMx2

1
你的前提错了。您可以设置对象的字段而无需获取其固有锁定。Java在设置对象的字段之前不需要线程来获取对象的内部锁。
ruakh

3

关于奇怪的行为:

由于该变量a未标记为volatile,因此在某个时候它可能a会被线程缓存。这两个aa != a然后缓存版本,因此始终是相同的(意思flag是现在总是false)。


0

甚至简单的阅读也不是原子的。如果along且未标记为,volatile则在32位JVM long b = a上不是线程安全的。


挥发性和原子性无关。即使我标记为挥发物,也将是非原子的
Narendra Pathai 2013年

易失的长字段的分配始终是原子的。像++这样的其他操作则没有。
ZhekaKozlov
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.