前言
线程并发问题一直都是面试的时候经常问的问题,为什么那些面试官、老总喜欢问这些问题呢,因为多线程运行起来要比快呀?那多线程就真的要比单线程快?在我看来未必,因为多线程存在上下文切换[1]、线程死锁、以及一些受限于硬件的问题。所以今天我们就面试当中的一些问题,一起来学习并解决线程并发问题。
自增自减原子性问题
曾经我遇到过这样的一个问题[在我刚刚毕业出来面试的时候,曾经遇到过这样的问题,你觉得在多线程的情况下i++数据安全么?我当时是这样想的,你问我肯定是需要问为什么的,如果我说安全那就没什么必要说为什么了,所以我当时的回答是不安全的,但是为什么我就回答出来了,所以回家后我就立志要这个问什么弄明白。]:你觉得在多线程的情况下i++数据安全么?
答案是不安全的。
现在我们一起来分析一下为什么?
首先i++;它本身不仅仅是表面上看上去只有一个操作,实际上它是一个“读取 - 修改 - 写入”的操作,i++可以看做一下三步操作:
- 先是将i的值从内存中拿出来;
- 然后将i的值加1;
- 最后将加1后的值存放到i的内存中;
所以i++存在原子性问题[2]。
解决方案有两个:
- 方案1:使用synchronized加锁,使i的数据同步,但是这个方式简单是简单,比较耗资源,在多线程竞争资源的时候,加锁、释放锁,线程之间不停的切换会引起性能问题。一个线程持有锁,其它线程需要等到的这把锁之后才能执行,期间线程是被挂起,容易导致死锁发生。
- 方案2:使用原子变量[3]AtomicInteger定义i.
在i++原子性问题中,方案2对比起方案1更加优。
原子变量AtomicInteger通过以下两个实现方式保证线程安全:
1.封装了volatile修饰的变量,使内存可见
2.方法都实现了CAS算法来实现非加锁的原子操作。
volatile轻量级锁的原理
volatile关键字可以保证多线程之间的数据可见性。其实就是一个线程修改的结果,另一个线程马上就能看到。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
没有volatile修饰的变量内存分析:
image.png
线程从内存中读取没有volatile修饰的变量的数据,都需要经过CPU的缓存,先从主内存中读取到CPU缓存中,然后线程从CPU缓冲中读取数据
volatile修饰的变量内存分析:
image.png
线程从内存中读取volatile修饰的变量的数据,直接从主内存中获取数据,不需要经过CPU缓存,这样使得多线程获取的数据都是一致的。
volatile不能够替代synchronized,原因有两点:
- 对于多线程,不是一种互斥关系
- 不能保证变量状态的“原子性操作”
所以为了保证AtomicInteger变量的安全,引入了CAS算法。
CAS算法
资料上面是这样描述CAS算法的:
CAS (Compare-And-Swap) 是一种硬件对并发的支持,针对多处理器 操作而设计的处理器中的一种特殊指令,用于管理对共享数据的并发访问。
什么意思?我们来看看AtomicInteger类中的自增方法:
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
它做了三件事情:
1.使用get方法获取主内存中的当前value值,get方法的实现。
public final int get() {
return value;
}
2.当前值current 加1;
3.比较当前值current 是否和加1之后的next值相等,如果不相等就继续下一次循环,直到current和 next相等。那么也就是主内存中的值+1成功之后结束CAS操作.compareAndSet方法如下:
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
其中,compareAndSwap方法中调用的compareAndSwapInt方法是JNI(Java Native Interface,JAVA本地调用)的代码,借助C来调用CPU底层指令实现的,目的为了比较内存中的值this和expect是否一致,一致则将this修改为update并返回true,通过这样的方式将内存中的值修改为update。
如果用java代码来模拟,表示如下:
if (this == expect) {
this = update;
return true;
} else {
return false;
}
这个CAS算法确实比synchronized修饰符更为高效的解决原子性问题,但是为什么synchronized没有被取代,也就说明CAS算法并不完美,它存在以下3点问题:
1)ABA问题:
ABA这个名字听着有点玄乎,其实就是说内存修改了,线程都不知道。为什么出现这样的情况,我们一起来分析一下:
场景:现在内存中的值value为10,有两个线程,线程1将value 的值修改为20,线程2将value的值修改为30。
时间点1:线程1获取value=10
时间点2:线程2获取value=10
时间点3:线程2比较value和修改值是否一致 10!=30,value=30
时间点4:线程1 比较value和修改值是否一致 30!=20,value=20
上述的案例最终线程1将value修改为20,但是中间线程2将value修改为30,这个线程1是无法感知的。这就是我们说的ABA问题。我们可以通过AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2)循环时间长开销大:
getAndIncrement方法中我们可以看到它利用了死循环,如果判断一直不为true结束方法,代码就一直执行到判断为true为止,这样compareAndSwapInt一直被调用,增加了CUP 的使用率,资源开销大。
3)只能保证一个共享变量的原子操作
如果多个变量就无法保证原子性了,但是这个问题后面出现了AtomicReference类保证多个变量的原子性,就是将多个变量封装到一个对象中,使用对象进行CAS算法操作。
【相关词汇】
[1]上下文切换:
单个CPU运行多个线程,由于时间片比较短,也就是代码运行时间,一般在几十毫秒,所以多个线程之间不停的切换,这个过程就叫做上下文切换。
[2]原子性问题:
原子是世界上的最小单位,具有不可分割性。比如 a=0;这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++;这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。
[3]原子变量:
java的concurrent包下提供了一些原子类,根据修改的数据类型,可以分为 4 类。
基本类型: AtomicInteger, AtomicLong, AtomicBoolean ;
数组类型: AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray ;
引用类型: AtomicReference, AtomicStampedRerence, AtomicMarkableReference ;
对象的属性修改类型: AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater 。
[4]JNI:
JNI是Java Native Interface的缩写,它提供了很多已经用C或者C++写好的接口给java调用,使得Java代码和其他语言写的代码进行交互。
网友评论