在Java中同步访问只读映射的正确方法

3

我正在编写一个类似于DatabaseConfiguration的类,它从数据库中读取配置信息,我需要一些关于同步的建议。

public class MyDBConfiguration{
   private Connection cn;
   private String table_name;
   private Map<String, String> key_values = new HashMap<String,String>();
   public MyDBConfiguration (Connection cn, String table_name) {
      this.cn = cn;
      this.table_name = table_name;
      reloadConfig();
   }
   public String getProperty(String key){
       return this.key_values.get(key);
   }
   public void reloadConfig() {
      Map<String, String> tmp_map = new HashMap<String,String> ();
      // read  data from database
      synchronized(this.key_values)
      {
          this.key_values = tmp_map;
      }
   }
}

我有几个问题。
1. 假设属性是只读的,在 getProperty 中,我需要使用 synchronize 吗?
2. 在 reloadConfig 中执行 this.key_values = Collections.synchronizedMap(tmp_map) 有意义吗?

谢谢。

3个回答

5
如果多个线程要共享一个实例,您必须使用某种同步方式。
同步主要有两个原因:
  • 它可以保证某些操作是原子的,从而系统保持一致性
  • 它保证每个线程在内存中看到相同的值
首先,由于您将reloadConfig()设置为public,因此您的对象实际上并不是不可变的。如果对象确实是不可变的,也就是说,在初始化其值后,它们不能更改(这是在共享的对象中具有的期望属性)。
出于以上原因,您必须同步对地图的所有访问:假设一个线程正在尝试从地图中读取数据,而另一个线程正在调用reloadConfig()。那么会出现问题。
如果这确实是情况(可变设置),则必须进行读写同步(出于明显的原因)。线程必须在单个对象上同步(否则就没有同步)。为了确保所有线程都在相同的对象上同步,您必须在对象本身上同步或在适当发布和共享锁上同步,例如:
// synchronizes on the in instance itself:
class MyDBConfig1 {
  // ...
  public synchronized String getProperty(...) { ... }
  public synchronized reloadConfig() { ... }
}

// synchronizes on a properly published, shared lock:
class MyDBConfig2 {
  private final Object lock = new Object();
  public String getProperty(...) { synchronized(lock) { ... } }
  public reloadConfig() { synchronized(lock) { ... } }
}

正确的发布仅由 final 关键字来保证。这很微妙:它保证了该字段在初始化后对每个线程都是可见的(如果没有它,线程可能会看到 lock == null,这将导致糟糕的事情发生)。

您可以通过使用(正确发布的)ReadWriteReentrantLock 来改进上面的代码。如果您关心并发性能,这可能会稍微提高一点。

假设您的意图是使 MyDBConfig 不可变,那么您不需要序列化访问哈希表(也就是说,您不一定需要添加 synchronized 关键字)。这可能会提高并发性能。

首先,将 reloadConfig() 设为私有方法(这将表明对于此对象的消费者,它确实是不可变的:他们唯一看到的方法是 getProperty(...),按其名称应该不修改实例)。

然后,您只需要保证每个线程都可以在哈希表中看到正确的值。为此,您可以使用上面介绍的相同技术,或者您可以使用一个 volatile 字段,像这样:

class MyDBConfig {
  private volatile boolean initialized = false;
  public String getProperty(...) { if (initialized) { ... } else { throw ... } }
  private void reloadConfig() { ...; initialized = true; }
  public MyDBConfig(...) { ...; reloadConfig(); }
}
volatile 关键字非常微妙。Volatile 写和 volatile 读之间具有先行发生关系。所谓 volatile 写在同一 (volatile) 字段的后续 volatile 读之前先行发生。这意味着,在 volatile 写之前已经修改的所有内存位置(按程序顺序)在它们执行后的任何其他线程中都是可见的,并且在它们执行后的后续 volatile 读取相同的 (volatile) 字段时也是如此。
在上面的代码中,您在设置完所有值后将 true 写入 volatile 字段。然后,读取值的方法 (getProperty(...)) 通过执行相同字段的 volatile 读取开始执行。然后,该方法保证能够看到正确的值。
在上面的示例中,如果您在构造函数完成之前不发布实例,则保证不会在方法 getProperty(...) 中抛出异常(因为在构造函数结束之前,您已经将 initialized 写入了 true)。

不,完全不是这样。volatile 只保证:(1) volatile 字段的值始终对所有线程可见;(2) 如果一个线程修改了其他内存位置,然后写入了一个 volatile 字段,稍后另一个线程从相同的 volatile 字段读取,则该第二个线程将看到所有其他修改过的内存位置的当前值。Vint 提出的方法行不通,因为持有哈希映射的 volatile 字段只会被写入一次,并且是在值被写入之前。这些值可能永远不会被其他线程看到。 - Bruno Reis
@Bruno 如果需要原子地填充Map,则需要同步所有的填充和赋值操作。但是,如果Map只需要像他在示例中尝试的那样正确发布,那么将字段声明为volatile绝对可以工作。 "这些值可能永远不会被其他线程看到。" 哪些值可能不会被看到?Map中的值吗?因为这并不是真的。 - John Vint
如果你仍然不同意或者不理解为什么你提出的方法行不通,那么你必须学习一些关于内存模型的知识。一个很好的起点是Brian Goetz所著的《Java并发编程实践》。 - Bruno Reis
@Bruno,这就是他在示例中所做的。我的答案依赖于这个假设。Map<String, String> tmp_map = new HashMap<String,String> (); // read data from database 填充tmp_map,然后将tmp_map分配给key_values是完全合法的,并符合happens-before关系。 - John Vint
这是一个很好的观点。我更新了我的答案,以清楚地说明我所做的假设的重要性。 - John Vint
显示剩余5条评论

3
  1. 假设在reloadConfig之后不会对key_values进行put,则需要同步访问map的读写。仅在赋值时同步是违反该规则的。您可以通过删除同步块并将key_values分配为volatile来解决此问题。

  2. 由于HashMap实际上是只读的,因此我不会分配Collections.synchronizedMap,而是分配Collections.unmodifiableMap(这不会影响Map本身,只会禁止其他人意外使用此类进行put操作)。

注意:您也不应该同步更改的字段。结果是非常不确定的。

编辑:关于其他答案,强烈建议同步所有共享的可变数据,因为效果是不确定的。key_values字段是一个共享的可变字段,对其进行分配必须进行同步。

编辑:为了澄清Bruno Reis的任何困惑。如果您仍然填充tmp_map,并在填充完成后将其分配给this.key_values,则volatile字段是合法的。它看起来像:

   private volatile Map<String, String> key_values = new HashMap<String,String>();

  ..rest of class 

   public void reloadConfig() {
      Map<String, String> tmp_map = new HashMap<String,String> ();
      // read  data from database

      this.key_values = tmp_map;
   }

你仍需要相同的样式,否则像Bruno Reis所指出的那样,它将不是线程安全的。


谢谢您的回答。只是为了确保我理解正确,可靠的方法是:1.添加一个成员变量,例如 Object key_value_lock = new Object()。2.每次访问底层映射表(即使是只读),都要在synchronize(key_value_lock)块中进行。对吗? - a1ex07
是的,这是一种安全的执行方式。但是我更倾向于使用 private volatile Map<String,String> key_values=...。volatile 加载比同步加载快得多。 - John Vint
此外,作为线程安全的一个注意事项。 如果对象可以声明为final,则最好这样做。 final Object key_value_lockfinal Connection cn等等。 - John Vint
再次感谢,我会尝试这两种方法。我有点不太愿意在Java中使用volatile - a1ex07
锁对象必须是final的,否则它就是有问题的(其他线程可能会看到该字段为空)。 - Bruno Reis
你为什么同意a1ex07使用外部锁来同步对map的读取访问?根据HashMap的javadoc,只有在可能会结构性更改(即put或delete)时才需要外部同步。 - ewan.chalmers

-1

我认为,如果您保证没有代码会结构性地修改您的映射,则无需对其进行同步。

如果多个线程同时访问哈希映射,并且至少有一个线程在结构上修改了映射,则必须在外部进行同步。 http://download.oracle.com/javase/6/docs/api/java/util/HashMap.html

您展示的代码仅提供对映射的读取访问。客户端代码无法进行结构修改。

由于您的重新加载方法更改了临时映射,然后将 key_values 更改为指向新映射,因此我再次说不需要同步。最糟糕的情况是有人从旧映射副本中读取。

现在我要低头等待负评了 ;)

编辑

如 Bruno 所建议的那样,问题在于继承。如果您不能保证您的类不会被子类化,则应更加谨慎。

编辑

只是回顾一下 OP 提出的具体问题...

  1. 假设属性是只读的,我在getProperty中是否需要使用synchronize?
  2. 在reloadConfig中使用this.key_values = Collections.synchronizedMap(tmp_map)有意义吗?

...我真的很想知道我的答案是否正确。所以我不会轻易放弃并删除我的答案 ;)


@sudocode。你的答案仍然是错的。它之所以错误不是因为HashMap内部的可变性,而是因为HashMap字段本身的可变性。即使另一个线程已经写入该字段,某个线程仍然有可能进来并查看到key_values映射的过时版本。为了确保多线程编程的正确性,你需要对所有读写共享可变数据进行同步访问。 - John Vint
最糟糕的错误实际上是NullPointerException。但是如果你这样做if(this.key_values != null)...,那么最糟糕的情况就是从旧映射中读取。但请记住,这种推理并不适用于所有赋值。例如,private long someLong = 10;如果您在一个线程中执行someLong = 5000000;,而在另一个线程中执行someLong = 10000000;,则读取线程可能会看到两者的组合。您可以在此处阅读更多信息:https://dev59.com/13A75IYBdhLWcg3wH1NB#3463673 - John Vint
在我们正在审查的代码中,key_values 怎么可能会为空? - ewan.chalmers
如果字段既不是final也不是volatile(并且没有使用synchronized进行读写),那么JVM允许延迟字段的赋值,只要在单线程环境中写入看起来不会出现顺序问题。因此,如果我初始化了MyDBConfiguration,并且另一个线程看到MyDBConfiguration已经创建并调用getProperty(),JVM可以推迟key_values的赋值,直到创建线程至少想要读取它为止。 - John Vint
话虽如此,你可能不太可能遇到它,但这并不意味着永远不会发生。 - John Vint
显示剩余2条评论

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