原子赋值
并非所有的Java赋值操作都是原子的。了解根据Java规范,哪些类型可以进行原子赋值。
概述
多线程的核心在于能够以原子方式执行操作,即在执行过程中不会受到其他线程的干扰。我们使用各种语言提供的机制来确保代码的关键部分能够原子化执行。然而,这也引出了一个问题:哪些操作是语言本身保证原子执行的?例如,我们已经知道,像下面这样递增一个整型计数器的操作从来不是线程安全的:
1counter++;
上述表达式会被分解为多个底层指令,而这些指令本身可能无法原子执行,因此我们无法保证整个表达式的执行是线程安全的。那么,像赋值这样的简单操作呢?考虑以下代码:
1void someMethod(int passedInt) { 2 counter = passedInt; // counter 是一个实例变量 3}
如果有多个线程同时调用 someMethod,并且各自传入不同的整数值,我们能否认为这个赋值操作是线程安全的?为了具体说明,假设两个线程分别调用 someMethod(5) 和 someMethod(7),那么 counter 可能出现三种结果:
counter被赋值为 5counter被赋值为 7counter得到一个任意值,因为部分位由第一个线程写入,另一部分由第二个线程写入
在本例中,变量 counter 是基本类型 int,在 Java 中占 32 位。细心的读者会质疑:赋值操作本身是否被保证为原子操作?
Java 规范
赋值操作是否具有原子性是一个合理的问题,Java 规范对此有明确说明:
-
对于除
double和long之外的所有基本数据类型,其读取和赋值操作始终是原子的。如果两个线程分别调用someMethod()并传入 5 和 7 给整型变量counter,那么counter的最终值只会是 5 或 7,不会是其他值,也不会出现某个线程只写入部分位而导致数据混乱的情况。 -
对
double和long类型的读写操作不保证是原子的。JVM 规范允许将 64 位的double或long类型的写入操作拆分为两次 32 位的写入操作。这可能导致两个线程同时写入double或long值时,第三个线程观察到前 32 位来自第一个线程的写入,而后 32 位来自第二个线程的写入,从而读取到一个既非任一线程写入、也可能是无效的数据。为了确保对double和long类型的读写操作是原子的,必须将其声明为volatile。规范保证了对volatile修饰的double和long类型的读写是原子的。需要注意的是,某些 JVM 实现可能会默认保证double和long的读写原子性,但这并不在所有 JVM 实现中都得到保证。我们将在后续课程中详细介绍volatile,目前只需记住:若要保证double和long类型赋值的原子性,必须使用volatile修饰。 -
所有引用类型的赋值操作都是原子的。所谓引用,是指变量保存了一个内存地址,该地址指向由 JVM 分配的对象。例如,考虑代码片段
Thread currentThread = Thread.currentThread();,变量currentThread存储了当前线程对象的内存地址。即使多个线程同时执行这段代码,currentThread变量也只会包含一个有效的内存地址,而不会出现一半来自一个线程、另一半来自另一个线程的“混合”地址。无论实际引用地址是 32 位还是 64 位,引用的读取和写入操作始终是原子的。
需要强调的是,Java 平台保证的原子赋值并不等同于线程安全。我们的示例方法 someMethod() 本身并不是线程安全的。对于更复杂的场景,比如先读取再赋值(如单例模式的初始化),仍需通过锁或 synchronized 来保证线程安全。