1.并发产生的原因
cpu、内存、io,三者速度的差异,为了更好的利用资源,平衡三者的差异。
i)CPU增加**缓存**,平衡与内存的差异
ii)操作系统添**加进程和线程**,进程和线程的切换,均衡与IO的差异
iii)编译程序**优化执行顺序**,更高效的使用缓存
2.并发的源头问题
a)缓存导致的可见性问题
单核的cpu,不同的线程切换,由于是同一缓存,
所以线程a对共享变量的修改对于线程b来说是可见的。
多核的CPU,使用不同的缓存,所以会存在不可见的问题。
eg:count +1;的命令,不同的线程在执行当前代码的时候,
由于缓存的不同,所以线程之间的操作,不可见,
所以无法得到我们期望的值,线程不安全
b)线程切换导致的原子性的问题
eg:count += 1,至少需要三条 CPU 指令。
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存或者是缓存
线程A、B执行上诉代码,线程A执行指令1的时候,线程B执行指令123,
cpu切换线程A,继续执行2,3指令,导致线程不安全。
c)编译优化导致的有序性的问题
编译器在不影响程序的执行结果,为了性能的提升,有时候会优化代码的执行顺序,单线程下无问题,但是多线程下会出现意想不到的bug,最常见的问题就是懒汉式单例模式(双重检查)
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
return instance;
}
}
由于new Object这个命令并不是原子的,完成当前代码需要多条指令,可以大致理解为:
1.分配内存
2.初始化对象的成员变量
3.instance记录对象的地址值
由于编译器的优化,打乱执行顺序,例如线程A在执行创建单例对象的代码的时候,执行指令1,分配内存之后,指令2,3乱序执行,并且在此时cpu进行线程切换,线程B执行第一层判断的时候,instance不为null,所以线程B拿到的对象之后再对其中内部属性进行操作的时候会出问题,解决方法:使用volatile修饰。
上诉的三个源头是基础,个人感觉是最重要的,是理解和分析并发问题的基础,cpu缓存导致的可见性问题,线程切换导致的原子性,编译器优化导致的有序性。
3.解决可见性以及有序性
java内存模型
这个只是个规范,规范含义就是可以按需的“禁止缓存”以及乱序执行。
具体的实现由jvm实现,体现在java中方法,包括:
1.volatile 2.synchronized 3.final 三个关键字
以及8项Happens-Before(前者的操作对于后者是可见的)规则
3.1 volatile
两个重要的性质:禁止指令重排序 以及 可见性问题
禁止指令重排序实现是:内存屏障
可见性问题:缓存一致性协议,我的理解对于变量的读写,必须从内存中进行读取或者是写入
3.2 Happens-Before 解决可见性
1.程序顺序性:一个线程中,前面的操作对于后面操作可见
2.volatile规则,volatile写操作对于读操作可见,
不同线程对于变量的操作,对于其他的线程都必须从内存中读取。
3.传递性
a 对于 b可见,b 对于 c可见,那么a对于c可见。
案例:
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42; v = true;
}
public void reader() {
if (v == true) {
sout("当前x:"+x);
}
}
}
根据程序顺序性和volatile变量规则以及传递性,分析:线程A在执行完writer方法之后,由于变量v是volatile修饰的,那么v的改变对线程B读取是可见的,由于程序的顺序性,x=42,对于v来说是可见的,最后由传递性可知,线程B在reader的时候,输出x=42。
4.管程中锁的规则
锁的解锁对于后续锁的加锁可见,管程:一种同步原语,synchronized是管程的实现
synchronized (this) { //此处自动加锁
}// 此处解锁
为什么使用synchronized锁住的代码块,其中的共享变量具有可见性,因为程序的顺序性保证共享变量的修改对于锁释放的可见,锁释放对于锁上锁可见,由于传递性,所以共享变量的修改在不同的线程中可见。
5.线程start()规则
线程A中调用线程B的start的方法,那么start()对于线程B中任意操作都可见
案例:
Thread B = new Thread(()->{
// var是多少
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();
说明:由于程序的顺序性,var对于B.start()可见,而start对于线程B中的操作均可见,由于传递性,所以线程B中的数据为77。
6.线程join()规则
线程A调用线程B的join方法,
线程B中对于共享变量的修改对于线程A调用join方法之后均可见
7.线程中断规则 8.对象终结规则
4.java内存模型实现
实现通过:内存屏障,
对于编译器来说,内存屏障是限制进行重排序
对于cpu来说,内存屏障是将cpu的缓存刷新操作。
5.管程解决原子性
管程模型:下图来源
java中synchronized就是管程中的实现,但是加锁和解锁的动作隐藏了,牢记一点,保护资源的锁要明确好自己的作用范围,也就是和R要对应好,不要拿着自己的锁锁别人家的门。
synchronized的实现是将当前线程id写入锁对象的markWord中,且这一步是原子的要么成功要么失败。
案例1:
value+=1,不是原子操作,如果想要解决并发问题,我们可以通过加锁,那么下面的代码就不存在并发问题么?
class Test1 {
long value = 0L;
long get() {
return value;
}
synchronized void add() {
value += 1;
}
}
分析:add方法保证了线程安全,但是add方法中对于value的修改对于get获取方法并不可见,所以还是存在线程安全的问题,解决方案:方法添加synchronized,根据java内存模型,管程中的锁规则,锁释放对于后续锁上锁可见,以及程序顺序规则和传递性,这样add修改完共享元素对于get获取value是可见的。
synchronized的底层实现:
monitorenter 和 monitorexit两条指令;
在jvms14(java虚拟机规范文档中有关于这两条指令的介绍):
monitorenter:
大致意思:
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit:
大致意思:
执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
至于那个线程获得锁,这个是在所对象的markWord中进行记录线程id的。