一个问题的产生,往往是各种原因导致的。如果不了解问题的源头,我们就没办法给出合理的解决方案,再说源头都不了解,还谈什么解决问题啊。今天,我们来浅析一下并发问题产生的原因。
一、CPU缓存导致的可见性问题
先说下题外话:缓存这个东西就是个双刃剑,合理运用缓存很重要!
为了平衡CPU、内存之间的速度差,CPU的硬件设计师引用了CPU缓存这个东西。其实倒退个几十年,大部分服务器还是单CPU的情况下,这么设计问题也不大。但是在现在起步都是双核的情况下,问题就来了,我先来手工画一幅多CPU跟内存的交互模型图(个人理解,纯手工制作,大家将就看吧)
CPU在读数据的时候,会先去CPU缓存里面找,如果没有,再去内存里面取。可是CPU之间的缓存却是相对隔离的,这就导致了各个缓存之间缓存的数据并不是可见的。因此,并发问题的第一大坑就来了。
顺带提一嘴这个问题的解决办法:就是禁用缓存,让每次都直接从内存里面去存取数据,不走缓存,不就可以解决这个问题了吗,java的具体解决方案留着后面再说。
二、线程切换导致的原子性问题
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
看上面的代码是一个单例模式的懒汉式实现方式,如果现在有两个线程同时调用getInstanc()方法,就有可能会发生线程安全问题。为什么会产生这样的问题呢,是因为在我们正常的概念中,一个方法应该执行完毕才会发生线程切换,可是实际上却并不是这样的。甚至对于高级语言来说,一行代码的执行,实际上会解析成多条指令,CPU只会保证单条指令的原子性。一句话来说线程随时可能切换。
当线程A调用getInstance的时候,由于instance == null,会进行instance = new Singleton();的初始化,但是这个时候发生了线程切换,线程B也调用了 getInstance(),也判断出instance == null,这个时候线程A拿到的对象实例就跟线程B拿到的对象实例就不是同一个实例了。
例子2
针对原子性的问题,我再举一个例子,案例参考于来源是《Java并发编程实战》
public class Test{
private long count;
public void getCount(){
return count;
}
public void test(){
++count;
}
}
先提出问题,这个Test类是线程安全的吗?
其实不是,++count看上去是一行代码,其实他也是一个“读取-修改-写入”的操作序列。由于线程随时可能切换,所以他可能在读取的时候发生切换,导致线程不安全的情况发生。
三、编译优化导致的有序性问题
接着第二点的代码,咱们给他优化一下成如下代码。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
这样的代码看着就无懈可击了,但是智者千虑终有一失,这里我就摘抄一下别人的文章的片段了。
以下文章节选自JAVA并发编程实战:
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。
这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:
分配一块内存 M;
在内存 M 上初始化 Singleton 对象;
然后 M 的地址赋值给 instance 变量。
但是实际上优化后的执行路径却是这样的:
分配一块内存 M;
将 M 的地址赋值给 instance 变量;
最后在内存 M 上初始化 Singleton 对象。
优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
第三点总结一下,编译器的优化工作,可能会导致我们的代码前后顺序紊乱,虽然他保证了不会影响程序最终结果。但是偶尔还是可能导致意向不到的BUG产生。