01|可见性、原子性和有序性问题:并发编程bug的源头
源头一:缓存导致的可见性问题
一个线程对共享变量的修改,另外一个线程能够立刻看到,称为可见性
多核时代,每颗cpu都有自己的缓存,当多个线程在不同的cpu上执行时这些线程操作的是不同的cpu缓存,比如下图中,线程A操作的是cpu-1上的缓存,而线程b操作的是cpu-2上的缓存,线程A对变量v的操作对于线程B而言,就不具备可见性了.
以下代码可以很好的反应缓存可见性导致的并发问题:
public class Test {
public long calc() throws InterruptedException {
final long[] count = {0};
final Test test = new Test();
//创建两个线程,执行add()操作
Thread th1 = new Thread(() -> {
int idx = 0;
while (idx++ < 1000000000) {
count[0] += 1;
}
});
Thread th2 = new Thread(() -> {
int idx = 0;
while (idx++ < 1000000000) {
count[0] += 1;
}
});
//启动两个线程
th1.start();
th2.start();
//等待两个线程执行结束
th1.join();
th2.join();
return count[0];
}
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
System.out.println(test.calc());
}
}
想通过calc()方法用两个线程计算count分别加1000000000次后的结果,期望结果是:2000000000,实际结果是:1007337040.
原因:假设线程A和线程B同时开始执行,第一次都会将count=0读到各自的cpu缓存中,执行完count+1之后,各自cpu缓存里的值都是1,同时写入内存后,内存中是1,而不是期望的2.之后由于各自的cpu缓存里都有了count的值,两个线程都是基于cpu缓存里的count值来计算,所以导致最终的count的值都是小于2000000000. 这就是缓存的可见性问题
源头二:线程切换带来的原子性问题
高级语言里一条语句往往需要多条cpu指令完成,例如上面代码中的count+=1,至少需要三条cpu指令.
- 指令1:首先,需要把变量count从内存加载到cpu的寄存器;
- 指令2:之后,在寄存器中执行+1操作;
- 指令3:最后,将结果写入内存(缓存机制导致可能写入的是cpu缓存而不是内存)
我们把一个或者多个操作在cpu执行的过程中不被中断的特性称为原子性
源头三:编译优化带来的有序性问题
在获取实例getInstance()的方法中,我们首先判断instance是否为空,如果为空,则锁定Singleton.class,并再次检查instance是否为空,如果还为空则创建Singleton的一个实例
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
问题出现在new Singleton()这里 这一行对cpu来讲,有三个指令:
- 1、分配内存空间
- 2、初始化对象
- 3、instance引用指向内存空间
正常执行顺序1->2->3,但是cpu指令重排序可能为1->3->2,那么就有问题了:
- 1、A、B线程同时进入第一个if判断
- 2、A首先进入synchronized块,由于instance为null,所以执行instance = new Singleton();
- 3、然后线程A执行1 JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance
- 4、在还没有进行第三步(将instance引用指向内存空间)的时候,线程A离开了synchronized块
- 5、线程B进入synchronized块,读取到了A线程返回的instance,此时这个instance并未进行物理地址指向,是一个空对象.
现在比较通用的做法是采用静态内部类的方式处理:
public class MySingleton {
//内部类
private static class MySingletonHandler {
private static MySingleton instance = new MySingleton();
}
private MySingleton() {
}
public static MySingleton getInstance() {
return MySingletonHandler.instance;
}
}
32位的机器上对long变量进行加减操作存在并发隐患:线程切换带来的原子性问题,非volatile类型的long和double型变量是8字节64位的,32位机器读或写这个变量时得把64位的long类型分成两个32位操作,可能一个线程读了某个值的高32位,低32位已经被另一个线程改了.所以推荐最好把long/double变量声明成volatile或是加同步锁synchronized以避免并发问题.
02|java内存模型:看Java如何解决可见性和有序性问题
什么是Java内存模型
解决有序性最直接的办法:禁用缓存和编译优化
03|互斥锁(上):解决原子性问题
用synchronized解决count+=1问题
SafeCalc这个类有两个方法:一个是get()方法,用来获取value的值;另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰,那么我们使用的这两个方法有没有并发问题?
class SafeCalc{
long value = 0;
long get(){
return value;
}
synchronized void addOne(){
value+=1;
}
}
被synchronized修改后,无论是单核cpu还是多核cpu,只有一个线程能够执行addOne()方法,所以一定能保证原子操作.管程中锁的规则:对一个锁的解锁happens-before于后续对这个锁的加锁,我们知道synchronized修饰的临界区是互斥的,而所谓“对一个锁解锁happens-before后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作是可见,综合happens-before的传递性规则,就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的.
锁和受保护资源的关系
上面的例子稍作改动:
class SafeCalc{
static long value = 0;
synchronized long get(){
return value;
}
synchronized static void addOne(){}
value += 1;
}
改动后我们发现使用两个锁包含一个资源,这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class,由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了.
思考题
下面的代码用synchronized修饰代码块来尝试解决并发问题,这段代码有问题吗?
class SafeCalc{
long value = 0;
long get(){
synchronized (new Object()){
return value;
}
}
void addOne(){
synchronized (new Object()){
value += 1;
}
}
}
解答:
- 加锁的本质是在锁对象的对象头中写入当前线程id,但是new Object每次在内存中都是新对象,所以加锁无效.
- 经过jvm逃逸分析的优化后,这个sync 代码直接会被优化掉,所以在运行时该代码是无效的.