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

Num*_*ron 11 java multithreading volatile thread-safety

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

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

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

  1. 如果是这样同步简单的getter方法的目的是什么?(参见示例1)此外,只要线程已同步到任何内容,是否所有更改都发送到主内存?例如,如果它被发送到一个非常高级别的同一地点进行大量工作,那么每次更改都会进入主存储器,并且没有任何缓存,直到它再次解锁?
  2. 如果不是更改必须显式位于同步块内,还是java实际上可以接受,例如,使用Lock对象?(见例3)
  3. 如果要么同步对象是否需要与以任何方式更改的引用/原语相关(例如,包含它的直接对象)?我是否可以通过同步一个对象来写,如果它安全的话,可以用另一个对象阅读?(见例2)

(请注意以下示例,我知道同步方法和synchronized(this)是不赞成的,为什么,但对此的讨论超出了我的问题的范围)

例1:

class Counter{
  int count = 0;

  public synchronized void increment(){
    count++;
  }

  public int getCount(){
    return count;
  }
}
Run Code Online (Sandbox Code Playgroud)

在此示例中,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;
    }
  }

}
Run Code Online (Sandbox Code Playgroud)

例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;
  }
}
Run Code Online (Sandbox Code Playgroud)

JB *_*zet 8

是的,读取也必须同步.这个页面说:

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

[...]

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

同一页说:

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

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

无论您使用同步块还是锁,只有读取器线程使用编写器线程相同的监视器或锁时才能保证可见性.

  • 示例1不正确:如果要查看计数的最新值,则必须同步getter.

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

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


Mar*_*nik 6

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

  1. 锁定(互斥),
  2. 内存屏障(缓存同步,指令重新排序屏障).

进入一个synchronized区块需要锁定和内存屏障; 离开synchronized区块需要解锁+记忆障碍; 读/写volatile字段只需要内存屏障.用这些术语思考我认为你可以自己澄清上面的所有问题.

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

示例2.是您提出的最有趣的问题.在这种情况下,JLS确实没有给出任何保证.在实践中,您将不会获得任何排序保证(就好像锁定方面根本不存在),但您仍然可以获得内存屏障的好处,因此您将观察到更改,与第一个示例不同.基本上,这与删除synchronized和标记intas 完全相同volatile(除了获取锁的运行时成本).

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