携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
不管面试时还是实际工作中,锁都是我们经常遇到的技术点,本文将从JVM的synchronized、ReentrantLock聊到Redis分布式锁,会介绍其中的思想和大致原理。
JVM锁
总所周知,Java是运行在JVM上,所以这里的JVM锁,就是指得synchronized关键字或者ReentrantLock实现的锁。JVM锁主要是为了解决线程安全问题。
线程安全问题
可以简单地理解为数据不一致(与预期不一致)。同时满足以下三个条件,才可能引发线程安全问题:
- 多线程环境
- 有共享数据
- 有多条语句操作共享数据/单条语句本身非原子操作(比如i++虽然是单条语句,但并非原子操作)
例如
线程A、B同时对int count进行+1操作(初始值假设为1)在一定的概率下两次操作最终结果可能为2,而不是3。
那加锁是否可以解决这个问题?
这里不谈原子性,内部屏障等问题,加锁是可以解决的,加锁的核心是保持互斥,保持线程安全。互斥,即为多线程之间,只有一个线程能能操作大家所需的资源。
中间人思想
解决线程之间互斥的问题。
没有什么问题是引入中间层解决不了的。
JVM锁就是线程和线程彼此的“中间人”, 多个线程在操作加锁数据前都必须征求“中间人”的同意。
在JDK中,锁的实现机制最常见的就是两种派系,
- synchronized关键字
- AQS
synchronized关键字要比AQS难理解,但AQS的源码比较抽象。下面会简要介绍一下Java对象内存结构和synchronized关键字的实现原理。
Java对象内存结构
我们可以来解剖一个真实的java对象。
从java对象的内存结构,我们可以回顾下一个对象创建过程中的内存使用。
1、jvm将对象所在的class文件加载到方法区中
2、jvm读取main方法入口,将main方法入栈,执行创建对象代码
3、在main方法的栈内存中分配对象的引用,在堆中分配内存放入创建的对象,并将栈中的引用指向堆中的对象
所以当对象在实例化完成之后,是被存放在堆内存中的
\
java对象在内存中存储可分为三个部分:对象头、实例数据、对齐填充
Mark Word 标记字段
主要内容是一系列的标记位,用于储存对象自身运行时的数据,如HashCode、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等。
占用内存为一个机器码,32为系统占用4个字节,64位系统位8个字节。
以32位系统为例:在无锁状态下包含,25Bit对象的hashcode、4bit 对象的分代年龄、1bit 是否是偏向锁、2bit 锁标志位
考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便于在极小空间内存存储尽量多的数据,他会根据对象的状态复用自己储存空间,也就是说,Mark Word会随着程序的运行发生变化。\
synchronized与锁升级
在早期版本JDK5之前,synchronized的实现是直接基于重量级锁的。只要在代码使用了synchronized,JVM就会向操作系统申请资源(不考虑当前是否有多线程),而向操作系统申请锁过程比较耗费资源,还会涉及到CPU内核态和用户态的切换,效率很低,导致性能低下。
在当时版本,JDK为了解决JVM性能问题, 引入了ReentrantLock, 基于CAS+AQS, 与自旋锁原理类似。
synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”
Monitor与java对象以及线程是如何关联 ?
1.如果一个java对象被某个线程锁住,则该java对象的Mark Word字段中LockWord指向monitor的起始地址
2.Monitor的Owner字段会存放拥有相关联对象锁的线程id
JDK6之后进行了优化,引入了轻量级锁和偏向锁, 拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程。
无锁
一个对象刚new出来的状态,此时不存在锁竞争,也就没有阻塞或等待。
锁标志位 01, 偏向锁0
偏向锁
多线程的情况下,锁不仅不存在多线程竞争,还存在锁 由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决 只有在一个线程执行同步时提高性能。
锁标志位01, 偏向锁1
锁第一次被拥有时,会记录下偏向线程ID。 偏向线程会一直持有锁,后续该线程进入或退出这一加了同步锁的代码时,不需要重新加锁和释放锁, 而是直接检查锁的对象头里面存的是不是该线程ID;
如果相等, 表示偏向锁时偏向当前线程的,不需要再次尝试获取锁,直到有其他线程竞争锁的才释放。理想状态下,使用锁的线程都是同一个,就没有额外开销,性能极高。
如果不等,表示锁已经发生了竞争,锁已经不偏向于同一个线程,这时候会尝试使用CAS来替换锁对象头里面的线程ID为最新线程ID; 新线程ID竞争成功,表示之前线程已不存在,将锁对象头的线程ID替换,锁不会升级,仍旧时偏向锁;新线程ID竞争失败,可能需要锁升级到轻量级锁,才能保证锁线程间公平。
注意:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的
竞争线程尝试CAS更新对象头失败,会等待到全局安全点(此时不会执行任何代码)撤销偏向锁。
- 第一个线程正在执行synchronized方法(处于同步块),它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现锁升级。此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
- 第一个线程执行完成synchronized方法(退出同步块),则将对象头设置成无锁状态并撤销偏向锁,重新偏向。
轻量级锁(自旋锁)
轻量级锁是在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋再阻塞。
升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁
锁标志位 00
自旋次数和程度自适应:意味着自旋的次数不是固定不变的,而是根据:同一个锁上一次自旋的时间。拥有锁线程的状态来决定。
自旋过程消耗CPU资源
轻量锁与偏向锁的区别和不同
争夺轻量级锁失败时,自旋尝试抢占锁。轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
重量级锁
有大量的线程参与锁的竞争,冲突性很高.
本质上操作系统会维护一个队列,用空间换时间,避免多个线程同时自旋等待耗费CPU性能,等到上一个线程结束时唤醒等待的线程参与新一轮的锁竞争即可。
标志位10
\
更详细参考blog.csdn.net/weixin_4384…
分布式锁
分布式服务之间肯定不是共享同一JVM内存,所以无法使用JVM锁。想要实现锁,就得使线程互斥,让分布式服务之间使用同一把锁,必须满足这几个条件,
- 多进程可见(独立于多节点系统之外的一片内存)
- 互斥(可以通过单线程,也可以通过选举机制)
- 可重入
- 高可用高性能的获取锁与释放锁
- 具备锁失效机制,防止死锁
分布式锁的几种实现方式
- 基于数据库实现分布式锁;
- 基于缓存(Redis等)实现分布式锁;
- 基于Zookeeper实现分布式锁;
- 基于ETCD实现分布式锁(GO语言实现);
分布式锁几种实现方式的具体实现,且看下篇
\