目录
前言:
并发编程中有两个非常重要的概念“一致性”、“可见性”。这两个是独立的不同的两个概念,不能混为一谈。而并发因为不可控性,本质其实就是想尽各种办法解决这两个东西。
一、从单例说起
说到并发必然离不开单例模式这一经典设计模式。
首先我们来看一个经典的双重检查锁的单例模式:
class Singleton{
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
这种双重检查锁目的有两个:
1.保证只有第一次创建instance的时候才进入锁块。如果锁方法的话则每次获取实例对象时都排队执行,降低了并发速度。
2.每次进入锁后不是直接执行new操作,而是再次判断。因为此时可能线程1进入if,并进入锁代码块,但没执行new操作。此时线程2也进入if,并等待锁释放。如果此时锁代码块中没有判断,则会导致线程1执行new操作并返回示例。线程2进入锁并同样执行new操作,此时就new了两个对象。而锁里加了判断则会让进入锁的线程再次判断,不会直接new对象。保证了单例唯一。
但这样做还是有问题。接下来就要说说java内存模型的问题。
cpu在执行java程序时,在单线程情况下是按照代码顺序执行,比如:
int a =10; //步骤1
String b = "20"; //步骤2
int c = 30; //步骤3
int d = a++; //步骤4
cpu会按照1、2、3、4的步骤顺序执行。但当多线程并发时,java会根据cpu性能进行指令重排序。真实的执行顺序可能是1234、1324、2314...等等,但有一个原则,那就是最后的执行结果一定和顺序执行是一致的。也就是说d依赖a的结果,那么步骤4一定是在步骤1执行完后执行的。
肯定有人会说,虽然指令重排序了,但似乎也不影响并发问题?
但请注意了,并发过程中,线程可能会在“任何”地方停止。这里的“任何”指的是每个原子性操作的地方。
那么上述代码中哪些是是原子性的?只有1、3是。java中“只有”基本类型的直接赋值操作是原子性的。
例如:步骤1和步骤3是原子性的。步骤4不是原子性的,因为这一步需要1:读取a数据:、2:赋值给d、3:进行加一操作。
每个线程有自己的内存区域,而读取数据时不一定是直接从内存中读取共享数据,而是从缓存行(主要针对多核cpu有多个独立的缓存行)读取。
volatile关键字就是保证,每个线程对volatile关键字修饰的变量都更新到内存。其他线程要用到该变量时会锁住缓存行,直接从内存读取。这样保证了变量对各个线程的可见性。
回到开头的单例模式。
new singleton()操作在Java中不是一个原子性的操作。这个动作大概分了3步
1.申请一个内存区域(空白内存)
2.调用构造方法等对singleton进行初始化(写内存)
3.将变量指针指向该对象内存区域(变量声明)
那么问题来了。虽然双重验证锁保证了进入锁时进行判断。但如果线程1在进行new操作时,由于指令重排序先执行了1、3而没有执行初始化的步骤。此时线程2进来判断instance非null直接返回了。那么线程2获得的是个不完整的对象,使用时就会报错。
如何优化?
二、保证可见性
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
在instance变量上加上volatile标志,这时能保证instance变量的修改对其他变量是可见的。避免上述的问题。
volatile关键字不仅保证多线程可见,还会保证volatile关键字前、后的步骤一定是在其前后。
比如:
int a =10; //步骤1
volatile String b = "20"; //步骤2
int c = 30; //步骤3
int d = a++; //步骤4
这样能保证步骤1一定在步骤2之前执行完,虽然不能保证步骤3、4的顺序但步骤3,4一定在步骤2之后执行。
应用volatile关键字的三个原则
(1)写入变量不依赖此变量的值,或者只有一个线程修改此变量(否则写入时可能不是当时对应的值)
(2)变量的状态不需要与其它变量共同参与不变约束
(3)访问变量不需要加锁
题外:
上文中双重检查锁是早期一种为了减轻锁影响的效率而发明的“聪明”的写法。因为1.5以前内置锁效率很低,为此搞出了很多奇门偏方,双重检查锁就是其中之一。实际上也是因为早期内存、cpu资源紧缺,很多东西都希望懒加载导致。双重检查锁这种方式是最不推荐的单例写法。这种方式只考虑了同步的一致性没考虑可见性,volatile只保证可见性不保证一致性。因此,两个东西结合起来才能真正保证并发安全。实际1.5之后的jdk,锁已经很轻量了。就单例而言,初始加载或者枚举是最简单也是最推荐的做法。