并发编程中的原子性,可见性,有序性问题
原子性
首先看到的这个原子性,对于我们肯定都不陌生,因为在接触数据库的四大特性的时候就遇到过(原子性,一致性,隔离性,持久性)。在数据库中,原子性是指事务包含的所有操作要么全部成功,要么全部失败回滚。当然此时说的原子性操作也类似,即线程执行一系列操作,这些操作都会被看着一个不可分割的整体,要么全部执行,要么全部不执行。
原子性是指,CPU在执行一个或多个操作的过程具有原子性,它们是一个不可分割的整体,在执行的过程中不会被中断。
举例来说明,几天是个好日子,你的老板看你工作劲头不错,要给你发10000元奖金,而进行转账包含两个操作,老板的银行卡上扣除10000元,你的银行卡上增加10000元,而这两个操作就是一个不可分割的整体,要么两个操作全部执行,要么全部不执行,不能单独出现只有老板银行卡扣除money或者只有你的银行卡增加money的情况。
那我们来深究原子性问题,什么原因导致了原子性问题?
就上面的例子来讲,两个操作是不可分割的,当CPU正在执行老板银行卡扣除money的操作时,此时还没有提交完成,CPU突然切换到你的银行卡增加money操作,这就会出错。
因此,原子性产生的原因还是线程的切换。如果线程正在执行一项操作,发生了线程切换,CPU去执行另一项操作,中断了线程执行的任务,就会产生原子性问题。
回归到更常见的例子,我们在写程序的初始阶段,肯定都写过以下这样的代码:
private int i =0; public void add(){ i++; }
在单线程情况下,这段代码不会发生的问题,但是在多线程并发的情况下,这段代码可能会发生问题。因为i++,++i等操作并非是原子性操作。这大致上包含三个步骤,1.将变量i从内存中加载到CPU的寄存器中;2.在CPU的寄存器中执行i++或++i的操作;3.将运算的i++的结果写入缓存中。因此在多线程访问时,有可能出现,线程1读取了i的值,线程2也读取了i的值,线程2将i的值进行+1并将i的值写入内存,但是这种情况下,线程1读到的i的值还是原始的那个,因此很容易出现多线程并发上的错误。
再多说一点,对于这种情况,我们可以用加锁和volatile的方法来解决多线程的问题,即:
private volatile int i =0; public synchronized void add(){ i++; }
但是加锁的方式可能会影响线程执行的效率,因为当线程1拿到锁,线程2无法执行方法,只能等线程1释放锁后再获得CPU的使用权,当线程1执行的时候,线程2处于阻塞状态,所以可能会影响线程的执行效率。
当然在Java中也有一些原子类,他们专门可以保证原子操作,此类位于package java.util.concurrent.atomic包中,这个在后面会详细介绍,大佬感兴趣的话可以先看源码。
因此做个总结,如果线程在执行的过程中发生线程切换,使得线程暂停当前的任务而去执行其他的任务,可能会发生原子性问题。
可见性
可见性指的是,在多线程下,一个线程修改了共享变量,其他线程能立即读取到共享变量的最新值。无论共享变量如何变化,其他线程总是能够及时读取到共享变量的最新的值。
当线程在串行程序中执行或者线程是在单核CPU情况下执行时,不会出现线程之间的可见性问题。因为在单核CPU中,即使有很多线程,但是一个时刻,CPU只能执行一个线程,也就是说只有一个线程来抢到CPU的资源,来对CPU缓存进行读写操作,在这个线程放弃CPU停止执行任务时,其他线程会对同一个CPU缓存进行读写操作,并且会读取CPU缓存中的最新值,所以不会发生线程的可见性问题。
而在多核CPU下恰恰相反,因为多核CPU下,每个CPU核心都有自己各自的缓存,多个线程在读取主内存的共享变量时,会把主内存中的共享变量复制到线程的私有内存中,每个线程在对数据进行读写操作时,都会直接操作自身工作内存的数据。
举例说明,此时线程1和线程2运行在两个不同的CPU核心中,线程1和线程2同时将主内存中的共享变量i复制到自己的CPU缓存中,进行读写操作,但是线程2无法及时读取都线程1***享变量i的值,线程1也无法及时读取到线程2***享变量i的值。所以线程1和线程2对共享变量i存在有可见性问题。
因此综上所述,多个线程在多个CPU上运行,会出现可见性问题,所以造成可见性的根本原因就是CPU的缓存机制。在串行程序和单核CPU上不存在可见性问题。
有序性
有序性,顾名思义,就是有顺序,按顺序执行。在并发编程中亦是如此。有序性是指能够按照编写代码的顺序执行,但是为了提高程序的执行性能和编译性能,计算机和编译器有时候会修改程序的执行顺序。然而,在多线程情况下,编译器对执行顺序的修改可能会造成错误。
下面举例来说明会出现有序性的情况。
在创建单例对象时,使用到了双重检测机制,在并发情况下,可能会出现问题。以下是创建单例对象的方法:
private static SingleInstance instance; public static SingleInstance getInstance(){ if(instance == null){ synchronized(SingleInstance.class){ if(instance ==null){ instance =new SingleInstance(); } } } return instance; }
假设现在有线程1和线程2,同时执行getInstance()方法获取instance的对象实例,当进行if判断时,instance均为null,此时由于方法加了synchronized锁,只能有一个线程获取锁,另一个线程阻塞,假如线程1获取了锁,创建了对象,执行完成后释放锁,线程2获取锁,发现此时instance不为null,因此不会再创建对象了。
我们在前面也说了new一个对象包括三个步骤:1.为对象分配内存空间;2.初始化对象;3.将对象的引用指向内存空间。
在正常情况下程序是按照顺序执行的,但是如果CPU对对象进行重排序,把第三个步骤排到了第二个步骤的前面,在并发情况下可能就会发生错误。
分析:假设线程1和线程2都进入到了if判断阶段,如果线程1获取了锁,进入到了代码块里,在new对象时,JVM会在堆中为对象找到一块存储空间,并且线程1会将instance的引用指向该内存空间,但是此时并没有为对象进行初始化,因此还是空对象,当线程切换到线程2时,首先会拿到锁,进入到代码块中去,由于instance是空对象,在使用instance时,就可能会出错。
综上,出现此错误的原因就是,编译器修改了创建对象的执行顺序,导致在多线程并发情况下,程序出现了错误。因此出现有序性的根本原因就是编译器修改了程序的执行顺序。
#Java##程序员#