锁是什么?
线程获取的锁到底是什么东西呢?每个对象都有的锁其实是一个用c++写的Monitor对象,每个Java对象有且只有一个Monitor对象。Monitor的数据结构如下:
ObjectMonitor() {
......
// 用来锁的进入次数,为0说明该锁还没有被获取
_count = 0;
// 锁的重入次数
_recursions = 0;
// 指向持有ObjectMonitor对象的线程
_owner = NULL;
// 存放处于wait状态的线程队列
_WaitSet = NULL;
// 存放处于等待锁block状态的线程队列
_EntryList = NULL ;
......
}
当线程获得了锁,即将_Owner属性修改为自己,把_cuont属性+1;释放锁即把_Owner属性改为null,把_count属性-1(CAS保证该操作原子性);
其他线程当然也能访问这个Monitor对象,但是当发现_count不为0的时候,就说明这把锁已经被占有了,就阻塞。
锁状态类型
- 无锁状态
- 偏向锁
- 轻量级锁
- 重量级锁
对象头
普通对象的对象头由两个字宽(在32位系统中一个字宽为4个字节,即32bit,64为则为64bit)组成,一个字宽为Mark Word(重点),一个字宽为Class Metedata Area(存储指向对象类型数据的指针),如果是数组,还会多一个字宽,用于记录数组的长度。
下面是32位系统的Mark Word:
可以看到,不同的锁状态对应对象头Mark Word的不同状态,所以锁状态应该是对象私有的。
Synchronized底层
每个对象都天生可以成为一个锁,synchronized加在不同位置,充当锁的对象就不同,有三种情况:
- 加在静态方法上,此时充当锁的对象是这个方法所属类的Class对象
- 加载非静态方法上,此时充当锁的对象就是调用这个方法的实例对象
- 同步块,此时充当锁的对象就是括号里的对象
如下:
class Main{
private Object lock = new Object();
static synchronized void A(){
System.out.println("这里作为锁的对象是Main的Class对象");
}
synchronized void B(){
/*
例如Main m = new Main();
m.B();所以这里作为锁的对象是m这个实例对象
*/
System.out.println("这里作为锁的对象是调用B()的实例对象");
synchronized (lock){
System.out.println("这里作为锁的对象就是这个Object对象lock");
}
}
}
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,具体细节不太一样。代码块同步是使用monitorenter和monitorexit指令实现的,方法同步使用ACC_SYNCHRONIZED来实现,但基本原理都差不多。
monitorenter指令是在编译后插入到同步代码块开始的位置,而monitorexit是插入到代码块结束和异常处,JVM保证每个monitorenter和monitorexit相匹配。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时将会尝试获取对象对应的monitor的所有权,即尝试获取对象的锁(将_count设为1且将_Owner设为自己)。
注意,只有重量级锁状态下才需要进入Monitor对象(即JVM才会使用monitorenter指令和monitorexit指令)。
锁升级与对比
虽然synchronized是重量级锁,但并不是进入同步方法就直接将锁状态变为重量级锁,而是从轻量级锁开始一直膨胀升级。这样是为了避免大量的CAS加锁解锁造成的系统开销。
锁状态个人理解是一种机制,并不是具体的锁类型,即开启了相应的锁状态JVM就会按照相应的机制去调度线程获取锁的过程,当然也可以关闭JVM的一些锁状态,比如关闭偏向锁状态,那么锁升级过程就会变成:无锁状态-轻量级锁-重量级锁。锁状态是从低到高升级的,不可降级。
由无锁状态-重量级锁状态的升级过程如下。
偏向锁(JDK1.6之后JVM默认开启)
单线程情况下对象头处于无锁状态,而一旦有线程进入了synchronized修饰的同步方法或者同步块,就会使用偏向锁机制。因为很多情况下不会出现锁竞争的情况,即一直都是同一个线程去获取锁。这种情况下执行同步块里面的代码就没必要做同步了。
偏向锁就是基于这种情况考虑,默认认为不会出现锁竞争的情况,就采取很少的控制手段来保证程序的效率。下面介绍偏向锁的获取锁和释放锁的过程。
偏向锁的获取
每个进入同步方法或者同步代码块的线程,都会在自己的虚拟机栈中创建一个栈帧,栈帧中会开辟出一个空间叫做锁记录(只有同步方法或者包含同步代码块的方法的栈帧中才会创建)。
当线程去尝试获取偏向锁成功后,就在对象头和锁记录中存储自己的线程ID(CAS),以后该线程进出同步块就可以不用CAS去加锁解锁,只要测试一下对象头里的偏向锁ID是否是自己就行了。
偏向锁的撤销
只有当出现锁竞争的时候,偏向锁才释放锁。当有其他线程用CAS尝试去获取锁,当前线程就知道出现了锁竞争,就等到JVM的全局安全点(此时无正在执行的字节码),然后暂停线程,将锁记录中的线程ID设为null,然后修改对象头的锁状态为轻量级锁(即锁升级),然后再恢复线程。
轻量级锁
轻量级锁加锁
在轻量级锁机制下,线程获取锁时会将对象头的Mark Word复制一份到锁记录中,称为Displaced Mark Word。然后线程尝试使用CAS去将对象头里面的Mark Word修改为指向锁记录的指针,如果成功就代表获取到了锁,如果失败就说明有其它线程竞争锁,就通过自旋来尝试获取锁。如果自旋获取锁失败就会导致锁升级为重量级锁,并且该线程阻塞。
轻量级锁解锁
线程会尝试使用CAS将锁记录中的Displaced Mark Word去替换对象头中的Mark Word,如果成功就代表释放了锁;如果失败就说明出现了锁竞争(可能是其他线程在自旋获取锁),锁就膨胀为重量级锁,等到竞争锁的线程因自旋获取锁失败阻塞后,轻量级锁就可以完成解锁,然后会唤醒阻塞等待的线程。
重量级锁加锁解锁即上面介绍过的synchronized底层
引申出自旋锁
可以看到轻量级锁中,在修改对象头的Mark Word时如果失败,线程会自旋(即做死循环去尝试获取锁,获取到锁或者达到相应的自旋次数才退出循环),这个时候就是自旋锁,即自旋锁是一种状态,用于描述线程不断自旋去尝试获取锁的状态。
为什么自旋?
因为当线程获取不到锁时需要阻塞,锁释放时线程又会恢复运行继续去竞争锁,这里线程就被挂起和恢复了一次,需要不小的系统开销。而大多数情况下锁被占用的时间很短,所以就可以让锁自旋一段时间来等待锁的释放,如果在自旋期间获取到了锁,那就省去了挂起恢复的开销。直到自旋达到一定次数还无法获取到锁才将线程真正挂起。
自适应自旋锁
自旋锁到底自旋多少次合适呢?因为自旋也是要占用CPU时间的,自旋太长时间不好,太短又等不到锁,由此出现了自适应自旋锁。
自适应自旋锁会根据这个线程之前的自旋情况来自动调整自旋次数。如果之前这个线程自旋有很高的概率获得锁,那么下一次线程会允许多自旋几次;反之就减少线程的自旋次数,甚至可能直接阻塞挂起。