写点什么

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

作者:自然
  • 2022 年 8 月 15 日
    广东
  • 本文字数:4097 字

    阅读完需:约 13 分钟

前言

文本已收录至我的 GitHub 仓库,欢迎 Star:https://github.com/bin392328206/six-finger

种一棵树最好的时间是十年前,其次是现在

我知道很多人不玩 qq 了,但是怀旧一下,欢迎加入六脉神剑 Java 菜鸟学习群,群聊号码:549684836 鼓励大家在技术的路上写博客


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 缓存和物理内存

Volatile 原理

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


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



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


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

volatile 是如何保证可见性的呢

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


  • 将当前处理器缓存行的数据回写到系统内存中

  • 这个回写操作,会导致其他线程的本地缓存无效(内部是通过缓存一致性协议,通过在总线上的传播数据来检查自己的缓存是否有效)

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 的优缺点,原子性问题等,后面的很多东西,但是要基于这个来实现的,今天就到这了吧


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

日常求赞

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


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


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

发布于: 刚刚阅读数: 3
用户头像

自然

关注

还未添加个人签名 2020.03.01 加入

小六六,目前负责营收超百亿的支付中台

评论

发布
暂无评论
史上最全的Java并发系列之Java并发机制的底层实现原理_多线程_自然_InfoQ写作社区