写点什么

从 JVM 锁到 Redis 分布式锁,对小白十分友好

  • 2021 年 11 月 11 日
  • 本文字数:2954 字

    阅读完需:约 10 分钟

所谓 JVM 锁,其实指的是诸如 synchronized 关键字或者 ReentrantLock 实现的锁。之所以统称为 JVM 锁,是因为我们的项目其实都是跑在 JVM 上的。理论上每一个项目启动后,就对应一片 JVM 内存,后续运行时数据的生离死别都在这一片土地上。



什么是锁、怎么锁?


=========


明白了“JVM 锁”名字的由来,我们再来聊什么是“锁”,以及怎么“锁”。


有时候我们很难阐述清楚某个事物是什么,但很容易解释它能干什么,JVM 锁也是这个道理。JVM 锁的出现,就是为了解决线程安全问题。所谓线程安全问题,可以简单地理解为数据不一致(与预期不一致)。


什么时候可能出现线程安全问题呢?


当同时满足以下三个条件时,才可能引发线程安全问题:


  • 多线程环境

  • 有共享数据

  • 有多条语句操作共享数据/单条语句本身非原子操作(比如 i++虽然是单条语句,但并非原子操作)


比如线程 A、B 同时对 int count 进行+1 操作(初始值假设为 1),在一定的概率下两次操作最终结果可能为 2,而不是 3。



那么加锁为什么能解决这个问题呢?


如果不考虑原子性、内存屏障等晦涩的名词,加锁之所以能保证线程安全,核心就是“互斥”。所谓互斥,就是字面意思上的互相排斥。这里的“互相”是指谁呢?就是多线程之间!


怎么实现多线程之间的互斥呢?


引入“中间人”即可。


注意,这是个非常简单且伟大的思想。在编程世界中,通过引入


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


“中介”最终解决问题的案例不胜枚举,包括但不限于 Spring、MQ。在码农之间,甚至流传着一句话:没有什么问题是引入中间层解决不了的。


而 JVM 锁其实就是线程和线程彼此的“中间人”,多个线程在操作加锁数据前都必须征求“中间人”的同意:



锁在这里扮演的角色其实就是守门员,是唯一的访问入口,所有的线程都要经过它的拷问。在 JDK 中,锁的实现机制最常见的就是两种,分别是两个派系:


  • synchronized 关键字

  • AQS


个人觉得 synchronized 关键字要比 AQS 难理解,但 AQS 的源码比较抽象。这里简要介绍一下 Java 对象内存结构和 synchronized 关键字的实现原理。


Java 对象内存结构


==========


要了解 synchronized 关键字,首先要知道 Java 对象的内存结构。强调一遍,是 Java 对象的内存结构


它的存在仿佛向我们抛出一个疑问:如果有机会解剖一个 Java 对象,我们能看到什么?



右上图画了两个对象,只看其中一个即可。我们可以观察到,Java 对象内存结构大致分为几块:


  • Mark Word(锁相关)

  • 元数据指针(class pointer,指向当前实例所属的类)

  • 实例数据(instance data,我们平常看到的仅仅是这一块)

  • 对齐(padding,和内存对齐有关)


如果此前没有了解过 Java 对象的内存结构,你可能会感到吃惊:天呐,我还以为 Java 对象就只有属性和方法!


是的,我们最熟悉实例数据这一块,而且以为只有这一块。也正是这个观念的限制,导致一部分初学者很难理解 synchronized。比如初学者经常会疑惑:


  • 为什么任何对象都可以作为锁?

  • Object 对象锁和类锁有什么区别?

  • synchronized 修饰的普通方法使用的锁是什么?

  • synchronized 修饰的静态方法使用的锁是什么?


这一切的一切,其实都可以在 Java 对象内存结构中的 Mark Word 找到答案:



很多同学可能是第一次看到这幅图,会感到有点懵,没关系,我也很头大,都一样的。


Mark Word 包含的信息还是蛮多的,但这里我们只需要简单地把它理解为记录锁信息的标记即可。上图展示的是 32 位虚拟机下的 Java 对象内存,如果你仔细数一数,会发现全部 bit 加起来刚好是 32 位。64 位虚拟机下的结构大同小异,就不特别介绍。


Mark Word 从有限的 32bit 中划分出 2bit,专门用作锁标志位,通俗地讲就是标记当前锁的状态。



正因为每个 Java 对象都有 Mark Word,而 Mark Word 能标记锁状态(把自己当做锁),所以 Java 中任意对象都可以作为 synchronized 的锁:


synchronized(person){


}


synchronized(student){


}


复制代码


所谓的 this 锁就是当前对象,而 Class 锁就是当前对象所属类的 Class 对象,本质也是 Java 对象。synchronized 修饰的普通方法底层使用当前对象作为锁,synchronized 修饰的静态方法底层使用 Class 对象作为锁。


但如果要保证多个线程互斥,最基本的条件是它们使用同一把锁:



对同一份数据加两把不同的锁是没有意义的,实际开发时应该注意避免下面的写法:


synchronized(Person.class){


// 操作 count


}


synchronized(person){


// 操作 count


}


复制代码


或者


public synchronized void method1(){


// 操作 count


}


public static synchronized void method1(){


// 操作 count


}


复制代码


synchronized 与锁升级


================


大致介绍完 Java 对象内存结构后,我们再来解决一个新疑问:


为什么需要标记锁的状态呢?是否意味着 synchronized 锁有多种状态呢?


在 JDK 早期版本中,synchronized 关键字的实现是直接基于重量级锁的。只要我们在代码中使用了 synchronized,JVM 就会向操作系统申请锁资源(不论当前是否真的是多线程环境),而向操作系统申请锁是比较耗费资源的,其中涉及到用户态和内核态的切换等,总之就是比较费事,且性能不高。


JDK 为了解决 JVM 锁性能低下的问题,引入了 ReentrantLock,它基于 CAS+AQS,类似自旋锁。自旋的意思就是,在发生锁竞争的时候,未争取到锁的线程会在门外采取自旋的方式等待锁的释放,谁抢到谁执行。



自旋锁的好处是,不需要兴师动众地切换到内核态申请操作系统的重量级锁,在 JVM 层面即可实现自旋等待。但世界上并没有百利而无一害的灵丹妙药,CAS 自旋虽然避免了状态切换等复杂操作,却要耗费部分 CPU 资源,尤其当可预计上锁的时间较长且并发较高的情况下,会造成几百上千个线程同时自旋,极大增加 CPU 的负担。



synchronized 毕竟 JDK 亲儿子,所以大概在 JDK1.6 或者更早期的版本,官方对 synchronized 做了优化,提出了“锁升级”的概念,把 synchronized 的锁划分为多个状态,也就是上图中提到的:


  • 无锁

  • 偏向锁

  • 轻量级锁(自旋锁)

  • 重量级锁


无锁就是一个 Java 对象刚 new 出来的状态。当这个对象第一次被一个线程访问时,该线程会把自己的线程 id“贴到”它的头上(Mark Word 中部分位数被修改),表示“你是我的”:



此时是不存在锁竞争的,所以并不会有什么阻塞或等待。


为什么要设计“偏向锁”这个状态呢?


大家回忆一下,项目中并发的场景真的这么多吗?并没有吧。大部分项目的大部分时候,某个变量都是单个线程在执行,此时直接向操作系统申请重量级锁显然没有必要,因为根本不会发生线程安全问题。


而一旦发生锁竞争时,synchronized 便会在一定条件下升级为轻量级锁,可以理解为一种自旋锁,具体自旋多少次以及何时放弃自旋,JDK 也有一套相关的控制机制,大家可以自行了解。


同样是自旋,所以 synchronized 也会遇到 ReentrantLock 的问题:如果上锁时间长且自旋线程多,又该如何?


此时就会再次升级,变成传统意义上的重量级锁,本质上操作系统会维护一个队列,用空间换时间,避免多个线程同时自旋等待耗费 CPU 性能,等到上一个线程结束时唤醒等待的线程参与新一轮的锁竞争即可。


拓展阅读(没太大必要):


线程安全(中)--彻底搞懂synchronized(从偏向锁到重量级锁)


死磕Synchronized底层实现--偏向锁


synchronized 案例


==============


让我们一起来看几个案例,加深对 synchronized 的理解。


  • 同一个类中的 synchronized method m1 和 method m2 互斥吗?



t1 线程执行 m1 方法时要去读 this 对象锁,但是 t2 线程并不需要读锁,两者各管各的,没有交集(不共用一把锁)。

评论

发布
暂无评论
从JVM锁到Redis分布式锁,对小白十分友好