D 的个人博客

开源程序员,自由职业者

小而美的 Java 博客系统 Solo
Golang 在线 IDE Wide
黑客与画家的社区 Sym
  menu
397 文章
1,693 评论
3312426 浏览
3 当前访客
ღゝ◡╹)ノ❤️

Java 原子操作与并发

由一个简单的例子引出并发处理时容易被忽视的陷阱,用来作为面试问题应该很适合。

某日,工作了 4 年多的 Java 程序员小 K 跳槽,面试时碰到这样一个题目....

 

public class P1 {
private long b = 0;

public void set1() {
	b = 0;
}

public void set2() {
	b = -1;
}

public void check() {
	System.out.println(b);

	if (0 != b && -1 != b) {
		System.err.println("Error");
	}
}

}

 

问题

调用 set1()、set2()、check(),会打印出 Error 么?

 

小K 的推理

“无论如何调用 set1()、set2() -> b 的值只可能是 0 或 -1 -> 在 check() 里面的判断条件(b 既不为 0 也不为 -1)永远不成立 -> 不打印 Error”

 

小 K 觉得有坑:这题目应该不会这么简单,再考虑一下多线程环境。

 

思前想后,小 K 得出结论:“在多线程环境下也不会打印 Error。这题目很简单,就是考察一下推理吧。”,K 暗自窃喜。

 

后来小 K 陆续又被问了几个多线程和 JVM 的问题。

后来,就没有后来了....

 

后来

后来还是有的。到家后,不甘心的小 K 验证了这道秒杀他的面试题。

 

	public static void main(final String[] args) {
		final P1 v = new P1();
	// 线程 1:设置 b = 0
	final Thread t1 = new Thread() {
		public void run() {
			while (true) {
				v.set1();
			}
		};
	};
	t1.start();

	// 线程 2:设置 b = -1
	final Thread t2 = new Thread() {
		public void run() {
			while (true) {
				v.set2();
			}
		};
	};
	t2.start();

	// 线程 3:检查 0 != b && -1 != b
	final Thread t3 = new Thread() {
		public void run() {
			while (true) {
				v.check();
			}
		};
	};
	t3.start();
}</pre>

 

使用 3 个线程分别重复执行 set1()、set2()、check()。执行输出结果部分如下:

....
0
0
1
1
1
Error
Error
-4294967296
0
0
4294967295
....

执行环境:

    Java(TM) SE Runtime Environment (build 1.6.0_31-b05)

    Java HotSpot(TM) Client VM (build 20.6-b01, mixed mode, sharing), 32bit

 

“确实打印了 Error,并且打印了 4294967295、-4294967296。我勒个去,只是啥情况?”

 

小 K 决定搞懂其中奥秘,重新审视了题目。以一个专业程序员的严谨,并经过无数次 Google 后....他似乎发现了问题所在。 

 

“这确实是一个并发问题!”

 

分析

这道题目有两个陷阱,分别考察了对并发执行的理解,以及对 JVM 基础(赋值操作)的掌握。

 

陷阱一:并发执行

并发执行就是多个操作一起执行,CPU 执行不同上下文(可理解为不同线程)发过来的指令。操作系统上层看上去就像是并行处理一样。

 

也就是说,在编程语言层面,一个简单的操作同样需要考虑并发问题。

 

小 K 首先是栽在了 check() 中的 if 判断上和设值是存在并发的,不能保证 0 != b 这个判断真(此时 b 为 -1)后恰好 b 被赋值为 0 时判断 1 != b。

 

除此外,无论 JVM、操作系统、CPU 层面对指令如何优化、重排,最终都是逐一执行单一指令,唯一不同的就是不同层面可能会对执行加以限制,

比如加入原子操作,最终保证 CPU 能够完整执行一组指令。

 

陷阱二:JVM 赋值操作

一些赋值操作不是原子性的。“纳尼?”

 

Java 基础类型中,long 和 double 是 64 位长的。32 位架构 CPU 的算术逻辑单元(ALU)宽度是 32 位的,在处理大于 32 位操作时需要处理两次。

 

“这不是<计算机组成原理与汇编>么”,小 K 顿时感到大学白上了,不懂学以致用 T_T~

 

题目执行打印 4294967295、-4294967296 就是因为读时高 32 位或低 32 位被其他写覆盖了(看一下这两个数字的二进制就知道了)。

 

Java 已经是封装底层细节很好的语言了,但依然需要注意这些陷阱,可以使用并发处理包 java.util.concurrent.atomic 中包含了一系列无锁原子操作类型,

也可以使用 volatile 关键字保证线程间变量的可见性。

 

其实这道题目只要解决了并发问题,也就保证了每个执行单元(set1()、set2()、check())中赋值、比较的正确性。可以把同步方法执行看作序列化的事务,各中操作不会相互影响。

 

再后来

虽然小 K 面试挂了,不过他挂得心服口服。

通过这个期间的不断翻阅文档以及实验,小 K 下次的面试应该不会被类似的题目秒杀了吧....

 

“按照这个简单面试题的标准,以前写过的程序简直就是通篇 bugs 啊!有木有,有木有啊!!!!”

 

骚年,继续充电吧!

内外兼修才是好程序员 :-)

评论
  • 😄 白学了那么多年

    Reply
  • focus @ armon

    可以的,兄弟

    Reply
  • armon

    在64位os下测了下,楼主的程序不会打印ERROR,我猜测大概原因如下:
    1)long是64位的问题。在32位os写高32位和低32位的问题,在64位机器貌似没有问题
    2)if的两个判断的问题。因为线程有自己的堆栈和寄存器,线程3在if读到b之后,第二次貌似就从自己的寄存器读取了

    把“private long b = 0;” 改成 “private volatile long b = 0;” 之后,就能打印ERROR了,原理是volatile要求线程每次都去内存读取b

    大家不信可以试试,实验是检验真理的唯一标准

    Reply
  • armon @ Novemser

    同样在java8下跑了很久都没有ERROR

    Reply
  • 杨正

    jmm规范虽然没有“要求”long/double的操作是原子操作,但是“强烈建议”,事实上绝大部分的jvm都是按照原子操作来实现的,这确实是线程不安全的,不过愿意是if里的判断,lowB

    Reply
  • Novemser


    Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
    Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, mixed mode)
    Windows 10 64bit

    运行上面的代码,跑了很久也没有发现输出-4294967296一样奇怪的值。。。有没有和我一样的小伙伴

    Reply
  • Ludash @ 许仙儿

    @许仙儿: 如果是64bit的OS和64bit的JVM也还是会打印出ERROR的,因为并发的存在。

    Reply
  • 顿时明白了很多,非常感谢

    Reply
  • 许仙儿 @ 许仙儿

    如果是64bit的OS和64bit的JVM,应该没问题吧!

    Reply
  • 许仙儿

    如果是64bit的OS和64bit的OS,应该没问题吧!

    Reply
  • 沉寂

    非原子的64位操作

    当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性( out-of-thin-air safety)。

    最低安全性适用于绝大多数变量,但是存在一个例外:非volatile 类型的64位数值变量(double和long,请参见3.1.4节)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

    Reply
  • zzj

    😱

    Reply
  • 为什么在Java中变量赋值中,除了long和double类型的变量外都是原子操作?

    由于long和double类型是大于32BIT的,JVM把他们作为2个原子性的32位值来对待,需要进行2次赋值。所以,不是原子操作。

    Reply
  • 转走~

    Reply
  • 😭骚年来从电了。

    决定以后跟你混好了。

    Reply
  • 😂,好贴啊,看来之前我大学里也白上了

    Reply
  • 我表示还是看不懂。。顺便把沙发拿走

    Reply