我是否有重新排序问题,是否由于引用转义?

Do I have reordering issue and is it due to reference escape?

我有这个 class 我在其中缓存实例并在我使用它们时克隆它们(数据是可变的)。

我想知道我是否会遇到重新排序的问题。

我看过 this answer 和 JLS,但我仍然没有信心。

public class DataWrapper {
    private static final ConcurrentMap<String, DataWrapper> map = new ConcurrentHashMap<>();
    private Data data;
    private String name;

    public static DataWrapper getInstance(String name) {
        DataWrapper instance = map.get(name);
        if (instance == null) {
            instance = new DataWrapper(name);
        }
        return instance.cloneInstance();
    }

    private DataWrapper(String name) {
        this.name = name;
        this.data = loadData(name);  // A heavy method
        map.put(name, this);  // I know
    }

    private DataWrapper cloneInstance() {
        return new DataWrapper(this);
    }

    private DataWrapper(DataWrapper that) {
        this.name = that.name;
        this.data = that.data.cloneInstance();
    }
}

我的想法:运行时可以在构造函数中重新排序语句并在初始化data之前发布当前DataWrapper实例(放入映射中)目的。第二个线程从映射中读取 DataWrapper 实例并看到空 data 字段(或部分构造)。

这可能吗?如果是,是否只是引用转义?

如果不是,能否用更简单的术语解释一下如何推理 happens-before 一致性?

如果我这样做会怎样:

public class DataWrapper {
    ...
    public static DataWrapper getInstance(String name) {
        DataWrapper instance = map.get(name);
        if (instance == null) {
            instance = new DataWrapper(name);
            map.put(name, instance);
        }
        return instance.cloneInstance();
    }
    
    private DataWrapper(String name) {
        this.name = name;
        this.data = loadData(name);     // A heavy method
    }
    ...
}

是否仍然容易出现同样的问题?

请注意,如果多个线程尝试同时为相同的值创建和放置实例,我不介意创建一两个额外的实例。

编辑:

如果名称和数据字段是最终的或可变的怎么办?

public class DataWrapper {
    private static final ConcurrentMap<String, DataWrapper> map = new ConcurrentHashMap<>();
    private final Data data;
    private final String name;
    ... 
    private DataWrapper(String name) {
        this.name = name;
        this.data = loadData(name);  // A heavy method
        map.put(name, this);  // I know
    }
    ...
}

还不安全吗?据我了解,构造函数初始化安全保证仅适用于引用在初始化期间未转义的情况。我正在寻找证实这一点的官方消息来源。

实施有一些非常微妙的注意事项。

看来你知道了,但要明确一点, 在这段代码中,多个线程可能会得到一个 null 实例并进入 if 块, 不必要地创建新的 DataWrapper 个实例:

public static DataWrapper getInstance(String name) {
    DataWrapper instance = map.get(name);
    if (instance == null) {
        instance = new DataWrapper(name);
    }
    return instance.cloneInstance();
}

看来你没问题, 但这需要假设 loadData(name)(由 DataWrapper(String) 使用)将始终 return 相同的值。 如果它可能 return 不同的值取决于时间, 无法保证最后一个加载数据的线程会将其存储在 map 中,因此该值可能已过时。 如果你说这不会发生或者这不重要, 没关系,但至少应记录此假设。

为了演示另一个微妙的问题,让我内联 instance.cloneInstance() 方法:

public static DataWrapper getInstance(String name) {
    DataWrapper instance = map.get(name);
    if (instance == null) {
        instance = new DataWrapper(name);
    }
    return new DataWrapper(instance);
}

这里的微妙问题是此 return 声明不是安全发布。 新的 DataWrapper 实例可能是部分构建的, 一个线程可能会在不一致的状态下观察它, 例如,对象的字段可能尚未设置。

有一个简单的解决方法: 如果您将 namedata 字段设置为 final, class 变得不可变。 不可变 classes 享有特殊的初始化保证, return new DataWrapper(this); 成为安全出版物。

通过这个简单的更改,并假设您对第一点没有意见(loadData 对时间不敏感),我认为实施应该可以正常工作。


我会推荐一项与正确性无关但与其他良好做法相关的额外改进。 当前的实现有太多的责任: 它是 Data 的包装器,同时也是缓存。 增加的责任使其阅读起来有点混乱。 顺便说一句,并发哈希映射并没有真正发挥其潜力。

如果将职责分开,结果会更简单、更好、更易于阅读:

class DataWrapperCache {

  private static final ConcurrentMap<String, DataWrapper> map = new ConcurrentHashMap<>();

  public static DataWrapper get(String name) {
    return map.computeIfAbsent(name, DataWrapper::new).defensiveCopy();
  }
}

class DataWrapper {

  private final String name;
  private final Data data;

  DataWrapper(String name) {
    this.name = name;
    this.data = loadData(name);  // A heavy method
  }

  private DataWrapper(DataWrapper that) {
    this.name = that.name;
    this.data = that.data.cloneInstance();
  }

  public DataWrapper defensiveCopy() {
    return new DataWrapper(this);
  }
}

如果您想符合规范,则不能应用此构造函数:

private DataWrapper(String name) {
  this.name = name;
  this.data = loadData(name);
  map.put(name, this);
}

正如您所指出的,允许 JVM 将其重新排序为:

private DataWrapper(String name) {
  map.put(name, this);
  this.name = name;
  this.data = loadData(name);
}

final 字段赋值时,这意味着在 末尾 处执行所谓的 冻结操作构造函数。内存模型保证此冻结操作与应用此冻结操作的实例的任何取消引用之间存在先于发生的关系。然而,这种关系只存在于构造函数的末尾,因此,你打破了这种关系。通过将发布拖出您的构造函数,您可以修复此关系。

如果你想更正式地了解这段关系,我推荐looking through this slide set. I also explained the relationship in this presentation starting at about minute 34