并发问题的解决
并发的三大特性是在并行开发中一定要保证的。
最简单的保证这三个特性的方式就是使用 synchronized关键字或使用锁。这样做既简单又包治百病,但是同步操作是悲观锁的方式,原理是让原本的并行在同步区域和锁区域中转为串行。相当于自废武功,如果滥用会失去并发的意义。
除了掌握 synchronized和锁以外,遇到并发问题时,采用更为合适的轻量级办法是必要的。
1. 实现原子性
Atomic
1)Atomic概述
Atomic 相关类在 java.util.concurrent.atomic 包中。针对不同的原生类型及引用类型,有 AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference 等。另外还有数组对应类型 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。
以 AtomicInteger 为例,一个简单的例子,运算逻辑是对变量 count 的累加。
假如 count 为 int 类型,多个线程并发时,可能各自读取到了同样的值,也可能 A 线程读到 2,但由于某种原因更新晚了,count 已经被其它线程更新为了 4,但是线程 A 还是继续执行了 count+1 的操作,count 反而被更新为更小的值 3。
现在的多线程程序是不安全的。如果把 count=count+1
放入 synchronized 代码块中肯定能够解决问题。但是这种同步操作是悲观锁的方式,每次都认为有其它线程在和它并发操作,所以每次都要对资源进行锁定,而加锁这个操作自身就有很大消耗。而且不是每一次 count+1 时都有并发发生,无并发发生时的加锁并无必要。直接用 synchronized 进行同步,效率并不高。
在声明 count 的时候,将其声明为 AtomicInteger 即可,然后把 count=count+1
的语句改为 count.incrementAndGet ()
问题就解决了。
2)Atomic源码分析
构造方法
以AtomicInteger为例,该类中有3个重要的变量,
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
private volatile int value;
- Unsafe对象,Atomic中原子操作都是借助于unsafe对象完成的
- AtomicInteger对象包装的变量在内存中的地址
- AtomicInteger对象包装的变量值,使用
volatile
关键字修饰,确保变量的变化能够被其他线程看到
AtomicInteger的构造方法如下,
public AtomicInteger(int initialValue) {
value = initialValue;
}
把传入的值赋予 value对象,同时AtomicInteger类中存在一段静态代码块,获取value的内存地址,
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
通过unsafe对象的方法获取到 value对象的内存地址并赋值给 valueOffset对象。
increamentAndGet方法
Atomic的方法都是原子性的,以AtomicInteger的自增方法为例,
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
原子性的操作实际上都是由 Unsafe类对象实现的,实例对象unsafe的getAndAddInt
源码如下,
public final int getAndAddInt(Object obj, long valueOffset, int var) {
int expect;
// 利用循环,直到更新成功才跳出循环。
do {
// 获取value当前的最新值
expect = this.getIntVolatile(obj, valueOffset);
// expect + var表示需要更新的值,使用CAS方式进行更新
// 更新成功则停止,反之再次尝试
} while(!this.compareAndSwapInt(obj, valueOffset, expect, expect + var));
// 返回当前线程在更改value成功后的,value变量原先值。并不是更改后的值
return expect;
}
Unsafe类中原子性操作本质上也是CAS算法,compareAndSwapInt
方法是native的,在不同的平台实现方式是不同的,不过总体的思路如下,
- 判断当前系统是否是多核处理器
- 执行CPU指令对新值和旧值进行交换。如果是多核处理器,会在交换前上锁。上锁的目的是使得当前处理器的缓存被锁定,同时其他处理器无法读写被访问数据的内存区域,故称为缓存锁定
CAS算法
1)CAS与synchronized差异
CAS 是 Compare and swap 的缩写,翻译过来就是比较替换。其实 CAS 是乐观锁的一种实现(此处与同步进行区分),而 Synchronized 则是悲观锁。这里的乐观和悲观指的是当前线程对是否有并发的判断。
锁类型 | 解释 |
---|---|
悲观锁 | 认为当前线程每次的操作大概率会有其它线程在并发,所以自身在操作前都要对资源进行锁定,这种锁定是排他的。悲观锁的缺点是不但把多线程并行转化为了串行,而且加锁和释放锁都会有额外的开支 |
乐观锁 | 认为当前线程每次操作时大概率不会有其它线程并发,所以操作时并不加锁,而是在对数据操作时比较数据的版本,和自己更新前取得的版本一致才进行更新。乐观锁省掉了加锁、释放锁的资源消耗,而且在并发量并不是很大的时候,很少会发生版本不一致的情况,此时乐观锁效率会更高。 |
2)CAS算法缺点
CAS将同步的消耗降到了最低,但是也存在如下缺点,
- CAS过程如果失败,则会一直循环,直至成功。这在并发量很大的情况下对 CPU 的消耗将会非常大
- 只能保证一个变量自身操作的原子性,但多个变量操作要实现原子性,是无法实现的
- ABA问题,假如本线程更新前取得期望值为 A,和更新操作之间的这段时间内,其它线程可能把 value 改为了 B 又改回了 A。 而本线程更新时发现 value 和期望值一样还是 A,认为其没有变化,则执行了更新操作。但其实此时的 A 已经不是彼时的 A 了
2. 实现可见性和有序性
上一节笔记可见性问题中已经简单提到过 volatile 关键字。
- 被 volatile 关键字修饰的变量,会确保值的变化被其它线程所感知,从而从主存中取得该变量最新的值。
- 在 happans-before 原则中有一条 volatile 变量原则,阐述了 vlatile 如何确保有序性。
volatile效果
以下面的代码为例,
private static class ShowVisibility implements Runnable{
public static Object o = new Object();
private volatile Boolean flag = false;
@Override
public void run() {
while (true) {
if (flag) {
System.out.println(Thread.currentThread().getName()+":"+flag);
}
}
}
}
public static void main(String[] args) throws InterruptedException {
ShowVisibility showVisibility = new ShowVisibility();
Thread visableThread = new Thread(showVisibility);
visableThread.start();
//给线程启动的时间
Thread.sleep(500);
//更新flag
showVisibility.flag=true;
System.out.println("flag is true, thread should print");
Thread.sleep(1000);
System.out.println("I have slept 1 seconds. Is there anything printed ?");
}
代码中使用 volatile 修饰 flag 变量,确保在多个线程并发时,任何一个线程改变了 flag 的值都会立即被其它线程所看到。以上程序 main 线程修改了 flag 值后,visableThread 能够立即打印出自己的线程 name。但如果把 flag 前的 volatile 去掉,可以看到 main 线程修改了 flag 值后,visableThread 也不会有任何输出。也就是说 visableThread 并不知道 flag 值已经被修改。
理解volatile
被volatile修饰后,该变量获得以下特性,
- 可见性。任何线程对其修改,其它线程马上就能读到最新值
- 有序性。禁止指令重排序
1)保证可见性
CPU 为了提升速度,采用了缓存,因此造成了多个线程缓存不一致的问题,是可见性的根源。为了解决缓存一致性,需要了解缓存一致性协议。
MESI 协议是目前主流的缓存一致性协议。此协议会保证,写操作发生时,线程独占该变量的缓存(锁定的是缓存),同时CPU会通知其它线程对于该变量所在的缓存段失效。只有在独占操纵完成之后,该线程才能修改此变量。而此时由于其它缓存全部失效,所以就不存在缓存一致性问题。而其它线程的读取操作,需要等写入操作完成,恢复到共享状态。
2)保证有序性
volatile 的有序性则是通过内存屏障。
内存屏障就是在屏障前的所有指令可以重排序的,屏障之后的指令也可以重排序,但是重排序的时候不能越过内存屏障。也就是说内存屏障前的指令不会被重排序到内存屏障之后,反之亦然。
3)无法保证原子性
volatile 能够保证变量的可见性和有序性,但是并不能保证原子性。比如用 volatile 修饰了变量 i,多线程并发执行i++
。假如有 10 个线程,每个线程执行 1 万次 i++
,那么最后 i 的结果肯定不是 10 万。因为 i++
实际为三步操作,
- 各线程从主内存中取得变量i的值存放到缓存
- i+1
- 赋值给i,并写入主存(赋值操作与缓存中变量是否失效无关)
这三步在没有原子性保证时多线程并发,就会导致不同线程同时执行了步骤 1,读取到了一样的 n 值,从而造成了重复的 +1 操作。多次 i++ 操作但只为 i 增加了 1。从试验结果可以明显的看出 volatile 并不会保证原子性。
volatile使用场景
首先对volatile的局限性进行说明,
- volatile的可见性和有序性只能作用与单一变量
- 不能保证原子性
- volatile不能作用于方法,只能修饰实例或者类变量
volatile 的以上特点,决定了它的使用场景是有限的,并不能完全取代 synchronized 同步方式。
一般使用 volatile 的场景是代码中通过某个状态值 flag 做判断,flag 可能被多个线程修改。如果不使用 volatile 修饰,那么 flag 不能保证最新的值被每个线程读取到。而在使用 volatile 修饰后,任何线程对 flag 的修改,都立刻对其它线程可见。此外其它线程看到 flag 变化时,所有对 flag 操作前的代码都已生效,这是 volatile 的有序性确保的。