线程:并发问题的解决


并发的三大特性是在并行开发中一定要保证的。

最简单的保证这三个特性的方式就是使用 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;
  1. Unsafe对象,Atomic中原子操作都是借助于unsafe对象完成的
  2. AtomicInteger对象包装的变量在内存中的地址
  3. 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的,在不同的平台实现方式是不同的,不过总体的思路如下,

  1. 判断当前系统是否是多核处理器
  2. 执行CPU指令对新值和旧值进行交换。如果是多核处理器,会在交换前上锁。上锁的目的是使得当前处理器的缓存被锁定,同时其他处理器无法读写被访问数据的内存区域,故称为缓存锁定

CAS算法

1)CAS与synchronized差异

CAS 是 Compare and swap 的缩写,翻译过来就是比较替换。其实 CAS 是乐观锁的一种实现(此处与同步进行区分),而 Synchronized 则是悲观锁。这里的乐观和悲观指的是当前线程对是否有并发的判断

锁类型 解释
悲观锁 认为当前线程每次的操作大概率会有其它线程在并发,所以自身在操作前都要对资源进行锁定,这种锁定是排他的。悲观锁的缺点是不但把多线程并行转化为了串行,而且加锁和释放锁都会有额外的开支
乐观锁 认为当前线程每次操作时大概率不会有其它线程并发,所以操作时并不加锁,而是在对数据操作时比较数据的版本,和自己更新前取得的版本一致才进行更新。乐观锁省掉了加锁、释放锁的资源消耗,而且在并发量并不是很大的时候,很少会发生版本不一致的情况,此时乐观锁效率会更高。

2)CAS算法缺点

CAS将同步的消耗降到了最低,但是也存在如下缺点,

  1. CAS过程如果失败,则会一直循环,直至成功。这在并发量很大的情况下对 CPU 的消耗将会非常大
  2. 只能保证一个变量自身操作的原子性,但多个变量操作要实现原子性,是无法实现的
  3. ABA问题,假如本线程更新前取得期望值为 A,和更新操作之间的这段时间内,其它线程可能把 value 改为了 B 又改回了 A。 而本线程更新时发现 value 和期望值一样还是 A,认为其没有变化,则执行了更新操作。但其实此时的 A 已经不是彼时的 A 了

2. 实现可见性和有序性

上一节笔记可见性问题中已经简单提到过 volatile 关键字。

  1. 被 volatile 关键字修饰的变量,会确保值的变化被其它线程所感知,从而从主存中取得该变量最新的值。
  2. 在 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. 可见性。任何线程对其修改,其它线程马上就能读到最新值
  2. 有序性。禁止指令重排序

1)保证可见性

CPU 为了提升速度,采用了缓存,因此造成了多个线程缓存不一致的问题,是可见性的根源。为了解决缓存一致性,需要了解缓存一致性协议。

MESI 协议是目前主流的缓存一致性协议。此协议会保证,写操作发生时,线程独占该变量的缓存(锁定的是缓存),同时CPU会通知其它线程对于该变量所在的缓存段失效。只有在独占操纵完成之后,该线程才能修改此变量。而此时由于其它缓存全部失效,所以就不存在缓存一致性问题。而其它线程的读取操作,需要等写入操作完成,恢复到共享状态。

2)保证有序性

volatile 的有序性则是通过内存屏障。

内存屏障就是在屏障前的所有指令可以重排序的,屏障之后的指令也可以重排序,但是重排序的时候不能越过内存屏障。也就是说内存屏障前的指令不会被重排序到内存屏障之后,反之亦然。

3)无法保证原子性

volatile 能够保证变量的可见性和有序性,但是并不能保证原子性。比如用 volatile 修饰了变量 i,多线程并发执行i++。假如有 10 个线程,每个线程执行 1 万次 i++,那么最后 i 的结果肯定不是 10 万。因为 i++实际为三步操作,

  1. 各线程从主内存中取得变量i的值存放到缓存
  2. i+1
  3. 赋值给i,并写入主存(赋值操作与缓存中变量是否失效无关)

这三步在没有原子性保证时多线程并发,就会导致不同线程同时执行了步骤 1,读取到了一样的 n 值,从而造成了重复的 +1 操作。多次 i++ 操作但只为 i 增加了 1。从试验结果可以明显的看出 volatile 并不会保证原子性。

volatile使用场景

首先对volatile的局限性进行说明,

  1. volatile的可见性和有序性只能作用与单一变量
  2. 不能保证原子性
  3. volatile不能作用于方法,只能修饰实例或者类变量

volatile 的以上特点,决定了它的使用场景是有限的,并不能完全取代 synchronized 同步方式。

一般使用 volatile 的场景是代码中通过某个状态值 flag 做判断,flag 可能被多个线程修改。如果不使用 volatile 修饰,那么 flag 不能保证最新的值被每个线程读取到。而在使用 volatile 修饰后,任何线程对 flag 的修改,都立刻对其它线程可见。此外其它线程看到 flag 变化时,所有对 flag 操作前的代码都已生效,这是 volatile 的有序性确保的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章