提示:参考 https://pdai.tech/md/interview/x-interview.html
CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
我们以3个工人打30000个螺丝为例,来模拟原子性问题
public class ConcurrentBugDemo {
/**
* 当前打的螺丝数量
*/
public static Integer count = 0;
/**
* 螺丝+1
* unsafe
*/
public static void incr() {
count++;
}
/**
* 创建了三个工人,每个工人打一万个螺丝,刚好三万个
*/
public static void main(String[] args) throws InterruptedException {
Thread worker1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
incr();
}
});
Thread worker2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
incr();
}
});
Thread worker3 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
incr();
}
});
worker1.start();
worker2.start();
worker3.start();
worker1.join();
worker2.join();
worker3.join();
System.out.println(count);
}
}
正常输出应该是30000个,但是运行程序可以看到结果并不是预期的结果:
这是为什么了,因为count++这个操作并不是原子操作,它涉及到三个操作:
我们可以通过IDEA的view->show Bytecode 进行验证:
// access flags 0x9
public static incr()V
L0
LINENUMBER 14 L0
//通过getstatic指令获取到count的值,并压到栈里
GETSTATIC count : I
//将常量1压入到栈里
ICONST_1
//从栈顶弹出两个整数,并将整数进行相加,将结果值压到
栈顶
IADD
//将结果值写回到静态变量count里面
PUTSTATIC count : I
对应的解决办法:
面试题:下面哪些是操作是原子性的:
x = 10; //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x; //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++; //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1; //语句4: 同语句3
上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
我们也通过一段代码来模拟:
public class VisibilityDemo {
public static void main(String[] args) throws InterruptedException {
Worker shiqi = new Worker();
shiqi.start();
Thread.sleep(2000);
shiqi.flag = true;
System.out.println("flag更改为true了");
System.out.println("等待打工人开始搬砖");
}
/**
* 工人线程
*/
static class Worker extends Thread {
//公告板
public boolean flag = false;
@Override
public void run() {
while(true) {
if(flag) {
System.out.println("奋力搬砖");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
}
}
控制台打印结果:
并没有预期的输出奋力搬砖,这就是可见性的问题。我们通过主线程去更改flag的值,但是对worker者线程来说是不可见的。
我们知道可见性的东西,是因为缓存引起的。 这个缓存不是Java层
面,应用服务层面的一个缓存,而是CPU底层的一个缓存。
CPU简单的原理:CPU相当于一个人的大脑,假如有两个数
字,一个1,一个2,让你来求一下两个数的和,你是不是先
要把1读到你的大脑里,再把2读到你的大脑里,再在你的大
脑里把1和2做一个加法计算。CPU其实做加法计算就是先从
内存里把1和2加载到CPU里,CPU通过一个加法器给你做一
个加法运算,再把这个结果给到你的内存里。
一个简单的比喻:假如你是一名小卖部的老板,有人来买东
西,直接从柜台上将商品拿出来的效率肯定是最高的,这就
相当于L1缓存。其次就是从柜台里面将商品拿出来的效率,
这个效率虽然低一点,但也是非常高的,相当于L2缓存;再
其次就是从货架上把商品拿过来,这个就相当于L3缓存,效
率低一点。但是也勉强能接受;最后就是从仓库里面把商品
拿过来了,这种效率非常低,这仓库其实就相当于主内存
了。
问题归根结底就是因为缓存的原因,有缓存就会有缓存一致
性的问题
将变量加上volatile关键字
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
有序性是指咱们写的代码是从头到尾执行的,是有顺序的执
行。正常思维是会顺序执行的,但是在某些极端情况下啊,
是会乱序执行的。好比你觉得送外卖的步骤,必须是先取到
餐,才能送餐,但是在并发编程里,就有先送餐再取餐的情
况发生。
引入一个新概念指令重排序
CPU它为了提升计算的效率,可能会对编译后的代码做一些
重新排序,来提升代码的性能。这里给大家举一个例子就很
好理解了,比如说送外卖一般都是同时送很多个人的单的,
外卖小哥一般都会制定好计划,第一个单送哪里,第二个单
送哪里,第三个单送哪里能够让行程最短,不要走回头路,
他并不会说按照接单的顺序去一个个送,不然会绕很多弯
路。CPU也是一样的道理,虽然是个机器,但是它也会想办
法说规划出更合理的执行方式。
● 原子性问题通过 Synchronized, AtomicXXX、Lock解决
● 可见性问题 Synchronized, volatile 解决
● 有序性通过 Synchronized,volatile 解决