线程是CPU调度的最小单元,线程中的字节码最终是放到CPU中执行的,CPU执行的时候伴随着数据的读写,在Java中所有的数据都是放在主内存(RAM)中的,这一过程如下所示:
随着CPU技术的发展,CPU的执行速度越来越快,但是内存技术并没有太大的改变,这就导致内存中数据的读写速度和CPU处理数据的速度差距越来越大,CPU需要较长时间等待内存的读写,这就意味着CPU会出现空转的情况。为了解决性能的瓶颈,进一步释放CPU的运算能力,在CPU中添加了高速缓存(cache)作为数据的缓冲。
在执行任务之前,CPU会首先将数据从主内存中复制到高速缓存中,让运算能够快速进行,当运算完成之后,再将缓存中的结果刷回到主内存中,这样CPU就不用等待主内存中数据的读写了。
目前市面上有的手机有多个CPU、一些CPU还有多核,每个CPU都可以运行一个线程,这就意味着主内存中的数据同时可能被多个线程同时读写,而CPU的高速缓存也是相互独立的,这就会导致主内存中数据的不一致的问题。
另外来自于硬件的指令重排也会导致数据的不一致。CPU内部的运算单元为了尽量被充分利用,处理器会对字节码进行指令重排。
比如下面的代码(b的赋值不影响a的运算):
编译之后的字节码为:
上面的指令中可以看到,指令7(对应最后a + 1)并不影响指令2和指令3,这种情况下,CPU会对指令的顺序进行调整:
从Java语言的角度,调整后的代码顺序:
上面的指令重排在多线程的情况下,由于指令的重拍,单个线程内并没有影响,但可能影响多线程中数据的读写操作,从而导致一些意想不到的结果。
Java的内存模型
如果任由CPU进行优化或多线程的操作,会导致Java程序运行结果出乎意料,为了解决这种问题,JVM推出了Java内存模型来解决。
在java内存模型中,我们统一使用工作内存(working memory)来当作CPU中的寄存器或高速缓存的抽象。线程之间的共享变量存储在主内存(main memory)中,每个线程有自己的工作内存,线程的工作内存中存储了共享内存中变量的副本。
在JMM规范中,又一个重要的规则happens-before。
happens-before先行发生原则
happens-before用于描述两个操作在内存中的可见性,通过保证数据的可见性,从而让应用程序免于数据竞争的干扰。
会发生指令重排的情况:
下面的代码:
int a = 10; // 1
b = b + 1; // 2
由于操作2和操作1之间并不会相互影响,这种情况下CPU为了提高计算单元的利用率,一般会进行指令重排。
但是我们要是把代码改成下面这种:
int a = 10; // 1
b = b + a; // 2
由于操作2依赖操作1的执行,这种情况下就不会发生指令重排了。
在Java内存模型(JMM)中,有以下的一些情况会自动符合happens-before规则:
1. 程序次序规则
在单线程中,一段代码中逻辑顺序靠前的字节码一定是对后续逻辑字节码可见的。
2. 锁定规则
无论是单线程还是多线程环境中,一个锁处于锁定状态,那么必须首先执行unlock才做,这个所才能被其他的线程获得并重新lock。
3. 变量可见规则
volatile关键字保证了变量的可见性。如果一个线程写了volatile的变量,另外一个线程读取这个变量,那么这个写操作一定是happens-before读操作的。
4. 线程启动规则
Thread对象的start方法先行发生于此线程的每一个动作。假设线程A在执行的过程中通过执行ThreadB.start()来启动线程B,那么线程A中对共享变量的修改,在线程B开始执行后对线程B可见。
5. 线程终结规则
假如线程A在执行的过程中,通过调用ThreadB.join()方法等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等到返回之后可见。
6. 对象终结规则
一个对象的初始化完成发生在它的finalize()方法之前,也就是对象初始化的数据对它的finalize方法可见。
两种happens-before化的方式
1. 使用volatile关键字
2. 使用synchronized关键字
通过上面两种方式,在一个线程中调用setValue设置的value对其他的线程可见,再setValue之后,其他的线程调用getValue获取到的value一定是1.