文:GentlemanTsao
前言:
Debug并发的bug通常十分困难,这些bug在测试阶段一般无法暴露,直到程序高负载时才被发现,而且很难复制和追踪问题。解决并发bug的关键不在于问题暴露以后,而是在设计时花更多的精力确保程序已经正确的同步了,这比debug一个漏洞百出的并发程序要轻松的多。
一个模型通常是为了解决一类问题而设计。Java内存模型(JMM)是为了解决并发中遇到的同步问题而产生的。所以,首先要讨论的是,程序并发执行时遇到了哪些问题?
1.可见性问题:麦琪的礼物
欧亨利的短篇小说《麦琪的礼物》讲述了这样的故事:麦琪和丈夫生活穷困,为了给彼此送圣诞礼物,麦琪卖掉长发换来表链,麦琪的丈夫卖掉金表换来梳子。他们都出于为对方着想,结果却都买了无用的礼物。为什么会这样?
假如把麦琪和麦琪的丈夫看作两个线程,长发和表链是两个变量的值。当其中一个线程更改变量的值后,另一个线程没能及时“看到”。
以下面的伪代码来表示
class 圣诞快乐{
String x = “长发”;
String y = “金表”;
public void 给丈夫买礼物{
//有金表,缺表链,所以用长发换表链
if(“金表”.equals(y)){
x = "表链";
}
}
public void 给麦琪买礼物{
//有长发,缺梳子,所以用金表换梳子
if(“长发”.equals(x)){
y = "梳子";
}
}
}
线程“麦琪”调用方法“给丈夫买礼物”,将x的值改为“表链”;
同时另一个线程“丈夫”调用方法“给麦琪买礼物”,但仍然以为x的值是“长发”!因而又将y的值改为“梳子”。
为什么线程“丈夫”看不到x的改变?
缓存的设计
多核系统中CPU通常有多层缓存(cache),cache的目的是为了改善读取速度(因为缓存速度更快)以及减少内存总线的占用(对cache操作代替了内存操作)。Cache的设计极大的提升了性能,但是同时带来了问题。当多个核心同时访问同一块内存时,因为cache的存在,它们看到的值可能是不一样的。这是从CPU层面看可见性问题。
JMM设计思想1:
从CPU层面上,java内存模型定义了一个充分必要条件,让当前核心能够看到其他核心对内存的修改,并且其他核心也能够看到当前核心对内存的修改。
2. 有序性问题:编译器大厨的排序自由
一段代码的执行顺序并不一定是完全按照代码书写顺序来执行的。
不妨将书写代码想象成前台点菜,你以为后厨按照你的菜单顺序出菜。你点了下面的菜单:
class 顾客{
String x,y;
public void 点菜{
x = “水煮鱼”;
y = “番茄炒蛋”;
}
public void 去厨房端菜{
String plate1 = x;
String plate2 = y; // plate2有值的情况下,plate1一定有值吗?
}
}
编译器大厨接到菜单,认为“水煮鱼”这个菜很耗时,可以放到后面做,先把“番茄炒蛋”烧起来。因为出于效率考虑,编译器大厨可以自由安排炒菜顺序。
某一时刻你发现plate2的值是“番茄炒蛋”,这表示“番茄炒蛋”已经做好装盘了,这时我们会以为“水煮鱼”也应该做好了,因为从代码的书写顺序上看,它排在前面。可实际上并非如此,plate1的值可能还是null.
在只有一个线程的时候,这个问题不会被发现;但多个线程的情况下,编译器的重排序会带来错乱。
事实上,不仅编译器,CPU也可以对指令重排,只要两个指令之间没有先后依赖关系。
JMM设计思想2:
Java内存模型描述了程序中的变量和计算机系统底层的内存、寄存器存取操作的对应关系。它定义了多线程代码的合法行为,以及线程和内存的交互方式。
3.原子性问题:ATM机取款的安全保障
“原子”的涵义是不可分割,指的是一个操作不可中断,或者完成,或者没有开始。我们以ATM机取款为例。银行认为ATM取款(插卡,取款,拔卡)应该是一个原子操作,不可以分割。换句话说,必须防止客户A插入了银行卡以后,客户B过来取款的事情发生。
如果把每个取款客户看作一个线程,原子操作保证了线程之间不会干扰该操作,所以该操作是线程安全的。
Java中的原子操作是对基本数据类型变量的读取和赋值。
注意点:
必须是对基本数据类型,例如int,Boolean;
只包括读取和赋值,不包括加减等运算
例如:
x = 1; //原子
y = x; //不是原子,包含读取x和给y赋值两个操作,虽然这两个操作是原子的,但合起来却不是。
在下一篇我们将讨论JMM是如何解决以上的并发问题的。
想了解更多?访问这个专栏:
java并发和多线程教程2020版
本文为博主原创。请帮点赞、评论,让我们共同进步。