关于多线程的文章非常多,但是有一个特点,大多数文章都只讲一个或几个点,并没有真正把知识串联起来,没有达到融汇贯通的效果。如何保证多线程操作的一致性问题,为何要用volatile,为何要用synchronized呢?我们要知其然,更要知其所以然。
多线程操作,就是多核cpu一起操作某个共享变量,这个操作如果不进行规范控制,那结果是不可预知的,也就是线程不安全,那么jvm是怎么来保证线程安全呢?下面用比较通俗的方式分为两类,可能这样分不太确切,但便于理解。
一、变量级别的一致性
volatile关键字
cpu执行时需要将数据从主内存读取到工作内存(缓存)中执行,而cpu的工作内存是每个核都有对应的独立内存空间,参与cpu运算的数据都会从主内存加载到工作内存进行操作。假如有两个线程,一个线程在修改工作内存的数据,另一个线程同时在主存读取数据,如果没有特殊规则,很可能会造成读取数据时读到了没有更新的数据,这就是不可见性。
出现以上问题,就是因为i=6虽然在工作内存已经变了,但是不会立即刷新到主存中。为了解决缓存不一致性问题,通常硬件层面有以下两种解决方法:
1.通过在总线加 LOCK# 锁的方式
2.通过缓存一致性协议
第一种方式:通俗理解就是某一核cpu的独占,可以比喻成排队,当某一核cpu在操作时会发出一个LOCK信号,在总线上锁定,等数据刷到主存了再由另一核cpu操作该数据。这种方式效率是比较低的,现代的cpu一般不会采用。
第二种方式:就是规定如果操作的变量是多线程共享的,那么得遵循以下规则,
1.写操作时必须立即把数据刷新到主存,并通过其它核的cpu把对应工作内存的数据变为失效。
2.读操作时直接从主存读取。
Intel 的 MESI 协议就是解决这个问题的。
但是java是号称可以在所有环境中执行的,那就必须争对这个问题有一个好的解决方案,这个方案就是volatile关键字实现的内存屏障。
内存屏障分为两种:
Load Barrier 读屏障
Store Barrier 写屏障
有两个作用
1.写的时候,强制把缓冲区/高速缓存中的数据写回主内存,并让缓冲中的数据失效;读的时候直接从主内存中读取。这个思路与Intel 的MESI很像,但这是程序级的实现。
2.阻止屏障两侧的指令重排序
指令重排又是怎么回事呢?指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序。(这部分内容后面描述)
那么问题来了,使用了volatile关键字就可以解决线程安全问题了吗?答案当然是不行的,为何呢?因为变量共享操作的一致性是解决了,但程序级别的一致性没解决。
所以如果将变量i只用volatile关键字修饰,多线程编码时是不能解决线程安全问题的。
CAS操作
CAS操作是多线程经常用到的操作,CAS:Compare and Swap,即比较再交换。我们熟悉的AtomicInteger、synchronized、Lock都是依赖CAS操作进行的。CAS与volatile配合可以保证多线程的数据一致性,因为CAS需要两个数据,一个旧值,一个新值,如果现在主内存中的值与旧值相等,那么就直接把旧值换成新值,否则不交换。概括一下,CAS其实就是乐观锁的思路,我要改一个值,前提是这个值必须等于我之前拿到的值,否则我不能改。
CAS是原子操作,也就是说执行比较交换这个指令是一条不可分割的指令,这是因为CPU硬件直接支持比较交换操作,所以这样的操作是线程安全的。简单说说AtomicInteger、synchronized、Lock是如何利用CAS操作的:
AtomicInteger:
这是原子累计操作,不需要上锁就可实现多线程的累加功能。其实现的本质就是volatile+CAS,volatile保证了多线程之间的数据可见即可获取最新的数据,而CAS把获取到最新的数据进行+1。如果此时另一个线程先+1了,则这次的操作会失败,会重新获取到新的值继续尝试+1直到成功。
synchronized、Lock:
原理是通过CAS操作去设置一个值,如果设置成功说明拿到了锁,如果设置失败那就会进行短时间等待后再偿试设置,如果还不成功则转到等待队列。锁的操作离不开volatile+CAS,锁的具体实现需要专门写一篇文章讲解,此处不再深入。
二、程序级别的一致性
先来看看下面这个经典的双重检查锁实现单例:
class Singleton{
private static volatile Singleton singleton;
private Singleton(){};
public static Singleton getInstance(){
if(singleton == null){
synchronized(Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
为何要写一个单例要这么复杂呢?因为存在两个问题:
1、多线程操作同一个变量
2、指令重排序
说说指令重排序:
问题出现在创建对象的语句singleton = new Singleton(); 上,在java中创建一个对象并非是一个原子操作,可以被分解成三行伪代码:
//1:分配对象的内存空间
memory = allocate();
//2:初始化对象
ctorInstance(memory);
//3:设置instance指向刚分配的内存地址
instance = memory;
上面三行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器中),即编译器或处理器为提高性能改变代码执行顺序,重排序之后的伪代码是这样的:
//1:分配对象的内存空间
memory = allocate();
//3:设置instance指向刚分配的内存地址
instance = memory;
//2:初始化对象
ctorInstance(memory);
如果不进行锁定检查,在多线程获取单例时很可能获取到一个指向了内存地址但是没有初始化的对象。singleton对象是用volatile修饰的,volatile可以保证不进行指令重排序。另外在类对象上加了synchronized锁,保证了同一个时间只能由一个线程执行代码块的new操作,这样就保证单例不会出空指针问题。当然一般情况下建议直接通过饿汉模式直接new一个静态类,也能保证单例,因为静态类型是在类加载的时候已经初始化好了。
以上就是对jvm保证多线程的数据一致性的一些理解。