Java在互斥同步方面上除了提供Lock API外,还提供Synchronized关键字来实现互斥同步原语。Synchronized是jdk内建的锁同步机制,在jdk1.6版本之前,Synchronized是java实现互斥同步的唯一方式。
Synchronized的使用有三种方式,作为一个关键字,可作用于代码块、普通方法和静态方法,作用于代码块表示对当前代码块加锁,如果其他线程同时访问同一个对象该代码块,将会被阻塞;如果作用于普通方法,那么当有多个线程同时访问同一个对象的这个方法时,只有一个线程能执行该方法,其他线程将等待先前的线程释放才可以执行;如果作用于静态方法,那么如果多个线程访问该类的这个静态方法时,其他线程将等待先前的线程释放才可以执行。
接下来我们通过一个例子来看它的基本用法。
/** * v * 2020/3/17 9:23 下午 * 1.0 */ public class SynchronizedTest { public void method1() { // 作用于代码块 synchronized (this) { System.out.println("this is method1"); } } // 作用于普通方法 public synchronized void method2() { System.out.println("this is method2"); } // 作用于静态方法 public static synchronized void method3() { System.out.println("this is method3"); } public static void main(String[] args) { SynchronizedTest test = new SynchronizedTest(); test.method1(); test.method2(); SynchronizedTest.method3(); } }
由于synchronized是java内建的关键字,因为synchronized的实现原理并不能从java语言层面去分析,只能通过实现java的C语言中去分析,这里限于个人能力也没办法拿出来分享一下。不过我们可以从它的字节码来看一下,被synchronized修饰的代码块有什么不同。
这里我们看一下上面代码编译出来的字节码,主要看一下method1、method2和method3这三个方法的字节码。
// class version 52.0 (52) // access flags 0x21 public class cn/v/SynchronizedTest { // compiled from: SynchronizedTest.java ...省略 // access flags 0x1 public method1()V TRYCATCHBLOCK L0 L1 L2 null TRYCATCHBLOCK L2 L3 L2 null L4 LINENUMBER 14 L4 ALOAD 0 DUP ASTORE 1 MONITORENTER L0 LINENUMBER 15 L0 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; LDC "this is method1" INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V L5 LINENUMBER 16 L5 ALOAD 1 MONITOREXIT L1 GOTO L6 L2 FRAME FULL [cn/v/SynchronizedTest java/lang/Object] [java/lang/Throwable] ASTORE 2 ALOAD 1 MONITOREXIT L3 ALOAD 2 ATHROW L6 LINENUMBER 17 L6 FRAME CHOP 1 RETURN L7 LOCALVARIABLE this Lcn/v/SynchronizedTest; L4 L7 0 MAXSTACK = 2 MAXLOCALS = 3 // access flags 0x21 public synchronized method2()V L0 LINENUMBER 21 L0 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; LDC "this is method2" INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V L1 LINENUMBER 23 L1 RETURN L2 LOCALVARIABLE this Lcn/v/SynchronizedTest; L0 L2 0 MAXSTACK = 2 MAXLOCALS = 1 // access flags 0x29 public static synchronized method3()V L0 LINENUMBER 27 L0 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; LDC "this is method3" INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V L1 LINENUMBER 28 L1 RETURN MAXSTACK = 2 MAXLOCALS = 0 ...省略 }
我们可以看出,在method1中,被synchronized修饰的同步代码块的入口和出口,分别插入了MONITORENTOR、MONITOREXIT字节码指令。然而,在Mthod2和method3中,并没有任何特别的字节码指令对它们进行修饰,这是因为在Class文件的方法表中,将该方法的access_flag字段中的synchronized设置为1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Class作为锁对象。
在jvm中,每个对象都有一个monitor监控器,MONITORENTER主要就是尝试获取这个monitor监视器,如果成功获取monitor,就将值+1,MONITOREXIT就是在线程离开同步代码块时,对其值-1。如果线程重入,就将值再-1,synchronized是可以重入的。ReentranLock的实现原理类似,也是内部维护一个volitile int类型的变量,通过cas操作对其加一减一来表示锁的获取和释放。
我们上边说过,synchronized在修饰方法的时候,是通过在其Class文件的方法表中,将该方法的access_flag字段中的synchronized设置为1来表示该方法加锁,同时它会在常量池中增加这一标识符,获取它的monitor监视器,本质上是一样的。
通过以下命令输出一些附加信息后,可以看到metod2、method3的方法flags上,多了一个ACC_SYNCHRONIZED标签。
javap -v SynchronizedTest.class
public void method1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String this is method1 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 10: 0 line 11: 8 public synchronized void method2(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #5 // String this is method2 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 13: 0 line 14: 8 public static synchronized void method3(); descriptor: ()V flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED Code: stack=2, locals=0, args_size=0 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #6 // String this is method3 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 17: 0 line 18: 8
在jdk1.6版本之前 ,jvm对synchronized实现是需要从用户态切换到内核态的,jvm会阻塞未获取到锁的线程,在锁被释放时去唤醒被阻塞的线程。而阻塞和唤醒操作是依赖操作系统来完成的,并且monitor调用的操作系统底层的互斥量(mutex),这会造成很大的开销,因此称之为重量级锁,这就是synchronized早期实现。
Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要从用户态切换到内核态之中,因此线程的状态转换需要有消费很多的处理器时间。Synchronized重量级锁是通过操作系统互斥性实现的,互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程都需要转入内核态中完成,这些操作会给操作系统带来很大的压力。同时,虚拟机的开发团队也注意到在许多的应用上,共享数据的锁定状态中会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。因些引入自旋锁的概念,就是让后面请求锁的那个线程等待一下但不放弃处理的执行时间,看看持有锁的线程是否很快就会释放,一般的操作为让线程执行一个忙循环(自旋)。
自旋锁虽然避免了线程切换的开销,但是它要占用处理器时间。因此如果锁被占用的时间非常短,那么使用自旋锁自旋等待的效果就会很好,相反,如果锁被占用的时间非常长,使用自旋锁就会浪费额外的处理器资源。因此,自旋锁需要设置一个自旋次数,默认值为10次,用户可以通过jvm参数 -XX:PreBlockSpin来进行修改。
在jdk 1.6引入了自适应的的自旋锁,自适应表明了自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的,如果在同一锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机也会认为这次自旋也可能成功获得到锁,进而提高自旋的时间。
在jdk1.6之后,为了降低使用synchronized时的性能损耗,引入轻量级锁的概念,也就是在实际没有锁竞争的情况下,将申请互斥量的这步操作省略。而轻量级的实现原理就是将对象头的Mark Word中后2个bit设置为00的标志位来进行控制,标志位的设置是通过cas原理来进行操作的。如果当前线程获取到对象的锁,那么会将该标志位置为00,同时在当前线程的栈帧中开辟出一块名为『锁记录』内存空间,用于存储Mark Word信息的拷贝,然后将对象头中原来存储Mark Word的区域通过CAS操作更新为指向锁记录的指针,即pointer to lock record,如果操作成功,说明当前线程获取锁成功。
上面说的是轻量级锁的加锁过程,它的解锁过程同样是通过通过CAS操作来进行操作的,操作过程与加锁相反,就是将存储在线程锁的Mark Word信息重新拷贝到对象头(java对象头的结构在后面会进行说明)的Mark Word中,如果拷贝完成,那么整个同步的过程就完成了,如果替换失败,那么说明当前有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
注意:轻量级锁能提升程序同步性能的前提条件是,对于绝大部分锁,在整个同步周期内都是不存在锁竞争的。如果没有锁竞争,就避免了使用操作系统互斥量的损耗,如果存放锁竞争,除了互斥量的损耗外,还进行CAS操作的额外开销,这种情况性能明显不如重量级锁。
为了进一步优化synchronized的实现,提出了偏向锁的概念,目的是消耗数据在无竞争情况下的同步原语,即将所有同步操作全部省略。它的实现与轻量级锁一样,通过对象头的Mark word中的后2个bit的标志位进行实现,不不同的是它的标志位为01。如果开启偏向锁的,对象头中Mark word的数据结构将是另一种实现,它会保存thread ID和epoch,即持有偏向锁的线程id和偏向时间戳。在未获取到偏向锁之前,它的threadId为0,当前进入同步代码块时,将通过cas进行操作标志位设置为01,同时将当前线程的id记录在Mark Word的Thread ID位置中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步代码块时,虚拟机都可以不再进行任务同步操作(locking、unlocking和Mark Word的update)。
如果当另外一个线程去尝试获取这个锁时,偏向模式就宣告失结束。根据锁对象目前是否处理被锁定的状态,撤销偏向锁后恢复到未锁定(标志位为01)或轻量级锁的状态,后续的同步操作就如上面的轻量级锁那样执行。
注意:偏向锁可以提高带有同步但无竞争的程序性能,它同样是一个带有效益权衡性质的优化,如果程序中有大多数的锁总是被多个不同的线程访问,那使用偏向锁会造成更多额外的开销,比如偏向锁的撤销,锁升级。
在Hotspot虚拟机中,对象在内容中存储的布局可以分为3块区块:对象头,实例数据和对齐填充。而对象头又包含两部分信息,第一部分是存储自身的运行时数据,如哈希码,GC分代年龄,锁状态标志位,线程持有的锁、偏向线程ID、偏向时间戳等,这一部分称之为"Mark Word"。由于对象需要存储的运行时数据很多,但对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计为一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的空间。
比如,在不同的状态(未锁定、轻量级锁、重量级锁、GC标记、可偏向/偏向锁)下的Mark Word的存储内容将会不一样。
对象头的另一部分是指向对象的类元数据的指针,虚拟机通过这个指针来确定当前对象是哪个类的实例。并不是所有的虚拟机实现都需要在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要通过对象本身(这是由对象访问定位的方式来决定的:使用句柄和直接指针,hotspot使用后者)。
Mark Word在不同状态下数据结构的变化可以参考下图
完整的对象头如下图所示,包含Mark Word和Klass pointer
本文为学习周志明老师著作《深入理解JVM虚拟机》的学习心得。