• 已删除用户
Administrator
发布于 2021-08-04 / 674 阅读
0

Java并发编程的艺术(上).md

Java并发编程的艺术(上)

一、Java并发机制的底层实现原理

1.1volatile的定义与实现原理

Java编程语言允许线程访问共享变量,volatile确保共享变量能被准确和一致地更新;

实现原理:

①Lock前缀指令会引起处理器缓存回写到内存;

②一个处理器地缓存回写到内存会导致其他处理器地缓存无效(通过嗅探在总线上传播地数据来检查自己缓存的值是不是过期);

1.2synchronized的实现原理与应用

1.2.1Java对象头

synchronized用的锁是存在Java对象头里的。Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。

image-20210728171019503

1.2.2锁的升级与对比

锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态,重量级锁状态,这几种状态会随着竞争情况逐渐升级,但不会降级。

1、偏向锁

当一个线程访问同步块并获取锁时,会在对象头和栈帧中记录存储偏向的线程ID,以后该线程进入和退出同步块不需要进行CAS操作来加锁和解锁,只需要检测对象头的Mark Word中是否存储指向当前线程的偏向锁。如果测试成功,表示获得锁,如果失败,则需要再测试一下偏向锁标识是否设置为1:如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程;

(1)偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程;

image-20210728173352054

(2)关闭偏向锁

偏向锁在Java 6和Java 7里是默认启动的,但它在应用程序启动几分钟之后才激活;

2、轻量级锁
(1)轻量级锁加锁

线程在执行同步块之前,JVM会先复制对象头中的Mark World到当前线程栈帧中用于存储锁记录的空间。然后线程尝试使用CAS将对象头中的Mark World替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,自旋获取锁;

(2)轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

image-20210730180533151

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级 成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时, 都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

image-20210730181135446

2.3原子操作的实现原理

2.3.1处理器如何实现原子操作

(1)使用总线锁保证原子性

第一个机制是通过总线锁保证原子性。总线锁使用处理器提供的一个Lock#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存;

(2)使用缓存锁保证原子性

第二个机制是通过缓存锁定来保证原子性。内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言Lock#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

2.3.2Java如何实现原子操作

(1)使用循环CAS实现原子操作

自选CAS实现的基本思路就是循环进行CAS操作知道操作成功为止。

(2)CAS实现原子操作的三大问题

①ABA问题:如果一个值原来是A,变成了B,又变成了A,那么CAS进行检查时,就会发现值没有发生改变。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A—B—A,就变成1A—2B—3A;

②循环时间长开销大:自选CAS如果长时间不成功,会给CPU带来非常大的执行开销;

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

(3)使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。除了偏向锁,其他都使用CAS来获取锁,退出时亦是。

二、Java内存模型

2.1Java内存模型基础

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步。在命令式编程中,线程间的通信机制有两种:共享内存和消息传递。Java的并发采用的是共享内存模型。

2.2.1Java内存模型的抽象结构

Java内存模型(JMM)定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。

2.2.2从源代码到指令序列的重排序

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。重排序分为3类:①编译器优化的重排序,②指令级并行的重排序,③内存系统的重排序;

2.2.3并发编程模型的分类

现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时通过批处理的方式刷新写缓冲区,减少对内存总线的占用。

因为写缓冲区导致处理器执行内存操作的顺序可能会与内存执行的操作执行顺序不一致。因此处理器都允许对写-读操作的重排序。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。(StoreLoad Barriers是一个“全能型”的屏障,当前处理器通常要把写缓冲区中的数据全部刷新到内存中)

2.2.4happens-before简介

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。

①程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;

②监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;

③volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;

④传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

2.2重排序

2.2.1数据依赖性

编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,但是仅针对单个处理器中执行的指令序列和单个线程中的执行操作。

2.2.2as-if-serial语义

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

2.3顺序一致性

JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性。

2.4volatile的内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存;

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,该线程接下来将从主内存中读取共享变量。

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着一个volatile变量的读,总是能看到对这个volatile变量最后的写入。

锁的语义决定了临界区代码的执行具有原子性。这意味着即使是64位的long型和double型变量,只要是volatile变量,对该变量的读/写就具有原子性。

简而言之,volatile变量自身具有下列特性:

可见性:对一个volatile变量的读,总是能看到对这个volatile变量最后的写入;

原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种符合操作不具有原子性;

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特性类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略:

在每一个volatile写操作的前面插入一个StoreStore屏障;

在每一个volatile写操作的后面插入一个StoreLoad屏障;

在每一个volatile读操作的前面插入一个LoadLoad屏障;

在每一个volatile读操作的前面插入一个LoadStore屏障;

2.5锁的内存语义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中;

当线程获取锁时,JMM会把线程对应的本地内存置为无效。

2.5.1锁内存语义的实现

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS)。

public static void main(String[] args) {
    ReentrantLock lock = new ReentrantLock();
    lock.lock();
}
public void lock() {
    sync.lock();
}
final void lock() {
    acquire(1);
}
public final void acquire(int arg) {
    //尝试获取锁,如果失败,则在AQS中添加等待线程,然后进行判断,如果是头节点则进行自旋获取锁,否则就阻塞当前线程
    if (!tryAcquire(arg) &&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        //调用线程的interrupt(),阻塞线程
        selfInterrupt();
}
//①获取锁的底层实现
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
         int nextc = c + acquires;
         if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
         setState(nextc);
         return true;
    }
    return false;
}
//②封装等待线程,并添加到AQS队列中(添加在头节点)
private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    //循环添加节点
    enq(node);
    return node;
}
//③如果是头节点则进行自旋获取锁,否则就阻塞当前线程
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        //自选
        for (;;) {
            final Node p = node.predecessor();
            //如果是头节点,尝试获取锁
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            //如果不是阻塞线程
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

2.5.2concurrent包的实现

由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现在有4种方式:

①A线程写volatile变量,随后B线程读这个volatile变量;

②A线程写volatile变量,随后B线程用CAS更新这个volatile变量;

③A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile这个volatile变量;

④A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量;

把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。通用模式为:首先,声明共享变量为volatile;然后,使用CAS的原子条件更新来实现线程之间的同步;同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

2.6final域的内存定义

对于final域,编译器和处理器要遵守两个重排序规则:

①在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序;

②初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序;

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

通过为final域增加写和读重排序规则,可以为java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“溢出”),那么不需要使用同步就可以保证任意线程都能够看到这个final域在构造函数中被初始化之后的值。

2.7双重检查锁

public class DoubleCheckedLocking{
    private static volatile Instance instance;
    public static Instance getInstance(){
        if(instance==null){
            synchronized (DoubleCheckedLocking.class){
                if(instance==null){
                    instance=new Instance();
                }
            }
        }
        return instance;
    }
}

通过声明对象的引用为volatile之后,禁止了对象创建过程中的指令重排序。

基于类初始化的解决方案:

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。