1 什么是synchronized同步锁?
synchronized
,是同步的意思,是java中synchronized锁
的关键字。
synchronized锁
也就是同步锁
,是JVM实现的,是一种对象锁(锁的是对象,而非引用),
可以用来实现对临界资源
的互斥访问,保证线程并发安全。
临界资源:是在多线程编程中的一种共享的
、可变的
的资源。比如:对象、变量、文件等等。
- 共享就是可以被多个线程访问;
- 可变就是可以被修改。
java的同步锁
具有以下特点:
- 互斥、独享:当一个线程获取到了锁,其它线程也要获取就只能等待该线程释放锁。
- 可重入:一个获得锁的线程,可以多次获得该锁。
- 锁对象内有一个计数器,无锁状态是0,每获得一次锁就+1,每释放一次锁就-1。
- 不可中断:等待锁的线程不能被中断。
- 线程在等待synchronized同步锁时,调用
interrupt
方法,不能中断其阻塞状态被唤醒。 - 等到线程拿到锁后,调用
interrupted
方法才能拿到这个中断信号。
- 线程在等待synchronized同步锁时,调用
2 怎么用synchronized?
- 同步实例方法:加在类的成员方法上面,锁的当前的实例对象
- 同步静态方法:加在类的静态方法上面,锁的是当前的类对象
- 同步代码块:锁的是括号内的对象
Demo如下:
public class Demo {
/**
* 同步实例方法
*/
public synchronized void test1() {
System.out.println("同步实例方法");
}
/**
* 同步静态方法
*/
public static synchronized void test2() {
System.out.println("同步静态方法");
}
/**
* 同步代码块
*/
public void test3() {
synchronized (this) {
System.out.println("同步代码块");
}
}
}
3 锁的实现原理
前面提到同步锁
是JVM实现,那么JVM是通过什么判断我们的代码要加同步锁
呢?
3.1 编译阶段
代码在被编译成字节码时,加入特殊的标记。
同步实例方法
和同步静态方法
都差不多,我们用上面那个Demo,编译后通过javap
命令来获取其编译后的附加信息。
javac -encoding utf8 Demo.java
javap -v Demo.class
输出的结果如下:
Classfile /D:/code/demo/Demo.class
Last modified 2024-2-29; size 671 bytes
MD5 checksum 5ba0185f8f0c63f2a3c36d5d4ad53190
Compiled from "Demo.java"
public class Demo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #8.#22 // java/lang/Object."<init>":()V
#2 = Fieldref #23.#24 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #25 // 同步实例方法
#4 = Methodref #26.#27 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = String #28 // 同步静态方法
#6 = String #29 // 同步代码块
#7 = Class #30 // Demo
#8 = Class #31 // java/lang/Object
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 test1
#14 = Utf8 test2
#15 = Utf8 test3
#16 = Utf8 StackMapTable
#17 = Class #30 // Demo
#18 = Class #31 // java/lang/Object
#19 = Class #32 // java/lang/Throwable
#20 = Utf8 SourceFile
#21 = Utf8 Demo.java
#22 = NameAndType #9:#10 // "<init>":()V
#23 = Class #33 // java/lang/System
#24 = NameAndType #34:#35 // out:Ljava/io/PrintStream;
#25 = Utf8 同步实例方法
#26 = Class #36 // java/io/PrintStream
#27 = NameAndType #37:#38 // println:(Ljava/lang/String;)V
#28 = Utf8 同步静态方法
#29 = Utf8 同步代码块
#30 = Utf8 Demo
#31 = Utf8 java/lang/Object
#32 = Utf8 java/lang/Throwable
#33 = Utf8 java/lang/System
#34 = Utf8 out
#35 = Utf8 Ljava/io/PrintStream;
#36 = Utf8 java/io/PrintStream
#37 = Utf8 println
#38 = Utf8 (Ljava/lang/String;)V
{
public Demo();
descriptor: ()V
flags: 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 test1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String 同步实例方法
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 6: 0
line 7: 8
public static synchronized void test2();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String 同步静态方法
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 13: 0
line 14: 8
public void test3();
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 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #6 // String 同步代码块
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
LineNumberTable:
line 20: 0
line 21: 4
line 22: 12
line 23: 22
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 17
locals = [ class Demo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
SourceFile: "Demo.java"
首先,我们可以看到同步实例方法
和同步静态方法
上面都添加了ACC_SYNCHRONIZED
的flags标识,根据这个JVM就知道要给调用的线程去获取同步锁
了。
另外,同步静态方法
上面还有ACC_STATIC
标识,那么JVM读取到后,应该就知道要获取的是类对象
的同步锁,而不是一个实例对象
的。
最后看同步代码块
的方法内,有一个monitorenter
的指令,JVM执行到这里应该就是要去获取锁了。但这里有两个monitorexit
指令,这是为什么呢?
看到那个异常表(Exception table)的了吧,那里target指向了第17行指令,那就清楚了。后一个monitorexit
是异常退出时,要去释放锁。
小结:
- 同步实例方法,编译后的
flags
有ACC_SYNCHRONIZED
标记; - 同步静态方法,编译后的
flags
有ACC_SYNCHRONIZED
和ACC_STATIC
标记; - 同步代码块,编译后有一个
monitorenter
的指令和两个monitorexit
指令。
3.2 运行阶段
3.2.1 Monitor对象
同步锁是一种对象锁,那是因为每个java的对象里面都有一个内置的Monitor对象,
C++写的,底层依赖于操作系统的Mutex lock
(互斥锁)实现的锁。
给个JVM原码感受一下:
ObjectMonitor() {
_header = NULL;
_count = 0; // 计数器,为0时为无锁状态
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 指向持有ObjectMonitor对象的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
Monitor对象的主要属性有:
- _count ---是一个计数器;
- _owner ---指向持有Monitor对象的线程;
- _WaitSet ---等待状态(Waiting 或 TimedWaiting)线程的集合,原持有Monitor对象的线程调用wait方法进入此集合;
- _EntryList ---Blocking状态线程的集合,是在等待锁的;
3.2.2 对象头的 Mark Word
首先,我们看看对象的内存分布。一个对象在内存中可分为三块区域:
- 对象头
- Mark Word:
- 存储对象自身的运行时数据。比如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 大小根据虚拟机位数而不同。32位虚拟机的就32bit,64位虚拟机的就64bit。
- 元数据指针:指向类对象。
- 数组长度:数组对象才有。
- Mark Word:
- 实例数据:存放的是对象的属性数据。
- 对齐填充:虚拟要求对象的大小是8字节的整数倍,不够就填充对齐。
32位虚拟机,对象头的Mark Word的内存区域(32 bit)是这划分的:
64位虚拟机,对象头的Mark Word的内存区域(64 bit)是这划分的:
另外,JVM默认会开启指针压缩
。就是在堆内存不大(小于32GB)的情况下,会对以下信息进行压缩:
- 对象的全局静态变量(即类属性)?
- 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节?
- 对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节?
- 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节?
3.2.3 获取锁过程
线程执行monitorenter
指令时,就会尝试去获取Monitor对象的所有权,也就是去获取同步锁:
- 无锁状态下,线程成功将Monitor对象的计数器+1后,Monitor对象的持有者属性
_owner
就会指向当前当前线程,表示线程已成功获取了锁; - 如果线程已经是持有者,再次获取时,Monitor对象的计数器就会再+1;
- 如果其它线程持有的锁,那么当前线程会进入(block)阻塞状态,等待锁被释放。
- 同步锁是非公平锁,在线程进入阻塞状态之前可以尝试的去获取锁,竞争到就能成为持有者,这对已经在排队的线程是不公平的;
- 没有获取到同步锁的线程,进入阻塞状态后,会放到一个等待队列(_EntryList)去排队等待;
- 等待队列的线程在获取锁之前,还要和新来的线程去竞争锁;
此外,持有同步锁的线程:
- 如果进入了等待状态(Waiting 或 TimedWaiting),都会释放持有的同步锁,进入Monitor对象的_WaitSet集合中;
- _WaitSet集合的线程被唤醒了,就会进入到等待队列(_EntryList)中去获取;
3.2.4 释放锁过程
线程执行monitorexit
指令时,必是锁的持有者:
- Monitor对象的计数器-1;
- 如果Monitor对象的计数器减为0,表示释放锁,Monitor对象的持有者属性
_owner
就不再指向当前线程。
4 锁的优化
同步锁是一个重量级锁,因为线程获取锁失败就得进入阻塞(Blocking)状态,这里会涉及到线程上下文的“用户态”和“核心态”间的切换,是比较消耗性能。
所以java后来对同步锁做一些优化,引入了锁粗化
、锁消除
、自旋锁
等技术来减少锁操作的开销。JDK6引入了偏向锁
、轻量级锁
来应对并发不大的场景。
4.1 锁粗化 & 锁消除
锁粗化:线程在执行过程中多次去获取同一把锁的操作,JVM给优化成只去获取一次。
- 比如:在用 StringBuffer 时的一连串 append 操作(append方法上加了 synchronized 关键字),JVM会在第一个 append 操作扩展到最后一个 append 操作,只加锁一次。
锁消除:是JVM经过逃逸分析
后,认定不存在锁竞争,就自动把获取锁的操作优化掉了。
- 比如: 还是 StringBuffer 的 append 方法。在很多情况下,StringBuffer 的引用只限于在一个普通方法内,其它线程根本无法访问到该引用。虽然 append 方法上有锁,但是被JVM安全的消除掉了。
什么是逃逸分析
?
逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
4.2 自旋锁
自旋锁这个锁优化是线程尝试获取锁(CAS操作)失败后,不会立刻进入阻塞状态。而是先自旋一下,再尝试获取锁,直到自旋次数达到了限制。
在锁竞争不激烈的情况,线程是可以很快获取到锁。这种自旋的机制可以避免线程从用户态
和核心态
之间的切换,能很好的提高并发性能。
5 锁升级过程
5.1 偏向锁
什么是偏向锁?
- 字面意思就是偏向某
一个线程
; - 在锁对象头的MarkWord中:
锁标志位
为01;是否偏向锁
为1;线程ID
栏指向的是获得锁的线程。为空就是可偏向,是无锁状态。
为什么要用偏向锁呢?
据说,因为大佬们发现,大多数情况锁总是同一线程获得。
为了减少获得锁(CAS操作)的代价,偏向锁会在锁对象的MarkWord上记录了已获得锁的线程ID,下次这个线程再来获取锁时就不用做同步操作了。
那么下次来获取锁的不是这个已经记录的线程怎么办?
那就得升级为轻量级锁
了。
当线程都到达安全点
(就是JVM垃圾回收时,暂停掉所有用户线程的这段时间,也叫Stop The World,STW)时:
- 锁对象头的MarkWord
是否偏向锁
的标识,由1设置为0,这就是偏向锁撤消
; - 锁标志位改为00;
- 如果持有锁的线程还在同步代码中,那就升级为
轻量级锁
。
其它:
-
对象头MarkWord在偏向锁状态有个
Epoch
,这个是干嘛用的呢?- 实例对象的Epoch初始值是其Class对象Epoch;
- 每个Class对象维护一个偏向锁撤销计数器,每次它的实例对象发生
偏向锁撤销
,计数器就累加; - 当偏向锁撤销计数器累加到阈值,就给来一波
批量重偏向
:- 对这个Class的所有正
处于加锁状态
的偏向锁对象,Epoch值累加1; - Epoch值改过后,那么线程就可以获取这些已经偏向其它线程的锁对象了。
- 对这个Class的所有正
-
锁升级的条件。除了有其它线程来获取偏向锁会导致锁升级外,代码中调用锁对象的
hashCode()
也就导致锁升级。 -
JDK8,默认是开启
偏向锁
的,但有延时。想要立即开启需要加以下启动参数:-XX:BiasedLockingStartupDelay=0
-
开启偏向锁 -XX:+UseBiasedLocking
-
关闭偏向锁 -XX:-UseBiasedLocking
5.2 轻量级锁
什么是轻量级锁?
- 加锁时,JVM会在当前线程的线程栈中开辟一块单独的空间,用CAS操作保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中也用CAS操作保存指向这片空间的指针;
- 加锁后,在锁对象头的MarkWord中:
- 有指针指向了栈中锁记录;
锁标志位
为00;
- 解锁时,通过 CAS 操作,把对象锁 Mark Word 和线程栈中的替换回来。
为什么用轻量级锁?
如果没开启偏向锁
的话,那么线程先获取的是轻量级锁
。 在线程竞争不激烈的情况下,线程交替执行同步块,那么用轻量级锁
能获取不错的性能。
但如果有两个线程同时获取这个锁怎么办?
- 因为获取轻级量级是用CAS操作,必有一个线程先拿到了锁;
- 没有获取到锁的线程,会先自旋一下尝试获取锁,失败后就把锁升级为
重量级锁
:- 锁对象头的MarkWord的锁标志位为10;
- 线程进入阻塞状态,被放到等待队列(_EntryList);
- 获取到锁的线程执行完同步的代码后,CAS操作必失败,就知道锁已升级为
重量级锁
了,去正常释放重量级锁的流程。
5.3 总结
偏向锁
能应付无竞争
场景;轻量级锁
能应付多个线程交替
获取锁的场景;重量级锁
是专门处理多个线程同时
获取锁的场景。
此外,锁升级是不可逆的。
注意:本文归作者所有,未经作者允许,不得转载