在jdk1.6之前,synchronized是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁都会带来 用户态和内核态的切换 ,从而增加系统的 性能开销 。在锁竞争激烈的情况下,synchronized同步锁的性能很糟糕。 JDK 1.6 ,Java对synchronized同步锁做了 充分的优化 ,甚至在某些场景下,它的性能已经超越了Lock同步锁
我们先来讲解synchronized关键字的底层原理,再讲解一下应用。
synchronized 在 JVM 的实现原理是基于进入和退出管程(Monitor)对象来实现同步。但 synchronized 关键字实现同步代码块和同步方法的细节不一样,代码块同步是使用 monitorenter 和 monitorexit 指令实现的,方法同步通过调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标志来隐式实现的。
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
Java对象头是synchronized实现的关键,synchronized用的锁是存在Java对象头里的。
synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字宽(一个字宽代表4个字节,一个字节8bit)来存储对象头(如果对象是数组则会分配3个字宽,多出来的1个字宽记录的是数组长度)。其主要结构是由 Mark Word 和 Class Metadata Address 组成。
虚拟机位数 | 对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
32/64bit | Array length | 数组的长度(如果当前对象是数组) |
其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等。Mark Word在不同的锁状态下存储的内容不同,在32位JVM中默认状态为下:
锁状态 | 25 bit | 4 bit | 1 bit是否是偏向锁 | 2 bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
运行期间,Mark Word里存储的数据随锁标志位的变化而变化,可能存在如下4种数据。
上面说到 JVM 基于进入和退出Monitor对象来实现方法同步和代码块同步。
Monitor(监视器锁)本质是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。Mutex Lock 的切换需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中的一个重量级操作。
public class SynTest{ public int i; public void syncTask(){ synchronized (this){ i++; } } } 复制代码
D:/Desktop>javap SynTest.class Compiled from "SynTest.java" public class SynTest { public int i; public SynTest(); public void syncTask(); } D:/Desktop>javap -c SynTest.class Compiled from "SynTest.java" public class SynTest { public int i; public SynTest(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public void syncTask(); Code: 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: aload_0 5: dup 6: getfield #7 // Field i:I 9: iconst_1 10: iadd 11: putfield #7 // Field i:I 14: aload_1 15: monitorexit 16: goto 24 19: astore_2 20: aload_1 21: monitorexit 22: aload_2 23: athrow 24: return Exception table: from to target type 4 16 19 any 19 22 19 any } 复制代码
关注 monitorenter 和 monitorexit:
3: monitorenter //省略 15: monitorexit 16: goto 24 //省略 21: monitorexit 复制代码
从字节码中可知同步语句块的实现使用的是 monitorenter 和 monitorexit 指令
public class SynTest{ public int i; public synchronized void syncTask(){ i++; } } 复制代码
反编译: javap -verbose -p SynTest
Classfile /D:/Desktop/SynTest.class Last modified 2020年4月2日; size 278 bytes SHA-256 checksum 0e7a02cd496bdaaa6865d5c7eb0b9f4bfc08a5922f13a585b5e1f91053bb6572 Compiled from "SynTest.java" public class SynTest minor version: 0 major version: 57 flags: (0x0021) ACC_PUBLIC, ACC_SUPER this_class: #8 // SynTest super_class: #2 // java/lang/Object interfaces: 0, fields: 1, methods: 2, attributes: 1 Constant pool: #1 = Methodref #2.#3 // java/lang/Object."<init>":()V #2 = Class #4 // java/lang/Object #3 = NameAndType #5:#6 // "<init>":()V #4 = Utf8 java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Fieldref #8.#9 // SynTest.i:I #8 = Class #10 // SynTest #9 = NameAndType #11:#12 // i:I #10 = Utf8 SynTest #11 = Utf8 i #12 = Utf8 I #13 = Utf8 Code #14 = Utf8 LineNumberTable #15 = Utf8 syncTask #16 = Utf8 SourceFile #17 = Utf8 SynTest.java { public int i; descriptor: I flags: (0x0001) ACC_PUBLIC public SynTest(); descriptor: ()V flags: (0x0001) ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 1: 0 public synchronized void syncTask(); descriptor: ()V flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=3, locals=1, args_size=1 0: aload_0 1: dup 2: getfield #7 // Field i:I 5: iconst_1 6: iadd 7: putfield #7 // Field i:I 10: return LineNumberTable: line 5: 0 line 6: 10 } SourceFile: "SynTest.java" 复制代码
注意: flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
JVM可以从方法常量池中的方法表结构(method_info Structure) 中的ACC_SYNCHRONIZED访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。
上面讲解了,synchronized 在开始的时候是依靠操作系统的互斥锁来实现的,是个重量级操作,为了减少获得锁和释放锁带来的性能消耗,在 JDK 1.6中,引入了偏向锁和轻量级锁。锁一共有4中状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几种状态会随着竞争情况逐渐升级,但不能降级,目的是为了提高锁和释放锁的效率。
大部分情况下,锁不存在多线程竞争,偏向锁就是为了在只有一个线程执行同步块时提高性能。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
其实就是上面锁获得过程的四五步。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(这个时间点没有正在执行的字节码)。
到全局安全点后,先暂停拥有偏向锁的线程,检查该线程是否或者。
不活动或已经退出代码块,则对象头设置为无锁状态,然后重新偏向新的线程。
如果仍然活着,则遍历线程栈中所有的 Lock Record,如果能找到对应的 Lock Record 说明偏向的线程还在执行同步代码块中的代码。需要升级为轻量级锁,直接修改偏向线程栈中的Lock Record。
此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
在 《Java并发编程的艺术》 书中这一部分是这样说的:
当一个线程访问同步块并获得锁时,会在对象头和栈帧中锁记录存储锁偏向的 线程ID ,以后该线程在进入退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获得了锁。如果失败,则需要再测试 Mark Word 中偏向锁的标识是否设置为 1(表示当前是偏向锁),如果没有设置,则使用 CAS 竞争锁,如果设置了,则尝试 CAS 将对象头的偏向锁指向当前线程。
个人觉得这一部分书中似乎稍微有点出入,我查看了很多博客,正常按逻辑分析的话,应该也是先判断 锁标志位 ,判断出现在锁的状态,而不是先判断锁的线程ID是否指向自己。
mark word
设置到 Lock Record
中去,我们称 Lock Record
中存储对象 mark word
的字段叫Displaced Mark Word。 重量级锁的上锁过程参考上面步骤 4 ,轻量级锁膨胀为重量级锁,Mark Word的锁标记位更新为10,Mark Word 指向互斥量(重量级锁)。
Synchronized 的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的,文章开头有讲解。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。
Java中每一个对象都可以作为锁,具体表现为如下三种形式:
简单地说,synchronized修饰,表现为两种锁,一种是对调用该方法的对象加锁,俗称对象锁或实例锁,另一种是对该类对象加锁,俗称类锁。
Java 中每个对象都有一个锁,并且是唯一的。假设分配的一个对象空间,里面有多个方法,相当于空间里面有多个小房间,如果我们把所有的小房间都加锁,因为这个对象只有一把钥匙,因此同一时间只能有一个人打开一个小房间,然后用完了还回去,再由 JVM 去分配下一个获得钥匙的人。
同一个对象在两个线程中分别访问该对象的两个同步方法
不同对象在两个线程中调用同一个同步方法
第一个问题,因为锁针对的是对象,当对象调用一个synchronized方法时,其他同步方法需要等待其执行结束并释放锁后才能执行。
第二个问题,因为是两个对象,锁针对的是对象,并不是方法,所以可以并发执行,不会互斥。形象的来说就是因为我们每个线程在调用方法的时候都是new 一个对象,那么就会出现两个空间,两把钥匙。
类对象只有一个,可以理解为任何时候都只有一个空间,里面有N个房间,一把锁,一把钥匙。
因为对静态对象加锁实际上对类(.class)加锁,类对象只有一个,可以理解为任何时候都只有一个空间,里面有N个房间,一把锁,因此房间(同步方法)之间一定是互斥的。因为是一个对象调用,所以,1、2都会互斥。
第三个问题,因为虽然是一个对象调用,但是两个方法的锁类型不同,调用的静态方法实际上是类对象在调用,即这两个方法产生的并不是同一个对象锁,因此不会互斥,会并发执行。
锁消除即删除不必要的加锁操作。虚拟机即时编辑器在运行时,对一些“代码上要求同步,但是被检测到不可能存在共享数据竞争”的锁进行消除。
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用 -XX:+UseSpinning 开启;在JDK1.6中默认开启。
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。
JDK1.6 引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在 java 中 synchronized 是基于原子性的内部锁机制,是可重入的,因此在一个线程调用 synchronized 方法的同时在其方法体内部调用该对象另一个 synchronized 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是 synchronized 的可重入性。
synchronized特点:保证内存可见性、操作原子性。在经过jdk6的优化,synchronized 的性能其实不必 JVM 实现的 Reentrantlock 差,甚至有的时候比它更优秀,这也是 Java concurrent 包下很多类的原理都是基于 synchronized 实现的原因。