目录
通过之前的线程八锁我们得到synchronized关键字最主要有以下3种应用方式:
- 修饰非静态方法(或者叫实例方法),作用于当前实例加锁,进入同步代码前要获得当前实例的锁;
- 修饰静态方法,作用于当前类加锁,进入同步代码前要或得当前类对象的锁;
- 修饰代码块,指定加锁对象,进入同步代码库前要获得给定对象的锁。
三种都是基于进入和退出管程(Monitor)对象实现:
修饰代码块:
基于monitorenter 和 monitorexit 指令(显式同步)
如下:
package com.cjian.synchroniezd;
public class SyncDmk {
public int i = 0;
public void test(){
synchronized (this){
i++;
}
}
public static void main(String[] args) {
}
}
public class com.cjian.synchroniezd.SyncDmk {
public int i;
public com.cjian.synchroniezd.SyncDmk();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #2 // Field i:I
9: return
//===========主要看看test方法实现================
public void test();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter //注意此处,进入同步方法
4: aload_0
5: dup
6: getfield #2 // Field i:I
9: iconst_1
10: iadd
11: putfield #2 // Field i:I
14: aload_1
15: monitorexit //注意此处,退出同步方法1
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //注意此处,退出同步方法2
22: aload_2
23: athrow
24: return
Exception table:
from to target type
4 16 19 any
19 22 19 any
public static void main(java.lang.String[]);
Code:
0: return
}
从上面的字节码可知同步代码块的使用是利用了monitorenter 和 monitorexit 指令,其中monitorenter指向同步代码块的开始位置,monitorexit指向同步代码块结束的位置,当执行monitorenter指令时,当前线程试图获取objectref(即对象锁)所对应的monitor的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
知识补充:每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因。
修饰方法时:
由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现(隐式同步):
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:
package com.cjian.synchroniezd;
public class SyncDmk {
public int i = 0;
public synchronized void test(){
i++;
}
public static void main(String[] args) {
}
}
//部分字节省略
public synchronized void test();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 11: 0
line 12: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/cjian/synchroniezd/SyncDmk;
从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。
Java虚拟机对synchronized的优化
Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。至于锁的降级并没有一个标准,在达到一定的苛刻条件之后可以进行降级,但是一般情况我们可以简单的认为锁不可以降级,这里不做过多的叙述。
知识补充:对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)
实例变量:存放类的属性数据信息,包括父类信息,如果是数组的实例部分还包括数组的长度
填充数据:字节对齐,虚拟机要求对象的起始地址必须是8的整数倍
对象头:
- Mark Word:用于存储自身的运行时数据,如hashcode、GC分代年龄、锁标记、偏向锁线程Id等;
- 类型指针:即对象指向它的类元信息,虚拟机也正是通过这个指针来确定这个对象是属于哪个类的实例(如果java对象是一个数组,那么对象头中还必须有一块用于记录数组长度的数据)
现在来看看synchronized底层都干了啥
偏向锁:
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,所以为了让线程获得锁的代价更低而引入了偏向锁
当一个线程访问加了同步锁的代码块时,会在对象头存储当前线程的ID,后续这个线程进入和退出加了同步锁的代码块时,不需要再次加锁和释放锁,而是直接比较对象头中是否存储了当前线程的ID,若果相等,则表示偏向锁是偏向于当前线程的,就不需要再次尝试获得锁了。
偏向锁的获取:
- 首先获取锁对象头中的Mark Word,判断当前对象是否处于可偏向状态(即当前没有对象获得偏向锁)。
- 如果是可偏向状态,则通过CAS原子操作将当前的线程ID写入到Mark Word,如果CAS成功,表示获得偏向锁成功,会将偏向锁的状态置为1,并写当前线程的ID;若果CAS失败,则说明当前有其他线程或得了偏向锁,即当前存在锁竞争,这时候就需要将已或得偏向锁的线程中的偏向锁撤销掉,并升级为轻量级锁。
- 如果当前线程是已偏向状态,需要检查Mark Word中的线程id是否和自己想等,如果相等则不需要再次获取,可以直接执行同步代码块,如果不相等,说明当前偏向的是其他线程,需要撤销偏向锁并升级为轻量级锁。
总结:偏向锁只允许一个线程拥有,如果存在竞争,则会撤销偏向锁,并升级为轻量级锁。
偏向锁的撤销:
偏向锁的撤销,需要等待全局安全点(即在这个时间节点上没有正在执行的字节码),然后会暂停拥有偏向锁的线程,并检查持有偏向锁的线程是否活着,主要有以下2种情况:
- 如果线程不处于活动状态,则将对象头设置为无锁状态;
- 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象的Mark Word要么重新偏向于其他线程(重偏向需要满足批量重偏向的条件),要么恢复到无锁或者标记对象不适合作为偏向锁。
最后唤醒暂停的线程。
偏向锁的批量重偏向:
一个线程创建了大量对象而且执行了同步操作后,另一个线程又来将这些对象作为锁对象进行操作,并且达到阈值,此时就会发生偏向锁重偏向的操作
(除了这种情况,其他情况只有有线程来竞争锁,则偏向锁状态就结束了)。
-XX:BiasedLockingBulkRebiasThreshold 为重偏向阈值JVM参数,默认20,可以通过-XX:+PrintFlagsFinal打印出默认参数。
偏向锁注意事项
- 偏向锁在Java SE 1.6和Java SE 1.7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下都处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:- UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
- 如果我们的应用中大多数情况存在线程竞争,那么建议是关闭偏向锁,因为开启反而会因为偏向锁撤销操作而引起更多的资源消耗。
轻量级锁
一般用于两个线程在交替使用锁的时候,由于没有同时抢锁,属于一种比较和谐的状态,就可以使用轻量级锁
轻量级锁加锁
线程在执行同步代码块之前,JVM会现在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word,然后线程尝试使用cas将对象头的Mark Word替换为指向所记录的指针,如果成功,当前线程获得锁,如果失败,表示存在竞争,当前线程便使用自旋锁来获取锁。
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除
public class StringBufferRemoveSync {
public void add(String str1, String str2) {
//StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
//因此sb属于不可能共享的资源,JVM会自动消除内部的锁
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
public static void main(String[] args) {
StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
for (int i = 0; i < 10000000; i++) {
rmsync.add("abc", "123");
}
}
}
自适应自旋
在 JDK1.7 开始,引入了自适应自旋锁,修改自旋锁次数的JVM参数被取消,虚拟机不再支持由用户配置自旋锁次数,而是由虚拟机自动调整。自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
重量级锁
当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待唤醒了。每一个对象中都有一个Monitor监视器,而Monitor依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。而且当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。我们可以简单的理解为,在加重量级锁的时候会执行monitorenter指令,解锁时会执行monitorexit指令。
synchronized的可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:
public class AccountingSync implements Runnable{
static AccountingSync instance=new AccountingSync();
static int i=0;
static int j=0;
@Override
public void run() {
for(int j=0;j<1000000;j++){
//this,当前实例对象锁
synchronized(this){
i++;
increase();//synchronized的可重入性
}
}
}
public synchronized void increase(){
j++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
正如代码所演示的,在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。
本文参考:https://blog.csdn.net/javazejian/article/details/72828483
https://blog.csdn.net/zwx900102/article/details/106305107