🔥史上最全的Java并发系列之Java并发机制的底层实现原理

3,970 阅读13分钟

前言

文本已收录至我的GitHub仓库,欢迎Star:github.com/bin39232820…
种一棵树最好的时间是十年前,其次是现在
我知道很多人不玩qq了,但是怀旧一下,欢迎加入六脉神剑Java菜鸟学习群,群聊号码:549684836 鼓励大家在技术的路上写博客

絮叨

昨天从大的方向上介绍了Java并发的一个全局观,了解了JDK的JUC,那么今天我们从最底层的原理来探索这些并发,这也是面试问的最多的地方之一,问底层,如果能理解当然是好的啦,前面的内容在下面的链接:

Java代码 编译之后 得到 Java字节码,被 类加载器加载到JVM中,最终 转化为汇编指令。Java中的并发机制依赖于JVM的实现和CPU的指令

并发编程的3个基本概念

原子性

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

原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1是原子性操作,但是a++和a +=1就不是原子性操作。Java中的原子性操作包括:

a. 基本类型的读取和赋值操作,且赋值必须是数字赋值给变量,变量之间的相互赋值不是原子性操作。

b.所有引用reference的赋值操作

c.java.concurrent.Atomic.* 包中所有类的一切操作

可见性

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

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

有序性

定义:即程序执行的顺序按照代码的先后顺序执行。

Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。

在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

锁的互斥和可见性

锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。

  • 互斥即一次只允许一个线程持有某个特定的锁,一次就只有一个线程能够使用该共享数据。

  • 可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。也即当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的。如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

    • 对变量的写操作不依赖于当前值。

    • 该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。事实上就是保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

Java的内存模型JMM以及共享变量的可见性

JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

对于普通的共享变量来讲,线程A将其修改为某个值发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。解决这种共享变量在多线程模型中的不可见性问题,较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,比较合理的方式其实就是volatile。

需要注意的是,JMM是个抽象的内存模型,所以所谓的本地内存,主内存都是抽象概念,并不一定就真实的对应cpu缓存和物理内存

存储器的内存结构

每次读一个数据都是先从上一直找到下 如果cpu高速缓存 1 2有的话,那么速度会快很多,越靠近CPU的地方 速度越快 成本越高
如果是写入一个数据,它也会一层一层的写到各个缓存中去 (这里还涉及到一个高级概念 缓存行 就是CPU做高速缓存的时候 他并不是说一个字节去缓存的,而是一行去缓存,并且这个大小是64个字节 所以JDK源码 中 为了避免 缓存行里面其他数据的更新,你可以定义64个字节的数据,去做缓存,可能比你做一个8个字节的要快)

Volatile原理

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。因为它不会引起线程上下文的切换和调度

当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。

而声明变量是 volatile 的,JVM 保证了每次读变量都从JMM中读,跳过 CPU cache(线程的本地缓存) 这一步。

volatile是如何保证可见性的呢

把有volatile关键字修改的代码 变成汇编代码的时候发现,它的代码前面多了一个lock 关键字,这个前缀的指令在多核处理器下会引发2件事情

  • 将当前处理器缓存行的数据回写到系统内存中
  • 这个回写操作,会导致其他线程的本地缓存无效(内部是通过缓存一致性协议,通过在总线上的传播数据来检查自己的缓存是否有效)
  • MESI Cache 缓存一致性协议
    • Modified 修改的
    • Exclusive 独占的
    • Shared 共享的
    • Invalid 无效的

synchronized

在多线程中,synchronized 一直是一个元老级别的角色,很多人会称呼他为重量级的锁,但是1.6对它的优化之后,并不那么重量了。

synchronized实现同步

Java中的每个对象都可以作为锁,它有以下三种表现形式

  • 对于 普通同步方法,锁是 当前实例对象。
  • 对于 静态同步方法,锁是 当前类的Class对象。
  • 对于 同步方法块,锁是 Synchonized括号里配置的对象(可能是实例对象。也可能是Class对象)。

Java对象头

synchronized用的锁是存在Java对象头里的。在32位 虚拟机中,1字宽 等于4字节,即32bit。

  • 数组类型,虚拟机用3个字宽存储对象头。
  • 非数组类型,虚拟机用2字宽存储对象头。

我们知道的Java的锁是存在每个对象里面,那具体是存在哪里呢?
在Java对象头里面有一个叫 Mark Word的区域,里面存着HashCode 分代年龄,锁标记位。

锁的4种状态

级别从低到高依次是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

这边说一下偏向锁的原理吧?自己总结的也不一定对,就是说当一个线程去获得一个偏向锁要走的几步

  • 第一步,先判断再对象头里面是否存储了当前线程的id和判断一下锁标志位的状态,
  • 第二步,如果是有当前线程id,就直接省去了CAS操作来加锁,解锁,如果没有则就行下一步
  • 第三步,通过CAS 获得锁,然后把当前线程id存到对象头里面,然后把同步代码块执行完成,第四步就是接下来,要释放偏下锁
  • 第四步,偏向锁的释放机制是当有下一个线程来竞争锁的时候,发现CAS不成功,那么就释放锁,然后再去竞争锁

至于 轻量级锁 我认为就是还是处于自旋 线程还没挂起的状态

原子操作的实现原理

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。

处理器如何实现原子操作

  • 使用总线锁保证原子性:所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
  • 使用缓存锁保证原子性:这个的意思是在每个线程的本地缓存中,我不会去管你,但是最后会写到内存中的时候,我会用缓存一致性原理让你只能有一个线程能回写内存成功,然后告知其他线程的本地缓存失效,让他们重新去更新本地缓存,再去操作,

以下两种情况不会使用缓存锁:

  • 当处理器不支持缓存锁定。
  • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定。

Java如何实现原子操作

Java 提供了2种原子性的方法

  • Java使用锁来保证原子性操作,锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。
  • 使用循环CAS实现原子性操作

什么是CAS?

在计算机科学中,比较和交换(Conmpare And Swap)是用于实现多线程同步的原子指令。它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值。这是作为单个原子操作完成的。 原子性保证新值基于最新信息计算; 如果该值在同一时间被另一个线程更新,则写入将失败。操作结果必须说明是否进行替换;这可以通过一个简单的布尔响应(这个变体通常称为比较和设置),或通过返回从内存位置读取的值来完成(摘自维基本科)

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。至于底层是unsafe包实现的 里面是调用的native方法(底层c++实现),操作cpu的,这边就不往下深入了,不是不想,是博主太菜了

CAS操作的三大问题

  • ABA问题,这个是最常见的问题之一了,这个也简单就是A变成了B 最后变成了A,那么内存值 和预期值是相当的,所以他认为这个操作是原子的,其实不是,
  • CAS是循环的去操作,如果长时间不成功,对于cou的消耗比较大
  • 只能保证对于一个共享变量的原子性操作,如果是多个建议用锁

结尾

第二章,介绍了并发机制的底层实现原理,valatile synchronized的实现原理,CAS 的优缺点,原子性问题等,后面的很多东西,但是要基于这个来实现的,今天就到这了吧

因为博主也是一个开发萌新 我也是一边学一边写 我有个目标就是一周 二到三篇 希望能坚持个一年吧 希望各位大佬多提意见,让我多学习,一起进步。

日常求赞

好了各位,以上就是这篇文章的全部内容了,能看到这里的人呀,都是神人

创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见

六脉神剑 | 文 【原创】如果本篇博客有任何错误,请批评指教,不胜感激 !