fraternityjava 2020-06-04
众所周知,JVM 创建一个对象分三步:
1.在堆内存开辟内存空间。
2.在堆内存中实例化Car里面的各个参数。
3.把对象指向堆内存空间。
为了提高运行效率,编译器在编译代码时可能会对指令进行重排序。重排序的原则是,保证单线程执行结果的正确性,并遵循 happen-before 原则。
指令间的依赖关系包括数据依赖和控制依赖。对于控制依赖,处理器 本身存在流水线冒险等行为,处理器层面会对存在控制依赖的指令进行结果检查,该类指令优化不在编译器层面。而对于存在数据依赖的指令,如果打乱顺序,显然会造成单线程情况下执行结果的变化。所以编译器不会打乱存在数据依赖的指令的顺序。
上述三步中的 2、3 两步并不存在数据依赖关系,单线程情况下将其顺序颠倒并不会导致执行结果的变化,并且这两步不在 happen-before 原则范围内,因此在编译过程中可能会被打乱顺序。
但在多线程环境下,这两步是可能存在数据依赖关系的。
比如一个比较复杂的例子,NIO 编程时,我们为 channel 注册了一个感兴趣的事件,并绑定一个 handler 实例:
如果事件触发,将会在一个新线程执行 handler 中的 run 方法,handler 的构造方法如下:
构造方法会初始化成员变量。run 方法中会使用这些变量:
如果在第一张图中,new Handler 时,2、3 步 被乱序执行,那么第三张图 read 函数调用会报空指针异常。因为指向 handler 对象存储空间的指针首先被返回赋值给了 channel 的 attachment ,但 handler 对象的成员还没有被初始化,所以 成员变量 socketChannel 还是空的。
事实也证实了这一点:
我们要保证的是,执行 handler 的 run 之前,handler 的构造方法得到完全执行,并且多核环境下执行结果对其它线程(核心)可见。
因此多线程环境下,想要程序按预想的情况执行,我们需要保证两点:
1. 构造函数的执行早于 run 函数的执行。
2. 构造函数必须完全执行,且结果对所有线程可见,才可以执行 run 方法。
对于 1, 我们在执行 run 之前可以对 attachment 进行判空,空的话什么都不做,等待事件下次触发再判断构造函数是否已经执行(感谢 NIO 的水平触发)。
对于 2,我们在 1 的基础上,通过锁使得 构造函数 与 run 方法互斥即可实现。通过 synchronized 的锁和其 monitorEnter 后、monitorExit 前的内存屏障可以充分保证可见性和有序性。一旦构造函数先执行,run 一定等待其执行完成才会执行;若构造函数未执行,run 立即返回等待下次被触发。
构造函数加锁:
run 函数加锁:
在写多线程代码时,如果存在类成员变量等线程共享变量,一定要注意其线程安全性。在写上述代码时(一个 Reactor 模型),因为一个 handler 对象只会被一个线程调用,因此忽略了对线程安全的考虑。但最后出错才发现,还有另一个线程在调用构造方法!这样便是两个线程操作 handler 对象,一个调用构造函数、一个调用其它函数。
考虑线程安全问题,构造方法一定要考虑在内!
作如上修改后,问题解决,大剂量测试没有异常发生: