一、认识synchronized
多线程并发编程中,synchronized关键字常用于来保证多线程情况下执行代码的同步,其一直是元老级别,许多人也称之为重量级锁。在java SE 1.6之后对该关键字进行了各种优化,在java SE 1.6中为了减少获得锁与释放锁带来的性能消耗引入了偏向锁和轻量级锁,根据不同的竞争情况会进行对synchronized锁进行升级!
JDK1.6前,称之为重量级锁。
JDK1.6后,Synchronized有一个锁升级过程。无锁->偏向锁->轻量级锁->重量级锁。
看一下synrhonzied应用的不同分类情况:
二、Synchronized原理分析
2.1、对象在内存中的布局
介绍对象的三部分
在Hotspot虚拟机(现在jvm使用的虚拟机)中,对象在内存中的存储布局,可分为三个区域:对象头(Header)、实例数据(Instance Data)、对其填充(Padding)。一般而言,synchronized使用的锁对象(monitor)是存储在Java对象头中,其实轻量级锁与偏向锁的关键。
一个对象实例包含这三个部分。
对象头:对象头又可以分为两块内容
第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别位32bit和64bit,官方称它为 Mark Word。
另一部分是类型指针,指向它的类元数据的指针,用于判断对象属于哪个Class的实例,另外,如果对像是一个数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
实例数据:
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。父类定义的变量会出现在子类定义的变量的前面。各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到一起,便于之后取数据。
对齐填充:
对齐填充并不是必然存在的,它仅仅起着占位符的作用。为什么需要有对齐填充呢?由于Hotspot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
Java对象头(四种状态,重要)
在对象头中主要包括两个部分数据:Mark Word(标记字段)、Class Poniter(类型指针)
其中的Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程的ID、偏向时间戳等,该标记字段是实现轻量级锁和偏向锁的关键。
下面是32位与64位对象头(MarkWord):
说明:上面在加锁时分别对应MarkWord可能存储的4种状态。
轻量级锁:00
重量级锁:10
GC标记(等待回收):11
偏向锁:01
2.2、JDK1.6之后锁升级(过程)
优化前与优化后说明
JDK1.6之前也就是没有优化之前,synchronized是重量级锁(悲观锁:很悲观认为每次拿对应数据都有其他线程会与其进行争抢,每次都会进行上锁,其他线程若想使用指定资源就需要阻塞等待),每次都要进行线程挂起与唤醒,这样会很浪费资源,影响性能,所以之后对synchronized关键字进行优化。
JDK1.6之后,将锁分为了无锁、偏向锁、轻量级锁、重量级锁这四种状态,根据不同的情况来进行锁升级,而不是从始到终都是重量级锁。
四种状态描述
锁升级过程:无锁->偏向锁->轻量级锁->重量级锁,这几个状态会随着竞争情况逐渐升级。
注意:锁可以升级但是不可以降级。
无锁:也就是没有对资源进行锁定,所有的线程都能够访问并修改同一个资源,同时只有一个线程能够修改成功,其他修改失败线程会不断重复直到修改成功。
偏向锁:指定的对象锁一直被同一线程执行,不存在多个线程竞争情况,该线程在后序执行中能够自动获取锁,从而降低了频繁获取锁带来的性能开销。
简而言之:偏向锁就是偏向第一个加锁的线程,该线程是不会主动释放锁的,只有当其他线程尝试竞争偏向锁才会释放。
撤销操作:需要在某个时间点没有字节码正在执行,先暂停拥有偏向锁的线程,之后判断锁对象是否处于被锁定状态。
若是线程不处于活动状态,会将对象头设置成无锁状态,并撤销偏向锁。
若是线程处于活跃状态,升级为轻量级锁状态。
轻量级锁:当锁是偏向锁时,该锁被第二个线程B锁访问,之后该对象的偏向锁就会升级为轻量级锁,第二个线程B会通过自旋形式不断尝试获取锁,线程不会进入阻塞状态,提升了性能。
自旋等待说明:若是当前只有一个等待线程,该线程会通过自旋进行等待(不断尝试获取锁),若是自旋超过一定数量时,轻量级锁就会升级为重量级锁。
升级锁情况:若是当前一个线程已持有锁,另一个线程在自旋尝试获取锁,此时第三个线程来访,轻量级锁会升级为重量级锁。
重量级锁:即当一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
通过对象内部监视器(monitor)实现,其本质是依赖于底层操作系统的Mutex Lock(互斥锁)实现,操作系统实现线程之间的切换需要从用户态切换到核心态,该切换成本就会很高。
总结
synchronized锁升级实际上就是把原本的重量级锁(悲观锁)变为了锁升级的一个过程,
若是只有1个线程获取请求使用该锁,该锁为偏向锁(请求锁时会自动获取)。
接着若是有第二个线程来获取访问,偏向锁升级为轻量级锁。第二个线程会进行自旋请求获取该锁,超过一定数量自旋时会升级为重量级锁或第三个线程来访(第一个线程持有锁、第二个线程在自旋状态)会升级为重量级锁。
重量级锁即为若是某个线程占有了该锁,那么其他线程都会进入阻塞等待。
不同锁使用情况:
偏向锁适用于单线程的情况。
轻量级锁适用于竞争不激烈的情况。(与乐观锁使用范围类似)
重量级锁适用于竞争激烈情况。
三、同步方法、代码块反编译
包含JVM规范The Java® Virtual Machine Specification[2]中关于方法级同步、同步代码块说明
JVM规范网址:Synchronization
测试程序如下,包含一个同步方法以及一个同步代码块:
/** * @ClassName Synchronized * @Author ChangLu * @Date 2021/4/7 13:23 * @Description synchronized(同步方法、代码块)反编译测试 */ public class SynchronizedTest { public static void main(String[] args) { } //同步方法 public static synchronized void test01(){ System.out.println("test01()"); } //同步代码块 public void test02(){ synchronized (this){ System.out.println("test02()"); } } }
通过JDK工具命令来进行反编译(记得先编译为class字节码文件):javap -v SynchronizedTest.class
同步方法反编译:JVM采用ACC_synchronized标记符来实现同步
根据JVM虚拟机对于同步方法的规范大致内容如下:
方法级的同步是隐式的,同步方法的常量池中有一个ACC_SYNCHRONIZED标志。
当某个线程要访问某个方法时,会检查是否有ACC_SYNCHRONIZED标志。
若是有:则需要先获取监视器锁(monitor),接着开始执行方法,方法执行完之后再释放锁。这个时间段若是有其他线程来请求执行方法,会因为无法获得监视器而被阻断掉。
若是获取锁后在方法执行过程中,发生异常,并且方法内部并没有处理该异常,那么异常被抛到外面之前监视器锁会先进行释放。
同步代码块反编译:JVM采用monitorenter、monitorexit两个指令来实现同步。
下面图片是官网上的注释内容,f是传入synchronized代码块中的对象 ,中间会有上锁与解锁的过程。
根据JVM虚拟机对于同步代码块的规范大致内容如下:
将monitorenter指令认为是加锁;monitorexit认为是释放锁。
每个对象维护着一个记录着被锁次数的计数器。对于未被锁定的对象计数器为0。
当一个线程获得锁(moitorenter指令)后,计数器自增1;线程释放锁(moitorexit指令),计数器自减1。对于同一个线程再次获取该对象锁时,计数器会再次自增,同理解锁自减(可以说明synchronized是可重入锁)。
当计数器为0时,锁就相当于被释放了,那么其他线程便可以去获取该锁。