可见性、原子性和有序性问题:并发编程Bug的源头

可见性,原子性,有序性,往往这些多线程的三要素都只会出现在高级编程知识中。并且涉及到了很多操作系统相关的知识,如果对操作系统不熟悉的话,就会遇见很多问题。

多线程编程经常会遇见一些玄学问题,所以编写正确的并发编程是一件很难的事情,而今天,就来重点说说并发编程bug的源头。

并发编程幕后的事情

这些年,CPU,内存,IO设备不断迭代,不断优化,但是还是有一个核心矛盾一直存在着,那就是这三者的速度差异。比如说,CPU执行一条普通指令需要一天,那么CPU读写内存要等待一年,而内存和IO设备的速度差异更大。

程序中大部分语句都要访问内存,有的时候还要访问IO,根据短板效应,导致程序的整体性能决定于IO设备的好坏,所以单纯提升CPU是无意义的。

为了合理提升CPU的性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都作出了贡献,主要体现为:

  1. CPU增加了缓存,以均衡与内存的速度差异
  2. 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与IO设备的速度差异
  3. 编译程序优化指令执行次序,使缓存更好的被使用
源头一:缓存导致的可见性问题

单核时代,相当于所有的线程都是在一个CPU上执行,CPU缓存和内存数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另一个线程来说肯定可见。比如,在线程A更新了V的值,那么B访问V,肯定是得到了最新值。这就称为可见性

而在多核的情况下,上面所提到的可见性就不具备了,因为每个CPU都有自己的缓存,在不同的CPU上执行不同的线程,必然无法读到对方修改的数据。

public class Test {
		private	long count	=	0;		
		private	void add10K(){				
		int	idx	= 0;				
		while(idx++	< 10000){
			count	+=	1;				
		}		
	}		
	public static long calc() {
		final Test test = new Test();	//	创建两个线程,执⾏add()操作				
		Thread	th1	=	new	Thread(()->{												
		test.add10K();				
	});				
		Thread	th2	=	new	Thread(()->{						
		test.add10K();				
		});				//	启动两个线程				
		th1.start();				
		th2.start();	//	等待两个线程执⾏结束				
		th1.join();				
		th2.join();				
		return	count;		
	}}

程序执行出来之后,count表面上应该等于20000,实际上为10000,因为各自的CPU都有各自的缓存,更新数据都是基于自己CPU的缓存去更新,而不是基于主存。

源头二:线程切换带来的原子性问题

由于IO更新太慢,所以早期的操作系统就发明了多进程,让单核的CPU也可以同时做好多事情。

操作系统允许一个进程执行一段时间之后再去任务切换,执行另一个进程,而执行一个进程的时间长度就叫做时间片。

在这里插入图片描述

老一代的CPU,不同进程之间是不共享内存空间的,所以进程要做任务切换,就必须改变内存映射地址,但是所有的线程都是共享一个内存空间的,所以切换成本低,现在的CPU,说是切换任务,实际上就是切换线程,所以更加轻量了。

java的并发都是基于多线程开发的,所以就会涉及到任务切换,那么任务切换,往往就是一个很复杂的事情,容易因此产生原子性相关的bug。

比如,上面的count的累加,就需要三条cpu指令。

  • 指令1:把count从内存加载到cpu的寄存器
  • 指令2:在寄存器中进行+1操作
  • 指令3:写入主存/缓存

所谓的任务切换,会有可能发生在任何一个指令结束后发生,比如在指令1结束之后,做线程切换,虽然,两个线程都执行了count++,但是结果却是1,而不是2。

在这里插入图片描述

而原子性,就是指令在执行的过程中,不会被中断的特性,就叫做原子性。而原子性的操作水平是CPU的指令级别的,而不是高级语言的层面。

源头三:编译优化带来的有序性问题

在程序的编译中,jvm会帮我们进行优化,比如说调整代码的执行顺序,这就会导致一定的有序性问题,比如说,原本我们的意思是让a=5,b=3,结果优化就变成了a=3,b=5,但是这个行为是不报错的,所以不影响程序的输出问题。

在java领域有一个很经典的案例就是利用双重检查创建单例对象:在获取实例getInstance()的 方法中,我们首先判断instance是否为空,如果为空,则锁定Singleton.class并再次检查instance是否为 空,如果还为空则创建Singleton的一个实例。

public class Singleton	{		
	static Singleton instance;		
	static Singleton getInstance(){				
	if	(instance == null) {						
		synchronized(Singleton.class) {								
		if	(instance == null)										
			instance = new Singleton();								
		}				
	}				
	return	instance;		
	} 
}

假设有两个线程AB,同时调用getInstance()方法,他们会发现intance == null,于是同时对Singleton.class加锁,此时JVM保证只有一个线程能够加锁成功(假设是线程A),另外一个线程就会处于阻塞状态(线程B),线程A会创建一个Singleton实例,然后释放锁,B被notify,这个时候B加锁,但是通过检查intance==null的时候已经不成立了,因为之前A已经实例化了,这就是单例模式。

但是实际上这个getIntance并不是使用的很好,众所周知,实例化的过程是三步:

  1. 分配一块内存M
  2. 在内存M上初始化Singleton对象
  3. M的地址赋值给intance变量

但是实际上优化后的执行顺序却是这样的:

  1. 分配一块内存M
  2. 将M的地址赋值给intance变量
  3. 在内存M上初始化Singleton对象

这样优化就会产生有序性的问题了:假设A执行完了getInstance,那么,指令2执行后进行线程切换,切换到B上,B也执行getInstance,这时因为instance已经被实例化了,所以不为null值,就能直接返回instance了,而此时的instance没有进行过初始化,所以访问会报空指针异常。

在这里插入图片描述
线程A进入第二个判空条件,进行初始化时,发生了 时间片切换,即使没有释放锁,线程B刚要进入第一个判空条件时,发现条件不成立,直接返回instance 引用,不用去获取锁。如果对instance进行volatile语义声明,就可以禁止指令重排序,避免该情况发生。 对于有些同学对CPU缓存和内存的疑问,CPU缓存不存在于内存中的,它是一块比内存更小、读写速度更 快的芯片,至于什么时候把数据从缓存写到内存,没有固定的时间,同样地,对于有volatile语义声明的 变量,线程A执行完后会强制将值刷新到内存中,线程B进行相关操作时会强制重新把内存中的内容写入 到自己的缓存,这就涉及到了volatile的写入屏障问题,当然也就是所谓happen-before问题。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值