乐趣区

Synchronized原理分析

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{

  1. Object lock =new Object();
  2. // 形式 1
  3. public synchronized void access(){
  4. //
  5. }
  6. // 形式 2, 作用域等同于形式 1
  7. public void access1(){
  8. synchronized(lock){
  9. //
  10. }
  11. }

②修饰静态同步方法
修饰静态同步方法或者静态对象、类,那么这个锁的作用范围是类级别。举个简单的例子,{SyncDemo sd=SyncDemo();SyncDemo sd2=new SyncDemo();} 两个不同的实例 sd 和 sd2,如果 sd 这个实例访问 access 方法并且成功持有了锁,那么 sd2 这个对象如果同样来访问 access 方法,那么它必须要等待 sd 这个对象的锁释放以后,sd2 这个对象的线程才能访问该方法,这就是类锁;也就是说类锁就相当于全局锁的概念,作用范围是类级别。

  1. public SyncDemo{
  2. static Object lock=new Object();
  3. // 形式 1
  4. public synchronized static void access(){
  5. //
  6. }
  7. // 形式 2 等同于形式 1
  8. public void access1(){
  9. synchronized(lock){
  10. //
  11. }
  12. }
  13. // 形式 3 等同于前面两种
  14. public void access2(){
  15. synchronzied(SyncDemo.class){
  16. //
  17. }
  18. }
  19. }

③. 同步方法块
同步方法块,是范围最小的锁,锁的是 synchronized 括号里面配置的对象。这种锁在实际工作中使用得比较频繁,毕竟锁的作用范围越大,那么对性能的影响就越严重。

  1. public SyncDemo{
  2. Object lock=new Object();
  3. public void access(){
  4. //do something
  5. synchronized(lock){
  6. //
  7. }
  8. }
  9. }

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,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的。

退出移动版