Synchronized作用以及实现原理
Synchronized基本原理
Synchronized是java提供的一个关键字,其作用是保证程序在执行的时候,有且只有一个线程同时在同步代码块里面执行。其实现的根本依据是JMM的先行发生原则中的管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。
Synchronized的三种实现方式
-
修饰静态方法代码及其字节码:
public static synchronized void test() { System.out.println("hello world"); }
public static synchronized void test(); descriptor: ()V flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED Code: stack=2, locals=0, args_size=0 0: getstatic #2 3: ldc #3 5: invokevirtual #4 8: return LineNumberTable: line 6: 0 line 7: 8
-
修饰实例方法代码及其字节码:
public synchronized void test() { System.out.println("hello world"); }
public synchronized void test(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic #2 3: ldc #3 5: invokevirtual #4 8: return LineNumberTable: line 6: 0 line 7: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 this Lcom/TestSynchronized;
-
修饰代码块代码及其字节码:
public void test() { synchronized (this) { System.out.println("hello world"); } }
public void test(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: getstatic #2 7: ldc #3 9: invokevirtual #4 12: aload_1 13: monitorexit 14: goto 22 17: astore_2 18: aload_1 19: monitorexit 20: aload_2 21: athrow 22: return .........
可以明显看到三种字节码不同的是,修饰代码块方式生成的字节码很长,但是没有ACC_SYNCHRONIZED这个flag。修改方法生成的字节码很短,具有ACC_SYNCHRONIZED这个flag,但是没有monitorenter和monitorexit这两个指令。
那我们可以猜想,synchronized在底层的实现,就是通过monitorenter和monitorexit这两个指令实现的,而方法同步虽然是通过另外的方式(flag)去实现的,但是其最终还是通过monitorenter和monitorexit这两个指令实现的。需要注意的是,一个monitorenter为什么会对应着两个monitorexit呢?这是因为如果代码没有显示有try-catch的时候,编译的时候会自动生成try-catch,然后在抛出异常之前,执行一次monitorexit,避免在运行同步代码块的时候发生异常而没有释放锁。
Synchronized实现对应的数据结构
-
对象头(Header)
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
synchronized用的锁是存在java对象头里面的,而java对象头由mark word、指向类的指针和数组长度(如果当前对象为数组)三种数据类型所组成,其结构图如下(来自《java并发编程的艺术》):
长度 内容 说明 32/64bit Mark Word 存储对象的hashCode或锁信息 32/64bit Class Metadata Address 存储到对象类型Class的数据指针(标志是哪个类的实例) 32/32bit Array length 数组的长度(如果当前对象为数组) -
Mark Word
先看一个Mark Word里面的内容图,图片来自网上。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3NBoxUHl-1571714821501)(image/synchronized/mark_word.png)]
可以看出来,Mark Word里面保存的是当前对象的一些基础信息。例如默认情况下(无锁)存储着对象的hashCode、分代年龄。在有锁的情况下,则根据具体的锁不同,记录的是不同的信息。
-
对象监视器(ObjectMonitor)
对象监视器可以理解为一个同步工具,或者是提供同步信息的一个对象。对象监视器里面的信息,除了记录对象信息的对象头(Header)之外,还有各种记录,例如等待锁的线程数、拥有当前锁对象的线程ID、陷入等待的线程队列、陷入锁竞争的阻塞队列等等。ObjectMonitor在JVM源码中的结构如下图:
锁类型以及锁升级
-
自旋锁
自旋锁(spin lock)是一种非阻塞锁,也就是说,如果某线程需要获取锁,但该锁已经被其他线程占用时,该线程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取锁。自旋锁并不是一种实际的锁,他更像是一种操作,嵌套在synchronized获得锁的过程中的操作。比如在轻量级锁里面,如果竞争不到锁,会用自旋锁+CAS重复竞争锁,多次竞争不到的时候再升级为重量级锁。
-
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一条线程获得。为了降低锁代价,所以引入了偏向锁。顾名思义就是锁偏向于某个线程。当线程访问同步块并获取锁,会在对象头和栈帧的锁记录里面存储(CAS操作)偏向锁的线程ID,以后该线程进入和退出同步块的时候,不用重新获取和释放锁,直到有新的线程过来竞争锁,持有偏向锁的线程才会释放锁。需要注意的是,持有偏向锁的线程在释放锁的时候,线程会进入暂停。另外只要一旦两个线程在偏向锁中陷入竞争,锁就会升级为轻量级锁。
获取和撤销流程图如下,图片来自《java并发编程的艺术》。
-
轻量级锁
引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。
轻量级锁的获取,线程在执行同步块之前,JVM会在当前线程的栈帧中创建用于存储Mark Word的空间,并把当前对象头的Mark Word复制到锁记录中。然后线程尝试CAS操作将对象头中的Mark Word替换为指向锁记录的指针。如果成功,则表示获得锁,反之尝试自旋来获取锁。当解锁时,会使用CAS操作将复制到栈帧中的Mark Word替换回到对象头,如果替换成功,则成功释放锁。如果替换失败,则表示锁存在竞争。锁就会膨胀为重量级锁。如下图,图片来自《java并发编程的艺术》。
-
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。在hotspot虚拟机中,通过ObjectMonitor类来实现monitor。他的锁的获取过程的体现会简单很多。简略流程图如下:
-
锁的优缺点对比
锁 优 点 缺 点 适 用 场 景 偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅仅存在纳秒级别的差距。 如果线程之间存在锁竞争,会带来额外的锁撤销的消耗。 适用于几乎只有一个线程访问的同步块场景。 轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,会因为自旋而消耗CPU 追求响应时间,同步块执行速度较快 重量级锁 线程竞争不用自旋,不会小号CPU 线程阻塞,响应时间缓慢,用户态和内核态之间切换带来额外开销 追求吞吐量,同步块执行时间较长
锁粗化
简单来讲,锁粗化,就是把多个可以合并的锁,合并成一把锁,扩大同一把锁的作用域。例如在for循环里面的锁,在编译的时候会把锁粗化到for循环之外。
例如粗化之前的代码:
public void doSomethingMethod(){
for(int i=0;i<100;i++){
synchronized(lock){
}
}
}
粗化之后的代码:
public void doSomethingMethod(){
synchronized(lock){
for(int i=0;i<100;i++){
}
}
}
锁消除
当编译器在编译的时候,检测到某些地方有加锁操作,但是该操作又不会产生同步问题的时候,就会把该锁去除掉。
例如下面这个例子,作为锁对象的是方法内部临时对象,也就是说这个对象不会逃逸到外部,所以不会有其他线程持有这个临时对象的锁。也就是说,这里所加上的锁是不必要的,在编译的时候,会把这把锁消除。
public void doSomethingMethod(){
Object lock = new Object();
synchronized(lock){
}
}
wait/notify的原理
调用wait方法,首先会获取监视器锁,获得成功以后,会让当前线程进入等待状态进入等待队列并且释放锁;然后
当其他线程调用notify或者notifyall以后,会选择从等待队列中唤醒任意一个线程,而执行完notify方法以后,并不会立马唤醒线程,而是把线程加入_chq或者 _EntryList队列,直到执行notify的线程执行了monitorexit释放锁之后,加入同步阻塞队列的线程才开始和其他处于阻塞等待的线程一起竞争锁,并且notify唤醒的线程没有竞争锁的优先级。
为什么wait/notify要在synchronized里面
wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列, 而这些操作都和监视器是相关(阻塞队列在ObjectMonitor里面),所以wait必须要获得一个监视器锁。而对于notify来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它要唤醒的线程在哪个监视器的等待队列里面。所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。