java多线程(原理篇)(代码片段)

offerNotFound offerNotFound     2022-12-06     715

关键词:

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

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线程池的工作原理(代码片段)

多线程并发是Java语言中非常重要的一块内容,同时,也是Java基础的一个难点。说它重要是因为多线程是日常开发中频繁用到的知识,说它难是因为多线程并发涉及到的知识点非常之多,想要完全掌握Java的并发相... 查看详情

java并发系列终结篇:学校门口保安小王,这次彻底搞懂了java线程池的工作原理(代码片段)

前言多线程并发是Java语言中非常重要的一块内容,同时,也是Java基础的一个难点。说它重要是因为多线程是日常开发中频繁用到的知识,说它难是因为多线程并发涉及到的知识点非常之多,想要完全掌握Java的并... 查看详情

java---多线程篇(代码片段)

多线程进程与线程并行与并发多线程的创建和启动Thread类创建线程的两种方式方式一:继承Thread类方式二:实现Runnable接口指定线程的名称,并获取继承方式和实现方式的联系和区别Thread类相关的方法1线程的优先级Thr... 查看详情

java总结篇系列:java多线程(代码片段)

Java总结篇系列:Java多线程(一)多线程作为Java中很重要的一个知识点,在此还是有必要总结一下的。一.线程的生命周期及五种基本状态关于Java中线程的生命周期,首先看一下下面这张较为经典的图:上... 查看详情

java总结篇系列:java多线程(代码片段)

多线程作为Java中很重要的一个知识点,在此还是有必要总结一下的。一.线程的生命周期及五种基本状态关于Java中线程的生命周期,首先看一下下面这张较为经典的图:上图中基本上囊括了Java中多线程各重要知识点。掌握了上... 查看详情

java多线程系列--“juc集合”02之copyonwritearraylist(代码片段)

概要本章是"JUC系列"的CopyOnWriteArrayList篇。接下来,会先对CopyOnWriteArrayList进行基本介绍,然后再说明它的原理,接着通过代码去分析,最后通过示例更进一步的了解CopyOnWriteArrayList。内容包括:CopyOnWriteArrayList介绍CopyOnWriteArrayList... 查看详情

java多线程系列--“juc集合”03之copyonwritearrayset(代码片段)

概要本章是JUC系列中的CopyOnWriteArraySet篇。接下来,会先对CopyOnWriteArraySet进行基本介绍,然后再说明它的原理,接着通过代码去分析,最后通过示例更进一步的了解CopyOnWriteArraySet。内容包括:CopyOnWriteArraySet介绍CopyOnWriteArraySet原... 查看详情

java多线程(基础篇)(代码片段)

...文记录于阿里一群大佬们手码的书:《深入浅出Java多线程》线程与进程的区别:进程是一个独立的运行环境,而线程是在进程中执行的一个任务进程单独占有一定的内存地址空间,所以进程间存在内存隔离,... 查看详情

带你深入理解多线程---锁策略篇(代码片段)

这里写目录标题乐观锁CASCAS概念组成部分机制原理乐观锁的实现存在ABA问题举例:银行转账ABA问题代码实现解决ABA问题悲观锁共享锁/非共享锁(独占锁)读写锁概念读写锁代码实现公平锁和非公平锁概念优点java实现... 查看详情

java多线程:copyonwritearraylist实现原理(代码片段)

文章目录CopyOnWriteArrayListCopyOnWriteArrayList概念CopyOnWriteArrayList原理基于COW机制CopyOnWriteArrayList源码分析问题思考CopyOnWriteArrayList优缺点CopyOnWriteArrayLis使用场景CopyOnWriteArrayListCopyOnWriteArrayList概念CopyOnWri 查看详情

java多线程:copyonwritearraylist实现原理(代码片段)

文章目录CopyOnWriteArrayListCopyOnWriteArrayList概念CopyOnWriteArrayList原理基于COW机制CopyOnWriteArrayList源码分析问题思考CopyOnWriteArrayList优缺点CopyOnWriteArrayLis使用场景CopyOnWriteArrayListCopyOnWriteArrayList概念CopyOnWri 查看详情

java多线程——线程的概念和创建(代码片段)

文章目录一、进程二、线程1.线程的概念2.进程和线程的关系3.进程和线程之间的区别和联系三、Java中的线程1.线程的创建方式2.start()和run()的区别3.jconsole4.多线程的好处一、进程关于进程更详细的介绍可以看一下上一篇博客计算... 查看详情

java多线程:线程间通信方式(代码片段)

文章目录Java线程通信wait()、notify()、notifyAll()API说明实现原理代码实现await()、signal()、signalAll()API说明实现原理代码实现BlockingQueueAPI说明实现原理代码实现Java线程通信在Java中线程通信主要有以下三种方式:wait()、notify()、noti... 查看详情

java多线程:线程间通信方式(代码片段)

文章目录Java线程通信wait()、notify()、notifyAll()API说明实现原理代码实现await()、signal()、signalAll()API说明实现原理代码实现BlockingQueueAPI说明实现原理代码实现Java线程通信在Java中线程通信主要有以下三种方式:wait()、notify()、noti... 查看详情

多线程学习(基础篇)(代码片段)

文章目录一、多线程概述1.进程和线程之间的关系2.创建线程的方式3.Thread的几个常见属性4.中断一个线程5.获取当前线程引用6.线程的状态二、线程安全1.演示线程不安全2.如何避免线程安全问题3.synchronized关键字4.Java标准库中的线... 查看详情

多线程学习(基础篇)(代码片段)

文章目录一、多线程概述1.进程和线程之间的关系2.创建线程的方式3.Thread的几个常见属性4.中断一个线程5.获取当前线程引用6.线程的状态二、线程安全1.演示线程不安全2.如何避免线程安全问题3.synchronized关键字4.Java标准库中的线... 查看详情

java多线程:blockingqueue实现原理(condition原理)(代码片段)

文章目录BlockingQueue原理ArrayBlockingQueue源码分析问题引出:Condition原理Condition的实现类Condition监视器模型Condition三部分等待队列等待操作通知操作BlockingQueue原理BlockingQueue是一个接口,它的实现类有ArrayBlockingQueue、DelayQueue... 查看详情

java多线程:blockingqueue实现原理(condition原理)(代码片段)

文章目录BlockingQueue原理ArrayBlockingQueue源码分析问题引出:Condition原理Condition的实现类Condition监视器模型Condition三部分等待队列等待操作通知操作BlockingQueue原理BlockingQueue是一个接口,它的实现类有ArrayBlockingQueue、DelayQueue... 查看详情