synchronized
是 Java 编程中的一个重要的关键字,也是多线程编程中不可或缺的一员。本文就对它的使用和锁的一些重要概念进行分析。
使用及原理
synchronized 是一个重量级锁,它主要实现同步操作,在 Java 对象锁中有三种使用方式:
- 普通方法中使用,锁是当前实例对象。
- 静态方法中使用,锁是当前类的对象。
- 代码块中使用,锁是代码代码块中配置的对象。
使用
在代码中使用方法分别如下:
普通方法使用:
/**
* 公众号:ytao
* 博客:https://ytao.top
*/
public class SynchronizedMethodDemo{
public synchronized void demo(){
// ......
}
}
静态方法使用:
/**
* 公众号:ytao
* 博客:https://ytao.top
*/
public class SynchronizedMethodDemo{
public synchronized static void staticDemo(){
// ......
}
}
代码块中使用:
/**
* 公众号:ytao
* 博客:https://ytao.top
*/
public class SynchronizedDemo{
public void demo(){
synchronized (SynchronizedDemo.class){
// ......
}
}
}
实现原理
方法和代码块的实现原理使用不同方式:
代码块
每个对象都拥有一个monitor
对象,代码块的{}
中会插入monitorenter
和monitorexit
指令。当执行monitorenter
指令时,会进入monitor
对象获取锁,当执行monitorexit
命令时,会退出monitor
对象释放锁。同一时刻,只能有一个线程进入在monitorenter
中。
先将SynchronizedDemo.java
使用javac SynchronizedDemo.java
命令将其编译成SynchronizedDemo.class
。然后使用javap -c SynchronizedDemo.class
反编译字节码。
Compiled from "SynchronizedDemo.java"
public class SynchronizedDemo {
public SynchronizedDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public void demo();
Code:
0: ldc #2 // class SynchronizedDemo
2: dup
3: astore_1
4: monitorenter // 进入 monitor
5: aload_1
6: monitorexit // 退出 monitor
7: goto 15
10: astore_2
11: aload_1
12: monitorexit // 退出 monitor
13: aload_2
14: athrow
15: return
Exception table:
from to target type
5 7 10 any
10 13 10 any
}
上面反编码后的代码,有两个monitorexit
指令,一个插入在异常位置,一个插入在方法结束位置。
方法
方法中的synchronized
与代码块中实现的方式不同,方法中会添加一个叫ACC_SYNCHRONIZED
的标志,当调用方法时,首先会检查是否有ACC_SYNCHRONIZED
标志,如果存在,则获取monitor
对象,调用monitorenter
和monitorexit
指令。
通过javap -v -c SynchronizedMethodDemo.class
命令反编译SynchronizedMethodDemo
类。-v
参数即-verbose
,表示输出反编译的附加信息。下面以反编译普通方法为例。
Classfile /E:/SynchronizedMethodDemo.class
Last modified 2020-6-28; size 381 bytes
MD5 checksum 55ca2bbd9b6939bbd515c3ad9e59d10c
Compiled from "SynchronizedMethodDemo.java"
public class SynchronizedMethodDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#13 // java/lang/Object."":()V
#2 = Fieldref #14.#15 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #16.#17 // java/io/PrintStream.println:()V
#4 = Class #18 // SynchronizedMethodDemo
#5 = Class #19 // java/lang/Object
#6 = Utf8
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 demo
#11 = Utf8 SourceFile
#12 = Utf8 SynchronizedMethodDemo.java
#13 = NameAndType #6:#7 // "":()V
#14 = Class #20 // java/lang/System
#15 = NameAndType #21:#22 // out:Ljava/io/PrintStream;
#16 = Class #23 // java/io/PrintStream
#17 = NameAndType #24:#7 // println:()V
#18 = Utf8 SynchronizedMethodDemo
#19 = Utf8 java/lang/Object
#20 = Utf8 java/lang/System
#21 = Utf8 out
#22 = Utf8 Ljava/io/PrintStream;
#23 = Utf8 java/io/PrintStream
#24 = Utf8 println
{
public SynchronizedMethodDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 5: 0
public synchronized void demo();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标志
Code:
stack=1, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: invokevirtual #3 // Method java/io/PrintStream.println:()V
6: return
LineNumberTable:
line 8: 0
line 10: 6
}
SourceFile: "SynchronizedMethodDemo.java"
上面对代码块和方法的实现方式进行探究:
- 代码块通过在编译后的代码中添加
monitorenter
和monitorexit
指令。 - 方法中通过添加
ACC_SYNCHRONIZED
标志,来决定是否调用monitor
对象。
Java 对象头
synchronized
锁的相关数据存放在 Java 对象头中。Java 对象头指的 HotSpot 虚拟机的对象头,使用2个字宽或3个字宽存储对象头。
- 第一部分存储运行时的数据,hashCode、锁标记位、是否偏向锁、GC分代年龄等等信息,称作为
Mark Word
。 - 第二部分存储对象类型数据的指针。
- 第三部分,如果对象是数组的话,则用这部分来存储数组长度。
Java 对象头 Mark Word 存储内容:
存储内容 | 标志位 | 状态 |
---|---|---|
对象的hashCode、GC分代年龄 | 01 | 无锁 |
指向栈中锁记录的指针 | 00 | 轻量级锁 |
指向重量级锁的指针 | 10 | 重量级锁 |
空 | 11 | GC标记 |
线程ID、Epoch(一个时间戳)、GC分代年龄 | 01 | 偏向锁 |
锁升级
synchronized 称为重量级锁,但 Java SE 1.6 为优化该锁的性能而减少获取和释放锁的性能消耗,引入偏向锁
和轻量级锁
。
锁的高低级别为:无锁
→偏向锁
→轻量级锁
→重量级锁
。
其中锁的升级是不可逆的,只能由低往高级别升,不能由高往低降。
偏向锁
偏向锁是优化在无多线程竞争情况下,提高程序的的运行性能而使用到的锁。在Mark Word
中存储一个值,用来标志是否为偏向锁,在 32 位虚拟机和 64 位虚拟机中都是使用一个字节存储,0 为非偏向锁,1 为是偏向锁。
当第一次被线程获取偏向锁时,会将Mark Word
中的偏向锁标志设置为 1,同时使用 CAS 操作来记录这个线程的ID。获取到偏向锁的线程,再次进入获取锁时,只需判断Mark Word
是否存储着当前线程ID,如果是,则不需再次进行获取锁操作,而是直接持有该锁。
撤销锁
如果有其他线程出现,尝试获取偏向锁,让偏向锁处于竞争状态,那么当前偏向锁就会撤销。
撤销偏向锁时,首先会暂停持有偏向锁的线程,并将线程ID设为空,然后检查该线程是否存活:
- 当暂停线程非存活,则设置对象头为无锁状态。
- 当暂停线程存活,执行偏向锁的栈,最后对象头的保存其他获取到偏向锁的线程ID或者转向无锁状态。
当确定代码一定执行在多线程访问中时,那么这时的偏向锁是无法发挥到优势,如果继续使用偏向锁就显得过于累赘,给系统带来不必要的性能开销,此时可以设置 JVM 参数-XX:BiasedLocking=false
来关闭偏向锁。
轻量级锁
代码进入同步块的时候,如果对象头不是锁定状态,JVM 则会在当前线程的栈桢中创建一个锁记录
的空间,将锁对象头的Mark Word
复制一份到锁记录
中,这份复制过来的Mark Word
叫做Displaced Mark Word
。然后使用 CAS 操作将锁对象头中的Mark Word
更新为指向锁记录
的指针。如果更新成功,当前线程则会获得锁,如果失败,JVM 先检查锁对象的Mark Word
是否指向当前线程,是指向当前线程的话,则当前线程已持有锁,否则存在多线程竞争,当前线程会通过自旋获取锁,这里的自旋可以理解为循环尝试获取锁,所以这过程是消耗 CPU 的过程。当轻量级锁存在竞争状态并自旋获取轻量级锁失败时,轻量级锁就会膨胀为重量级锁,锁对象的Mark Word
会更新为指向重量级锁的指针,等待获取锁的线程进入阻塞状态。
解锁
轻量级锁解锁是使用 CAS 操作将锁记录
替换到Mark Word
中,如果替换成功,则表示同步操作已完成。如果失败,则表示其他竞争线程尝试过获取该轻量级锁,需要在释放锁的同时,去唤醒其他被阻塞的线程,被唤醒的线程回去再次去竞争锁。
总结
通过分析
synchronized
的使用以及 Java SE 1.6 升级优化锁后的设计,可以看出其主要是解决是通过多加入两级相对更轻巧的偏向锁和轻量级锁来优化重量级锁的性能消耗,但是这并不是一定会起到优化作用,主要是解决大多数情况下不存在多线程竞争以及同一线程多次获取锁的的优化,这也是根据平时在编码中多观察多反思得出的权衡方案。
推荐阅读
《volatile 手摸手带你解析》
《Java 线程通信之 wait/notify 机制》
《Java 多线程中使用 JDK 自带工具类实现计数器》
《Java 线程基础,从这篇开始》