【Java多线程】synchronized关键字
synchronized关键字的实现
我们先来看看synchronized的三个常用方法:
- 普通同步方法,锁是当前实例;
- 静态同步方法,锁是当前类的Class实例,Class数据存在永久代中,是该类的一个全局锁;
- 对于同步代码块,锁是synchronized括号里配置的对象。
Java中的每个对象都可以作为锁。当一个线程访问同步代码块时,需要首先获取锁,退出代码块或抛出异常时必须释放锁。
通过反汇编来看看锁是怎么实现的:
- 同步代码块
public class SynchronizedBlock { public void method() { synchronized (this) { System.out.println("..."); } System.out.println("..."); } }
反汇编后的代码(javap -c):
public class SynchronizedBlock { // Method descriptor #6 ()V // Stack: 1, Locals: 1 public SynchronizedBlock(); 0 aload_0 [this] 1 invokespecial java.lang.Object() [8] 4 return Line numbers: [pc: 0, line: 5] Local variable table: [pc: 0, pc: 5] local: this index: 0 type: SynchronizedBlock // Method descriptor #6 ()V // Stack: 2, Locals: 2 public void method(); 0 aload_0 [this] 1 dup 2 astore_1 3 monitorenter //在同步块开始位置插入monitorenter指令 4 getstatic java.lang.System.out : java.io.PrintStream [15] 7 ldc <String "..."> [21] 9 invokevirtual java.io.PrintStream.println(java.lang.String) : void [23] 12 aload_1 13 monitorexit //在同步块结束位置插入 14 goto 20 17 aload_1 18 monitorexit //在抛出异常位置释放锁 19 athrow //抛出异常指令 20 getstatic java.lang.System.out : java.io.PrintStream [15] 23 ldc <String "..."> [21] 25 invokevirtual java.io.PrintStream.println(java.lang.String) : void [23] 28 return Exception Table: [pc: 4, pc: 14] -> 17 when : any [pc: 17, pc: 19] -> 17 when : any Line numbers: [pc: 0, line: 7] [pc: 4, line: 8] [pc: 12, line: 7] [pc: 20, line: 10] [pc: 28, line: 11] Local variable table: [pc: 0, pc: 29] local: this index: 0 type: SynchronizedBlock Stack map table: number of frames 2 [pc: 17, full, stack: {java.lang.Throwable}, locals: {SynchronizedBlock, SynchronizedBlock}] [pc: 20, chop 1 local(s)] }
同步方法
public class SynchronizedMethod { //普通同步方法 public synchronized void method1() { System.out.println("..."); } //静态同步方法 public synchronized static void method2() { System.out.println("..."); } }
反汇编后(javap -v):
public class SynchronizedMethod SourceFile: "SynchronizedMethod.java" minor version: 0 major version: 51 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Class #2 // SynchronizedMethod #2 = Utf8 SynchronizedMethod #3 = Class #4 // java/lang/Object #4 = Utf8 java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Methodref #3.#9 // java/lang/Object."<init>":()V #9 = NameAndType #5:#6 // "<init>":()V #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 LSynchronizedMethod; #14 = Utf8 method1 #15 = Fieldref #16.#18 // java/lang/System.out:Ljava/io/PrintStream; #16 = Class #17 // java/lang/System #17 = Utf8 java/lang/System #18 = NameAndType #19:#20 // out:Ljava/io/PrintStream; #19 = Utf8 out #20 = Utf8 Ljava/io/PrintStream; #21 = String #22 // ... #22 = Utf8 ... #23 = Methodref #24.#26 // java/io/PrintStream.println:(Ljava/lang/String;)V #24 = Class #25 // java/io/PrintStream #25 = Utf8 java/io/PrintStream #26 = NameAndType #27:#28 // println:(Ljava/lang/String;)V #27 = Utf8 println #28 = Utf8 (Ljava/lang/String;)V #29 = Utf8 method2 #30 = Utf8 SourceFile #31 = Utf8 SynchronizedMethod.java { public SynchronizedMethod(); flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #8 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this LSynchronizedMethod; public synchronized void method1(); flags: ACC_PUBLIC, ACC_SYNCHRONIZED //同步方法增加了ACC_SYNCHRONIZED标志 Code: stack=2, locals=1, args_size=1 0: getstatic #15 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #21 // String ... 5: invokevirtual #23 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 6: 0 line 7: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 this LSynchronizedMethod; public static synchronized void method2(); flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED Code: stack=2, locals=0, args_size=0 0: getstatic #15 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #21 // String ... 5: invokevirtual #23 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 10: 0 line 11: 8 LocalVariableTable: Start Length Slot Name Signature }
通过反汇编代码可以观察到:
同步代码块是使用MonitorEnter和MoniterExit指令实现的,在编译时,MonitorEnter指令被插入到同步代码块的开始位置,MoniterExit指令被插入到同步代码块的结束位置和异常位置。任何对象都有一个Monitor与之关联,当Monitor被持有后将处于锁定状态。MonitorEnter指令会尝试获取Monitor的持有权,即尝试获取锁。
同步方法依赖flags标志ACC_SYNCHRONIZED实现,字节码中没有具体的逻辑,可能需要查看JVM的底层实现(同步方法也可以通过Monitor指令实现)。ACC_SYNCHRONIZED标志表示方法为同步方法,如果为非静态方法(没有ACC_STATIC标志),使用调用该方法的对象作为锁对象;如果为静态方法(有ACC_STATIC标志),使用该方法所属的Class类在JVM的内部对象表示Klass作为锁对象。
下面是摘自《Java虚拟机规范》的话:
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有管程,然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获得同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。
同步一段指令集序列通常是由Java语言中的synchronized块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要编译器与Java虚拟机两者协作支持。
Java虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现。无论是显式同步(有明确的monitorenter和monitorexit指令)还是隐式同步(依赖方法调用和返回指令实现的)都是如此。
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须有执行其对应monitorexit指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令。
Monitor是什么?一个制作萎缩的对象是怎么工作的?
Java对象头:
对象头含有三部分:Mark Word(存储对象自身运行时数据)、Class Metadata Address(存储类元数据的指针)、Array length(数组长度,只有数组类型才有)。
重点在Mark Word部分,Mark Word数据结构被设计成非固定的,会随着对象的不同状态而变化,如下表所示。
锁的级别从低到高:无锁、偏向锁、轻量级锁、重量级锁。Monitor
Monitor可以理解为一种同步工具,也可理解为一种同步机制,常常被描述为一个Java对象。Monitor Record是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。1)互斥:一个Monitor在一个时刻只能被一个线程持有,即Monitor中的所有方法都是互斥的。
这就是说,synchronized方法执行的时候,synchronized方法影响的范围并不是单单方法本身,而是这个类中所有带有synchronized 的方法,synchronized线程都会等待其执行完成。这样子a调用了b,而b又要等待a执行完成才能执行,造成了死锁。
多个线程访问同一个类的synchronized方法时, 都是串行执行的 ! 就算有多个cpu也不例外 ! synchronized方法使用了类java的内置锁, 即锁住的是方法所属对象本身. 同一个锁某个时刻只能被一个执行线程所获取, 因此其他线程都得等待锁的释放. 因此就算你有多余的cpu可以执行, 但是你没有锁, 所以你还是不能进入synchronized方法执行, CPU因此而空闲. 如果某个线程长期持有一个竞争激烈的锁, 那么将导致其他线程都因等待所的释放而被挂起, 从而导致CPU无法得到利用, 系统吞吐量低下. 因此要尽量避免某个线程对锁的长期占有 !
不信?我们进一段代码:
class ShareData { public static synchronized void methodA() throws InterruptedException { System.out.println("start do A..."); TimeUnit.SECONDS.sleep(10L); } public static synchronized void methodB() throws InterruptedException { System.out.println("start do B..."); TimeUnit.SECONDS.sleep(10L); } } public class test { public static void main(String[] args) throws InterruptedException { ShareData.methodA(); ShareData.methodB(); } }
2)signal机制:如果条件变量不满足,允许一个正在持有Monitor的线程暂时释放持有权,当条件变量满足时,当前线程可以唤醒正在等待该条件变量的线程,然后重新获取Monitor的持有权。
所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高。
jdk1.5后的锁升级
偏向锁:
所谓的偏向锁是指在对象实例的Mark Word(说白了就是对象内存中的开头几个字节保留的信息,如果把一个对象序列化后明显可以看见开头的这些信息),为了在线程竞争不激烈的情况下,减少加锁及解锁的性能损耗(轻量级锁涉及多次CAS操作)。Jvm会在锁对象的Mark Word中把标志位设为01(当然是使用cas操作),并保存这上次使用这个对象锁的线程ID信息,如果这个线程再次请求这个对象锁,那么只需要读取该对象上的Mark Word的偏向锁信息(也就是线程id)跟线程本身的id进行对比,如果是同一个id就直接认为该id获得锁成功,而不需要在进行真正的加解锁操作。其实说白了就是你上次来过了,这次又来,并且在这两次之间没有其他人来过,对于这个线程来说,锁对象的资源随便用都是安全的。这次用缓存来换取性能的做法,不过偏向锁在锁竞争不激烈的情景下使用才能获取比较高的效率。当使用CAS竞争偏向锁失败(表示有超过1个线程尝试获取对象的锁),表示竞争比较激烈,偏向锁升级为轻量级锁,标志位设为00。
轻量级锁:
所谓轻量级锁是比偏向锁更耗资源的锁,实现机制是,线程在竞争轻量级锁前,在线程的栈内存中分配一段空间作为锁记录空间(就是轻量级锁对应的对象的对象头的字段的拷贝),拷贝好后,线程通过CAS去竞争这个对象锁,试图把对象的对象头字段改成指向锁记录空间,如果成功则说明获取轻量级锁成功,如果失败,则进入自旋(说白了就是循环)取试着获取锁。如果自旋到一定次数还是不能获取到锁(竞争的线程太多了),则升级为重量级锁。- 自旋锁:
说白了就是获取锁失败后,为了避免直接让线程进入阻塞状态而采取的循环一定次数去试着获取锁的行为。(线程进入阻塞状态和退出阻塞状态是涉及到操作系统管理层面的,需要从用户态进入内核态,非常消耗系统资源),为什么能这样做呢,是因为试验证明,锁的持有时间一般是非常短的,所以一般多次尝试就能竞争到锁。
- 自旋锁:
重量级锁:
所谓的重量级锁,其实就是最原始和最开始java实现的阻塞锁。在JVM中又叫对象监视器。这时的锁对象的对象头字段指向的是一个互斥量,所有线程竞争重量级锁,竞争失败的线程进入阻塞状态(操作系统层面),并且在锁对象的一个等待池中等待被唤醒,被唤醒后的线程再次去竞争锁资源。总结:所谓的锁升级,其实就是从偏向锁->轻量级锁(自旋锁)->重量级锁,之前一直被这几个概念困扰,网上的 文章解释的又不通俗易懂,其实说白了,一切一切的开始源于java对synchronized同步机制的性能优化,最原始的synchronized同步机制是直接跳过前几个步骤,直接进入重量级锁的,而重量级锁因为需要线程进入阻塞状态(从用户态进入内核态)这种操作系统层面的操作非常消耗资源,这样的话,synchronized同步机制就显得很笨重,效率不高。那么为了解决这个问题,java才引入了偏向锁,轻量级锁,自旋锁这几个概念。拿这几个锁有何优化呢?网上也没有通俗易懂的解释,其实说白了就是,偏向锁是为了避免CAS操作,尽量在对比对象头就把加锁问题解决掉,只有冲突的情况下才指向一次CAS操作,而轻量级锁和自旋锁呢,其实两个是一体使用的,为的是尽量避免线程进入内核的阻塞状态,这对性能非常不利,试图用CAS操作和循环把加锁问题解决掉,而重量级锁是最终的无奈解决方案,说白了就是能通过内存读取判断解决加速问题优于〉通过CAS操作和空循环优于〉CPU阻塞,唤醒线程。
那么synchronized和Lock有什么区别呢
- 原始构成
synchronized是关键字,属于JVM层面,monitorenter和monitorexit(底层通过monitor对象来完成,其实wait/nodify等方法也依赖于monitor对象,只有在同步块或方法中才能调用)。
Lock是具体类(java.util.concurrent,locks,Lock)是APi层面的锁。 - 使用方法
synchronized不需要用户手动释放锁,当synchronized代码执行完后系统会自动释放对锁的占用。
实现Lock接口的锁需要显式地获取和释放锁,如果不释放,可能导致死锁。 - 等待是否可中断
synchronized不可中断,除非正常运行完成或者抛出异常。
实现Lock接口的锁可中断,你可以通过- 设置超时方法tryLock(long timeout, TimeUnit unit)
- lockInterruptibly()放入代码块中,调用interrupt()方法就可中断
- 是否支持公平锁,是否可重入
synchronized是非公平锁,可重入。
实现Lock接口的锁如最常见的可重入锁ReentrantLock,既可声明为公平锁,也可以声明为非公平锁(默认)。它显然是可重入锁。 - 是否可以绑定多个Condition
synchronized不可以,wait/nodify/nodifyAll只能随缘唤醒或者唤醒全部线程。
ReentrantLock可以实现精准唤醒,你只需要Condition con = lock.newCondition;
甚至可以声明多个Condition,就像Doug Lea在阻塞队列中的实现一样。