刚学习并发编程时候,肯定有这几个疑问:volatile是啥,为啥要用,我的代码怎么就不安全了。。。。。
从0开始,手把手教你,把你拉扯大,
操作系统的粗糙模型
cpu处理指令非常快,但是如果涉及到与内存交互,比如取一条数据,那么假设cpu直接向内存取值。cpu是填上一天,内存是地上一年。所以在cpu增加了缓存,cpu和缓存交互,缓存和内存交互。用来解决处理速度差异过大的问题。所以读到这,你得给我知道操作系统的粗糙模型是这样的。
内存可见性
单核时代,上面的粗糙模型正好使用,那么单核的多线程,就是一颗cpu的分时处理,所以不管你开多少个线程,都是一颗cpu,一片缓存,一个内存,多个线程之间读写数据对于cpu来说也是互相可见。 大概就是这样的, 可以理解为两个客人(线程)通过一个服务员(缓存)安排一个厨师(内存)做事。
所以可见性就是一个线程的操作其他线程都是可见的,就这么通俗易懂,主角是线程!
多核时代,问题就来了,每个cpu都有自己的缓存,就好比每个客人都有自己服务员。
这种情况不好好控制,厨师就疯了。
public class StackTest {
private long count = 0;
private void addMoney(){
for (int i = 0; i < 10000; i++){
count += 1;
}
}
public long getCount(){
return count;
}
public static void main(String[] args) throws Exception {
StackTest stackTest = new StackTest();
Thread thread1 = new Thread(() ->{
stackTest.addMoney();
});
Thread thread2 = new Thread(() ->{
stackTest.addMoney();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(stackTest.getCount());
}
}
最后发现结果总是(10000,20000]。
这是因为多核cpu的多个缓存中都维护着一份count的变量。且多个缓存间不可见。那么就会出现:cpu1,cpu0最开始都缓存了count的值,都是0,然后都执行加1,两份缓存中的中值都变成了1,然后写回到内存后就是1,而不是我们期望的2。
这就是可见性导致的bug。
操作原子性
那就会有杠精问,老子就用单核的是不是就没问题了?
那我劝你再去学习一哈操作系统。算了,我接着拉扯你吧,首先要知道cpu的执行单位是指令,而不是高级语言中的一条语句。就比如上面的count += 1;需要三条指令才能完成。
1,将count变量从内存加载到cpu寄存器。
2,在寄存器中执行+1
3,写回到缓存。
乍一看没毛病,但是你还得知道,即便是单核下编写的多线程java,没有了多核并行还有单核的时间片切换!
就这么切换你能受得了?
所以cpu在执行一个或者一系列操作时候不被中断的特性叫做原子性,这就是原子性引发的bug
编译有序性
不要以为你写了个 int a = 1; int b = 2;处理器就一定会这么执行。我们写的高级语句,最后会被编译为计算机能够执行的指令,在这个过程,编译器会有一个优化阶段。可能会导致语句顺序发生变化。即:指令重排。
说这个你可能不懂,说双重检查锁定你就懂了,如果双重检查锁定都不懂,那就得拉扯你一把了。
public class SingleTon {
static SingleTon instance;
static SingleTon getInstance(){
if (instance == null){
synchronized (SingleTon.class){
// if (instance == null){
instance = new SingleTon();
//}
}
}
return instance;
}
}
就这么几行代码。你是不是得问,指令重排在哪里发生了,才导致要写这么麻烦的代码。
是这样的:针对 instance = new SingleTon()。优化以后是这样的顺序,1,分配一块内存M。2,M的内存地址赋值给instance变量,3,在内存M上初始化对象。
来看流程图
看完上面的关键流程图,真的,以后别问我为啥多线程编程有bug了,喂什么吃什么