由于CPU直接访问主存的速度过慢,导致CPU资源受到很大限制,降低了CPU的整体的吞吐量。所以就有了CPU缓存的出现。现在的缓存一般有2-3级,最靠近CPU的是1级缓存。CPU的cache比较大,一般128KB,被分为多个固定大小的cache line,cache line通常是32byte或者64byte.
因为CPU cache的访问主存的速度比CPU直接访问主存的速度快很多,所以程序运行时,运算所需数据首先会先复制一份到cache中,CPU计算时就可以直接去cache直接或取,然后将计算结果写入到cache中,再由cache将结果刷新进主存。从而cache代替了CPU直接访问主存,极大的提高了CPU的吞吐能力。
缓存的出现,极大提高了CPU的吞吐量,但是引入了缓存不一致的问题。比如i++操作,在程序运行时,会先从主存复制一份数据到cache中,然后CPU寄存器直接从缓存中获取数据进行计算,再写入cache中,最后将cache的结果刷新到主存中。如果在单线程模式下,此方式没什么问题,如果是多线程下 访问就会出现数据不一致的问题。
CPU解决缓存不一致的问题主要有两种方式
1、总线加锁(CPU与其他组件通信都是靠数据总线、控制总线、地址总线)
2、缓存一致性协议
总线加锁机制效率低,因为每次只要一个总线能获取到锁,其他总线就需要等待,这是一只悲观锁。
缓存一致性协议,最出名的是MESI协议。此协议保证了缓存使用的每一个共享变量都是是一致的。大致的思想:CPU操作cache的数据是,发现变量是一个共享变量,也就是说在其他缓存也存在这样的一个副本,它大概会做如下的操作
1、读取操作,不做任何操作,寄存器直接获取cache中的值。
2、写入操作,发出信号通知其他cache中的改变了cache line置为无效状态,其他CPU当需要读取这个共享变量的时候就必须去主存重新获取。
java内存模型(java memory mode,JMM)指定了Java虚拟机如何与计算机的的主存进行工作。JMM决定了一个线程对共享变量何时对其他线程可见,JMM定义了线程和主存之间的抽象关系:
A、共享变量保存在主内存中,每个线程都可以访问
B、每个线程都有自己的工作内存或者称本地内存
C、工作内存只存储改线程对共享变量的副本
D、线程不能直接操作主存,必须先操作工作内存,然后由工作内存刷新到主存
E、工作内存和JMM都是抽象概念
并发编程的三个重要特性,原子性,有序性,可见性
1、原子性
Java语言中,对基本数据类型的读取和赋值是原子操作,对引用类型的变量读取和赋值操作也是原子性的,因此此类操作是不可被打断的。
2、可见性
在多线程环境下,某个线程首次读取共享变量,需要先从主存获取到工作内存,然后再从工作内存直接读取。同样,如果修改某个共享变量,需要先写入到工作内存中,再由工作内存刷新到住内存中,但是什么时候刷新到主内是不确定的。Java是通过下述3种方式保证可见性的:
3、有序性
在JMM中,允许编译器和处理器对指令的重排序,在单线程下没问题,如果在多线程下就会出现问题。JMM具备天生的有序规则,所以不需要同步手段就可以保证有序性,这个规则就是happens-before规则。
Volatile只能修饰实例变量和类变量。
volatile语义:
1、保证了不同线程之间的对共享变量的可见性,即一个变量被修改了,其他线程可以立即可见。
2、禁止指令重排序
所以volatile只保证可见性和有序性。volatile并不保证原子性。
例如一个简单的多线程例子:
1、线程1从主存获取intValue的值到工作内存中
2、线程1将intValue的值修改为1,并将其刷新到主存中
3、线程2工作内存中的intValue的值失效
4、线程2需要从主存再次获取intValue值到工作内存中使用
其实实现的主要原因是因为只要是volatile修饰的过的指令,在他修改值之后会立即刷新至主存中,并通知其他线程的值为无效值。
Volatile直接禁止JVM对有volatile关键字修饰过的指令重排序,但是对于前后无依赖关系的指令可以随便排序。
int x =0;
int y = 10;
volatile int z = 20;
x++;
y--;
z的赋值跟x、y没有依赖关系,可以重排序。
private volatile boolean flage = false;
private Context context;
public Context load(){
if(!flage){
context = doLoad();
flage = true;
}
return context;
}
在此段代码中,如果if内重排序,线程1重排序后修改了为true,线程2发现可以直接调用,但是如果context还未初始化完毕,就会导致空指针报错。
通过OpenJDK下的unsafe.cpp源码阅读。会发现被volatile修饰的变量存在一个“lock”的前缀,源码如下
"lock"前缀实际上相当于内存屏障,改内存屏障提供以下几个保障:
1、利用volatile的可见性特性作开关控制。
public class ThreadTest extends Thread{
private volatile boolean flage = true;
@Override
public void run(){
while(flage){
//doing
}
}
public void shutDown(){
this.flage = false;
}
}
当外部线程执行ThreadTest的shutDown方法时,ThreadTest会立即看到flage的变化(flage的值会立即被刷新到缓存中)。如果没有volatile修饰,有可能线程在其工作内存中修改了其值,但并没有刷新至主存中,或者ThreadTest一直在自己的工作内存中读取flage的值,都有可能导致flage=false不生效,导致线程无法关闭。
2、利用volatile的有序性作状态标记
就是使用指令重排序的特性,在前面doLoad()方法就有体现,这里就不在叙述。
3、在单例模式中double-check模式中使用其有序性
public final class Singleton{
private static Singleton instance = null;
private Socket socket;
private Singleton(){
this.socket //初始化
}
public static Singleton getInstance(){
if(null == instance){
synchronized(Singleton.class){
if(null == instance){
instance = new Singleton();
}
}
}
return instance;
}
}
分析:当两个线程同时进入null == instance时候,只有一个线程能获取到锁,完成对instance的初始化,随后的线程发现instance不为null,则不需要做任何操作了,以后对getInstance不需要数据同步的保护了。
虽然这种方案既满足了懒加载有保证的instance的唯一性,但是这种方式在多线程环境下还是有可能发生空指针的情况。在Singleton构造函数中还需要实例化Socket资源,还有Singleton自身,根据JVM运行时指令重排序和happen-before规则,这两者实例化的顺序无前后关系的约束,那么就有可能在sokcet未完成实例化,而instance已经完成实例化了,这个时候如果调用socket的方法就会报空指针的错误。
如果有volatile关键字的修饰,就可以防止指令重排序,保证实例化了instance之前,socket是被实例化的。
修改: private volatile static Singleton instance = null;