并发笔记(一)三大问题源头以及解决方法

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的。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值