Java:同步操作究竟与波动性有什么关系?

时间:2012-05-28 07:46:52

标签: java multithreading thread-safety volatile

对不起,这是一个很长的问题。

我最近在多线程中进行了大量研究,因为我慢慢将其应用到个人项目中。然而,可能由于存在大量略微不正确的例子,在某些情况下使用同步块和波动对我来说仍然有点不清楚。

我的核心问题是:当一个线程在同步块内时,对引用和原语的更改是否自动易失(即在主内存而不是缓存上执行),或者读取也必须同步它运作正常吗?

  1. 如果是这样同步简单的getter方法的目的是什么? (参见示例1)此外,只要线程已同步到任何内容,是否所有更改都发送到主内存?例如,如果它被发送到一个非常高级别的同一地点进行大量工作,那么每次更改都会进行到主存储器,并且没有任何缓存,直到它再次解锁?
  2. 如果不是更改是否必须显式位于同步块内,或者java实际上是否可以接受,例如,使用Lock对象? (参见示例3)
  3. 如果同步对象是否需要与以任何方式更改的引用/基元相关(例如包含它的直接对象)?我是否可以通过同步一个对象来写,如果它安全的话,可以用另一个对象阅读? (参见示例2)
  4. (请注意以下示例,我知道同步方法和synchronized(this)是不赞成的,为什么,但讨论超出了我的问题的范围)

    示例1:

    class Counter{
      int count = 0;
    
      public synchronized void increment(){
        count++;
      }
    
      public int getCount(){
        return count;
      }
    }
    

    在此示例中,increment()需要同步,因为++不是原子操作。因此,两个同时递增的线程可能导致计数总体增加1。 count原语需要是原子的(例如,不是long / double / reference),并且它很好。

    是否需要在此处同步getCount()以及为什么?我听到的最多的解释是,我不能保证返回的计数是增量前还是后增量。然而,这似乎是一些略有不同的解释,那就是错误的地方。我的意思是,如果我要同步getCount(),那么我仍然看不到保证 - 它现在归结为不知道锁定顺序,不知道实际读取是否恰好在实际写入之前/之后。

    示例2:

    以下示例线程是否安全,如果您假设通过此处未显示的技巧,这些方法中的任何一个都不会同时被调用?如果每次都使用随机方法完成,那么计数会以预期的方式递增,然后被正确读取,或者锁是否是同一个对象? (顺便说一下,我完全明白这个例子多么荒谬,但我对理论比对实践更感兴趣)

    class Counter{
      private final Object lock1 = new Object();
      private final Object lock2 = new Object();
      private final Object lock3 = new Object();
      int count = 0;
    
      public void increment1(){
        synchronized(lock1){
          count++;
        }
      }
    
      public void increment2(){
        synchronized(lock2){
          count++;
        }
      }
    
      public int getCount(){
        synchronized(lock3){
          return count;
        }
      }
    
    }
    

    示例3:

    之前发生的关系只是一个java概念,还是内置于JVM中的实际内容?即使我可以保证下一个例子的概念发生 - 之前的关系,如果它是一个内置的东西,java是否足够聪明地接受它?我假设它不是,但这个例子实际上是线程安全的吗?如果它的线程安全,那么如果getCount()没有锁定呢?

    class Counter{
      private final Lock lock = new Lock();
      int count = 0;
    
      public void increment(){
        lock.lock();
        count++;
        lock.unlock();
      }
    
      public int getCount(){
        lock.lock();
        int count = this.count;
        lock.unlock();
        return count;
      }
    }
    

2 个答案:

答案 0 :(得分:8)

是的,读取也必须同步。 This page说:

  

一个线程写入的结果保证对a可见   只有在写入操作发生之前,才由另一个线程读取   读操作。

     

[...]

     

监视器的解锁(同步块或方法退出)   发生在每个后续锁定之前(同步块或方法)   那个监视器的入口)

同一页说:

  

“释放”同步方法之前的操作,例如Lock.unlock,   Semaphore.release和CountDownLatch.countDown发生在操作之前   在成功的“获取”方法之后,例如Lock.lock

因此,锁提供与同步块相同的可见性保证。

无论您使用同步块还是锁定,只有在读者线程使用相同监视器或锁定作为编写器线程时才能保证可见性。

  • 您的示例1不正确:如果您想查看最新的计数值,也必须同步getter。

  • 您的示例2不正确,因为它使用不同的锁来保护相同的计数。

  • 您的示例3没问题。如果getter没有锁定,您可以看到较旧的计数值。之前发生的事情是由JVM保证的。例如,JVM必须通过将缓存刷新到主存储器来遵守指定的规则。

答案 1 :(得分:6)

尝试根据两个截然不同的简单操作来查看它:

  1. 锁定(互斥),
  2. 内存屏障(缓存同步,指令重新排序屏障)。
  3. 输入synchronized块需要锁定和内存屏障;离开synchronized区块需要解锁+内存屏障;读/写volatile字段只需要内存屏障。用这些术语思考我认为你可以自己澄清上面的所有问题。

    对于示例1,读取线程将不具有任何类型的存储器屏障。这不仅仅是在看到读取之前/之后的值之间,而是关于从不观察线程启动后对var的任何更改。

    示例2.是您提出的最有趣的问题。在这种情况下,JLS确实没有给出任何保证。在实践中,您将不会获得任何订购保证(就好像锁定方面根本不存在),但您仍然会受益于内存障碍,因此您将观察到更改,与第一个例子不同。基本上,这与删除synchronized并将int标记为volatile完全相同(除了获取锁定的运行时成本之外)。

    关于示例3,通过“只是一个Java事物”,我觉得你有一些关于擦除的泛型,只有静态代码检查才能知道。这不是那样的 - 锁和内存屏障都是纯运行时工件。实际上,编译器根本无法对它们进行推理。