提示:参考 https://pdai.tech/md/interview/x-interview.html
序言
多线程的出现是要解决什么问题的?
CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU 增加了缓存,以均衡与内存的速度差异;// 导致可见性问题
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;// 导致原子性问题
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致有序性问题
一、原子性
我们以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++这个操作并不是原子操作,它涉及到三个操作:
- 从内存读取count的值
- 对count执行++操作
- 将执行后的结果赋值给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
对应的解决办法:
- incr方法加上synchronized关键字
- count改为原子类 AtomicInteger
面试题:下面哪些是操作是原子性的:
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 解决