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

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

多线程编程经常会出现一些玄学问题,所以编写正确的并发程序是一件极困难的事情,今天就重点来聊聊这些Bug的源头。。。

并发程序幕后的故事

这些年,我们的CPU、内存、I/O设备都在不断迭代,不断朝着更快的方向努力。但是还是有一个核心矛盾一直存在,就是这三者速度的差异。。 比如说,CPU执行一条普通指令要一天,CPU读写内存需要等待一年,内存和IO设备的速度差异更大。

程序中大部分语句都要访问内存,有些还需要访问IO,根据木桶理论,程序整体的性能取决于最慢的操作——读写IO设备,就是说单方面提高CPU性能是无效的。。。

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

  1. CPU增加了缓存,以均衡与内存的速度差异。
  2. 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与IO设备的速度差异
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理的利用。

源头一:缓存导致的可见性问题

在单核时代,所有的线程都是在一个CPU上执行,CPU缓存和内存的的数据一致性容易解决。因为所有线程都是操作在同一个CPU的缓存,一个线程对缓存的写,对另一个线程来说一定是可见的。比如:线程A更新了V的值,那么线程B访问V,得到的一定是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,不同进程间是不共享内存空间的,所以线程要做任务切换就要切换内存映射地址,一个线程创建的所有线程都是共享一个内存空间的,所以线程做任务切换成本就很低了。。现在的操作系统都基于更轻量的线程来调度,现在提到的“任务切换”都是指“线程切换”。。

Java并发程序都是基于多线程的,自然会涉及到任务切换,任务切换是并发编程里诡异Bug的源头之一。任务切换,往往就是一个很复杂的事情,容易因此产生原子性相关的Bug。

例如上面代码中的count+=1,至少需要三条CPU指令。

  • 指令1:首先,需要把变量count从内存加载到CPU的寄存器
  • 指令2:之后,在寄存器中执行+1操作
  • 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)

操作系统做任务切换,可以发生在任何一条CPU指令执行完,比如在指令1结束之后,做线程切换,虽然,两个线程都执行了count++,但是结果却是1,而不是2。
在这里插入图片描述
原子性就是指令在执行过程中,不会被中断的特性,就叫做原子性。原子性操作水平是CPU指令级别的,而不是高级语言的层面。。

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

程序的编译中,JVM会帮我们进行优化,比如调整代码的执行顺序,这会导致一致性问题,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,这个行为是不会报错的所以不影响程序的输入问题。。。

在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;		
	} 
}

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

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

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

但是实际上优化后的执行路径是这样的:

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

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值