Java并发编程详解

上一篇文章 多线程相关概念的梳理(个人理解) 主要从宏观层面上讲了多线程并发的一些概念,这篇文章则围绕Java,聊聊并发编程。

sychronized关键字

JVM实际上只提供了一种锁,即 sychronized关键字,这一点我们从Java的Thread类中定义的State可见一斑。

Java中线程状态总共有NEWRUNNABLEBLOCKEDWAITINGTIMED_WAITINGTERMINATED。其中BLOCKED只对应线程进入sychronizer块获取锁失败的情况。而基于AQS实现的锁,如果线程发生阻塞,状态是为WAITING,因为AQS其实也是调用LockSupport类的方法实现线程阻塞的,而这些方法对应的WAITING状态,这些在java.lang.Thread.State的注释上都有写。

另外值得注意的是,Java的线程虽然和实际操作系统的是一一对应的关系(在HotSpot上),但其实如果系统调用进行IO操作,阻塞了线程,当前线程的状态还是RUNNABLE状态,这是因为操作系统的线程状态和Java的线程状态,并不完全对应,也并不完全统一。

对象头

上面说了sychronized提供的锁是依靠JVM实现的,说明JVM中必然有什么变量来储存锁的状态。确实是这样,在Java中每个对象内部都有一部分空间用于存储对象头信息

JVM对象的内存布局分为三个区域:对象头实例数据对齐填充。其中实例数据存储的就是对象真正的信息。
JVM对象内存布局的详细内容可以参见这篇文章:Java对象结构与锁实现原理及MarkWord详解。

对象头中包含很多信息,其中Mark Word用于存放hashCode和对象的锁信息。

在Mark Word中,如果对象存在锁,会存储一个指向monitor的指针,这个monitor对象就相当于是对象的锁。Java源码注释里一般称为monitor lock

我们知道在Java中每个对象都有一个monitor监视器与之对应。利用javap反编译一个含有sychronized块的源码,我们可以发现monitorentermonitorexit

重量级锁和轻量级锁

总是听到说sychronized是重量级锁,那重量级锁和轻量级锁到底是什么呢?

重量级锁指的是竞争该锁失败的线程都会进入阻塞状态,之后再次运行就需要等待唤醒了。

轻量级锁简单来说,指的是竞争该锁的线程会不断通过CAS自旋获取锁,即 不会放弃线程执行机会。很明显一直自旋会白白浪费掉CPU性能,所以sychronized会在自旋一定次数后升级为重量级锁,做到一定程度上避免线程阻塞和唤醒而影响性能(因为这些操作用户态无法完成,都涉及到用户态到内核态的来回切换)。

除此之外还有一个概念是偏向锁,它诞生的场景是因为我们发现很多时候争抢锁的都是同一个线程,所以设计出了偏向锁这一概念,即 一个获得过该锁的线程如果再次获得该锁,不必要经历轻量级锁自旋获取锁的过程,而是可以直接获取,从而提升性能。

一个对象一开始是处于无锁状态,之后会经历偏向锁、轻量级锁、重量级锁这些阶段,sychronized加的锁就是这样一个过程,期间锁只能升级,不能降级。

Javasychronized锁的具体细节可以参照这篇文章:不可不说的Java“锁”事

wait/notify机制

有了sychronized锁,我们保证了多线程下的数据一致性,这时候再通过Object类提供的wait/notify方法,我们就能实现多线程之间的通信。

为什么wait/notify要加锁使用

值得注意的是wait/notify一定要在有锁的情况下使用,这是因为以下两点:

  1. 不加锁的无法保证happens before规则。那么你对条件变量做的修改可能对其他线程暂时不可见。不过这一点只要对条件变量加上volatile关键字修饰也能达到相同效果。
  2. 会导致lost wake up问题。比如没有加锁的话,一个线程刚wait,此时另一个线程notify了其他线程,由于第一个线程还未被加入到等待序列,会导致此次通知没接收到。

基于以上两点,Java的设计师们强制要求使用wait/notify时一定要加锁的,而且调用wait/notify的对象必须就是当前线程持有的锁所对应的对象。(锁必须一样应该只是方便设计而已,毕竟重点是一定得有锁)

关于这一点的详细讨论可以参考:为什么Java中调用notify()要求持有锁。

notify()真的会唤醒线程吗

上面我们说了notify()要在有锁的情况下使用,那么这就要求我们得先notify再unlock。但根据注释我们知道notify()又会唤醒等待该锁的线程,那么不就会造成被notify的线程刚苏醒,由于获取不到锁(假设此时还没有unlock),就又被阻塞了吗?

很明显notify()不能唤醒线程,否则将会出现这样的事故。这一点我们可以从AQS中的java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObject得到证实。AQS维护了两种类型的队列,等待获取锁的等待队列和被await()阻塞的线程的条件队列,而signal()时,是将条件队列的队首元素出队,加入到等待队列准备竞争锁。(关于AQS源码后面会详细解析)

所以我们可以合理推测notify()不能唤醒线程,这一点也可以通过自己写个小demo得到证实。

我们知道notify()是一个本地方法,底层是由c语言实现的,所以不太好直接查看源码。Java中的wait()对标c中的pthread_cond_wait(),这个方法要求很宽松,甚至不要求一定要加锁调用,所以我找到了这样一个问题:c中是应该先unlock在notify之前还是之后,可以看到高赞回答中的解释是一般来说设计者会考虑到notify再unlock的话会导致被唤醒线程又陷入阻塞,所以设计者不会设计notify直接唤醒线程。

为什么wait()要在循环体中

注意到java.lang.Object#wait()有这样的注释:

A thread can also wake up without being notified, interrupted, or timing out, a so-called spurious wakeup. While this will rarely occur in practice, applications must guard against it by testing for the condition that should have caused the thread to be awakened, and continuing to wait if the condition is not satisfied. In other words, waits should always occur in loops, like this one:
synchronized (obj) {
while (condition does not hold)
obj.wait(timeout);
… // Perform action appropriate to condition
}

说是wait()可能会存在虚假唤醒的情况,这要求我们循环检查条件变量是否满足条件,调用wait()

我们知道wait()这些函数最后是需要系统调用来做的,因为涉及到线程阻塞等操作,这不是能在用户态完成的操作。而这些阻塞式的系统调用,在被中断时会返回EINTR,这个时候如果再去等待,就可能发生问题,因为你无法确定这段过程中其他线程是否调用了notify()去唤醒你,如果你执意要去重新等待,可能会错过唤醒,导致永久死锁的情况出现。因此,在操作系统层级,只要你调用了wait(),如果被中断,就不会选择继续等待了,即使有可能发生虚假唤醒。

关于这一点的讨论可以参考这个答案:为什么条件锁会产生虚假唤醒现象(spurious wakeup)?。

另外值得注意的是java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObject#await()并不会产生虚假唤醒,因为它的实现里就彻底杜绝了这种情况的发生(它里面也是无线循环判断条件)。

notify()的唤醒是随机的吗

根据java.lang.Object#notify的注释,我们知道这个方法是随机唤醒一个等待中的线程,但其实具体也是根据实现来的:

The choice is arbitrary and occurs at the discretion of the implementation.

在HotSpot虚拟机中notify()遵循FIFO的顺序,而notifyAll()遵循LIFO的顺序。

对于notify()顺序:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Main main = new Main();
        for (int i = 0; i < 5; i++) {
            String name = "线程-" + i;
            Thread.sleep(1000);
            new Thread(()->{
                main.await(name);
            }).start();
        }
        for (int i = 0; i < 5; i++) {
            Thread.sleep(1000); // 由于synchronized不是公平锁,这里得每隔一段时间notify一次
            main.signal();
        }
    }

    private synchronized void await(String name){
        try {
            System.out.println(name + "被阻塞");
            wait();
            System.out.println(name + "继续执行");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private synchronized void signal(){
        notify();
    }

    private synchronized void signalAll(){
        notifyAll();
    }
}

程序打印为:

线程-0被阻塞
线程-1被阻塞
线程-2被阻塞
线程-3被阻塞
线程-4被阻塞
线程-0继续执行
线程-1继续执行
线程-2继续执行
线程-3继续执行
线程-4继续执行

对于notifyAll()顺序:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Main main = new Main();
        for (int i = 0; i < 5; i++) {
            String name = "线程-" + i;
            Thread.sleep(1000);
            new Thread(()->{
                main.await(name);
            }).start();
        }
        Thread.sleep(1000); // 这里得等所有线程被wait方法阻塞后再notifyAll
        main.signalAll();
    }

    private synchronized void await(String name){
        try {
            System.out.println(name + "被阻塞");
            wait();
            System.out.println(name + "继续执行");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private synchronized void signal(){
        notify();
    }

    private synchronized void signalAll(){
        notifyAll();
    }
}

程序打印为:

线程-0被阻塞
线程-1被阻塞
线程-2被阻塞
线程-3被阻塞
线程-4被阻塞
线程-4继续执行
线程-3继续执行
线程-2继续执行
线程-1继续执行
线程-0继续执行

AQS

除了JVM提供的sychronized锁外,我们还可以靠AbstractQueuedSynchronizerAQS去实现锁。典型的比如ReentrantLockReentrantReadWriteLock等等都是基于AQS实现的锁,可以说JUC的核心就是AQS。

基于AQS实现的锁提供了更细粒度的加锁,多个条件队列(Condition)等等优于sychronized锁的特点,可以说能完全替代sychronized锁了。可能sychronized锁为唯一的好处就是更易用、更易读了。

如果理解了AQS的源码,会对锁的理解更加深刻。如我上一篇文章所说,锁是一种高级的抽象,方便我们解决并发问题,底层还是依靠CPU那些指令来构建的。AQS里面大量用到了CAS操作以及LockSupport类去构建,这里做一个宏观的总结:

源码分析

AQS是同步队列,利用这个队列,我们可以实现锁的概念。lock()就对应调用AQS中的acquire()unlock()就对应调用AQS中的release()

acquire()是模板方法,它默认调用tryAcquire()acquireQueued(),前者是真正的去获取锁,AQS中并没有给出实现,后者相当于将tryAcquire()失败的节点————获取锁失败的节点,加入队列,并循环获取,一般来说循环中获取锁失败会阻塞,等待前驱节点————正在持有锁的节点去唤醒它。

release()也差不多,也是一个模板方法,里面会调用tryRelease(),这也是一个待子类实现的释放锁的方法,在释放成功后,如果当前节点就是头节点的话(因为存在队列中为空的情况,这个时候第一个获取到锁的线程并不会入队,入队只在enq()中实现),就会再执行unparkSuccessor(),唤醒下一个线程。

其他诸如acquireShared()等等也一样,都是模板方法。

关于ConditionObject:

AQS中还实现了Condition功能,这里面维护着一个条件队列,区别于AQS维护的等待队列,这两个队列并不相同。在ConditionObject里面的方法都给出完整实现。条件队列中的节点的waitStatusCONDITION

await()中,会调用AQS中的方法fullyRelease(),实现该线程释放锁,退出等待队列,然后再加入条件队列。然后在signal()方法中,层层调用,最终会调用到transferForSignal(),实现退出条件队列,加入等待队列的操作。

值得注意的是,不管是条件队列还是等待队列,一直遵循的是FIFO的顺序,而非随机顺序。另外Object中的notify()也遵循FIFO,而notifyAll()遵循的是LIFO。不过这些都是本地方法,主要取决于JVM的实现,在HotSpot中至少是这样的。

关于独占锁和共享锁:

如前面所说,AQS中维护着两种类型队列,等待队列条件队列。在Node类中,有一个成员变量nextWaiter来记录条件队列中的下一个节点。

而在等待队列中,这个变量用来标志该节点的模式是共享模式还是独占模式,等待队列中用成员变量next来指示下一个节点。你可能会好奇,那条件队列中用什么变量来标识该队列节点的模式,其实根本就没要为条件队列中的节点标识模式,因为条件队列只能在独占模式下访问。

从这个方面来说,其实AQS源码的可读性不太好,毕竟一个变量用作多个用途,而且AQS源码还有大量一行代码实现多个功能的写法,可读性确实差。但不可否认性能确实高,至少在牺牲可读性的前提下。

在等待队列中,通过acquireShared()获取锁,当获得到共享锁的时候,会循环调用setHeadAndPropagate()方法判断获得到共享锁的后续节点的waitStauts是不是小于0(因为PROPAGATE可能或变成SIGNAL),如果是的话,再判断后续节点状态是否为共享锁,或者为null,就唤醒后续节点。

这里还要判断是否为null是因为为null并不能代表该节点在队尾了。这是因为在enq()中入队的操作,是先node.prev = t;,将入队节点的前驱节点指向队尾,再compareAndSetTail(t, node),CAS替换队尾,最后再t.next = node;

我们知道如果一直是多个线程获取共享锁的话只需要给state不断增加就好了,但是可能存在某一次加了一个独占锁,在期间内,后面堆积了很多共享锁的节点,这个时候如果独占锁释放,那么后面第一个共享锁的节点获得到锁后,就应该通知其后面连续的所有的共享锁节点。

这里再拓展一下。一个节点获得到锁,是不会加入队列的,只有获取失败失败才会加入队列(通过enq()),后面成功获得之后,又会被移除队列(比如acquireQueued())。且我们要实现的算法只涉及到怎么获取锁,怎么释放锁,至于队列的出对入队管理,逻辑全部已经在AQS中实现了。比如公平锁,非公平锁这种逻辑,应该是我们在子类中实现给出,因为这属于怎么获取释放锁。

关于取消节点:

在AQS的Node类中,waitStatus还有一种值是CANCELLED,表示该节点已超时或者被中断。

它通过cancelAcquire()设置。而cancelAcquire()方法在所有获取锁的方法(比如acquireQueued())中的finally块中被调用。

cancelAcquire()这个方法只会把要取消的节点的next指向自身,并不会将其移除队列,出队操作会在别的方法比如shouldParkAfterFailedAcquire()或者再次cancelAcquire()中执行。

关于方法中是否有Interruptibly:

有这个关键字的方法会抛出中断异常。没有的话则是调用当前线程的interrupt(),至于调用该方法的会发生什么,可以参照java.lang.Thread#interrupt的注释,不同情况会有不同反应:

Java并发编程详解_第1张图片

序号分别对应着四种线程被interrupt时的情况

你可能感兴趣的:(jvm,java,算法)