专栏首页用户5521492的专栏并发编程(一)| Volatile 与 Synchronized 深度解析

并发编程(一)| Volatile 与 Synchronized 深度解析

今天这篇是我的好朋友 evil say的投稿,这小伙现在大四,客观来说,大四有这个实力,我觉得很不错。他目前正在找实习,如果看了本文觉得他可以,有公司有坑位、愿意抛出橄榄枝的话。请联系他:hack7458@outlook.com

一、Volatile 关键字的实现及定义

1.1 定义

Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致性的更新,线程应该确保通过排他锁单独获得这个变量。Java 语言提供了 volatile,在某些情况下比锁要更加的方便。如果一个字段被声明成 Volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。

instance = new Sigleton; // instance 是 volatile 变量

// 转变成汇编代码
// 0x01a3deld: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0 x 0,(%esp)

1.2 实现

  1. Lock 前缀指令会引起处理器回写到内存。Lock 信号确保在声言 (不达目的,不罢休) 该信号期间处理器可以独占任何内存,以前是锁总线(CPU 不能访问系统内存),但是在最近的处理器当中一般都是锁缓存,它会锁住这块内存区域的缓存并回写到内存当中,此操作叫缓存锁定,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效,处理器使用嗅探技术保证它的内部缓存,系统内存和其他处理器缓存的数据在总线上保持一致性,在下次访问相同内存地址的时候,强制执行缓存行填充

Lock 前缀指令:Lock 前缀指令在多核 CPU 下将当前处理器缓存行写回到系统内存,这个写回操作会使其他 CPU 缓存了该内存的地址的数据无效

缓存行填充:当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存 (L1,L2,L3 或所有)。

1.3 Volatile 是如何保证可见性的呢?

如果对声明了 Volatile 的变量进行写操作,JVM 会向处理器发送一条 Lock 前缀指令,将这个变量所在缓存行的数据写回到系统内存当中。在多处理器下,为了保证各个处理器缓存的缓存是一致的,就会实现缓存一致性协议当处理器发现自己缓存行对应的内存地址被修改,就会将当前的处理器缓存设置为无效,处理器对这个数据进行修改操作时,会重新从系统内存中把数据读到处理器缓存里

缓存行:CPU 高速缓存中可以分配的最小存储单位,处理器填写缓存行时会加载整个缓存行。

缓存一致性协议:缓存一致性协议通俗来讲是在多 CPU 的场景下,为了实现多线程同步而采取的一种技术手段,就像多线程同步是一种线程级别间的一致性保证。

特性

  1. 可见性:当一个线程修改一个共享变量时,另一个线程能读到这个修改的值。
  2. 禁止指令重排序

禁止重排序: 编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

缺点

  1. 在多线程中写多读少的情况下,使用 Volatile 会导致性能问题,及数据丢失问题。
  2. 对任意单个 Volatile 变量的读写具有原子性,但类似于 Volatile++ 复合操作不具备原子性

1.4 为什么 volatile++ 复合操作不具备原子性呢?

为了保证处理器中缓存一致性,会将当前的处理器缓存设置为无效的,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。注意的是这里的修改操作,是指的一个操作。可以知道自增操作是三个原子操作组合而成的复合操作。在一个操作中,读取了 inc 变量后,是不会再读取的 inc 的,所以它的值还是之前读的 10,它的下一步是自增操作。

二、Synchronized 关键字的实现及定义

2.1 定义

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。JavaSE1.6 之后相继为 Synchronized 为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁轻量级锁

2.2 Synchronized 使用场景

  • 对于普通同步方法,锁是当前的实例对象
  • 对于静态同步方法,锁是当前类的 Class 对象
  • 对于同步方法块,锁是 Synchronized 括号里配置的对象

2.3 Synchronized 实现原理

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

2.3.1 Java 对象头

在 JVM 存储大量存储对象同时,存储时为了实现一些额外的功能,需要在对象头添加一些标记字段用于增强对象功能,这些标记字段组成了对象头,Java 对象头分为数组类型跟非数组类型一个占用 4 字节,一个占用 8 字节。

2.3.2 Java 对象头的组成部分

  • Mark Word:存储对象的 hashcode 信息及锁信息等。
  • Class MeteData Address:存储对象类型数据的指针
  • ArrayLength: 数组的长度(如果对象是数组的话)

2.3.3 单例模式下的 Synchronized 的使用

//双重检查锁
public class Singleton(
    //使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
    private volatile static Singleton instance
    
    private Singleton()
    
    public Singleton getInstance(
        if(instance == null){
            synchronized(Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    )
)

2.3.4 偏向锁

在多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,为了让其获得锁的代价更低而引入了偏向锁。

  • 当一个线程访问同步块并获取锁时,会在对象头和栈帧中锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需要简单测试一下对象头的 MarkWord 里是否存储着指向当前线程的偏向锁
  • 如果成功表示当前已经获得了锁
  • 如果没有设置则使用 CAS 竞争锁
  • 如果设置了尝试使用 CAS 将对象头的偏向锁指向当前线程

2.3.5 轻量级锁

引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过 CAS 竞争锁,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

  • 轻量级锁的执行过程:线程执行同步块的之前,如果同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储对象目前 MarkWord 的拷贝,然后线程尝试使用 CAS 将对象头中的 MarkWord 替换为指向锁记录的指针。如果成功,当前线程获得锁,并将 MarkWord 中存储锁的信息更新为 00,如果失败了说明当前锁存在竞争,锁就会膨胀成重量级锁随后更新 MarkWord 锁的信息为 10。

2.3.6 重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

2.4 锁的优缺点对比

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距

如果线程间存在锁竞争,会带来额外的锁撤销的消耗

适用于只有一个线程访问同步块场景

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度

如果始终得不到锁竞争的线程使用自旋会消耗 CPU

追求响应时间,锁占用时间很短

重量级锁

线程竞争不使用自旋,不会消耗 CPU

线程阻塞,响应时间缓慢

追求吞吐量,锁占用时间较长

三、简单讲讲 CAS

全称是 Compare and Swap,即比较并交换。是通过原子指令将获取存储在内存地址的原值和指定的内存地址进行比较,只有当它们值相等时,交换指定的预期值和内存中的值,这个操作是原子操作,若不相等,则重新获取存储在内存地址的原值。

3.1 CAS 流程?

CAS 是一种无锁算法,有 3 个关键操作数,内存地址,旧的内存中预期值,要更新的新值,当内存值和旧的内存中预期值相等时,将内存中的值更新为新值。

3.2 CAS 有什么弊端吗?

比较著名有 ABA 问题,当 CAS 在操作的时候会检查变量的值是 A,接着变成 B,最后又变成 A,实际上这个值已经是被修改过的,为了解决这个问题,JDK 中提供了 AtomicStampedReference 类解决 ABA 问题,用 Pair 这个内部类实现,包含两个属性,分别代表版本号和引用,在 compareAndSet 中先对当前引用进行检查,再对版本号标志进行检查,只有全部相等才更新值。

3.3 自旋锁与自适应自旋

线程的挂起和恢复会极大的影响开销,很多线程在等待锁的时候,在很短的一段时间就获得了锁,所以它们在线程等待的时候,并不需要把线程挂起,而是让他无目的的循环,一般设置 10 次。这样就避免了线程切换的开销,极大的提升了性能。

而适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋 10 次一下。他可以根据它前面线程的自旋情况,从而调整它的自旋,甚至是不经过自旋而直接挂起。


-END-

本文分享自微信公众号 - 一个优秀的废人(feiren_java),作者:nasus

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-02-05

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • mybatis 缓存机制

    mybatis支持一、二级缓存来提高查询效率,能够正确的使用缓存的前提是熟悉mybatis的缓存实现原理;

    一个优秀的废人
  • 聊聊 mybatis 的缓存机制

    mybatis支持一、二级缓存来提高查询效率,能够正确的使用缓存的前提是熟悉mybatis的缓存实现原理;

    一个优秀的废人
  • 为什么我们做分布式使用Redis?

    绝大部分写业务的程序员,在实际开发中使用 Redis 的时候,只会 Set Value 和 Get Value 两个操作,对 Redis 整体缺乏一个认知。这里...

    一个优秀的废人
  • 读书笔记《Java并发编程的艺术 - 方腾飞》- 并发机制的底层实现原理

    volatile 是轻量级的 synchronize , 它可以保证变量在多线程环境的"可见性", "可见性"是指当一个线程修改了共享变量, 另一个线程能够读到...

    星尘的一个朋友
  • redis缓存穿透、缓存雪崩、热点Key问题分析及解决方案

    我们通常使用 缓存 + 过期时间的策略来帮助我们加速接口的访问速度,减少了后端负载,同时保证功能的更新。

    阿dai学长
  • 快速了解缓存穿透与缓存雪崩

    缓存系统,一般流程都是按照key去查询缓存,如果不存在对应的value,就去后端系统(例如:持久层数据库)查找。如果key对应的value是一定不存在的,并且对...

    全菜工程师小辉
  • 【缓存】缓存穿透、缓存雪崩、缓存击穿

    缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿...

    Leetcode名企之路
  • Confluence 6 配置文件和key 原

    缓存的配置文件是存储在 <confluence-home>/shared-home/config/cache-settings-overrides.proper...

    HoneyMoose
  • AppleWatch开发入门八——Watch中图片缓存的处理

            由于iWatch在存储和性能上都和iPhone有着很大的差距,这就要求开发者对程序有更高的性能优化,下载与传输图像,在Watch操作中是一个非时...

    珲少
  • Spring -- Cache原理

    Spring Cache并不是一种缓存的实现方式,而是缓存使用的一种方式,其基于Annotation形式提供缓存存取,过期失效等各种能力,这样设计的理由大概是缓...

    屈定

扫码关注云+社区

领取腾讯云代金券