共计 4034 个字符,预计需要花费 11 分钟才能阅读完成。
1. 什么时候需要用 Synchronized
Synchronized 主要作用是在多个线程操作共享数据的时候,保证对共享数据访问的线程安全性。比如两个线程对于 i 这个共享变量同时做 i ++ 递增操作,那么这个时候对于 i 这个值来说就存在一个不确定性,也就是说理论上 i 的值应该是 2,但是也可能是 1。而导致这个问题的原因是线程并行执行 i ++ 操作并不是原子的,存在线程安全问题。所以通常来说解决办法是通过加锁来实现线程的串行执行,而 synchronized 就是 java 中锁的实现的关键字。
synchronized 在并发编程中是一个非常重要的角色,在 JDK1.6 之前,它是一个重量级锁的角色,但是在 JDK1.6 之后对 synchronized 做了优化,优化以后性能有了较大的提升
2.Synchronized 的使用
synchronized 有三种使用方法,这三种使用方法分别对应三种不同的作用域,代码如下:
①修饰普通同步方法
将 synchronized 修饰在普通同步方法,那么该锁的作用域是在当前实例对象范围内, 也就是说对于 SyncDemosd=newSyncDemo(); 这一个实例对象 sd 来说,多个线程访问 access 方法会有锁的限制。如果 access 已经有线程持有了锁,那这个线程会独占锁,直到锁释放完毕之前,其他线程都会被阻塞。
public SyncDemo{
- Object lock =new Object();
- // 形式 1
- public synchronized void access(){
- //
- }
- // 形式 2, 作用域等同于形式 1
- public void access1(){
- synchronized(lock){
- //
- }
- }
②修饰静态同步方法
修饰静态同步方法或者静态对象、类,那么这个锁的作用范围是类级别。举个简单的例子,{SyncDemo sd=SyncDemo();SyncDemo sd2=new SyncDemo();} 两个不同的实例 sd 和 sd2,如果 sd 这个实例访问 access 方法并且成功持有了锁,那么 sd2 这个对象如果同样来访问 access 方法,那么它必须要等待 sd 这个对象的锁释放以后,sd2 这个对象的线程才能访问该方法,这就是类锁;也就是说类锁就相当于全局锁的概念,作用范围是类级别。
- public SyncDemo{
- static Object lock=new Object();
- // 形式 1
- public synchronized static void access(){
- //
- }
- // 形式 2 等同于形式 1
- public void access1(){
- synchronized(lock){
- //
- }
- }
- // 形式 3 等同于前面两种
- public void access2(){
- synchronzied(SyncDemo.class){
- //
- }
- }
- }
③. 同步方法块
同步方法块,是范围最小的锁,锁的是 synchronized 括号里面配置的对象。这种锁在实际工作中使用得比较频繁,毕竟锁的作用范围越大,那么对性能的影响就越严重。
- public SyncDemo{
- Object lock=new Object();
- public void access(){
- //do something
- synchronized(lock){
- //
- }
- }
- }
3.Synchronized 的实现原理分析
synchronized 实现的锁是存储在 Java 对象头里,什么是对象头呢?在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域: 对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)
当我们在 Java 代码中,使用 new 创建一个对象实例的时候,(hotspot 虚拟机)JVM 层面实际上会创建一个 instanceOopDesc 对象。instanceOopDesc 的定义在 Hotspot 源码中的 instanceOop.hpp 文件中,另外,arrayOopDesc 的定义对应 arrayOop.hpp
从 instanceOopDesc 代码中可以看到 instanceOopDesc 继承自 oopDesc,oopDesc 的定义载 Hotspot 源码中的 oop.hpp 文件中。
在普通实例对象中,oopDesc 的定义包含两个成员,分别是 _mark 和 _metadata,其中_mark 表示对象标记、属于 markOop 类型,也就是 Mark World,它记录了对象和锁有关的信。_metadata 表示类元信息,类元信息存储的是对象指向它的类元数据 (Klass) 的首地址,其中 Klass 表示普通指针、_compressed_klass 表示压缩类指针。
Mark Word
前面说的普通对象的对象头由两部分组成,分别是 markOop 以及类元信息,markOop 官方称为 Mark Word。在 Hotspot 中,markOop 的定义在 markOop.hpp 文件中,代码如下
Mark word 记录了对象和锁有关的信息,当某个对象被 synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit、在 64 位虚拟机的长度是 64bit。Mark Word 里面存储的数据会随着锁标志位的变化而变化。
锁标志位的表示意义
锁标识 lock=00 表示轻量级锁
锁标识 lock=10 表示重量级锁
偏向锁标识 biased_lock= 1 表示偏向锁
偏向锁标识 biased_lock= 0 且锁标识 =01 表示无锁状态
4. 锁的升级
前面提到了锁的几个概念,偏向锁、轻量级锁、重量级锁。在 JDK1.6 之前,synchronized 是一个重量级锁,性能比较差。从 JDK1.6 开始,为了减少获得锁和释放锁带来的性能消耗,synchronized 进行了优化,引入了 偏向锁和 轻量级锁的概念。所以从 JDK1.6 开始,锁一共会有四种状态,锁的状态根据竞争激烈程度从低到高分别是: 无锁状态 -> 偏向锁状态 -> 轻量级锁状态 -> 重量级锁状态。这几个状态会随着锁竞争的情况逐步升级。为了提高获得锁和释放锁的效率,锁可以升级但是不能降级。
偏向锁
在大多数的情况下,锁不仅不存在多线程的竞争,而且总是由同一个线程获得。因此为了让线程获得锁的代价更低引入了偏向锁的概念。偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。偏向锁可以通过 -XX:+UseBiasedLocking 开启或者关闭。
偏向锁的获取
偏向锁的获取过程非常简单,当一个线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程 ID,表示哪个线程获得了偏向锁,结合前面分析的 Mark Word 来分析一下偏向锁的获取逻辑
首先获取目标对象的 Mark Word,根据锁的标识为和 epoch 去判断当前是否处于可偏向的状态
如果为可偏向状态,则通过 CAS 操作将自己的线程 ID 写入到 MarkWord,如果 CAS 操作成功,则表示当前线程成功获取到偏向锁,继续执行同步代码块
如果是已偏向状态,先检测 MarkWord 中存储的 threadID 和当前访问的线程的 threadID 是否相等,如果相等,表示当前线程已经获得了偏向锁,则不需要再获得锁直接执行同步代码;如果不相等,则证明当前锁偏向于其他线程,需要撤销偏向锁。
偏向锁的撤销
当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,撤销偏向锁的过程需要等待一个全局安全点 (所有工作线程都停止字节码的执行)。
首先,暂停拥有偏向锁的线程,然后检查偏向锁的线程是否为存活状态
如果线程已经死了,直接把对象头设置为无锁状态
如果还活着,当达到全局安全点时获得偏向锁的线程会被挂起,接着偏向锁升级为轻量级锁,然后唤醒被阻塞在全局安全点的线程继续往下执行同步代码
轻量级锁
前面我们知道,当存在超过一个线程在竞争同一个同步代码块时,会发生偏向锁的撤销。偏向锁撤销以后对象会可能会处于两种状态
一种是不可偏向的无锁状态,简单来说就是已经获得偏向锁的线程已经退出了同步代码块,那么这个时候会撤销偏向锁,并升级为轻量级锁
一种是不可偏向的已锁状态,简单来说就是已经获得偏向锁的线程正在执行同步代码块,那么这个时候会升级到轻量级锁并且被原持有锁的线程获得锁
轻量级锁加锁
JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间(LockRecord)
将对象头中的 Mark Word 复制到锁记录中,称为 Displaced Mark Word.
线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针
如果替换成功,表示当前线程获得轻量级锁,如果失败,表示存在其他线程竞争锁,那么当前线程会尝试使用 CAS 来获取锁,当自旋超过指定次数(可以自定义) 时仍然无法获得锁,此时锁会膨胀升级为重量级锁
轻量锁解锁
尝试 CAS 操作将所记录中的 Mark Word 替换回到对象头中
如果成功,表示没有竞争发生
如果失败,表示当前锁存在竞争,锁会膨胀成重量级锁
一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于重量级锁状态,其他线程尝试获取锁时,都会被阻塞,也就是 BLOCKED 状态。当持有锁的线程释放锁之后会唤醒这些现场,被唤醒之后的线程会进行新一轮的竞争
重量级锁
重量级锁依赖对象内部的 monitor 锁来实现,而 monitor 又依赖操作系统的 MutexLock(互斥锁),假设 Mutex 变量的值为 1,表示互斥锁空闲,这个时候某个线程调用 lock 可以获得锁,而 Mutex 的值为 0 表示互斥锁已经被其他线程获得,其他线程调用 lock 只能挂起等待。
为什么重量级锁的开销比较大呢?原因是当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗 CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的。