synchronize深入理解

在上一篇章中对synchronize的一些常见用法进行了分析,主要就是 synchronize 修饰的同步方法还是静态方法,锁对象还是类
https://editor.csdn.net/md/?articleId=90712939

一、反编译(使用synchronize 同步代码块)

在这里插入图片描述
这里有两种方法
1)是使用javap 反编译,结果如下
在这里插入图片描述
2)利用idea安装插件jclasslib
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1.1 monitorenter和 monitorexit

这两个名词在JVM虚拟机规范的解释是:
1.1.1 monitorenter
java会为每个object对象分配一个monitor对象(后面会具体讲什么是monitor),当一个线程获取到对象锁之后,也就成为这个对象对应的monitor的所有者
任何对象都有一个monotor与之关联,当且且当一个monitor被持有后,它就会处于锁定状态,线程执行到monitorenter指令后,将会尝试获取对象所对应的monitor的所有权。过程如下:
① 如果monitor的计数器为0,则线程进入成功,将计数器值设置为1,就意味着当 前线程是monitor的所有者。
② 如果当前线程已经占有了当前monitor,则直接进入,将计数器加1(线程的重进入)
③ 如果monitor已经被其他线程占有,那么当前线程被阻塞,直到monitor的计数器 变成0,重新尝试获取monitor的所有权。
1.1.2 monitorexit
主要作用就是用来退出一个对象的monitor,反编译时出现了两个monitor,第一次为同步正常退出释放锁,第二次为发生异步退出释放锁
执行monitorexit指令的线程必须是对象对应的monitor的所有者,执行时,线程把monitor的计数器值减1,执行如果减1后计数器值为0(重进入情况要会多次执行这个过程),那线程退出monitor,不再是这个monitor的拥有者,其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
小结一下:
其实synchronize的底层语义就是使用monitor对象来实现的。

二、反编译(synchronize 同步方法)

使用synchronize 同步代码块和synchronize 同步方法,反编译之后会不一样
在这里插入图片描述
分析:
当方法调用时,调用指令将会检査方法的 ACC SYNCHRONIZED访向标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后オ能执行方法体方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor对象。
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

三、synchronize的实现

3.1 Java对象头

在Hotspot虚拟机中,对象在内存中的布局分为三块区域:
1)对象头:(Mark Word、Class Metadata Address,如果为对象数组,还会有记录数组长度的数据),synchronized使用的锁对象是存储在Java对象头里,它是轻量级锁和偏向锁的关键
2)实例数据: 存放类的属性数据信息,包括父类的属性信息
3)对齐填充: 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
在这里插入图片描述

3.1.1 Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)。不管是在32位或是 64位操作系统中,后面3位代表的意义都是固定的
图3.1.1
在这里插入图片描述
在这里插入图片描述
a.Mark Word 中,最后2位标识了该对象锁的状态
b.在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01 + 前一位是0 ,当前线程获取到这个对象的偏向锁时,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录( Lock Record)“的空间用于存储锁对象的 Mark Word的拷贝,官方把这个拷贝称为 Displaced Mark Word整个 Mark Word?及其拷贝至关重要
c.Lock Record是线程私有的数据结构,每一个线程都有一个可用 Lock Record列表,同时还有一个全局的可用列表。
d.同时 Lock Record中有一个 Owner.字段存放拥有该锁的线程的唯一标识(或者 object mark word),表示该锁被这个线程占用。
在这里插入图片描述

3.1.2 Class Metadata Address

类型指针 即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

3.1.3 Array length

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。因为VM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度

3.2 monitor

Java虚拟机给每个对象和class字节码都设置了一个监听器Monitor(每一个java对象都自带了一把带不见的锁,叫做内部锁或者Monitor锁),用于检测并发代码的重入,同时在Object类中还提供了notify和wait方法来对线程进行控制。
即通过synchronized关键字实现线程同步来获取对象的Monitor。
3.2.1 同步代码块
在这里插入图片描述
上述代码表示在进入同步代码块之前,先要去获取obj的Monitor,如果已经被其他线程获取了,那么当前线程必须等待直至其他线程释放obj的Monitor。
– > 如果(this)获取当前对象的Monitor
–>如果为(obj)对象,就需要获取obj的Monitor
–>如果为(类.class),就需要获取这个类的字节码的Monitor
3.2.2 同步方法
–>synchronized修饰实例方法,先要去获取当前对象的Monitor
–>synchronized修饰类方法(static),就需要获取这个类的字节码的Monitor
Synchronized在JVM里的实现都是基于进入和退出 Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 Monitorenter和 Monitorexit指令来实现。
Monitorenter指令:插入在同步代码块的开始位置,当代码执行到该指令时将会学试获取该对象 Monitore的所有权,即尝试获得该对象的锁;
Monitorexit指令:插入在方法结束处和异常处,保证线程能释放该对象锁
JVM保证每个 Monitorenter必须有对应的 Monitorexit,
在java虚拟机(HotSpot)中,Monitore是通过ObjectMonitore(C++编写)实现的
3.3 ObjectMonitor
在这里插入图片描述

  1. _owner:指向持有ObjectMonitor对象的线程
  2. _WaitSet:存放处于wait状态的线程队列
    3 _EntryList:存放处于等待锁block状态的线程队列
  3. _recursions:锁的重入次数
  4. _count:用来记录该线程获取锁的次数
    Objectmonitort中有两个队列, Waitset和 Entrylist,用来保存 Objectwaiter对象列表(每个等待锁的线程都会被封装成 Objectwaiter对象),当多个线程同时访向一段同步代码时首先会进入 Entry List集合,当线程获取到对象的 monitor后,进入 Owner区域并把 monitor中的 owner变量设置为当前线程,同时 monitor中的计数器 count加1,若线程调用wait()方法,将释放当前持有的 monitor, owner变量恢复为null, counte自減1同时该线程进入 Waitset集合中等待被唤醒;若当前线程执行完毕,释放 monitor(锁)并复位 counte的值,以便其他线程进入获取 monitor(锁);
    在这里插入图片描述

四、synchronize的优化

从JDK5引入了现代操作系統新增加的CAS原子操作(JDK5中并没有对 synchronized关键字做优化,而是体现在java.util.concurrent中,所以在该版本 concurrent包比synchronized有更好的性能),从DK6开始,就对 synchronized的实现机制进行了较大调整,包括:

  1. 使用JDK5引进的CAS自旋锁
  2. 自适应CAS自旋
  3. 锁消除
  4. 锁粗化
  5. 偏向锁
  6. 轻量锁
    由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭
    所以推荐在允许的情況下尽量使用此关键字(concurrent包的使用需要一定的基础),
    阻塞代价
    java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
    如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
    如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。

4.1 自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。在这个等待的过程中,会执行一段无意义的循环去尝试获取拿到对象锁
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;
如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

4.2 自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

4.3 锁消除

锁消除是一种更为彻底的优化,在 JIT 编译时,对运行上下文进行扫描,去除不可能存在共享资源竞争的锁

public void add(String str1,String str2){
         StringBuffer sb = new StringBuffer();
         sb.append(str1).append(str2);
}

我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。

4.4 锁粗化

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。原则上,我们都知道在加同步锁的时候,尽可能的将同步块的作用范围限制在尽量小的范围,但是如果在一个方法中,反复的对代码块进行加锁,即使没有线程竞争共享资源,频繁的进行加锁操作也会导致性能损耗。严重的情况就是在循环中同步代码块:

public String test(String str){      
       int i = 0;
       StringBuffer sb = new StringBuffer():
       while(i < 100){
           sb.append(str);
           i++;
       }
       return sb.toString():
}

在这种情况下,JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外,或者包含多个同步代码块),使得这一连串操作只需要加一次锁即可。

4.5 偏向锁

锁可以从偏向锁升级到轻量锁,再升级到重量锁。但是这个过程是单向的,只能从低到高,锁不能发生降级
在JDK 1.6 之后。默认开启偏向锁和轻量锁,可以使用参数-XXK:- Usebiased Locking来禁止偏向锁(但这是个JVM参数,不能针对某个对象锁来单独设置)。
偏向锁是JDK6中的重要引进,因为 Hotspot作者经过研究实践发现,在大多数情況下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁
引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换 Threadid的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须徹销偏向锁,向轻量锁升级,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)
每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,偏向锁对性能的优化主要体现在 :一个线程第一次获得了监视对象(monitore),之后就可以让监视对象偏向这个线程ID,重进入是只要判断监视对象Mark Word中记录的线程ID是否相等,如果为true则无需再执行加锁/解锁的流程,避免CAS操作
4.5.1 CAS操作
CAS为什么会引入本地延迟?这要从SMP(对称多处理器)架构说起,下图大概表明了SMP的结构:
在这里插入图片描述
其意思是所有的CPU会共享一条系统总线(BUS),靠此总线连接主存。每个核都有自己的一级缓存,各核相对于BUS对称分布,因此这种结构称为“对称多处理器”。
Core1和Core2可能会同时把主存中某个位置的值Load到自己的L1 Cache中,当Core1在自己的L1 Cache中修改这个位置的值时,会通过总线,使Core2中L1 Cache对应的值“失效”,而Core2一旦发现自己L1 Cache中的值失效(称为Cache命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总线被设计为固定的“通信能力”,如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“缓存一致性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。
而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个Core CAS成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除CAS,降低Cache一致性流量。

4.5.2偏向锁的实现

偏向锁获取过程

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark
    Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争,有别的线程和它抢锁并且抢赢了。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stopthe word)
  5. 执行同步代码。

注意:第四步中到达安全点safepoint会导致stop the word,时间很短。

4.5.3 偏向锁的释放

偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。
偏向锁的撤销需要等待全局安全点(在这个时间点上没有字节码正在执行),过程如下:
它会首先暂停拥有偏向锁的线程
判断锁对象是否处于被锁定状态
–> 否,撤销偏向锁后恢复到未锁定(标志位为“01”),允许其他的线程竞争
–> 是,暂时挂起当前的线程,并将当前线程的锁记录地址(Lock record)的指针放入对象头的Mark Work,升级为轻量级锁(标志位为“00”)的状态。然后恢复挂起的线程继续执行(锁的monitoreenter线程没有发生转移)Mark Work中不再储存偏向的线程ID而是这个锁记录地址(Lock record)的指针,

4.5.4 偏向锁的适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用;

4.6 轻量锁

轻量级锁是由偏向所升级来的,关闭偏向锁/偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁

4.6.1 轻量级锁的加锁过程

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图:

在这里插入图片描述
2.拷贝对象头中的Mark Word复制到锁记录中;
3.拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object的 mark word。如果更新成功,则执行步骤4,否则执行步骤5。
4.如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。
在这里插入图片描述
5.如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,
–> 是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。
–> 否则说明多个线程竞争锁,进入自旋执行,若是自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,
膨胀为重量级锁后,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 (而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,减少轻量锁升级为重量级锁的几率,而采用循环去获取锁的过程)。

4.6.2 轻量锁的作用

对于轻量锁,其提升性能的依据是:”对于绝大部分的锁,在整个生命周期内都不会存在竞争关系,即使存在竞争关系,也能在拥有锁的线程快速执行完成后,等待的线程通过自旋的方式获取到锁,避免自旋结束发生阻塞向重量级锁升级的情况”

4.6.3 轻量锁的使用场景

轻量锁的使用场景是线程交替执行同步块的情况(通过自旋就能获得对象锁),如果存在同一时间大量线程访问同一锁的情况,必然会导致轻量级锁向重量级锁的膨胀升级,这种情况下轻量级锁可能比重量锁更慢(轻量锁建立,轻量锁撤销等)

4.7 重量级锁

Synchronized是通过对象内部的一个叫做监视器锁( Monitor)来实现的,Monitor的知识已经在前面提到了
但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock来实现的
而操作系统实现线程之间的切换这就需要从用户态转换到核心,这个成本非常高状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized效率低的原因,这种依赖于操作系统 Mutex Lock所实现的锁我们称之为“重量级锁”。

4.8 锁的优缺点

  1. 各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。
  2. 每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,开销逐渐加大。
  3. 如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了
  4. 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
  5. 如果其他线程通过一定次数的CAS学试没有成功,则进入重量级锁
  6. 在第3种情况下进入同步代码块就要做偏向锁建立、偏向锁撤销、轻量级锁建立、升级到重量级锁最终还是得靠重量级锁来解决问题,那这样的代价就比直接用重量级锁要大不少了。
  7. 所以使用种技术,一定要看其所处的环境及场景,在绝大多数的情況下,偏向锁是有效的这是基于 Hotspot作者发现的“大多数锁只会由同一线程并发申请”的经验规律
  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值