11

抱歉,这是一个很长的问题。

我最近一直在对多线程进行大量研究,因为我慢慢地将它实施到个人项目中。然而,可能由于大量稍微不正确的示例,在某些情况下使用同步块和波动性对我来说仍然有点不清楚。

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

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

(请注意以下示例,我知道 synchronized 方法和 synchronized(this) 不受欢迎以及为什么,但对此的讨论超出了我的问题范围)

示例 1:

class Counter{
  int count = 0;

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

  public int getCount(){
    return count;
  }
}

在这个例子中,increment() 需要同步,因为 ++ 不是原子操作。因此,两个线程同时递增可能会导致计数整体增加 1。计数原语需要是原子的(例如不是长/双/引用),这很好。

getCount() 是否需要在这里同步,为什么?我听到最多的解释是,我无法保证返回的计数是 pre-increment 还是 post-increment。但是,这似乎是对某些稍有不同的解释,那就是发现自己在错误的地方。我的意思是,如果我要同步 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;
  }
}
4

2 回答 2

8

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

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

[...]

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

同一页说:

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

所以锁提供了与同步块相同的可见性保证。

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

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

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

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

于 2012-05-28T07:55:10.447 回答
6

尝试从两个不同的简单操作的角度来看待它:

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

进入一个synchronized块需要锁定和内存屏障;离开synchronized区块需要解锁+内存屏障;读/写一个volatile字段只需要内存屏障。从这些方面考虑,我认为您可以自己澄清上述所有问题。

与示例 1 一样,读取线程不会有任何类型的内存屏障。这不仅仅是在读取之前/之后查看值之间,它是关于在线程启动后永远不会观察到 var 的任何变化。

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

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

于 2012-05-28T07:52:54.333 回答