为什么要使用synchronized
在并发编程过程中由于共享数据的原因,会存在线程安全问题。关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或代码块,同时synchronized可以保证一个线程的变化可见(可见性),即可以代替volatile。
synchronized使用场景
1.修饰实例方法,对当前实例对象加锁;
2.修饰静态方法,对当前类的class对象加锁;
3.修饰代码块,指定一个加锁的对象,给对象加锁。
Java对象构成
讲加锁实现原理之前,先回忆下Java对象构成。
- 对象头
- Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- klass point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- 实例数据
- 存放类的数据信息及其父类的信息
- 对其填充
- 虚拟机要求对象起始地址必须是8字节的整数倍,对其填充不是必要的,仅仅是为了字节对齐。(空对象占8字节)
synchronized底层实现
同步代码:每个对象的对象头会关联一个monitor,每当进入一个方法时执行monitorenter, 就会获取当前对象的所有权。
同步方法:判断是否有标志位acc_synchronized,该标志位也会隐式的调用monitorenter、monitorexit。所以归根到底还是对monitor的争夺。
锁升级过程
synchronized加锁过程中会涉及到锁升级,锁一共有四种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁、重量级锁,这几种状态随着竞争情况逐渐升级。为了提交锁获取和释放时的效率,锁只能升级不能降级(偏向锁可变为无锁状态)。
(1)偏向锁:
为什么要引入偏向锁:
因为经过大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁的升级:
当线程A访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程A再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致,说明还是线程A获取锁对象,则无需使用CAS来加锁、解锁;如果不一致:其他线程,如线程B要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程A的threadID,那么需要查看Java对象头中记录的线程A是否存活,如果没有存活,那么锁对象被重置为无锁状态,线程B可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程A)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程A,偏向锁升级为轻量级锁,如果线程A不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁的取消:
偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;
如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;
(2)轻量级锁:
为什么要引入轻量级锁:
轻量级锁考虑的是竞争锁对象的线程较少,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞(自旋)这个线程,让它自旋这等待锁释放。
轻量级锁什么时候升级为重量级锁:
线程A获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程A的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程A存储的锁记录(DisplacedMarkWord)的地址;
如果在线程A复制对象头的同时(在线程A的CAS之前),线程B也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程B的CAS的时候,发现线程A已经把对象头换了,线程B的CAS失败,那么线程B就尝试使用自旋锁来等待线程A释放锁。
但是如果自旋太久也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,如果自旋次数到了线程A还没有释放锁,或者线程A还在执行,线程B还在自旋等待,这时又有一个线程C过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的其他线程都阻塞,防止CPU空转。
锁状态 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁解锁无需额外消耗,和非同步方法时间相差纳秒级别 | 如果竞争的线程较多,会带来额外的撤销锁的消耗 | 基本没有线程竞争锁的同步场景 |
轻量级锁 | 竞争的线程不会阻塞,适用自旋提高响应速度 | 长时间自旋会消耗CPU | 适用于少量线程竞争锁对象,且线程持有锁的时间不长,追求响应速度 |
重量级锁 | 等待线程不使用自旋,不会造成CPU空转 | 线程阻塞,响应时间较长 | 大量线程竞争锁对象,追求吞吐量的场景 |
锁粗化
按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
锁消除
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。