Java编程高级Java开发员

为什么Java内存模型的发生在先(happens-before)关系无法保证最终字段的不可变性,当'this'引用在对象构造期间逃逸时?

用 Hintsage AI 助手通过面试

问题的回答。

Java内存模型JMM)保证一旦构造函数完成,对final字段的写入将对任何读取对象引用的线程可见,前提是该引用在构造期间没有逃逸。如果**this引用提前泄漏——通过被传递给另一个线程或在构造函数返回之前存储在静态集合中——则构造函数对final字段的写入与其他线程对该字段的读取之间的发生在先关系被切断。因此,观察线程可能会看到默认值(零、false 或 null),而不是构造值,这打破了表面上的不可变性。安全发布要求在构造完成之前,构造中的对象没有引用逃逸,确保对final**字段的冻结动作发生在任何线程能够加载引用之前。

生活中的情况

我们在一个高频交易系统中遇到过这种情况,其中Service实例在构造函数中将自己注册到一个全局的ConcurrentHashMap中,以便进行查找。该类定义了一个final long instrumentId,从构造函数参数初始化,但监控线程在创建后立即查询注册表时偶尔读取到零。

一个提议的解决方案是将**instrumentId声明为volatile而不是final**,希望强制跨核心的即时可见性。这种方法保证了原子性和可见性,但放弃了不可变性合同,并在每次读取时产生了完全的内存屏障成本,不必要地降低了后续通过创建后不再更改的值的吞吐量,并使对象状态的推理变得复杂。

另一个建议涉及用synchronized块封装构造函数逻辑,同步对注册表的所有访问,理论上认为锁定可以刷新内存缓存。虽然这防止了竞争条件,但在全局注册表锁上引入了严重的争用,将并发结构变成了串行瓶颈,违反了市场数据摄取的严格延迟要求。

我们选择了一个工厂模式,将实例化与注册解耦。构造函数保持私有,工厂方法完全调用**new Service(id),只有在那之后才将完全构建的引用发布到ConcurrentHashMap**。这利用了JMM的最终字段冻结语义,无需同步开销,确保对**instrumentId**的检索立即可见。

这一变化消除了零可见性异常,并恢复了服务查找的预期微秒级延迟,同时保留了不可变设计的意图。

候选人常常遗漏的内容

为什么final不保证可见性,如果我只是通过线程安全的集合如ConcurrentHashMap发布引用呢?

ConcurrentHashMap的put和get操作提供的发生在先关系在映射的内部状态更改之间建立顺序,而不是构造函数的写入与映射的发布之间。如果**this在构造期间逃逸,则对final**字段的写入发生在一个线程中,而映射的发布则同时发生,缺少所需的发生在先关系以防止指令重排序。因此,读取线程可能在构造函数的写入被刷新到主内存之前通过映射观察引用,看到默认值。

我可以通过将注册表字段设置为volatile而不是对象的字段来解决这个问题吗?

将注册表引用标记为volatile仅确保对注册变量本身的更改是可见的,而不是它所包含对象的内部状态。由于问题在于对象字段写入的时间相对于引用变为可见,因此对容器的volatile不建立构造函数和对象消费者之间的必要顺序。您仍然会观察到部分构造的实例。

在构造函数中使用synchronized是否防止不安全的发布?

在构造函数上放置synchronized或使用它来保护注册防止其他线程并发进入临界区,但如果注册方法将引用泄漏到超出该锁的线程,它不会阻止**this引用的逃逸。JMM明确要求在构造函数完成之前,任何对对象的引用不得逃逸,以保持final**字段的语义;没有正确的发布顺序的同步无法恢复该保证。