并发编程对于很多人说都是比较难的,总是出现一些莫名其妙的bug,让我们很是苦恼,那么他到底是难在哪里呢,今天就带大家看看引起并发bug的根源
缓存引起的可见性问题
在单核时代,所有的线程都操作在一个cpu上执行,因此cpu缓存和内存的可见性很容易解决,如下图,线程A对变量V的改变之后,线程B是可以之间看到的,因此不存在可见性问题
但是在现如今的多cpu时代,往往就不那么容易了,如下图,当线程A修改了变量V之后,对于线程B是不可见的,两个线程各自操作不同的CPU缓存,比如,初始值V的值为0,当线程A修改了V=V+1,此时CPU1里面的变量的V的值是1,同时同步到主内存中,此时线程B获取到CPU2缓存的值是V=0,此时就会引起数据不一致,为什么线程A已经修改了V的值,而线程B看到的值还是V=0,这个就是可见性的问题
线程切换到时的原子性问题
在早期的时候,由于IO性能太差,就引进了多进程的操作,正如操作系统允许某个程序执行一小段时间,比如50毫秒,过了50毫秒就会执行另外一段程序,这里的50毫秒就是我们说的时间分。
在一个时间片中,如果一个进程执行IO操作,此时就可以把CPU让出来,让CPU执行其他程序,当到上一个Io读取操作完成,再次唤醒进程,就可以再次获取CPU的执行权限.由于早期这种多进程是不进行共享内存的,因此各自执行各自的互不影响,任务的切换仅仅切换内存映射地址,而现在的并发编程中,我们使用的多线程,进行任务调度,多线程任务切换成本比较低,但是多个线程之间是共享内存的,因此会带来一系类问题.这也是并发编程出现问题的源头之一.
正如我们在代码中写count+=1操作,他的完成并不是一条指令完成的,会分成三步,
如上操作,在操作系统中,我们执行代码,一句代码并不是真正的一条指令,可能分了很多指令,如下图,线程A和线程B 同时执行代码count+=1,由于线程切换导致计算错误,我们期望的是2,但是实际上计算出来的值却是1.
线程切换的发生可以在count+=1之前也可以在之后,但是不可能发生在中间,我们往往把一个或多个操作在CPU指令中不被中断的特性叫做原子性,但是我们潜意识中任务一句代码就是一个原子操作,因此就会造成我们意想不到的问题。
编译优化带来的有序性问题
我们程序中写的代码顺序往往并不是真正执行的顺序,如下声明变量
int a=7
int b=6
在代码编译之后的顺序就是下面这种
int b=6
int a=7
上面虽然顺序改变了,其实是并不影响结果,但是这种编译优化也会带来我们一向不到的问题,如下面代码,经典的单例模式代码
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.且同时满足,然后到了synchroized中,此时只有一个线程能够获取到锁,假设是线程A获取到,然后线程A实例化instance,在释放锁,此时线程B获取到锁,然后发现实例instance已经不为null,就会直接返回,整体流程很是完美,实际上是有很大的问题,
首先,我们看一下new一个对象的正常过程
但是经过编译优化之后,他的流程可能就是下面这样
如果是优化后的流程,会引起什么问题呢,当我们的线程A在执行完第二步的时候,线程切换到了线程B,此时instance!=null,线程直接返回线程A创建的实例,但是此时的实例并没有进行初始化,因此在后面我们使用实例的属性的时候,就可能导致空指针。
最后我们把正确的单例模式的代码切出来(仅仅是在变量上加了一个volatile)
public class Singleton {
static volatile Singleton instance=null;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
下次我们继续讲解有什么办法避免上面的问题,如果文章对你有一丝丝帮助麻烦点击关注,也欢迎转发或点赞,谢谢