Java面试_synchronized的原理及实现
参考博客: https://www.jianshu.com/p/dab7745c0954
参考博客: https://blog.dreamtobe.cn/2015/11/13/java_synchronized/
一、synchronized 的表现方式
sunchronized有三种实现方式
1. 普通同步方法
锁的是当前执行该方法的实例对象。
public synchronized void fun(){}
2. 静态同步方法
锁的是当前的Class对象。
public static synchronized void fun(){}
3. 同步方法块
锁的是Synchonized括号里配置的对象。
// 锁对象
synchronized ( this ) { }
// 锁类
synchronized ( this.getClass() ) { }
二、 实现机制
- 对象被创建在堆中。并且对象在内存中的存储布局方式可以分为3块区域:对象头、实例数据、对齐填充。
- 当一个线程试图访问同步代码块时,它首先必须得到锁,退出或者抛出异常时必须释放锁,而锁的存储位置就在对象头中
1. 对象头
对象头分成两部分信息:
1.1 存储对象自身的运行时数据
如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据长度在32位和63位的虚拟机中分别为32bit和64bit,官方称它为"Makr Word"。
1.2 类型指针
类型指针即代表对象指向它的类元数据的指针,虚拟机通过这个指针来确定该对象是哪个类的实例。但查找对象的元数据信息并不一定要经过对象本身。如果对象是一个Java数组,在对象头中还须有一块用于记录数组长度的数据,因为虚拟机可通过普通Java对象的元数据信息确定Java对象的大小,但从数组的元数据中无法确定数组的大小。
2. 基本实现
synchronized通过查看某个对象保存在自己对象头中的信息来判断当前对象的对象锁的使用情况,包括对象锁的状态,获得锁的线程等
三、synchronized锁的宏观实现(重量级锁定)
1. monitor
-
synchronized的对象锁,在升级为重量级锁之后,对象头中的指针指向的是一个 monitor对象,每一个对象都会有一个monitor,它随对象一起创建、销毁,或者是线程视图获取对象锁是自动生成。
-
monitor是由ObjectMonitor实现,里面的代码字段有这几种:
ObjectMonitor(){ //部分变量 _EntryList = NULL; _owner = NULL; //当某个线程获得该对象锁时,_owner就会指向获得该对象锁的线程;同时_count也会自增1,如果当前线程调用该对象锁的wait()方法,线程就会释放当前持有的monitor,同时_owner变量变为null,_count自减1 _count = 0; _WaitSet = NULL; _WaitSetLock = 0; }
- owner: 持有ObjectMonitor对象的线程,也就是获得该对象锁的线程; 在初始化时为 NULL ,表示当前没有任何线程拥有该 monitor record ,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
- EntryList: 存放等待锁的线程队列;多个线程同时访问一块代码块的时候,首先会进入这个队列中,调度器将基于某一种标准(比如FIFO)来选择线程执行
- WaitSet: 存放处于Wait状态的线程队列,阻塞状态
四、synchronized代码块的底层实现
synchronized的应用从synchronized关键字放置的位置大致可以看为 同步代码块 和 同步方法,而这两种在生成的字节码还是有一些差异的。
1. 同步代码块
- **monitorenter:**指令在同步代码块开始的地方,执行该指令时,该线程就会先去尝试获得锁,也就会monitor对象,如果这个对象没被锁定或者当前线程已经拥有了这把锁,就会拿着这个锁继续往下执行,如果竞争锁失败,则进入EntryList 等待队列,等待锁释放。
- 第一个 monitorexit: 指令插入到同步方法结束和异常处,执行该指令也就是释放锁。
- **第二个 monitorexit:**为什么会有两个这个指令,是因为编译器需要保证方法中调用过的每条minitorenter指令都要执行对应的monitorexit指令,因为对象锁最终是要释放掉的,带来的问题就是在方法异常时,无法执行第一个释放锁指令,就需要多一个来执行异常时释放mobitor的。
//同步代码块
private int i = 0;
public void fun(){
synchronized(this){
i++;
}
}
pub1ic void fun() ;
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_ size=1
O: aload_0
1: dup
2: astore_1
3: monitorenter // monitorenter
4: aload_V
5: dup
6: getfield #2
9: iconst_1
10: iadd
11: putfield #2
14: aload_1
15: monitorexit //第一个monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //第二个monitorexit
22: aload_2
23: athrow
24: return
Exception table:
2. 同步方法
同步方法使用ACC_SYNCHRONIZED来标记该方法是一个同步方法,也是执行刚才说的获得monitor对象和释放minitor对象进行代码的同步,它也可以通过 monitorenter 和 monitorexit 来实现
//同步方法
public synchronized void fun(){
//TODO
}
public synchronized void fun();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED
Code:
stack=3, locals=l, args_ size=1
0: aload_0
1: dup
2: getfield #2
5: iconst_1
6: i add
7: putfield #2
10: return
LineNumberTable:
line 11: 0
line 12: 10
五、锁升级
在Java SE 1.6时,对synchronized有了较大的改变,为了降低获得锁和释放锁带来的性能消耗,改变为锁的升级,也就是从 无锁状态 到 偏向锁状态 到 轻量级锁状态 再到最后的 重量级锁状态。
1. 偏向锁
- 当线程访问同步代码块时,会在对象头和栈帧中记录偏向锁的线程ID,因为偏向锁是不会自己释放锁的所以如果当同一个线程再次获取锁的时候,就需要比较当前线程的线程ID和Java对象头中的线程ID是否一致
- 一致的话就不再需要在使用CAS操作加锁解锁了;
- 不一致的话(另外一个线程2)就需要比较对象头中的线程1是否存活:
- 没有存活的话就说明线程1已经不需要该对象了,这个时候就将对象重置为无锁状态
- 存活的话,那么又要查找线程1的栈帧信息,查看该线程是否还需要持有这个锁对象
- 如果线程1不再使用这个锁对象,也就会将锁对象的状态设置为无锁状态,重新偏向新的偏向锁。
- 需要持有这个对象锁,就会撤销偏向锁,升级为轻量级锁
2. 轻量级锁
- 轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。
- 因为阻塞线程需求CPU用用户态转化为内核态,代价较大,而且如果刚阻塞不久的锁被释放了,这个代价就有点大了,这时候可以让他不阻塞,让他自旋等待锁的释放。
- 轻量级锁的自旋次数也是有限制的,比如10次或者100次,如果自旋次数到了预设的次数后,或者线程1还在执行,线程2还在自旋等待,又来了一个线程3来竞争这个锁对象,那么这个时候轻量级锁就会升级为重量级锁。重量级锁将会把除了拥有锁的线程全部阻塞,防止CPU空转。
3. 锁的特点
锁状态 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁解锁无需额外的消耗,只需将偏向的指向改变即可 | 如果竞争的线程多,那么会带来额外的锁撤销的消耗 | 基本没有线程竞争锁的同步场景,系统一直偏向于同一个线程执行 |
轻量级锁 | 竞争的线程不会被阻塞,使用自旋等待锁的释放,提高程序的响应速度 | 如果一直不能获得锁,长时间的自旋等待会造成CPU的空转,导致CPU的消耗 | 适用于少量线程竞争锁对象,而且线程持有锁的时间不长,追求响应速度的场景 |
重量级锁 | 线程竞争适用于CPU自旋,不会导致CPU空转消耗CPU资源 | 线程阻塞,响应时间长 | 很多线程竞争锁,且锁持有的时间长,追求吞吐量的场景 |
六、锁优化
1. 自旋锁
- 自旋锁:
- 当一个线程竞争资源时,发现资源被占用,则这个线程不会被挂起,而是在自旋等待锁的释放。
- 自旋次数默认
- 存在理论:
- 适用于少量线程竞争锁对象,而且线程持有锁的时间不长,追求响应速度的场景;
- 如果一个线程竞争锁失败了,被挂起,但是刚挂起这个锁就被释放了,又要唤醒这个线程;
- 这样线程的频繁挂起、唤醒负担较重,可以认为每个线程占有锁的时间很短,线程挂起再唤醒得不偿失。