前言
无论在日常工作还是面试过程中,synchronized关键字作为并发场景下的操作,是一定要掌握的,本文从synchronized的使用方式、原理及优化三个方面,对synchronized关键字作一个系统化的说明。
使用方式
synchronized主要有三种使用方式,分别是
- 修饰普通方法,锁作用于当前对象
- 修饰静态方法,锁作用于类
- 修饰代码块,锁作用于当前对象实例,需要指定加索的对象
1、修饰普通方法
当synchronized关键字加到普通方法上时,这个方法就被加上了同步锁,这也使得某一时间只有一个线程可以访问该方法。
package com.example.dailyrecords.demo;
import java.util.Date;
/**
* @author zy
* @version 1.0.0
* @ClassName synchronizedTest.java
* @Description TODO
* @createTime 2022/12/22
*/
public class synchronizedTest {
public synchronized void test(){
try{
System.out.println(Thread.currentThread().getName()+new Date());
Thread.sleep(5000);
System.out.println("结束"+new Date());
}catch (Exception e){
e.printStackTrace();
}
}
public static void main(String[] args) throws Exception {
//初始化
synchronizedTest synchronizedTest = new synchronizedTest();
for (int i = 1; i < 3; i++) {
new Thread(()->{
synchronizedTest.test();
},String.valueOf(i)+"号线程").start();
}
}
}
上面定义了一个普通方法test,然后开启两个线程去执行test方法,由于加上了synchronized关键字,因此,某一时间只有一个线程获取到锁并执行,另一个线程被阻塞,直到获取锁的线程释放锁,另一个才执行。
2、修饰静态方法
由于静态方法是在类初始化的时候加载的,因此synchronized关键字也就在类初始化时作用到了当前类对象上,因此锁住的是整个类。
package com.example.dailyrecords.demo;
import java.util.Date;
/**
* @author zy
* @version 1.0.0
* @ClassName synchronizedTest.java
* @Description TODO
* @createTime 2022/12/22
*/
public class synchronizedTest {
public static synchronized void test(){
try{
System.out.println(Thread.currentThread().getName()+new Date());
Thread.sleep(50000);
System.out.println("结束"+new Date());
}catch (Exception e){
e.printStackTrace();
}
}
public static void main(String[] args){
for (int i = 1; i < 3; i++) {
new Thread(()->{
test();
},String.valueOf(i)+"号线程").start();
}
}
}
3、修饰代码块
synchronized的锁的粒度能不能更小呢?那就是锁住一块代码块,如下所示
public void test1(){
synchronized (this){
//代码块
}
}
这里锁住的就是括号里面的内容,这里的synchronized (this),代表着只有当前对象才可以访问这段代码。
原理
synchronized作为一个关键字,它的底层是通过monitor监视器锁实现的。我们先将代码编译得到字节码文件,javac xxx.java
,然后通过javap -v xxx.class
查看字节码命令,如下
public class com.example.dailyrecords.demo.synchronizedTest2
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#21 // java/lang/Object."<init>":()V
#2 = Class #22 // com/example/dailyrecords/demo/synchronizedTest2
#3 = Methodref #2.#21 // com/example/dailyrecords/demo/synchronizedTest2."<init>":()V
#4 = Methodref #2.#23 // com/example/dailyrecords/demo/synchronizedTest2.method1:()V
#5 = Methodref #2.#24 // com/example/dailyrecords/demo/synchronizedTest2.method2:()V
#6 = Class #25 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 method1
#12 = Utf8 method2
#13 = Utf8 StackMapTable
#14 = Class #22 // com/example/dailyrecords/demo/synchronizedTest2
#15 = Class #25 // java/lang/Object
#16 = Class #26 // java/lang/Throwable
#17 = Utf8 main
#18 = Utf8 ([Ljava/lang/String;)V
#19 = Utf8 SourceFile
#20 = Utf8 synchronizedTest2.java
#21 = NameAndType #7:#8 // "<init>":()V
#22 = Utf8 com/example/dailyrecords/demo/synchronizedTest2
#23 = NameAndType #11:#8 // method1:()V
#24 = NameAndType #12:#8 // method2:()V
#25 = Utf8 java/lang/Object
#26 = Utf8 java/lang/Throwable
{
public com.example.dailyrecords.demo.synchronizedTest2();
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 10: 0
public synchronized void method1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 13: 0
public void method2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
LineNumberTable:
line 15: 0
line 17: 4
line 18: 14
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 9
locals = [ class com/example/dailyrecords/demo/synchronizedTest2, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class com/example/dailyrecords/demo/synchronizedTest2
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method method1:()V
12: aload_1
13: invokevirtual #5 // Method method2:()V
16: return
LineNumberTable:
line 21: 0
line 22: 8
line 23: 12
line 24: 16
}
可以看到,method2()方法加上synchronized关键字之后,添加了monitorenter和monitorexit命令,monitorenter存在于同步代码块开始的位置,而monitorexit存在于同步代码块结束的位置,它们分别代表着获取锁和释放锁。每一个monitorenter都必须对应一个monitorexit。
monitor主要由计数器count、阻塞线程集合_EntryList、释放锁线程集合_WaitSet和持有锁_Owner构成,当没有线程获取这把锁的时候,count值为0,如果有一个线程获取这把锁,它的值就会+1,并且设置该线程为锁的持有者。_owner指向的就是当前持锁线程。如果该线程已经占用该锁,并且重新进入,那么count的值就会+1。当执行到monitorexit的时候,count的值就会-1,直到count值为0的时候,该持锁线程会进入到WaitSet里面,将状态改为等待状态,让其他处于EntryList里的阻塞线程重新自旋获取这把锁。
锁的优化
这里主要对锁升级做一些说明,可能大家也都了解自旋锁、偏向锁、轻量级锁和重量级锁这个升级过程,下面详细说明。
自旋锁
当一个线程在获取锁的时候,如果该锁已被其它线程获取到,那么该线程就会去循环自旋获取锁,不停地判断该锁是否能够已经被释放,自选直到获取到锁才会退出循环。通常该自选在源码中都是通过for(; ;)或者while(true)这样的操作实现,但是如果一直自旋下去,也会造成CPU资源的浪费,因此,当自旋次数超过一定次数后,这个线程就会被挂起。
偏向锁(可重入锁)
首先,一个对象在内存中由对象头、示例数据和数据填充三部分组成。当一个线程获取锁之后,这个锁对象在对象头中就会记录这个线程的ID,之所以偏向就是因为有这个ID,如果后面还是这个线程进入和退出同步时,只要检查是否是这个偏向的线程ID即可。
轻量级锁
由偏向锁升级而来,当一个线程获取到锁后,此时这把锁是偏向锁,此时如果有第二个线程来竞争锁,偏向锁就会升级为轻量级锁,底层通过自旋来实现,并不会阻塞线程,需要强调的是,轻量级锁并不是用来代替重量级锁的。引入轻量级锁的目的在于:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁。
重量级锁
如果自旋多次仍然没能获取到锁,则会升级为重量级锁,重量级锁会导致线程阻塞,除了持有锁的线程其他全部阻塞,防止CPU空转
锁升级
synchronized锁升级是同过修改对象头来实现的
偏向锁:线程1第一次进入同步代码块的线程,将对象头的线程id修改成自己的id,此时是偏向锁,偏向锁不会自动释放锁,以后线程1再次请求就无需加锁解锁
轻量锁:线程2要竞争锁对象,而因为偏向锁不会自动释放锁,因此对象头的线程id还是线程1的id,此时需要需要查看线程1是否存活,通过cas来判断
若不存活,锁对象置为无锁状态,线程2竞争锁设置为偏向锁;
若存活,查看线程1的栈帧信息,若需要继续持有这个锁对象,那么暂停线程1,撤销偏向锁,升级为轻量级锁;若不需要持有,那么将锁对象设为无锁状态,重新偏向线程
重量锁:如果自旋次数到达且线程1还没有释放锁,又或者一直自旋,此时又有其他线程来竞争锁,轻量锁就会膨胀为重量锁,重量锁会将未获取到锁的线程阻塞,防止CPU空转
锁消除
在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁,例如下面代码片段
public void method() {
synchronized (new Object()) {
//代码逻辑
}
}
这段代码里面,我们new了一个Object对象来作为锁对象,但是这个对象也只有在method( )中被使用,其完整的生命周期都在这个方法中,也就是说JVM在经过逃逸分析后会对它进行栈上分配,由于在底层变成了私有数据,那么也就无需加锁了。
锁粗化
在JIT编译时,发现如果有一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁,例如下面的代码片段
public void method() {
for(int i = 0;i < 100; i++) {
synchronized (new Object()) {
//代码逻辑
}
}
}
如果按照正常的synchronized步骤走,这个循环需要进行多次的加锁解锁操作,当这段代码在即时编译时,JVM检测到每一次都是对同一个对象加锁,那么就会把这一串连续频繁的加锁解锁操作优化成仅仅一次公共的加锁解锁操作。