Java 多线程(原理篇)

Posted offerNotFound

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 多线程(原理篇)相关的知识,希望对你有一定的参考价值。

本文有点长,请慢慢食用…(当然想更清楚还是去看上次推荐的书)

Java 内存模型(JMM)

JMM的抽象示意图:

由图可知:

  1. 所有的共享变量都存在主内存中。
  2. 每个线程都保存了一份该线程使用到的共享变量的副本。
  3. 如果线程A与线程B之间要通信的话,必须经历下面2个步骤:
    a. 线程A将本地内存A中更新过的共享变量刷新到主内存中去。
    b. 线程B到主内存中去读取线程A之前已经更新过的共享变量。

因为根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。说人话就是:线程A操作的结果对线程B是不可见的,必须要等结果刷新回主存才变成可见的变量。

JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。


重排序与happens-before

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

指令重排一般分为以下三种:

  • 编译器优化重排
    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令并行重排
    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统重排(导致了内存可见性的问题)
    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

JMM提供了happens-before规则(JSR-133规范),满足了程序员的需求——简单易懂,并且提供了足够强的内存可见性保证。换言之,程序员只要遵循happens-before规则,那他写的程序就能保证在JMM中具有强的内存可见性。

JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。happens-before关系的定义如下:

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。

总之,如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程。

volatile关键字呢由于本人已经挺熟悉的了,就不写了,想了解的小伙伴就自行去看书吧…


内存屏障

JVM通过内存屏障来实现限制处理器的重排序。

什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)写屏障(Store Barrier)。内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。(注意这里的缓存主要指的是CPU缓存,如L1,L2等)

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的JMM内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:

  • 在每个volatile写操作前插入一个StoreStore屏障;
  • 在每个volatile写操作后插入一个StoreLoad屏障;
  • 在每个volatile读操作后插入一个LoadLoad屏障;
  • 在每个volatile读操作后再插入一个LoadStore屏障。

synchronized与锁

synchronized底层原理

首先需要明确的一点是:Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。

我们通常使用synchronized关键字来给一段代码或一个方法上锁。它通常有三种形式:

  • synchronized在实例方法上,锁为当前实例
  • synchronized在静态方法上,锁为当前Class对象(即类模板)
  • synchronized在代码块上,锁为括号里面的对象
public void blockLock() 
   Object o = new Object();
   synchronized (o)  // 即锁的是这个o
       // code
   

一、当synchronized修饰的是代码块时

synchronized 的底层是和JVM挂钩,synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

二、当synchronized修饰的是方法

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

锁与synchronized的优化

在Java 6 及其以后,为了优化synchronized,一个对象其实有四种锁状态,它们级别由低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量锁状态
  4. 重量锁状态

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻。

偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。(大白话就是对锁置个变量,如果发现为true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为false,代表存在其他线程竞争资源,那么就会走后面的流程。

实现原理

这就牵扯到一个对象头的概念了,每个Java对象都有对象头。如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽来存储对象头。在32位处理器中,一个字宽是32位;在64位虚拟机中,一个字宽是64位。对象头的图:

对象头中与锁有关的关键Mark Word的格式:

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。

如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

轻量级锁

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM采用轻量级锁来避免线程的阻塞与唤醒。

轻量级锁的加锁过程

线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。(JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。)

自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。

需要注意的是,当调用一个锁对象的waitnotify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。

锁升级流程

第一步:检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

第二步:如果MarkWord里不是自己的ThreadId,锁升级,然后新线程就会根据这个ThreadId 通知之前线程暂停,之前线程将Markword的内容置为空。

第三步:两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。

第四步:第三步中成功执行CAS的获得资源,失败的则进入自旋 。

第五步:自旋成功则获得资源,依旧轻量级锁;失败就升级成重量级锁。


CAS与原子操作

锁可以分为两大类:悲观锁与乐观锁。

悲观锁:

悲观锁就是我们常说的锁。对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

乐观锁:

乐观锁又称为“无锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。

CAS

CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:

判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。说人话就是,当一个线程要去改一个变量时,会先去看这个变量是否被其他线程动过,如果没有则可以改;如果被动过就不改。

注意:CAS会引发三大问题:

1、ABA问题(这个变量被改过,但最后一次更改又变回的原值,看似这个变量就是没被动过)。解决方法:给这个变量增加一个版本号。

2、CAS长时间自旋不成功,给CPU带来很大的性能开销。解决方法:JVM能支持pause指令,效率会有一定的提升。

3、只能保证一个共享变量的原子操作。对多个共享变量操作时,不能保证原子性。解决方法:加锁;共享变量合并成一个共享变量。


AQS

最难,也是最重要的玩意来了…(手动狗头)

AQS是AbstractQueuedSynchronizer的简称,即抽象队列同步器,从字面意思上理解:

  • 抽象:抽象类,只实现一些主要逻辑,有些方法由子类实现;
  • 队列:使用先进先出(FIFO)队列存储数据;
  • 同步:实现了同步的功能;

面试中常提到的ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。

AQS的数据结构

AQS内部使用了一个volatile的变量state来作为资源的标识。同时定义了几个获取和改变state的protected方法,子类可以覆盖这些方法来实现自己的逻辑:

这三种叫做均是原子操作,其中compareAndSetState的实现依赖于Unsafe的compareAndSwapInt()方法。

它内部使用了一个双端队列,并使用了两个指针head和tail用于标识队列的头部和尾部。它并不是直接储存线程,而是储存拥有线程的Node节点。

AQS的主要方法源码解析

AQS的设计是基于模板方法模式的,它有一些方法必须要子类去实现的。

这些方法虽然都是protected方法,但是它们并没有在AQS具体实现,而是直接抛出异常(这里不使用抽象方法的目的是:避免强迫子类中把所有的抽象方法都实现一遍,减少无用功,这样子类只需要实现自己关心的抽象方法即可)。

获取资源逻辑

获取资源的入口是acquire(int arg)方法。arg是要获取的资源的个数,在独占模式下始终为1。

public final void acquire(int arg) 
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();

流程图:

释放资源逻辑

源码:

public final boolean release(int arg) 
    if (tryRelease(arg)) 
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    
    return false;


private void unparkSuccessor(Node node) 
    如果状态是负数,尝试把它设置为0
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    得到头结点的后继结点head.next
    Node s = node.next;
    如果这个后继结点为空或者状态大于0
    通过前面的定义我们知道,大于0只有一种可能,就是这个结点已被取消
    if (s == null || s.waitStatus > 0) 
        s = null;
        等待队列中所有还有用的结点,都向前移动
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    
    如果后继结点不为空,
    if (s != null)
        LockSupport.unpark(s.thread);

以上是关于Java 多线程(原理篇)的主要内容,如果未能解决你的问题,请参考以下文章

Java并发多线程编程——CyclicBarrier

Java多线程-两种常用的线程计数器CountDownLatch和循环屏障CyclicBarrier

java 多线程分段等待执行完成状况,循环屏障CyclicBarrier | Java工具类

Java并发包中CyclicBarrier的工作原理使用示例

Java多线程_同步工具CyclicBarrier

concurrent同步屏障 CyclicBarrier & 源码分析