一、概述
synchronized是Java中实现线程同步的一种方式,其在JavaSE 1.6之前被称为"重量级锁",之后对其进行了优化在原有重量级锁的基础上诞生了轻量级锁和偏向锁
synchronized是借助对象头中的Mark Word与锁相关联完成同步机制,这个锁就是Monitor
下面介绍下这些基本概念
1.1 Java对象内存布局—Mark Word
Java创建对象时会在堆区中开辟一定的空间用来存放对象,一个对象主要包含了以下信息
- 自身运行时的数据:如HashCode、GC分代年龄,锁状态标志等
- 类型指针:该指针指向方法区中的该对象所属的类的元数据的区域,通过这个指针可以知晓本对象属于哪个类
- 对象本身的实例数据:对象的某些成员属性等
- 对齐填充:内存对齐所填充的无效信息
官方把存储自身运行时的数据这一块区域称之为对象的"Mark Word",其结构如下图所示
在32位虚拟机中:
通过后2bit的标志位值可以知道,当前对象持有的时哪种锁,前面30bit可以知道具体和锁相关联的线程的信息
也正是这个Mark Word的这些信息,可以说每个对象都可以作为一把"锁"
1.2 管程—Monitor
- 管程可以理解为一个类,将管理线程同步需要的属性和方法封装成一个类,这个类抽象叫为"管程"
- 管程使用锁(lock)确保了在任何情况下管程中只有一个活跃的线程,即确保线程互斥访问临界区
- 管程使用条件变量(Condition Variable)提供的等待队列(Waiting Set)实现线程间协作,当线程暂时不能获得所需资源时,进入队列等待,当线程可以获得所需资源时,从等待队列中唤醒
- Java 内置的管程方案是synchronized,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量
- 管程原理示意图:
参考:https://www.cnblogs.com/xidongyu/p/10891303.html
管程是一个抽象概念,不同语言或与会有不同的实现形式
在Java中管程的实现是Monitor
每个对象都可以绑定一个Monitor对象,使用synchronized(obj)会将obj对象的对象头中Mark Word前30位设置成一个指向Monitor对象的指针,Monitor对象结构如下:
- Onwer:指向执行synchronized(obj)代码的线程,该线程就是持有锁的线程
- EntryList:实现管程中的入口队列,该队列中的线程仅在等待CPU资源,处于BLOCKED状态,一旦Owner释放锁会唤醒EntryList中的线程进行锁的竞争
- WaitSet:实现管程中的条件变量等待队列,该队列中的线程需要被唤醒才可以进入EntryList中,处于WAITING状态
1.3 synchronized实现同步的三种形式
- 修饰实例方法:此时锁是当前实例对象
- 修饰静态方法:此时锁是当前类的Class对象
- 同步方法块:锁是synchronized(obj)的obj
修饰实例方法 和 修饰静态方法与同步方法快的底层实现原理并不一样,在JVM规范里没有说明
但是同步方法块的底层实现是通过moniterenter和monitorexit两条指令,在执行这两条指令的具体动作会在下文细述,下文也是以同步方法块为例说明synchronized实现同步的底层原理
二、synchronized— 重量级锁
通过字节码看原理
Java源码:
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用 (synchronized开始)
3: dup
4: astore_1 // lock引用 -> slot 1
5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
6: getstatic #3 // <- i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- lock引用
15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
16: goto 24
19: astore_2 // e -> slot 2
20: aload_1 // <- lock引用
21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
LineNumberTable:
line 8: 0
line 9: 6
line 10: 14
line 11: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
monitorenter指令:将对象 MarkWord 置为 Monitor 指针
monitorexit指令:将 lock对象 MarkWord 重置, 唤醒 EntryList
可以看到,重量级锁上锁(synchronized (lock))就是 将 lock对象 MarkWord 置为 Monitor 指针,解锁也就是将 MarkWord 重置,随后唤醒在lock关联的Monitor对象的EntryList等待的线程。
重量级锁的自旋优化
重量级锁可以完全的实现互斥访问,但是其获取锁和释放锁的过程开销过大,无法获取锁资源的线程会进入WaitSet被阻塞会进行一次上下文切换,之后再获取锁又会进行一次上下文切换开始运行,虽然保障了线程安全,但是降低了CPU利用率
可以利用CAS+自旋的方式进行优化,让获取锁失败的线程不被阻塞,而是 通过CAS+自旋的方式不断的获取锁,保持着线程的运行状态,这样一旦获取到锁就可以立刻开始运行,无需进行两次上下文切换。示例过程如下:
线程2再没有获取到锁的情况下自旋,在几次自旋后获取到锁,没有经历过阻塞的过程
三、synchronized— 轻量级锁
重量级锁即便通过自旋优化,也不可能无止尽的自旋下去,因为自旋本身是线程运行时的动作,需要占用CPU的某个"核"去运行,因此一般尝试自旋一定次数之后会仍旧没有获取到锁就会阻塞该进程,示例如下:
加上获取重量级锁的过程需要申请Monitor是个极度耗费资源的过程,因此使用重量级锁是个很耗费资源的行为。
这种耗费资源在有些场景中体现的尤为明显,比如下面的场景
场景:有多线程要加锁,但加锁的时间是错开的(也就是几乎没有竞争)
这种场景下虽然几乎没有竞争,但是为了保证线程安全每次进出同步代码块都要申请释放一次Monitor,完全是一种资源的浪费,
这种时候就需要一种开销非常小的锁,可以在线程运行时提供一道"防线",该"防线"可以感知到其它线程是否在竞争锁,如果在竞争,阻止其它线程在本次竞争中获取锁,并将"防线"加固变为高并发情况下适用的重量级锁,这个防线就是轻量级锁
下面解释轻量级锁加锁/解锁底层原理,记住这张图
3.1 轻量级锁加锁
进入synchronized(obj)代码块时:
- 请求锁的线程在线程内部空间中创建锁记录对象,该对象中存有指向obj的Mark Word的地址
- 线程尝试用锁记录对象替换obj的Mark Word(使用CAS保证原子性),成功表示获取到锁,失败进入步骤3
- CAS失败有两种可能:其它线程抢先获取锁(步骤4) or 自己重入(步骤5)
- 进入锁膨胀过程(升级为重量级锁),修改obj Mark Word相关信息,然后自己进入对应的Monitor的EntryList中,变成BLOCKED状态
- 添加一条锁记录,当作重入计数器
图例说明如下:
-
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
-
让锁记录中 Object reference 指向锁对象,并尝试用 CAS替换 obj的 Mark Word,将 Mark Word 的值存入锁记录
-
如果 CAS 替换成功,对象头中存储了 锁记录地址和状态 00 ,表示由该线程给对象加锁
-
如果CAS失败有两种情况
- 其它线程竞争获取过锁,这时表明有竞争,进入锁膨胀过程
- 自己重入则添加一条锁记录,但是此时锁记录中存放锁对象地址的部分为null作为本次是重入的标志
-
锁膨胀过程:轻量级锁变成重量级锁
- thread-0为当前拥有锁的线程,此时Thread-1尝试进行CAS获取锁,但是会失败,开始锁膨胀
- 锁膨胀时,Thread-0关联的Object对象会修改Mark Word的值为一个Monitor对象的地址,表示升级为重量级锁
- Thread-0成为该Monitor对象的Owner
- Thead-1加入EntryList队列中
- thread-0为当前拥有锁的线程,此时Thread-1尝试进行CAS获取锁,但是会失败,开始锁膨胀
3.2 轻量级锁解锁
当每一次退出synchronized代码块(解锁)时
- 查看锁记录如果为null,说明是轻量级锁且为重入锁,则消除该锁记录,重入数减一
- 查看锁记录不为null则尝试使用CAS,用锁记录替换obj的Mark Word
1) CAS成功则消除轻量级锁
2) CAS失败(发现Mark Word中后2bit标记不是00)则进入重量级锁解锁阶段,将Owner置null,唤醒EntryList所有线程
四、synchronized— 偏向锁
轻量级锁是对重量级锁的一次优化,适用于线程之间几乎没有竞争的情况下,仅仅使用简单的数据交换就完成了加锁/解锁过程,实现了重量级锁不具备的高响应速度,但是其本身仍要使用CAS这种操作去获取锁,释放锁。
HosSpot的作者研究发现,大多数情况下,锁不仅不存在竞争,而且总是由同一个线程获取锁,如果使用轻量级锁,每次重入都需要进行一次CAS操作。
所以,轻量级锁也是可以通过减少上述的CAS操作来进行优化的,这就是偏向锁。
如果开启了偏向锁,那么初始是没有任何线程获取锁,线程ID出为0,偏向模式位为1
4.1 偏向锁加锁
synchronized(obj)时
- 检查obj Mark Word是否存储由当前线程的线程ID,如果有表示线程已经获取锁,无需做任何操作(主要优化点就是这里减少了CAS操作次数)
- 如果没有,则检查后三位是否是101,
1). 如果是的话说明其它线程已经拥有锁,此时表明存在线程间竞争锁的情况,偏向锁不再适用,然后替换锁为轻量级锁
2). 锁标志位不是101则表示还没有其它线程获取到锁,使用CAS尝试获取锁,修改偏向模式标记为1,并记录线程ID到Mark Word中
4.2 偏向锁解锁
轻量级锁一旦有线程获取到,在执行完同步代码块后也不会释放锁,直到其它线程来获取锁时再释放锁
- 如果线程A在占有偏向锁但已经离开同步代码区的时候,线程B来竞争锁,则该锁升级成为轻量级锁,撤销原偏向锁,线程B为该轻量级锁的持有者
- 如果线程A在占有偏向锁但还未离开同步代码区的时候,线程B来竞争锁,出现代码交错的现象,该锁直接升级成为重量级锁
五、常见的问题
synchronized关联的锁对象
synchronized可以修饰方法,类和代码块。
修饰代码块的时候如上文所说关联括号中的obj对象
-
修饰非静态方法时:关联调用方法的当前对象
- 此时多个线程调用同一个对象的同步方法会阻塞,调用不同对象的同步方法不会阻塞
-
修饰静态方法/或者代码块直接使用A.class的形式都会直接对该类的Class对象上锁因此相当于对整个类上锁,该类对象只要使用到synchronized修饰的代码块都会被阻塞
- 比如下面的情况:运行Main的main()方法时,会先执行 A.method(),该方法是被synchronized修饰的静态方法,运行时直接对A.class这个对象上锁,之后运行methodMain()时碰到synchronized (A.class) 代码块,去争取A.class的锁失败被阻塞
public class A {
public synchronized static void method() {
System.out.println("A 的method");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
public class Main {
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
A.method();
}
}).start();
try {
Thread.sleep(100); // 保证第一个线程先执行
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
new Thread(new Runnable() {
@Override
public void run() {
methodMain();
}
}).start();
}
public static void methodMain() {
synchronized (A.class) {
System.out.println("Main的方法");
}
}
}