此文已由作者徐赟授权网易云社区发布。
欢迎访问
一、 java并发
并发问题可以分为两个方面,操作原子性和内存可见性,正确的并发操作必须同时保证这两方面。保证并发代码执行正确最简单的方式是使用sychronized关键字或lock将整段存在并发问题的代码包裹起来,但这样很大程度上降低了代码的并发性。相对比较好的方式是使用ReadWriteLock或CopyOnWriteArrayList甚至是java8提供的StampedLock(一些场景下比ReadWriteLock效率高),但这些工具只在一定场景中相对适用,不能解决所有问题。而从底层理解java并发有助于理解JUC包,也能更好的掌握并发代码的编写。
操作原子性比较好理解,常见的例如i++操作,实际上由一个读操作,一个加操作,一个写操作组成,当读写操作之间有其他线程或处理核对i变量做修改,就会因为破坏了操作原子性而导致计算结果出错。
相对于操作原子性,内存可见性相对比较隐晦。简而言之,内存可见性是因为多线程存在缓存而造成的变量更改不能对所有线程可见的问题。
二、 问题代码
public class RecordingProblem1{
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws Exception{
int count = 0;
while(true) {
Thread one = new Thread(new Runnable() {
public void run(){
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run(){
b = 1;
y = a;
}
});
one.start();other.start();
one.join();other.join();
System.out.println("第" + count++ +"次(" + x + "," + y + ")");
if ((x | y) == 0) {
break;
}
x = 0;
y = 0;
a = 0;
b = 0;
}
}
}
理论上会有以下三种执行过程
1. one线程执行完,other开始执行,这样结果是(0,1)
2. other线程执行完,one开始执行,这样结果是(1,0)
3. one线程执行一半,other开始执行,one线程继续执行,这样结果是(1,1)代码输出结果如下:
第557358次(0,1)
第557359次(0,1)
第557360次(0,1)
第557361次(0,1)
第557362次(0,1)
第557363次(0,0)
最终结果出现了(0,0),这就是因为内存可见性问题导致的。
三、 JMM
JMM(java memory model)是java语言规范中定义的java内存模型,用于规范并发线程间共享内存的可见性问题。由于不同机器的硬件设备不同、cpu优化重排的方式不同,因此为了解决跨平台过程中的内存可见性问题,抽象出JMM概念,以屏蔽平台差异。
JMM中抽象出两种内存模型,线程独有的本地内存和线程间共享的主内存。本地内存是一个抽象的概念,并不代表内存中具体的硬件设备,所有因为性能原因导致主内存中数据产生副本的情况都可视为数据被存放在本地内存的结果(例如硬件上的寄存器、多级缓存、写缓冲区等)。
JMM中提出重排序概念,重排序指代码的执行顺序(效果)和用户代码书写逻辑不同的现象,重排序也是一个抽象的概念。重排序分为三种,编译器优化,指令集并行,内存系统重排序。编译器再不改变单线程执行语义的情况下,能够重新安排语句的执行顺序,其目是优化代码的执行效率。
例如:
1. 处理器在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待(缓存未命中时,现代 CPU 可以根据内存加载的结果做一个假设,然后基于这个假设继续执行直至实际数据的加载完成),此时指令被乱序执行(out-of-order execution,简称OoOE或OOE);
对于处理器依照假设结果对指令重排序的情况比较难理解,举例如下:
原代码:
if (flag) {
int i = a * a;
}
重排序后的代码:
int temp = a * a;
if (flag) {
int i = temp;
}
这种优化方式在指令级并行的前提下是完全有可能的,并且不改变单线程执行代码的逻辑,这就是单线程允许对指令进行重排序的原因。
2. 单处理器在一个指令周期中能同时执行一次加法,一次乘法,一次读和一次写,这被称为指令级并行,因此处理器为了提高执行效率,会对执行做重排,造成指令执行顺序改变。
3. 硬件上的多级缓存结构造成数据写入操作延迟生效(在写入主存之前对其他处理器不可见,此为无效状态),多处理器并发执行时,表现效果类似于重排。
4. Java运行时环境的JIT编译器也会做指令重排序操作,即生成的机器指令与字节码指令顺序不一致。
重排不会改变单线程代码执行的结果,这是代码重排的最低要求。但当出现多线程之间共享变量时,代码的依赖关系耦合并复杂化,由此产生的并发问题会造成执行结果出错。
基于内存可见性不可靠的前提,两个关键字volatile和final的内存语义得到了扩充,用以解决内存可见性问题。
四、 volatile
volatile字面意思是易挥发的,最初的含义是对变量的读写不经过缓存。但由于指令重排情况的存在,简单的volatile语义已经不能解决多线程间的内存可见性问题。java5中volatile语义得到了加强,对volatile变量操作的前后会被加上内存屏障(一类处理器指令),保证内存可见性。
上图罗列了volatile变量对指令重排的影响,总结下来就是以下三点:
1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
volatile写会将当前线程本地内存的所有共享变量同步到主内存里,因此,对volatile变量的写操作意味着,逻辑上排序在volatile写操作之前的操作,都会在volatile写操作执行的一瞬间同时对其他线程可见。所以volatile写操作之前的操作不能被重排序到volatile写操作之后,因为若某变量的操作重排到volatile写操作之后,就不能确保该变量的变更对其他线程可见。
volatile读会从主存中重新加载线程的共享变量到本地内存,因此,对volatile变量的读操作意味着,volatile变量的读操作更新的一瞬间会刷新本线程所有共享变量的数值到最新,volaitle读操作之后的其他操作将会使用这一瞬间刷新的共享变量值,也即逻辑上排在volatile读操作之后的数据将被保证获得这一瞬间的最新共享值。所以volaitle读操作之后的其他操作不能重排序到volatile读操作之前,因为这样不能保证其他操作能够使用最新的共享值。
volitile读写操作相互之间不允许重排。
综上所述,volatile的语义不仅能保证自身的内存可见性,还能在一定程度上保证其他共享变量的内存可见性,这就是jsr-133协议对volatile增强最重要的一点,也是JUC中保证内存可见性的依据。
JMM通过插入内存屏障的方式实现volatile变量操作对内存可见性的影响,当前插入策略似乎比较粗暴:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障(StoreLoad开销最大,x86机器只有这种屏障)。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
内存屏障是一个抽象概念,对应每种类型cpu提供指令集中的某一些指令。
五、 final
final关键字在JMM中也有特殊的语义。简而言之,定义为final的变量能够被保证在所属对象构建结束后被完全创建。
创建一个对象的操作可以被拆分成两步:
1. 创建一个对象。包括堆内存分配,变量初始化等
2. 对象引用赋值。赋值后,对象就能通过引用被调用
正常逻辑应该是在1执行完之后再执行2,但由于多线程中表现出来的指令重排现象,在其他线程看来可能出现2先于1执行完毕的情况。由于2先执行完毕,对象已经处于可以被使用的状态,此时就会有未被完全创建的对象被使用的风险,从而产生错误的结果。
六、 小结
对java并发知识的学习是理解JUC原理的基础,是编写高并发代码的前提,也是进阶java高手的必经之路。文章伊始列举的(0,0)问题,就是由多线程共享变量而表现出来的代码重排所造成的,在遇到此类问题时要利用volatile和final关键字约束共享变量行为,从而规避该问题。
网易云
更多网易技术、产品、运营经验分享请