对象内存布局(前置知识)
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三部分,对象头(Header),实例数据(Instance Data)和对其填充(Padding)。示例图如图:对象内存布局示例图所示
对象头
对象头中又分为对象标记(Mark Word)和类元信息。其中对象标记占用8个字节,类元信息占用8个字节,总共的对象头是16个字节
对象头:存储着对象的哈希码,GC标记,GC次数,同步锁标记,偏向锁持有者等信息
类元信息:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
通过代码的形式演示对象头的布局
引入依赖
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.16</version> </dependency>
编写代码
public class ObjectHeader { public static void main(String[] args) { Object o=new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }
运行结果图
由运行结果图可见,前两行是对象标识的16进制表达式,读法是从右下脚开始读,每八个又是一组
实例数据
包括类里面的属性数据信息,父类的属性信息
对其填充
虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的。仅仅是为了字节对其这部分内存按8字节补齐对其
new一个对象占用多少的内存?(面试题)
如果这个类里面什么都没有(属性,方法),那么这个类new出来的对象就只有一个对象头,其中对象标记占8个字节,类元信息占8个字节,总共是16个字节
锁的升级过程
synchronized用的锁是存在Java对象头里的Mark Word中,锁升级功能主要依赖MarkWord中锁标志位和释放偏向锁标志位。
偏向锁:MarkWord存储的是偏向的线程ID
轻量级锁:MarkWord存储的是指向线程栈中Lock Record的指针
重量级锁:MarkWord存储的是指向堆中的monitor对象的指针
偏向锁
当线程A第一次竞争到锁时,通过操作修改MarkWord中的偏向线程ID、偏向模式。如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要进行同步。当一段代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获取锁即偏向锁在资源没有竞争的情况下消除了同步语句
理论说明
在实际应用运行过程中,锁总是被同一个线程所持有,很少发生竞争,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程
那么只需要在锁第一次被拥有的时候,纪录下偏向线程ID,这样偏向线程就一直持有着锁,后续这个线程进入和退出加了同步锁的代码块时,不需要在加锁和释放锁,而是直接去检查锁的MrkWord里面的是不是放的自己的线程ID
如果相等,表示偏向锁是偏向于当前线程的,就不需要在尝试获取锁了,直到竞争出现才释放锁,以后每次同步,检查锁的偏向线程ID与当前线程是否相等,一致则直接进入同步代码块,无需每次加锁解锁都去CAS更新对象头。如果自始自终使用锁的线程都只有一个,很明显偏向锁的几乎没有额外的开销,性能极高
如果不相等,表示发生了竞争,锁已经不是总是偏向于同一个线程了,这个时候会尝试使用CAS来替换MarkWord里面的线程ID为新线程的ID
竞争成功,表示之前的线程不存在了(偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放(前提是当前持有线程已经使用完成)否则线程是不会主动释放偏向锁的),更新MarkWord里面的线程ID,锁不会升级,仍然为偏向锁
竞争失败,这时候可能升级为轻量级锁,才能保证线程之间的公平竞争
技术实现
一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的MarkWord中将偏向锁修改状态位,同时还会占有前54位来存储线程指针作为标识。若该线程再次访问同一个synchronized方法时,该线程只需去对象头的MarkWord中去判断一下是否持有偏向锁指向本身的ID无需再进入Monitor去竞争对象了
代码中偏向锁的相关操作
jdk中偏向锁默认是开启
开启偏向锁的JVM指令:-XX:+UseBiasedLocking
关闭偏向锁的JVM指令: -XX:-UseBiasedLocking
关闭偏向锁延迟,因为JDK默认是在程序运行4秒之后才开启偏向锁的:-XX:BiasedLockingStartupDelay=0
偏向锁的撤销
偏向锁使用一种等到竞争才会释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会撤销。撤销需要等待全局安全点(该时间上没有字节码正在执行),同时检查持有偏向锁的线程是否还在执行。
撤销逻辑:
1.第一个线程正在执行synchronized方法(处于同步块),还没有执行完,其他线程来进行争夺,该偏向锁会被取消并出现锁升级,此时轻量级锁由原来持有偏向锁的线程持有,继续执行同步代码块,而正在竞争的线程会进入自旋等待获得该轻量级锁
2.第一个线程执行完成synchronized方法(退出同步代码块),则将对象头设置位无所状态并撤销偏向锁,重新偏向
撤销流程图如图:偏向锁撤销流程图所示
偏向锁在JDK15开始移除
轻量级锁
概念:多线程竞争,但是任意时刻只有一个线程竞争,即步存在锁竞争太过激烈的情况,也就没有线程阻塞,轻量级锁时为了在线程近乎交替执行同步块时提高性能
主要目的:在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生性能消耗,先进行自旋,不行才升级为阻塞
升级时机:当关闭偏向锁功能或者多线程竞争偏向锁时会导致偏向锁升级为轻量级锁
轻量级锁的获取
假如线程A已经拿到锁,这时线程B来抢夺该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已经是偏向锁了,而线程B在争抢时发现对象头MarkWord中的线程ID不是线程B自己的线程ID(而是线程A)那线程B就会进行CAS操作希望获得锁,此时线程B操作中会有两种情况
如果获取锁成功,直接替换MarkWork中的线程ID为自己的线程ID,重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前持有偏向锁的线程释放了锁),该锁会保持偏向锁状态
如果获取失败,则偏向锁升级为轻量级锁(设置偏向锁标识为0并设置锁标志位为00),此时轻量级锁由原持有偏向锁的线程获得,继续执行同步代码,而正在竞争的B线程会进入自旋等待获取该轻量级锁
轻量级锁的加锁
JVM会为每个线程在当前线程的栈帧中创建用于存储锁纪录的空间,官方称之为Displaced Mark Word,若一个线程获得锁时发现是轻量级锁,会把锁的MarkWord复制到自己的Displaced Mark Word中,然后线程尝试用CAS将锁的MarkWord替换为指向Displaced Mark Word的指针,如果成功,当前线程获得锁,如果失败,表示MarkWord已经被替换成了指向其他线程的Displaced Mark Word,说明与其他线程竞争锁,当前线程就尝试使用自旋来获取锁
轻量级锁的释放
在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的MarkWord里面,如果没有发生竞争,那么这个复制的操作会成功,如果有其他线程因为自旋多次导致轻量级锁升级成为了重量级锁,那么CAS操作会失败,此时释放锁并唤醒被阻塞的线程
自旋几次之后升级为重量级锁呢?
jdk6之前,自旋十次或自旋的线程达到了CPU核数的一半。jdk6之后使用自适应自旋锁,大致原理是线程如果自旋成功了,那下次自旋的最大次数会增加,因为JVM认为既然上次成功了,那么这一次也会很大概率会成功,反之,如果很少会自旋成功,那么下次会减少自旋的次数甚至不自旋,避免CPU空转
偏向和轻量的区别
- 争夺轻量级锁时,线程会自旋尝试抢占锁
- 轻量级锁每次退出同步块都需要释放锁,而偏向锁实在竞争发生时才释放锁
重量级锁
实现原理:Java中的synchronized的重量级锁是基于进入和退出Monitor对象实现的,在编译时会在同步代码块的开始位置插入monitor enter指令,在结束位置插入monitor exit指令,当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了。即获取了锁,会在Monitor的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor
锁和哈希码的关系
- 在无锁状态下,Mark Word中存储对象的identity hash code值,当对象的hashCode()方法第一次被调用时,JVM会生成对应的identity hash code值并将值存储到Mark Word中
- 偏向锁,在线程获取偏向锁时,会用Thread ID和epoch值覆盖identity hash code所在的位置,如果一个对象的hashCode()方法已经被调用过一次后,这个对象不能被设置为偏向锁,因为如果可以的话,那么MarkWord中的identity hash code必然会被偏向线程ID覆盖,这就会导致hashCode()方法得到的结果不一致了,所以如果调用过一次hashCode()方法,那么会直接略过偏向锁从而升级为轻量级锁
- 轻量级锁,JVM会在当前线程的栈帧中创建一个锁纪录(Lock Record空间,即之前提过的官方称之位Displaced Mark Word),用于存储对象的Mark word拷贝,该拷贝中可以包含identity hash code,所以轻量级锁可以和identity hash code共存,哈希码和GC年龄自然保存在此,释放锁后会将这些信息写回到对象头
- 重量级锁,Mark Word保存重量级锁指针,代表重量级锁的ObjectMonitor类里有字段纪录非加锁状态下的MarkWord,锁释放也会将这些信息写回对象头
优缺点
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级别的差距(因为只需比较对象头中的线程ID即可) | 如果线程间存在锁竞争,会带来额外的锁撤销消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的相应速度 | 如果始终得不到锁,竞争的线程使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间慢 | 追求吞吐量同步块执行时间较长 |
总结
jdk1.6之前,synchronized是直接升级为重量级锁的,但是1.6之后从无锁——>偏向锁——>轻量级锁——>重量级锁逐步升级,其实这样做从最开始的重量级锁我们直到是使用Monitor来实现的,这样会导致频繁的从用户态(程序)切换到内核态(操作系统),从而消耗性能
扩展
锁消除
锁消除案例
public class LockEliminateTest {
static final Object lockObject=new Object();
private static void test(){
Object o=new Object();
//每次加锁的对象都是新new出来的
synchronized (o){
System.out.println("o hashCode: "+o.hashCode()+"\t lockObject hashCode: "+lockObject.hashCode());
}
}
public static void main(String[] args) {
LockEliminateTest lockEliminateTest=new LockEliminateTest();
for (int i=0;i<10;i++){
new Thread(()->{
lockEliminateTest.test();
}).start();
}
}
}
从案例代码中可以看到。每次加锁的对象都是新new出来的,那么每个相当于每个线程都持有自己的对象锁,不存在相互竞争的情况了。这种时候
锁粗化