并发编程基础
并发出现的根源
可见性
一个线程对共享变量的修改,另外一个线程能够立刻看到。出现问题的原因是:cpu缓存引起的可见性问题。
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
- 当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
- 线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
- 线程的可见性:线程1修改的变量i之后,线程2并不能立刻就看到修改的值
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。出现问题的原因:操作系统增加了进程、线程,以分时复用CPU(操作系统允许某个进程执行一小段时间,然后将时间片让给其他进程),进而均衡CPU与I/O设备的速度差异
经典转账问题:账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。如果这两个操作不是原子性的话。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
x = 10; //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x; //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++; //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1; //语句4: 同语句3
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
有序性
即程序执行的顺序按照代码的先后顺序执行。出现问题的原因:编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
public class Singleton {
public static Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
正常实例化一个对象其实可以分为三个步骤:
-
分配内存空间。
-
初始化对象。
-
将内存空间的地址赋值给对应的引用。
但是操作系统可以对指令重排序,那么上面的过程可能变成
- 分配内存空间。
- 将内存空间的地址赋值给对应的引用。
- 初始化对象
如果是按照重排序之后的流程,那么其中线程就可以会获取到一个未初始化的对象,从而导致不可预料的结果。
在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。