monitorenter和monitorexit
在Java多线程的情况下经常会出现很多的问题,即使是简单的++操作也会每次执行的结果都不一样。于是我们经常使用下面这样的方式对我们所执行的代码块使用synchronized进行加锁来保证程序的正确执行。
public class Test {
private volatile int count;
public void incCount() {
synchronized (this) {
count = count + 1;
}
}
}
首先我们先把程序运行一下,之后再通过javap -v Test.class来反编译。反编译之后的结果如下图所示:
image.png
其中真正执行count = count + 1的指令是第4条到第14条指令。我们可以看到在执行count = count +1 的前后出现了两条指令monitorenter和monitorexit。这两条指令是由虚拟机在把我们的java文件编译成class文件的时候由编译器帮我们插入的。这其实就是synchronized真正的底层实现。当java虚拟机执行到monitorenter这一行指令的时候,表示现在要获取锁,也就是要获取一个Monitor对象的所有权。谁拿到了这个Monitor对象谁就获得了执行权。
锁的存放位置
在虚拟机中一个对象的内存布局分为对象头、实例数据、对齐填充。
对象头又有两部分信息:
- 用于存储对象自身的运行数据(HashCode、GC分代年龄,锁状态标志等)
-
类型指针(Klass Point),用于指向它的类元数据。虚拟机通过这个指针确定这个对象是哪个类的实例
(如果是数组的话还会有一个记录数组长度的)
对象头划分
Mark Word是一个非固定的数据结构以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。
synchronized进行的优化
锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态
下图是各种锁在变化的时候对对象头进行的修改:
我们知道synchronized是一个比较重的操作,被阻塞的线程会被挂起,发生上下文切换。而一次上下文切换的时间大概相当于5000~20000个CPU时间周期。于是java开发人员就对synchronized进行了一系列的优化。要理解synchronized所做的优化首先要了解一些锁的概念:
- 偏向锁 虚拟机研究人员经过统计发现大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。无竞争时不需要进行CAS操作来加锁和解锁。假如有一个线程A访问了同步代码块,那么虚拟机会检查对象头中是否储存了线程A,如果没有的话,则会通过CAS操作来替换Mark Word,根据上图可知Mark Word的信息会被改为线程的ID,Epoch,分代年龄等等。然后从无锁状态扩张为偏向锁状态。当下次需要访问同步代码块的时候,只需要检查对象头中的线程ID是不是自己的ID。是的话就直接执行同步代码块。
- 轻量级锁 在偏向锁的基础上,如果此时来了一个线程B和线程A发生了竞争。那么此时就会由偏向锁升级为轻量级锁。而在撤销偏向锁的时候则会触发stop the world机制,也就是暂停所有的线程。这是因为线程B要去修改线程A中对象头的信息。如果线程A一直在执行,对象头中的信息会一直改变,会导致线程B无法修改线程A的数据(跨线程修改在虚拟机里是可以的)。上文也提到了进行一次上下文切换的开销是很大的,那么升级为轻量级锁的时候,则是通过CAS机制进行加锁和解锁。那么在这种情况下轻量级锁又引入了自旋锁的概念。自旋锁就是假如同步代码块很快就被执行完了,那么就可以通过不断的循环检查其他线程有没有释放锁从而避免阻塞发生上下文切换。但是如果同步代码块的执行时间很长,比如一次网络请求,如果一直在不断的循环检查,那么就会导致CPU被一直占用而干不了其他的工作。那么在这种情况下在自旋锁之上引入了适应性自旋锁。适应性自旋锁则是把循环的次数上做限制。早期的时候,自旋的次数设定为10次。后期则是改为由虚拟机自己判定旋转时间。一般来说这个循环的时间就是一次上下文切换的时间。这也很好理解,引入自旋锁就是为了避免上下文切换带来的影响。如果自旋的时间超过了一个上下文切换的时间,那么引入适应性自旋锁也就没必要了。
-
重量级锁 当轻量级锁的自旋时间超过了上下文切换的时间之后,那么就会由轻量级锁膨胀为重量级锁。
不同锁的优缺点
网友评论