作为Java程序员可能使用synchronized的频次不是很多大部分时间都是在crud,但是作为一个技术人还是要有点追求的,本篇文章想写了好久在此之前需要了解Java运行时数据区、Java字节码与字节码文件、Java线程模型(挖坑),再此基础上细致的聊一聊Java为了实现线程安全synchronized都做了那些事情。
一、synchronized的使用
synchronized是Java的一个关键字,可以把任意一个非null引用对象作为锁对象。在使用上可以放到方法的定义上,也可以锁住某一部分代码块,但是在实际效果上放到静态方法上和锁住该静态方法所在的class是相同的,除此之外放到实例方法上和锁住对象this是效果相同的。
1.1 作用在静态方法上
public static synchronized void test1() {
}
该种使用方法实际上是锁了当前类的Class对象。
1.2 作用在实例方法上
public synchronized void test3() {
}
该种使用方法实际上是锁了当前实例对象this。
1.3 修饰代码块
public void test2() {
synchronized (ByteCodeTest.class) {
}
}
public void test4() {
synchronized (this) {
}
}
test2的效果和作用在静态方法上是相同的,当然synchronized里面如果写其他的class就不相同了。test4的效果和作用在实例方法上是相同的,同样的如果synchronized里面不是this就不相同了。
二、对象的组成和Monitor
2.1、对象的组成
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)《深入理解Java虚拟机》。
2.1.1 对象头
HotSpot虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据官方称它为“Mark Word”,对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例(并不是所有的虚拟机实现都必须在对象数据上保留类型指针)。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
2.1.1.1 Mark Word
Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0。当然还有其他状态他们的布局也不仅相同。
- 锁标志位(lock)
区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。 - biased_lock
是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。 - 分代年龄(age)
表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。 - 对象的hashcode(hash)
运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。 - 偏向锁的线程ID(JavaThread):
偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。 - epoch
偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。 - ptr_to_lock_record
轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。 - ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
2.1.1.2、Klass Word
里面存的是一个地址,占32位或64位,是一个指向当前对象所属于的类的地址,可以通过这个地址获取到它的元数据信息。klass 包含类的元数据信息,像类的方法,常量池等。你可以把它当作 java 里的 java.lang.Class 对象。如果应用的对象过多,使用64位的指针将浪费大量内存。64位的JVM比32位的JVM多耗费50%的内存。 现在使用的64位 JVM会默认使用选项+UseCompressedOops 开启指针压缩,将指针压缩至32位。
2.1.2 实例数据
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
2.1.3 对齐填充
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者
2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
2.2 Monitor
我们可以把Monitor理解为一个同步工具,也可以认为是一种同步机制。它通常被描述为一个对象,所有的Java对象都是天生的Monitor,每一个Java对象都有成为Monitor的潜质。因为在Java的设计中 ,每一个Java对象都带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
三、synchronized锁的优化
高效并发是从JDK 5升级到JDK 6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。
3.1 偏向锁
偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
前面已经讲过Mark Word的结构了,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。注意这个时候一定是不需要获取Monitor对象的,有很多网上的文章讲到synchronized就会讲进入同步块就获取Monitor对象,好像synchronized和Monitor是强绑定一样,其实不是的如果不是重量级锁是不需要Monitor对象的,这点一定要能分清楚。
偏向锁的出现是有条件的首先是开启了偏向锁(-XX:+UseBiased Locking),其次由于JVM刚刚启动的时候会创建大量的类,而且虚拟机刚启动时用到的这些类大多数是竞争非常多的,那么在这个时候刚启动的时候会默认偏向失效当然这个也是可以由参数控制的-XX:BiasedLockingStartupDelay=0表示从第几ms偏向模式生效。除了虚拟机的参数用户的非锁操作也可以使偏向失效比如计算hashcode值。
偏向锁出现在同步资源只分配给一个线程的情况下,如果同一个同步资源有两个线程都会获取那么这时就会触发锁升级,如果没有竞争则升级成轻量级锁,如果有竞争且严重会从轻量级锁升级成重量级锁。
3.2 轻量级锁
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
3.2.1 加锁过程
在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。
虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态。
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
3.2.2 锁释放过程
解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
需要注意的是轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。