Java多线程安全构造疑问:构造器最后语句发布对象为何不安全?
你这个问题问到点子上了——我当初啃JCIP的时候也在这个点上卡了好久!你的猜测完全正确,但咱们得把这个逻辑拆得更通透,不然总觉得有点反直觉。
核心原因就是你想到的JVM指令重排序,而且这里的重排序还涉及到Java内存模型(JMM)里的规则,咱们一步步说:
首先,先看一段典型的不安全代码:
public class UnsafeExample { private final int importantValue; public UnsafeExample() { importantValue = 100; // 初始化关键字段 GlobalRegistry.register(this); // 构造器最后一条语句,发布this引用 } }
从代码的书写顺序来看,我们是先给importantValue赋值,再把对象发布到全局注册表。但JVM的即时编译器(JIT)为了优化性能,可能会调换这两个操作的执行顺序——也就是先执行GlobalRegistry.register(this),再给importantValue赋值。
这时候,如果有其他线程刚好从GlobalRegistry拿到这个实例,它看到的importantValue可能是默认值0,而不是我们预期的100——因为对象的初始化操作还没完成,就已经被发布出去了。
为什么会允许这种重排序?因为JMM规定,只要在当前线程内的执行结果看起来是符合代码顺序的(也就是单线程语义),编译器和CPU就可以自由重排序指令。而构造器内的初始化和对象发布这两个操作,在当前线程内的执行顺序不影响结果,所以JVM就可能做这个优化。
而且还有个容易被忽略的点:对象的创建过程本身就可能被重排序。JVM创建对象的步骤大概是:
- 分配内存空间
- 给对象的字段设置默认值(比如int是0,对象引用是null)
- 执行构造器里的初始化操作(给字段赋预期值)
- 把对象的引用赋值给变量(也就是发布对象)
JMM允许把步骤4和步骤3重排序,也就是先把引用赋值出去,再执行构造器初始化。这就直接导致其他线程拿到的是一个“半初始化”的对象。
你提到的构造器内启动线程的情况,本质上也是一样的——哪怕启动线程是构造器最后一条语句,JVM也可能把“启动线程”这个操作和构造器内的初始化操作重排序,导致线程一启动就访问到未完全初始化的对象。
那怎么避免这个问题呢?常见的方案有两种:
- 绝对不在构造器内发布对象,把发布操作放到构造器外面,比如用静态工厂方法:
public class SafeExample { private final int importantValue; private SafeExample() { importantValue = 100; } public static SafeExample create() { SafeExample instance = new SafeExample(); GlobalRegistry.register(instance); // 构造完成后再发布 return instance; } } - 如果必须在构造器相关逻辑里发布,就用同步手段保证happens-before关系,比如把发布的对象引用声明为
volatile,或者用锁来保护发布操作。
内容的提问来源于stack exchange,提问作者Vip

