高并发 - 基础

重构地球

同步/异步、阻塞/非阻塞

同步/异步是 API 被调用者的通知方式。阻塞/非阻塞则是 API 调用者的等待方式(线程挂机/不挂起)。

  • 同步非阻塞

Future方式,任务的完成要主线程自己判断。
如NIO,后台有多个任务在执行(非阻塞),主动循环查询(同步)多个任务的完成状态,只要有任何一个任务完成,就去处理它。这就是所谓的 “I/O 多路复用”。

同步非阻塞相比同步阻塞:
优点:能够在等待任务完成的时间里干其他活了(就是 “后台” 可以有多个任务在同时执行)。
缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询一次,而任务可能在两次轮询之间的任意时间完成。

  • 异步非阻塞

CompletableFuture方式,任务的完成的通知由其他线程发出。
如AIO,应用程序发起调用,而不需要进行轮询,进而处理下一个任务,只需在I/O完成后通过信号或是回调将数据传递给应用程序即可。

异步非阻塞相比同步非阻塞:
不需要主动轮询,减少CPU操作。


并发、并行

图片描述

死锁、饥饿、活锁

  • 死锁

线程A持有lock1,线程B持有lock2。当A试图获取lock2时,此时线程B也在试图获取lock1。此时二者都在等待对方所持有锁的释放,而二者却又都没释放自己所持有的锁,这时二者便会一直阻塞下去。

  • 饥饿

对于非公平队列来说,线程有可能一直获取不到对锁的占用。

  • 活锁

由于某些条件没有满足,导致两个线程一直互相“谦让”对锁的占用,从而一直等下去。活锁有可能自行解开,死锁则不能。


什么是线程安全

如果多个线程同时运行你的代码,每一个线程每次运行结果和单线程运行的结果是一样的,就是线程安全的。

原子性、可见性、有序性

  • 原子性

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

  • 可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  • 有序性

即程序执行的顺序按照代码的先后顺序执行。(在单线程中,编译器对代码的重排序没有问题,但在多线程程序运行就可能有问题)

x = 10;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4
上面4个语句只有语句1的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量)才是原子操作。
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。

重排序

编译器可能会对程序操作做重排序(为了让CPU指令处理的流水线更加高效,减少空闲时间)。编译器在重排序时,会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
所以重排序会使得多线程不安全。

关键字volatile

volatile修饰的变量不保留拷贝,直接访问主内存中的变量,即保证可见性。
volatile前面的代码肯定在volatile之前,volatile后面的代码肯定在volatile之后,即保证有序性。
volatile修饰的变量缺少原子性的保证。如volatile n=n+1、n++、n = m + 1 等,在多线程情况下,该操作不是原子级别的;而n=false是原子的,所以volatile一般用于状态标记。如果自己没有把握,可以使用synchronized、Lock、AtomicInteger来代替volatile。

关键字synchronized

synchronized与static synchronized 的区别:

  • synchronized是对类的当前方法的实例进行加锁,类的两个不同实例的synchronized方法可以被两个线程分别访问。
  • static synchronized是类java.lang.Class对象锁。因为当虚拟机加载一个类的时候,会会为这个类实例化一个 java.lang.Class 对象。类的不同实例在执行该方法时共用一个锁。

synchronized方法只能由synchronized的方法覆盖:
继承时子类的覆盖方法必须定义成synchronized。

两个线程不能同时访问同一对象的不同synchronized方法:
因为synchronized锁是基于对象的。但同一对象的普通方法和synchronized方法能同时被两个线程分别访问。

Happen-Before

程序顺序原则:一个线程内保证语义的串行性
volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性
锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
传递性:A先于B,B先于C,那么A必然先于C
线程的start()方法先于它的每一个动作
线程的所有操作先于线程的终结(Thread.join())
线程的中断(interrupt())先于被中断线程的代码
对象的构造函数执行结束先于finalize()方法
这些原则保证了重排的语义是一致的。

CAS(Compare and swap)

CAS算法:CAS(V, E, N)。V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

以AtomicInteger为例:

private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
private volatile int value;    //保证线程间的数据是可见的

static {
    try {    //valueOffset就是value
        valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

public final int getAndSet(int newValue) {
    return unsafe.getAndSetInt(this, valueOffset, newValue);
}

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

public final boolean compareAndSet(int expect, int update) {
    //对于this这个类上的偏移量为valueOffset的变量值如果与期望值expect相同,那么把这个变量的值设为update。
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

以上compareAndSet方法类似以下:

if (value == expect) {
    Value = update;
    return true;
} else {
    return false;
}

那么问题来了,如果value== expect之后,正要执行value= update时,切换了线程更改了值,则会造成了数据不一致。但这个担心是多余的,因为CAS操作是原子的,中间不会有线程切换。
如何保证原子性,即一个步骤?
实际上compareAndSet()利用JNI(Java Native Interface)来执行CPU的CMPXCHG指令,从而保证比较、交换是一步操作,即原子性操作。


CAS缺点

  • ABA问题

    static final AtomicReference<Integer> ref = new AtomicReference<Integer>(1);

    public final int incrementAndGet() {
        while (true) {
            int current = ref.get();
            int next = current + 1;    // 1        
        if (ref.compareAndSet(current, next)) {    // 2
                return next;
            }
        }
    }

在代码1和代码2之间,若其他线程将value设置为3,另一个线程又将value设置1,则CAS进行检查时会错误的认为值没有发生变化,但是实际上却变化了。这就是A变成B又变成A,即ABA问题。
解决思路就是添加版本号。在变量和版本号绑定,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A,当版本号相同时才做更新值的操作。

java.util.concurrent.atomic.AtomicStampedReference<V>可以解决ABA问题,其内部类:

private static class Pair<T> {
    final T reference;
    final int stamp;
    ......
 }

AtomicStampedReference的compareAndSet方法会首先检查当前reference是否==预期reference(内存地址比较),并且当前stamp是否等于预期stamp,如果都相等,则执行Unsafe.compareAndSwapObject方法。

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));    //Unsafe.compareAndSwapObject
}

  • 循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的PAUSE指令那么效率会有一定的提升,PAUSE指令提升了自旋等待循环(spin-wait loop)的性能。

  • 只能保证一个共享变量的原子操作

对于多个共享变量操作,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者把多个共享变量合并成一个共享变量来操作。JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

普通变量的原子操作
java.util.concurrent.atomic.AtomicIntegerFieldUpdater<T>类的主要作用是让普通变量也享受原子操作。
就比如原本有一个变量是int型,并且很多地方都应用了这个变量,但是在某个场景下,想让int型变成AtomicInteger,但是如果直接改类型,就要改其他地方的应用。AtomicIntegerFieldUpdater就是为了解决这样的问题产生的。

public static class V {
    volatile int score;

    public int getScore() {
        return score;
    }

    public void setScore(int score) {
        this.score = score;
    }
}

public final static AtomicIntegerFieldUpdater<V> vv = AtomicIntegerFieldUpdater.newUpdater(V.class, "score");

public static void main(String[] args) {
    final V stu = new V();
    vv.incrementAndGet(stu);
}

注:
Updater只能修改它可见范围内的变量。因为Updater使用反射得到这个变量。
变量必须是volatile类型的。
由于CAS操作会通过对象实例中的偏移量(堆内存的偏移量)直接进行赋值,因此,它不支持static字段(Unsafe.objectFieldOffset()不支持静态变量)。

阅读 1.5k

rust-zero

68 声望
4 粉丝
0 条评论

rust-zero

68 声望
4 粉丝
文章目录
宣传栏