文章目录
synchronized可以说是java并发领域使用的最多的同步方式了,而且也是Java并发领域面试的高频问题.如果只是对synchronized简单的使用有所了解的话是不够的.下面让我们一起探索一下synchronized的底层原理吧.
synchronized特性概述:
- 原子性
- 可见性
- 有序性
- 可重入性
- 非公平性
1.synchronized的简单使用
synchronized的使用有两种方式,本质是一样的.通过一个锁对象对同步代码块进行加锁
- synchronized同步代码块,加锁对象是显示指定的object
Object object = new Object();
synchronized (object)
{
// code blocks
}
2.synchronized方法.加锁对象是调用该方法的对象.如果该方法同时也是static,那么加锁对象就是调用该方法的对象的类的.class
public synchronized void test1()
{
//synchronized function
}
public static synchronized void test2()
{
}
2.synchronized字节码分析
2.1 synchronized字节码
我们先看一个简单synchronized的使用.
public class SynchronizedTest
{
static final Object object = new Object();
static int counter;
public static void main(String[] args)
{
synchronized (object)
{
counter++;
}
}
}
这段代码我相信大家都清楚,那下面我们对这段代码进行反编译后查看它对应的字节码文件内容.
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // Field object:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter //进入了Monitor
6: getstatic #3 // Field counter:I
9: iconst_1
10: iadd
11: putstatic #3 // Field counter:I
14: aload_1
15: monitorexit //退出了Monitor
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //退出了Monitor
22: aload_2
23: athrow
24: return
Exception table: //异常表
from to target type
6 16 19 any
19 22 19 any
大家只需要观察code中: 序号5到序号15,这里开始有一个monitorenter(进入monitor) 最后有一个monitorexit(退出monitor).这个其实就是synchronized在对中间的代码进行一个加锁操作.中间代码就是在执行counter++.那么这就很自然的引出一个问题:什么是Monitor呢?
2.2 monitor
monitor如果翻译成中文的话就是监视器 或者 管程.但是我个人觉得这两种翻译都无法体现它在当前语境中的含义,反而会让人不知所以.那就直接用它的英文名吧.Monitor示意图如下:
大家都知道synchronized是通过一个对象来完成同步的.而且这个对象可以是任何java对象.这是为什么呢?因为任意一个java对象都有对象头,对象头中的Mark Word会对对象的加锁信息进行存储(不清楚的可以去看我JVM专栏里面的Java对象内存布局的文章).其中如果当对象加的是重量级锁(synchronized加的就是重量级锁,后期进行了优化).既Mark Word中的state为Heavyweight Locked.那么Mark word中就会有一个指向monitor的指针:ptr_to_heavyweight_monitor.
|-------------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1(0)| lock:2(01) | Normal |
|-------------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1(1) | lock:2(01)| Biased |
|-------------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2(00) | Lightweight Locked |
|-------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2(10) | Heavyweight Locked |
|-------------------------------------------------------------|--------------------|
| | lock:2(11) | Marked for GC |
|-------------------------------------------------------------|--------------------|
后面的()中的内容代表在该特定状况下的二级制值,比如lock在Normal状况下占两位,值为(01)
2.3 synchronized锁的获取过程
了解到Monitor的相关知识时候,我们就来探究一下重量级锁的加锁过程.
假设现在有两个线程,Thread1 和 Thread2会去执行同步代码块.当Thread1先来执行同步代码块的时候,它会先检查这个对象是否已经加锁,这个时候是无锁状态.于是进行加锁过程:系统会分配一个monitor,然后对象的ptr_to_heavyweight_monitor指向Monitor.Monitor的Owner变成Thread1.
当Thread2去申请对象的时候,方向对象已经上锁,于是进入EntryList中,进入Blocked状态.直到Thread1执行完成.然后OS会唤醒EntryList中等待的线程.
还有一个问题,就是WaitSet是用来干嘛的呢?这个是用来存放调用了Wait方法的线程.这个在wait/notify方法解析中会详细解释.
至此,就完成了对临界区的重量级锁的加锁过程.释放锁的过程则是一个反向过程.
2.3 字节码再探
但是如果如果细心的同志就会发现,反编译的字节码文件中有两个monitorexit,那么第二个monitorexit是什么呢?其实这是考虑到可能出现的异常情况.如果在一个线程持有锁的过程中发生了异常,就可能导致锁不能正常释放.所以就需要在发生异常的情况下也成功释放锁.
3.synchronized优化一自旋
上面讲到,synchronized在JDK1.6之前都是重量级锁,重量级锁不仅需要一个OS分配的Monitor来进行加锁操作,而且当其他的申请线程进入EntryList并且进入Blocked状态之后还要将其唤醒,这其中会有一个用户态到内核态的转换.这是一笔很大的开销.但是在JDK1.6以后,java团队对synchronized进行了一系列的优化.这使得synchronized更加强大.下面我们一起来看看吧.
3.1自旋锁和自适应自旋
上面讲到,当一个共享资源已经别上锁之后,其他申请的线程会进入Blocked状态,并进入EntryList等待.将Blocked状态的线程唤醒相对来说是一个很大的开销.关键是有的时候,已经占有资源的线程很快就会执行完成.但是在最初的情况下只要资源是被上锁的,就会进入Blocked状态.而自旋锁就是为了解决这个问题而产生来的.
3.1.1自旋锁
有了自旋锁之后,在出现同样的状况的时候,正在申请的线程不会立马进入阻塞等待状态,而是继续占有CPU的使用权,只是它什么都不做,执行一个忙循环(自旋,原地转圈圈,好像在做事其实只是原地踏步).看一下锁会不会被释放. 可以预见到如果这个锁被占用的时间很短,那么效果就会很好.但是如果这个锁被占用了很久才释放,那么就会得不偿失.因为这样线程就浪费CPU大量的执行时间.因此自旋等待的时间是有一定的限度的.自旋次数的默认值是10次.用户也可以通过参数-XX:PreBlockSpin来自行更改.
3.1.2自适应自旋
而自适应自旋可以看成是一个智能的自旋.有点机器学习的意思.它会根据某个锁上的自旋时间来判断自旋的程度.如果在一个锁上,多次成功通过自旋获得了锁,那么JVM就会认为下一次也会有很大概率通过自旋来成功获得锁.于是会允许更多的自旋次数.但是如果在一个锁上很少通过自旋获得锁,那么JVM就会认为下一次的自旋也不太可能获得锁,认为这是不值得的,就会减少自旋次数,甚至直接取消自旋.
4.synchronized优化一轻量级锁
在很多情况下,锁在同一时刻不会存在竞争,这个时候使用传统的重量级锁就会显得有点浪费资源.于是在同一时刻没有竞争的情况下,就可以使用轻量级锁来取代重量级锁.
4.1轻量级锁的加锁过程
当Thread0在申请锁的时候,在线程Thread0的栈帧中会产生一个Lock Record.Lock Record包括两个部分:
- Lock Record 地址 :用于等下替换object的Mark Word.
- Object reference: 用于指向Object.
产生LockRecord之后,Thread0将Object reference指向Object.**并且尝试用CAS操作(保证原子性)**来将(Lock Record地址 )与Object的MarkWord进行交换 并将Object的锁标志位变为00.如果交换成功,就代表轻量级锁加锁成功.这个时候对象的MarkWord中存放的就是Lock record 地址+00.大家可以往上翻翻关于MarkWord在加轻量级锁情况下的值.
4.2轻量级锁的重入过程
有的时候已经拥有锁的线程可能会再次进入由object为锁的代码段.并且在synchronizedTest1方法中调用了synchronizedTest2.这个时候就会有一个锁的重入.
//两个同步代码段都是使用了dog对象,并且
public void synchronizedTest1()
{
synchronized (dog) { synchronizedTest2();
}
public void synchronizedTest2()
{ synchronized (dog) { } }
由于调用了一个新的方法,就会在Thread0的VM Stack(虚拟机栈)中新产生一个栈帧frame.由于要申请锁,所以又会产生一个Lock Record.并且再一次发生对轻量级锁的一个申请过程.但是这一次会失败,因为Object对象的MarkWord已经被替换.但是由于Object中的 ptr_to_lock_record指向的是当前线程.所以新的方法还是会执行,只不过第一个数据变为了null.以后每一次重入都会再次生成一个LockRecord.LockRecord的数量就代表了重入的次数.每执行完一个方法就会消失一个LockRecord.重入锁的计数就会减一.
4.3轻量级锁的锁膨胀
刚才我们说过,轻量级锁是在同一时刻没有资源的竞争的情况下对重量级锁的一个优化.但是当一个轻量级锁被占有的时刻又有另一个线程去申请,既在同一时刻有竞争这个时候就会产生锁膨胀.由轻量级锁升级为重量级锁.
5.synchronized优化—偏向锁
前面说到轻量级锁是对重量级锁在同一时刻没有竞争的情况下的一种优化,但是持有轻量级锁的线程在再次进入锁以及锁重入的阶段还是会进行CAS操作.这还是一个比较消耗系统资源的过程.这个时候就可以使用偏向锁来对其进行进一步的优化.使得只要线程第一次获得了该锁, 那么以后的进入和重入就不用再进行CAS的操作.因为其实在很多情况下一个资源不仅在同一时刻没有竞争而且一直是由同一个线程持有.这个时候就可以再次优化,将轻量级锁变为偏向锁
5.1偏向锁的开启
在JDK1.6之前偏向锁是默认关闭的,在1.6之后则是默认开启的.用户也可以通过VM Options中-XX:+UseBiasedLocking来开启或者关闭.
5.2偏向锁的延迟性
虽然在JDK1.6之后偏向锁是默认开启的,但是它并不是在程序一启动就立马开启,而是有一定的延迟.但是用户也可以通过
-XX:BiasedLockingStartupDelay=0来自己设置偏向锁的延迟时间.设置为0自然就是立马开启.
5.3 偏向锁的加锁过程
当一个线程访问同步代码块获取锁的时候,
- 先获取目标对象的MarkWord,根据锁的标识和epoch去判断当前是否处于可偏向状态
- 如果为是可偏向状态,则通过CAS操作将自己的ThreadID写入到MarkWord中,如果CAS操作成功,则表示当前线程已经成功获取到了偏向锁(MarkWord在偏向状态下可以存储线程ID) 并且没有特殊情况会一直保留 既"偏向"该线程,继续执行同步代码块.
- 如果再次进入同步代码块,则先判断MarkWord中存储的ThreadID是否和当前访问的线程的ThreadID相同,如果相同表明当前线程已经成功的获取到了偏向锁.则不需要再获取锁,直接执行同步代码.如果不相同说明偏向锁的存在条件已经不存在,则进行偏向锁的撤销.
5.4偏向锁的撤销
上面说过,偏向锁是一种对条件要求条件比较苛刻的锁,所以当情况不满足的时候就会进行偏向锁的撤销,有以下一种情况会进行偏向锁的撤销.
- 调用hashCode()方法,之前在JVM中讲过,如果一个对象不调用hashCode()方法,就不会在对象的对象头中生成hashCode.所以一旦调用了hashCode()方法,就会给对象头中分配hashCode,但是大家观察MarkWord格式会发现,如果一个对象处于可偏向状态,那么在MarkWord中就没有地方存储hashCode.所以这个时候就会将偏向锁的撤销,并升级为轻量级锁.因为轻量级锁的hashCode可以存储在LockRecord中,重量级锁的hashCode可以存储在Monitor中.
- 调用wait/notify/notifyAll()方法.因为wait()/notify()方法是只有重量级锁才有.所以调用了之后就升级为重量级锁
- 产生了锁的竞争:这里也有两种情况:
- 如果在同一时刻没有竞争,当不是一个线程一直使用.在线程Thread1使用偏向锁的时候没有竞争,但是当它使用完之后又有一个其他的线程使用了该偏向锁.那么就会升级为轻量级锁
- 不仅不是持有者一直使用,而且在使用的过程中也存在锁的竞争:就会升级为重量级锁.