1、Java 对象头
对象头包含两部分:运行时元数据(Mark Word)和类型指针 (Klass Word)
- 运行时元数据
1)哈希值(HashCode),可以看作是堆中对象的地址
2)GC分代年龄(年龄计数器) (用于新生代from/to区晋升老年代的标准, 阈值为15)
3)锁状态标志 (用于JDK1.6对synchronized的优化 -> 轻量级锁)
4)线程持有的锁
5)偏向线程ID (用于JDK1.6对synchronized的优化 -> 偏向锁)
6)偏向时间戳
- 类型指针:指向类元数据InstanceKlass,确定该对象所属的类型。指向的其实是方法区中存放的类元信息
说明:如果对象是数组,还需要记录数组的长度
以 32 位虚拟机为例,普通对象的对象头结构如下
其中的Klass Word为类型指针,指向方法区对应的Class对象
数组对象
其中 Mark Word 结构为: 无锁(001)、偏向锁(101)、轻量级锁(00)、重量级锁(10)
一个对象的结构
2、Monitor 原理 (Synchronized底层实现-重量级锁)
- 多线程同时访问临界区: 使用重量级锁
- JDK6对Synchronized的优先状态:偏向锁–>轻量级锁–>重量级锁
Monitor被翻译为监视器或者管程
每个Java对象都可以关联一个(操作系统的)Monitor,如果使用synchronized给对象上锁(重量级),该对象头的MarkWord中就被设置为指向Monitor对象的指针
原理解释
- 当Thread2访问到synchronized(obj)中的共享资源的时候,首先会将synchronized中的锁对象中对象头的MarkWord去尝试指向操作系统的Monitor对象,让锁对象中的MarkWord和Monitor对象相关联
- 如果关联成功, 将obj对象头中的MarkWord的对象状态从01(无锁)改为10(重量级锁)
- 因为Monitor没有和其他的obj的MarkWord相关联, 所以Thread2就成为了该Monitor的Owner(所有者)
- 又来了个Thread1执行synchronized(obj)代码, 它首先会看看能不能执行该临界区的代码;
- 它会检查obj是否关联了Montior, 此时已经有关联了, 它就会去看看该Montior有没有所有者(Owner), 发现有所有者了(Thread2)
- Thread1也会和该Monitor关联, 该线程就会进入到它的EntryList(阻塞队列)
- 当Thread2执行完临界区代码后, Monitor的Owner(所有者)就空出来了. 此时就会通知Monitor中的EntryList阻塞队列中的线程, 这些线程通过竞争, 成为新的所有者
- 刚开始时Monitor中的Owner为null
- 当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
- 当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的 (仍然是抢占式)
- 图中 WaitSet 中的Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析
- 它加锁就是依赖底层操作系统的 mutex相关指令实现, 所以会造成用户态和内核态之间的切换, 非常耗性能 !
- 在JDK6的时候, 对synchronized进行了优化, 引入了轻量级锁, 偏向锁, 它们是在JVM的层面上进行加锁逻辑, 就没有了切换的消耗
3、synchronized字节码原理
代码示例
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
字节码
- 5:monitorenter 上锁,15:monitorexit 解锁;5~15是synchronized的同步代码块
- 21:monitorexit 异常解锁(兜底),synchronized的同步代码块无论正常运行还是异常都会释放对象锁
- 方法级别的 synchronized 不会在字节码指令中有所体现
4、轻量级锁 (用于优化Monitor这类的重量级锁)
- 通过锁记录的方式, 场景 : 多个线程交替进入临界区
- 轻量级锁的使用场景: 如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化(A线程访问完之后,B线程才会访问,不会同时访问)
- 期间有线程来竞争的话, 就会升级为重量级锁(synchronized)
- 轻量级锁对使用者是透明的,即语法仍然是synchronized (jdk6对synchronized的优化)
假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
- 每次指向到synchronized代码块时,都会在栈帧中创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的MarkWord和锁对象引用reference
- 让锁记录中的Object reference指向锁对象地址
- 并且尝试用CAS(compare and sweep)将栈帧中的锁记录的(lock record 地址 00)替换Object对象的Mark Word,将Mark Word 的值(01)存入锁记录(lock record地址)中 ------相互替换(01-无锁 00-轻量级锁)
- 如果cas替换成功, 获得了轻量级锁,那么对象的对象头储存的就是锁记录的地址和状态00,如下所示
- 线程中锁记录, 记录了锁对象的锁状态标志; 锁对象的对象头中存储了锁记录的地址和状态, 标志哪个线程获得了锁
- 此时栈帧中存储了对象的对象头中的锁状态标志,年龄计数器,哈希值等; 对象的对象头中就存储了栈帧中锁记录的地址和状态00, 这样的话对象就知道了是哪个线程锁住自己。
- 如果cas替换失败,有两种情况 : ① 锁膨胀 ② 重入锁失败
- 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入 锁膨胀阶段,此时对象Object对象头中已经存储了别的线程的锁记录地址 00,指向了其他线程;
- 如果是自己的线程已经执行了synchronized进行加锁,那么再添加一条 Lock Record 作为重入锁的计数 – 线程多次加锁, 锁重入
- 在上面代码中,临界区中又调用了method2, method2中又进行了一次synchronized加锁操作, 此时就会在虚拟机栈中再开辟一个method2方法对应的栈帧(栈顶), 该栈帧中又会存在一个独立的Lock Record, 此时它发现对象的对象头中指向的就是自己线程中栈帧的锁记录; 加锁也就失败了. 这种现象就叫做锁重入; 线程中有多少个锁记录, 就能表明该线程对这个对象加了几次锁 (锁重入计数)
解锁流程
- 当线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录 ,表示有锁重入,这时重置锁记录,表示重入计数减一
- 当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用cas将Mark Word的值恢复给对象, 将直接替换的内容还原。
- 解锁失败,表示有竞争, 则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程 (Monitor流程)
5、锁膨胀
- 如果在尝试加轻量级锁的过程中,cas替换操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀(有竞争),将轻量级锁变成重量级锁
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁, 此时发生锁膨胀
- 这时Thread-1加轻量级锁失败,进入锁膨胀流程
- 因为Thread-1线程加轻量级锁失败, 轻量级锁没有阻塞队列的概念, 所以此时就要为对象申请Monitor锁(重量级锁),让Object指向重量级锁地址 10,然后自己进入Monitor 的EntryList 变成BLOCKED状态
- 当Thread-0 线程执行完synchronized同步块时,使用cas将Mark Word的值恢复给对象头, 肯定恢复失败,因为对象的对象头中存储的是重量级锁的地址,状态变为10(重量级锁)了之前的是00(轻量级锁), 肯定恢复失败。那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList中的Thread-1线程
6、自旋锁优化 (优化重量级锁竞争)
- 当发生重量级锁竞争的时候,还可以使用自旋来进行优化 (不加入Monitor的阻塞队列EntryList中)
- 如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换(持锁线程执行完synchronized同步块后,释放锁,Owner为空,唤醒阻塞队列来竞争,胜出的线程得到cpu执行权的过程) 就获得了锁
- 优化的点: 不用将线程加入到阻塞队列, 减少cpu切换
自旋重试成功的情况
自旋重试失败的情况
自旋了一定次数还是没有等到 持锁的线程释放锁, 线程2就会加入Monitor的阻塞队列(EntryList)
总结:
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋
- 总之,比较智能。Java 7 之后不能控制是否开启自旋功能
7、偏向锁 (biased lock) (用于优化轻量级锁重入)
场景: 没有竞争的时候, 一个线程中多次使用synchronized需要重入加锁的情况; (只有一个线程进入临界区)
- 在经常需要竞争的情况下就不使用偏向锁, 因为偏向锁是默认开启的, 也可以通过JVM的配置, 将偏向锁给关闭
- 在轻量级的锁中,我们可以发现,如果同一个线程对同一个对象进行重入锁时,也需要执行CAS替换操作,这是有点耗时
- 那么java6开始引入了偏向锁,将进入临界区的线程的ID, 直接设置给锁对象的Mark word, 下次该线程又获取锁, 发现线程ID是自己, 就不需要CAS了
1)升级为轻量级锁的情况 (会进行偏向锁撤销) : 获取偏向锁的时候, 发现线程ID不是自己的, 此时通过CAS替换操作, 操作成功了, 此时该线程就获得了锁对象。( 此时是交替访问临界区, 撤销偏向锁, 升级为轻量级锁)
2)升级为重量级锁的情况 (会进行偏向锁撤销) : 获取偏向锁的时候, 发现线程ID不是自己的, 此时通过CAS替换操作, 操作失败了, 此时说明发生了锁竞争。( 此时是多线程访问临界区, 撤销偏向锁, 升级为重量级锁)
示例
public class Test {
static final Object obj = new Object();
public static void m1() {
synchronized (obj) {
// 同步块A
m2();
}
}
public static void m2() {
synchronized (obj) {
// 同步块B
m3();
}
}
public static void m3() {
synchronized (obj) {
// 同步块C
}
}
}
- synchronized锁原来只有重量级锁,依赖操作系统的mutex指令,需要用户态和内核态切换,性能损耗十分明显
- 重量级锁用到monitor对象,而偏向锁则在Mark Word记录线程ID进行比对,轻量级锁则是拷贝Mark Word到Lock Record,用cas+自旋方式获取
- 只有一个线程进入临界区,偏向锁
- 多个线程交替进入临界区,轻量级锁
- 多个线程同时进入临界区,重量级锁
7.1、偏向锁状态
行时元数据(Mark Word)的结构
- Normal:一般状态,没有加任何锁,前面62位保存的是对象的信息,最后2位为状态(01),倒数第三位表示是否使用偏向锁(未使用:0)
- Biased:偏向状态,使用偏向锁,前面54位保存的当前线程的ID,最后2位为状态(01),倒数第三位表示是否使用偏向锁(使用:1)
- Lightweight:使用轻量级锁,前62位保存的是锁记录的指针,最后2位为状态(00)
- Heavyweight:使用重量级锁,前62位保存的是Monitor的地址指针,最后2位为状态(10)
对象的创建过程
- 如果开启了偏向锁(默认开启),在创建对象时,对象的Mark Word后三位应该是101
- 但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态
- 如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟
- 如果没有开启偏向锁,对象的Mark Word后三位应该是001
- 处于偏向锁的对象解锁后,线程id仍存储于对象头中
输出结果
- 测试禁用偏向锁:如果没有开启偏向锁,那么对象创建后最后三位的值为001,这时候它的hashcode,age都为0,hashcode是第一次用到hashcode时才赋值的
- 在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking禁用偏向锁(禁用偏向锁则优先使用轻量级锁),退出synchronized状态变回 001
- 禁止偏向锁, 虚拟机参数-XX:-UseBiasedLocking; 优先使用轻量级锁
- 输出结果: 最开始状态为001,然后加轻量级锁变成00,最后恢复成001
7.2、撤销偏向锁-hashcode方法
测试 hashCode:当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存hashcode的值了
7.4、撤销偏向锁-发生锁竞争 (升级为重量级锁)
偏向锁、轻量级锁的使用条件, 都是在于多个线程没有对同一个对象进行锁竞争的前提下, 如果有锁竞争,此时就使用重量级锁
7.5、撤销偏向锁 - 调用 wait/notify
- 调用wait方法会导致锁膨胀而使用重量级锁
- 会使对象锁变成重量级锁,因为wait/notify方法之后重量级锁才支持
8、批量重偏向
- 如果对象被多个线程访问,但是没有竞争 (上面撤销偏向锁就是这种情况: 一个线程执行完, 另一个线程再来执行, 没有竞争), 这时偏向T1的对象仍有机会重新偏向T2
- 重偏向会重置Thread ID
- 当撤销偏向锁101 升级为 轻量级锁00超过20次后(超过阈值),JVM会觉得是不是偏向错了,这时会在给对象加锁时,重新偏向至加锁线程 (T2)。
8.1、批量撤销偏向锁
当 撤销偏向锁的阈值超过40以后 ,就会将整个类的对象都改为不可偏向的
8.2、同步省略 (锁消除)
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程
- 如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除
示例
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
代码中对hellis这个对象加锁,但是hellis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
- 字节码文件中并没有进行优化,可以看到加锁和释放锁的操作依然存在
- 同步省略操作是在解释运行时发生的