synchronized详解

一、介绍

  synchronized是JDK为解决同步问题设计的一种锁,synchronized保证被其修饰的方法或者代码块在任何时候都只能有一个线程访问。但在JDK1.5之前它的效率十分低下,属于重量级的锁。

  为什么之前的 synchronized 效率十分低下呢?

  是由于锁依赖于 监视器(monitor)来实现,而其底层依赖的操作系统的 Mutex Lock,java的线程都是映射到原生线程之上来执行的,而操作系统线程之间的切换涉及到内核态和用户态的切换,如果一味的利用这个来进行切换,造成资源浪费,所以之前的 synchronized 称为重量级锁。

  在JDK1.5后对 synchronized 在JVM层面进行了优化,即在JVM层面就将锁的功能实现,而不是依赖操作系统去实现,这里就省去了操作系统内核态和用户态的频繁切换,引入了自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

二、synchronized的作用

1、原子性:指一个操作或者多个操作,要么不执行,一旦执行就全部执行完,不受其他操作的影响。synchronized 修饰的方法或者代码块就能保证原子性

2、可见性:可见性指一个资源被访问时,其锁的状态,资源变量状态对于其他线程而言都是可见的,当一个资源被 synchronized 修饰时,线程想要占有必须先看锁有没有被占用,如果锁被占有,就代表这个资源正在被其他线程所使用,并且在使用完后对应的变量会重新写入内存,其他线程也可以知道变量的变化情况。

3、有序性:有序性指程序执行按代码顺序执行,在Java中JVM会对指令进行优化,有时候会调整指令执行的顺序,这样对单线程的操作是没有影响的,但对于多线程是有影响的,可能会导致得出错误的结果,如:对于以下顺序的指令:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

  对于JVM对指令的优化,很可能变成 1-3-2,而当 线程T1进行这个初始化的时候, 线程 T2 在T1还没有初始化这个内存的时候,调用了这个内存,造成了一个T1已经创建并且初始化的假象,而实际上T2调用的是没有被初始化的内存块。synchronized 就可以解决这个问题。

三、synchronized的使用

  一 、修饰实例方法

1 synchronized public void test(){
2         
3     }

  二 、修饰静态方法

1  synchronized public static void stest(){
2         
3     }

  注意在这里修饰静态方法有其特殊性,由于静态方法在类加载的时候就进行了初始化,不属于任何一个实例对象,并且不管new多少实例对象永远只有一份,所以 synchronized 修饰的静态方法 和 修饰实例对象不是一个概论,具体来说,以下情况是允许的:

类 M 拥有一个 静态方法 SM ,类M拥有一个类锁, 静态方法SM拥有一个 静态方法锁

当线程 T1 占用了类M的一个对象,但没有占有这个静态方法,此时线程T2就可以直接访问这个静态方法,而一旦线程T2占用了静态方法,线程T1就无法使用静态方法

  三 、修饰代码块

1 synchronized(this) {
2             //业务代码
3         }

  指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁

 

总结一下:

synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。

synchronized 关键字加到实例方法上是给对象实例上锁。

四、用synchronized实现一个单例模式

 1 public class danli {
 2     private volatile static danli dan;//创建一个 volatile 的类对象 
 3     private danli(){
 4 
 5     }//将构造器私有化 
 6     public static danli getInstance(){
 7         if(dan == null){//保证永远只有一个实体对象
 8             synchronized (danli.class){//获得了 类 的锁才能进行初始化
 9                 dan = new danli();//进行初始化 分配空间
10             }
11         }//保证同时只有一个线程能对其进行初始化 并且分配空间
12         return dan;
13     }
14 }

五、synchronized 的原理

   数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM,而juc.Lock给出的答案是在硬件层面依赖特殊的CPU指令。

先给一个实例:

1 public class Thread2 {
2     public void method() {
3         synchronized (this) {
4             System.out.println("synchronized 代码块");
5         }
6     }
7 }

利用反编译查看其字节码:

 

发现在代码块由两个 monitorenter 组合在一起,这里的指令就是线程尝试着获取代码块的锁,而 monitorenter 就指明了这个地方的指令需要获得监视器 monitor才能执行

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

 

六、synchronized 同步的概念

 1、Java对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充

synchronized用的锁是存在Java对象头里的。

Hotspot 有两种对象头:

  • 数组类型,如果对象是数组类型,则虚拟机用3个字宽 (Word)存储对象头
  • 非数组类型:如果对象是非数组类型,则用2字宽存储对象头。

对象头由两部分组成

  • Mark Word:存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容。
  • Klass Pointer:类型指针指向它的类元数据的指针。

 

 

2、监视器(monitor)

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

  1. MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;
  2. MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;

那什么是Monitor?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。

与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁

也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的。

 

七、Synchronized 的优化

  从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 ),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级

 

  上面我们说过重量级锁之所以耗费资源主要是因为会造成cpu在用户态和核心态不断的切换,在这样的切换中需要操作系统保护用户态的数据以及将相关数据传入内核中,所以会消耗大量资源,而造成不断切换的主要原因是,当多个线程竞争一个锁的时候,重量级锁的策略就是直接发现锁被占用,直接将其他竞争的线程直接挂起,等待被唤醒,这样一个挂起和唤醒的过程就是造成操作系统不断切换的主要原因。

  其优化策略主要就是在不同情况下尽量避免将线程进行休眠

一、偏向锁

  偏向锁使用的场所主要是一个资源一般只有同一个线程访问的情况。

  当线程访问对象块会在对象头中写入线程ID,以后线程退出进入就不需要再进行CAS自旋去拿锁,只需检查对象头是否存储这当前线程的ID,如果存储有,则直接进入。

  如果线程测试失败,发现对象头没有记录自己的线程ID,接下来线程会检查MarkDown位置记录偏向锁的位置是否是1,如果是1(如果不是1则会尝试获取轻量锁),说明之前是偏向锁,则尝试用CAS去竞争这个锁,当CAS竞争的次数超过阙值时,则代表这里不适合偏向锁,则会撤销偏向锁。

  偏向锁的撤销:需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

偏向锁的获取过程

 

 

 二、轻量锁

  引入轻量锁的目的是有多线程交替执行一段程序的时候,避免多次的线程休眠和唤起的操作,减少了资源的消耗。

  轻量缩的加锁过程:当有线程想要访问代码块的时候,线程首先会将对象头的MarkDown数据写入线程栈中(也就是线程的锁记录,相当于对线程头数据的一个保护),然后线程尝试用CAS去将对象头修改成为指向锁记录的指针,如果成功,该线程获得锁,如果失败,则自旋去竞争锁,而如果自旋竞争失败,锁就会升级为重量级锁

 轻量锁

 三、自旋

   我们可以发现无论是偏向锁还是轻量锁都会有自旋的操作,自旋的操作是对重量锁直接将竞争线程阻塞进行优化的一个策略,自旋的线程会一直尝试去获取锁,避免了阻塞唤醒的过程,但是自旋同样会消耗CPU资源,所以在轻量锁中,线程通过设定好的自旋次数还是无法获得锁的话,那就只能将其阻塞,也就是改用重量级锁。

 

四、总结

各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。

如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了;

如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;

如果其他线程通过一定次数的CAS尝试没有成功,则进入重量级锁;

 重入:对于不同级别的锁都有重入策略,偏向锁:单线程独占,重入只用检查threadId等于该线程;轻量级锁:重入将栈帧中lock record的header设置为null,重入退出,只用弹出栈帧,直到最后一个重入退出CAS写回数据释放锁;重量级锁:重入_recursions++,重入退出_recursions--,_recursions=0时释放锁

 

优点缺点适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法仅有纳米级的差距 如果线程间存在锁的竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问的同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的相应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间
同步响应非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量
同步块执行速度较长

 

 

 

posted @ 2022-03-04 15:33  空心小木头  阅读(425)  评论(0编辑  收藏  举报