Java JVM锁 和Redis分布式锁上 | 小册免费学

765 阅读16分钟

JVM锁

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

  • 什么是锁?怎么锁?
  • JVM锁能干什么? JVM锁的出现,就是为了解决线程安全的问题,可以简单的理解为数据与预期不一致

出现线程安全问题的几个方面(需同时满足):

  • 多线程环境
  • 有共享数据
  • 有多条语句操作共享数据、或者单条语句本身非原子操作

两个线程同时进行count++时结果可能就会与预期值不同

为什么加锁可以解决这个问题呢? 如果不考虑内存屏障,原子性等隐晦的名词,加锁之所以能保证线程安全,核心就是’互斥‘,互斥即:互相排斥,多个线程之间,只允许一个线程操作临界资源。

怎么实现多线程之间的互斥呢? 引入中间人即可。伟大的思想,没有什么问题是引入中间层解决不了的。

JVM锁就是多线程之间彼此的’中间人‘,多个线程在执行同一个加锁数据(临界资源)时,必须征求’中间人‘的同意。

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

  • synchronized关键字
  • CAS+AQS

java内存对象结构: 对象在内存中的存储布局:对象头、实例数据、对齐填充

对象头: MarkWord 用于存储对象自身运行时数据,如哈希码、gc分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位虚拟机中分别位32或64个BITS Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

对象内存结构大致分为几块: Mark Word(锁相关) 元数据指针(指向当前实例所属的类) 实例数据 对齐填充

  • 为什么任何对象都可以作为锁
  • Object对象锁和类锁有什么区别
  • synchronized修饰的普通方法使用的锁是什么
  • synchronized修饰的静态方法使用的锁是什么

MarkWord结构:

简单理解:主要记录锁的信息(存储对象运行时的信息) Mark Word从有限的32位划分出2位,专门做锁的标志位,通俗的讲就是标记当前锁的状态 正因为每个Java对象都有MarkWord,而且MarkWord能标记锁的状态(把自己作为锁),所以Java中任意对象都可以作为synchronized的锁。

保证多个线程互斥的基本条件: 线程之间使用同一把锁

为什么要标记锁的状态呢?是否意味着synchronized锁有多种状态呢? jdk6以前,synchronized锁是重量级锁,只要代码中使用了synchronized,JVM就会向操作系统申请锁资源在(不论当前环境是否是多线程环境),而向操作系统申请锁资源是非常耗资源的,其中涉及到用户态和内存态的切换等,费时且耗性能。

JDK为了解决JVM锁性能低下的问题,引入了可重入锁ReentrantLock,它基于CAS和AQS,类似自旋锁,自旋的意思:发生锁竞争时,未争取到锁的线程会在门外采取自旋的方式等待锁的释放,谁抢到谁执行,在JVM代码层面就可实现不需要向操作系统申请锁适用于操作临界资源时间比较短且并发不高的操作

好处: 不需要通过从用户态切换到内核态申请操作系统的重量级锁,在JVM层面即可实现自旋等待,但CAS自旋虽然避免了线程切换等复杂操作,但却需要消耗部分CPU资源,尤其当可预计上锁时间较长且并发较高的情况下,会造成大量线程同时自旋,增加了cpu的负担。

JDK1.6后,synchronized进行了优化,提出了锁升级的概念,把synchronized的锁划分为多个状态

  • 无锁
  • 偏向锁
  • 轻量级锁(自旋)
  • 重量级锁

无锁: java对象刚刚new新建出来的的状态。此时时不存在锁竞争,所以不会存在阻塞和等待 偏向锁: 当线程第一个线程第一次操作临界资源时,此时会把线程的ID值赋给Mark Word 为什么要设计偏向锁这个状态呢? 项目中并发的场景不多,大部分项目的大部分时候,某个变量都是单个线程在执行,此时直接向操作系统申请重量级锁显然没有必要,因为此时根本不会发生线程安全问题。

一旦发生锁竞争时,synchronized并会在一定条件下升级为轻量级锁,自旋锁。jdk根据相关控制机制判断自旋多少次放弃自旋。

同样时自旋,所以synchronized也会遇到Reentantlock的问题,如果上锁时间长且自旋线程多的话会很消耗性能

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

通过synchronized加锁,实际上都是对对象加锁。 对于synchronized直接在方法上,虚拟机会根据synchronized修饰的是实例方法还是类方法,去取对象的实例对象还是类Class对象。 jdk1.6之前,synchronized这个关键字,是一个重量级锁,开销很大,1.6以后,进行了优化 进行了什么优化呢?为何重量级锁开销就大呢?

  • 轻量级锁、重量级锁、自旋锁、自适应自旋锁、偏向锁有什么区别?
  • 锁加在对象上的,如何知道这个对象被加锁了呢?
  • 如何知道他加的是什么类型的锁呢?
  • synchronized-->如和从偏向锁升级到重量级锁的。

锁对象: 锁实际是加在对象上的,那么被加了锁的对象我们称之为锁对象,在java中,任何一个对象都能成为锁对象。

虚拟机如何知道当前对象是否为锁对象? java对象在内存中的存储结构: 对象头 实例数据 填充数据 而对象头中的数据主要是一些运行时的数据。 对象头结构: 长度 内容 说明 32/64bit Mark Work hashCode,GC分代年龄,锁信息 32/64bit Class Metadata Address 指向对象类型数据的指针 32/64bit Array Length 数组的长度(当对象为数组时) 代码分析: LockObject lockObject = new LockObject();//随便创建一个对象 synchronized(lockObject){ //代码 }

当我们创建一个LockObject时,该对象的部分mark word数据如下 bit fields 是否偏向锁 锁标志位 hash 0 01 无锁 偏向锁的标志位是01,而状态位是0,表明该对象还未加上偏向锁。(1标识被加上偏向锁),该对象被创建出来的那一刻,就有了偏向锁的标志位,着也说明了所有对象都是可偏向的,但所有对象的状态都为0,同时说明所有刚刚创建出来的对象的偏向锁并没有生效。

偏向锁 不过,当线程第一次执行到临界资源是,此时会利用CAS操作,将线程ID插入到MarkWord中,同时修改偏向锁的标志位。临界区,就是只允许一个线程进去执行操作的区域,即同步代码快,CAS是一个原子性操作。 此时mark Work的结构信息如下: bit fields   是否偏向锁 锁标志位 threadId epoch 1 01 此时偏向锁的状态位1,说明对象的偏向锁生效了,同时可以看到,哪个线程获取到了该对象的锁。

那么什么是偏向锁? 偏向锁是jdk1.6引入的锁优化,偏向锁的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,加入该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程永远不需要进行同步操作。

也就是说: 在此线程之后的执行过程中,如果再次进入或者退出同一段同步代码块,并不需要进行加锁或者解锁操作,而是会做下面的步骤:

  • 1.Load-and-test,简单判断一下当前线程id是否与MarkWord当中的线程id是否一致。
  • 2.如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码。
  • 3.如果不一致,则要检查一下对象是否还是可偏向,即’是否偏向锁‘标志位的值。
  • 4.如果还未偏向,则利用CAS操作竞争锁,也即是第一次获取锁时的操作。

如果此对象已经偏向了,并且偏向的不是自己线程,则说明存在了竞争,此时可能就要根据另外线程的情况,可能时重新偏向,也有可能时做偏向撤销,但大部分情况下 就是升级成轻量级锁了。

可以看出,偏向锁时针对于一个线程而言的,线程获取锁之后就不会再有解锁等操作了,这样可以省略很多开销。加入有两个线程来竞争该锁的话,那么偏向锁就失效了,进而升级成轻量级锁了。 为什么这么做呢?经验表明,大部分情况下,都是同一个线程进入同一块同步代码块,如果每一次进入同步代码块是都需要从用户态转为内核态向操作系统申请锁的话,会消耗性能。

jdk1.6中,偏向锁的开关是默认开启的,使用于只有一个线程访问同步块的场景。 锁膨胀 在处于偏向锁时,当出现两个线程来竞争锁的话,那么偏向锁就失效了,此时锁就会膨胀,升级为轻量级锁,这也是我们经常所说的锁膨胀。

锁撤销 由于偏向锁失效了,那么接下来就该把锁撤销,锁撤销的花费还是挺大的,其大概的过程如下:

  • 1.在一个安全点停止拥有锁的线程
  • 2.遍历线程栈,如果存在锁记录的话,需要修复锁记录和MarkWord,使其变成无锁状态
  • 3.唤醒当前线程,将当前锁升级成轻量级锁。

所以,如果某些同步代码块大多数情况下都是有两个及两个以上的线程竞争的话,那么偏向锁就会是一种累赘,对于这种情况,可以一开始就把偏向锁这个默认功能关闭。

轻量级锁 锁撤销升级为轻量级锁之后,那么对象的MarkWord也会进行相应的变化。锁撤销之后升级为轻量级锁的过程:

  • 1.线程在自己的栈帧中创建锁记录LockRecord
  • 2.将锁对象的对象头中的MarkWord复制到线程刚刚创建的锁记录中
  • 3.将锁记录中的Owner指针指向锁对象
  • 4.将锁对象的对象头MarkWord替换为指向锁记录的指针

之后的MarkWord结构: bit fields 锁标志位 指向LockRecord的指针 00 锁标志为00,表示为轻量级锁

轻量级锁分为两种

  • 自旋锁
  • 自适应自旋锁

自旋锁 所谓自旋,就是当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到哪个获得锁的线程释放锁之后,这个线程就可以立马获得锁了, 锁在原地循环时,会消耗cpu,就相当于在执行一个啥也没有的for循环。 所以,轻量级锁适用于哪些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能获得锁了,经验表明,大部分同步代码块执行的时间都是很短很短的,也正是这个原因才有了轻量级锁这个东西。

自旋锁的一些问题

  • 1.如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时候,其他线程原地等待空消耗cpu,这会很消耗性能。
  • 2.本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cpu,甚至有可能一直获取不到锁。

基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,锁会再次膨胀,升级为重量级锁。

默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin来进行更改。 自旋锁jdk1.4.2引入

自适应自旋锁 所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会根据实际情况来改变自旋等待的次数。

大概原理: 假如一个线程1刚刚成功获取了一个锁,当它把锁释放了之后,线程2就获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能过再次成功获取到该锁的,所以会延长线程1自旋的次数。另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源。

轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待,串行执行。

重量级锁 轻量级锁膨胀之后,就升级为重量级锁了,重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的mutexLock(互斥锁)来实现的,所以重量级锁也就成为互斥锁了。当轻量级锁经过锁撤销等步骤升级为重量级锁之后,它的MarkWord部分数据如下: bit fields 锁标志位 指向Mutex的指针 10

为什么说重量级锁开销大呢? 主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu,但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要用户态转换到内核态,而转换状态是需要消耗很多时间,有可能比用户执行代码的时间还要长。

这就是说为什么重量级线程的开销是很大的。

互斥锁(重量级锁)也陈伟阻塞同步、悲观锁。

总结: 通过上面的分析,我们知道了为甚恶魔synchronized关键字的演变过程了,也就是说jdk1.6之后,synchronized关键字并非一开始就为对象加上重量级锁,也就是从偏向锁->轻量级锁,再到重量级锁的过程。这个过程也告诉我们,假如我们一开始就应该使用重量级锁了,从而省掉一些锁转换的开销。

synchronized案例 看synchronized同步代码是否持有同一把锁。

Redis分布式锁的概念

  • 什么是分布式
  • 什么是分布式锁
  • 为什么需要分布式锁
  • Redis如何实现分布式锁

分布式有个显著的特点是,ServiceA和ServiceB极有可能并不是部署在同一服务器上,所以他们不共享同一片JVM内存,而上面介绍了,要想实现线程互斥,必须保证所有访问的线程使用的是同一把锁,(JVM锁{不在同一JVM内存中}此时就无法保证互斥)

从分布式项目来看,有多少台服务器,就有多少个JVM内存,所以无法设置同一把JVM锁。

此时如何保证每个JVM上的线程共用一把锁呢? 把锁抽取出来,让线程们在同一片内存相遇。

但是锁不能单独存在的,本质还是要在内存中,此时可以使用Redis缓存作为锁的宿主环境,使其共用一片内存。

Redis的锁长啥样 synchronized关键字和ReentrantLock,他们都是实实在在已经实现的锁,而且在对象头还有标志位,但Redis就是一个内存,怎么作为锁呢?

有一点大家要明确,Redis之所以能用来做分布式锁,肯定不只是因为它是一片内存。

自定义一个分布式锁,需要满足的几个条件:

  • 多进程可见(独立于多节点系统之外的一片内存)
  • 互斥(可以通过单线程,可以通过选举机制)
  • 可重入

如果我们能够设计一种逻辑,它能造成某个场景下的互斥事件,那么它就可以被称为锁。 Redis提供了setnx指令,如果某个key当前不存在则设置成功并返回true,否则不再重复设置,直接放回false,即key存在返回false,不存在返回true。

思考题:分布式系统是否一定要分布式锁呢? 如果你需要的是写锁,那么可能的确需要分布式锁保证单一线程处理数据,而如果是为了防止缓存击穿(热点数据定时失效),那么使用JVM本地锁也没有太大关系,比如某个服务有十个节点,在使用JVM锁的情况下,及时某个时刻每个节点涌入1000个请求,虽然总共有1w个请求,但最终访问数据库的也只有10次,数据库层面是完全可以抗住这请求量的,又由于是查询,所以不会造成线程安全问题。