0%

synchronized关键字

synchronized关键字

synchronized作为java的一个关键字,用来保证同一时刻最多只有一个线程执行synchronized修饰的方法/代码块,保证线程安全,解决多线程并发问题。悲观锁

被认定为是悲观锁的原因是并发策略是悲观的,不管是否会产生竞争,任何数据操作都必须要加锁,当然后续的JVM的升级使得synchronized也有了锁升级的过程

作用

  • 保证原子性,由于同一时刻只能有一个线程能够执行临界区的代码,使得临界区的代码是一个原子操作
  • 保证可见性

synchronized的使用

1
2
3
4
5
6
7
8
锁当前对象  synchronized(this){

}

等价于 锁整个方法
synchronized method(){

}
1
2
3
4
5
6
7
锁当前类 synchronized(T.class){

}
等价于 锁静态方法
synchronized static method(){

}

synchronized原理

  • 依赖于JVM monitorenter和monitorexit两个字节码指令,在执行monitorenter指令时,首先需要尝试获取对象锁,如果获得锁,把锁的计数器+1;在执行monitorexit指令时,会将锁计数器-1,当计数器为0时,锁被释放
  • 底层通过一个监视器对象(monitor)完成,wait()、notify()等方法也依赖于monitor对象,monitor(监视器锁)本质依赖于底层操作系统的互斥锁(Mutex Lock)实现,只有拥有锁标记时才能访问这个资源
  • 锁标记存放在java对象头的Mark Word(标记字段)中

早期的JDK中,synchronized是重量级的,需要去操作系统申请,完全依赖于操作系统内部的互斥锁,将一个线程进行阻塞或唤起都需要从用户态切换到内核态

在JDK1.6进行了锁升级分别为偏向锁、自旋锁(轻量级锁)、重量级锁

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69



while(true){
synchronized (this){
if(ticket > 0){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"售票,票号"+ticket--);
} else {
break;
}
}

}
可以看到,在synchronized同步块操作时会有monitorenter和monitorexit



0 aload_0
1 dup
2 astore_1
3 monitorenter
4 aload_0
5 getfield #2 <com/zhanghe/study/thread/WindowSynchronized.ticket>
8 ifle 72 (+64)
11 ldc2_w #3 <100>
14 invokestatic #5 <java/lang/Thread.sleep>
17 goto 25 (+8)
20 astore_2
21 aload_2
22 invokevirtual #7 <java/lang/InterruptedException.printStackTrace>
25 getstatic #8 <java/lang/System.out>
28 new #9 <java/lang/StringBuilder>
31 dup
32 invokespecial #10 <java/lang/StringBuilder.<init>>
35 invokestatic #11 <java/lang/Thread.currentThread>
38 invokevirtual #12 <java/lang/Thread.getName>
41 invokevirtual #13 <java/lang/StringBuilder.append>
44 ldc #14 <售票,票号>
46 invokevirtual #13 <java/lang/StringBuilder.append>
49 aload_0
50 dup
51 getfield #2 <com/zhanghe/study/thread/WindowSynchronized.ticket>
54 dup_x1
55 iconst_1
56 isub
57 putfield #2 <com/zhanghe/study/thread/WindowSynchronized.ticket>
60 invokevirtual #15 <java/lang/StringBuilder.append>
63 invokevirtual #16 <java/lang/StringBuilder.toString>
66 invokevirtual #17 <java/io/PrintStream.println>
69 goto 77 (+8)
72 aload_1
73 monitorexit
74 goto 90 (+16)
77 aload_1
78 monitorexit
79 goto 87 (+8)
82 astore_3
83 aload_1
84 monitorexit
85 aload_3
86 athrow
87 goto 0 (-87)
90 return

对象头

对象头中主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)

  • Mark Word:用于存储对象自身的运行时数据,如存储对象的HashCode、分代年龄和锁标志位信息(锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等)。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化

  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

对于原子性和可见性的支持

synchronized不仅可以进行同步互斥,还有内存可见性的功能,当一个线程修改了对象状态后,其他线程可以看到该变化

  • 在JMM中规定,对一个变量执行unlock之前,必须先把变量同步到主内存,以此保证了可见性
  • 在JMM中规定,如果对一个变量执行lock操作,将清空工作内存中此变量的值,在执行引擎使用该变量时,需要重新执行load或assign操作初始化变量的值

锁升级

在java6中为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,使得在synchronized中,锁存在四种状态,分别为无锁偏向锁轻量级锁重量级锁,会随着竞争情况逐渐升级,只可以升级不会降级

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,如CAS操作,线程会不断的尝试修改共享资源

对象头的Mark Word内容存储:对象的hashcode、对象分代年龄、是否是偏向锁(0) ,标志位01

偏向锁

大部分情况是没有竞争的,可以通过偏向锁来提升性能,一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价,适用于只有一个线程访问同步块的场景,加锁和解锁不需要额外的消耗,引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可

对象头的Mark Word内容存储:偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) ,标志位01

偏向锁原理

对于同一个线程多次获得锁,使用偏向锁。一个线程在访问加了synchronized修饰的代码块时,会在对象头中存储当前线程的id,之后该线程进入和退出时,不需要再次加锁和释放锁,而是直接比较对象头里是否存储了指向当前线程ID的偏向锁

使用偏向锁逻辑

获取偏向锁

  • 首先获取锁对象的Markword,判断是否处于可偏向状态(biased_lock=1、且ThreadId为空)

  • 如果是可偏向状态,则通过CAS操作,在对象头和栈帧中锁记录里存储锁偏向的线程ID,如果成功,获得偏向锁,CAS失败,表示当前锁存在竞争,需要撤销已获得偏向锁的线程,升级为轻量级锁

  • 如果是已偏向状态,检查markword中存储的ThreadId是否为当前线程ThreadId,如果不是,说明当前锁已经偏向于其他线程,需要撤销,并升级为轻量级锁

撤销偏向锁

偏向锁只有在竞争之后才释放锁,所以只有当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁

  • 偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)
  • 首先会暂停拥有偏向锁的线程
  • 检查持有偏向锁的线程是否活着(是否执行完同步代码块),如果线程不处于活动状态,则将对象头设为无锁状态并且争抢锁的线程可以基于CAS重新偏向

  • 如果线程仍然存活,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程

可以使用jvm参数 -XX:+UseBiasedLocking来设置是否使用偏向锁

优缺点及场景

优点:加锁和解锁不需要额外的消耗

缺点:如果线程间存在锁竞争,会带来额外的锁撤销的消耗

适用场景:适用于只有一个线程访问同步块场景

轻量级锁(自旋锁)

多个线程在不同时段获取同一把锁,不存在锁竞争的情况,也就没有线程阻塞

当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能,同步块执行速度非常快,竞争的线程不会阻塞,但是如果始终得不到竞争的线程,使用自旋会消耗CPU

对象头的Mark Word内容存储:指向栈中锁记录的指针,标志位00

自旋锁即在把线程进行阻塞操作之前先让线程自旋等待一段时间,如果在等待期间其他线程已经解锁,就无需再让线程执行阻塞操作,避免了用户态到内核态的切换

轻量级锁加锁逻辑
  • 线程在栈帧中创建锁记录(Lock Record),用于存储对象目前的Mark Word的拷贝

  • 将锁对象的对象头中Mark Word复制到线程创建的锁记录中

  • 虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将LockRecord中Owner指针指向对象的Mark Word

  • 对象Mark Word的锁标志位设置为00

轻量级锁在加锁过程中,用到了自旋锁

锁在自旋的时候会消耗cpu,一直在for循环,默认情况下是自旋10次,可以使用jvm参数-XX:preBlockSpin来修改

轻量级锁解锁

解锁是获得锁的逆向逻辑,通过CAS操作把线程栈帧中的LockRecord替换到对象的Markword中,如果成功表示没有竞争,如果失败,表示当前锁存在竞争,轻量级锁会成为重量级锁。

优缺点及场景

优点:竞争的线程不会阻塞,提高了程序的响应速度

缺点:如果始终得不到锁竞争的线程,使用自旋会消耗CPU

适用场景:追求响应时间,同步块执行速度非常快

重量级锁

当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁,同步块执行时间较长,线程竞争不使用自旋,不会消耗CPU,但是会导致线程阻塞,响应时间缓慢

对象头的Mark Word内容存储:指向互斥变量的指针,标志位10

重量级基本原理

使用monitorenter/monitorexit获取和释放monitor监视器,使得其它被阻塞的线程可以尝试去获得这个监视器monitor,依赖于操作系统的MutexLock(互斥锁)来实现的,线程被阻塞后进入内核(Linux)调度状态,会导致系统在用户态和内核态之间来回切换,严重影响锁的性能。

优缺点及场景

优点:线程竞争不适用自旋,不会消耗CPU

缺点:线程阻塞,响应时间慢

适用场景:追求吞吐量,同步块执行时间较长

锁升级的完整过程
  • 一个锁对象在刚开始创建的时候,没有任何线程访问,此时线程的状态是无锁状态,Mark word标志位01、是否是偏向锁(0)

  • 当线程A来访问这个对象锁时,它会偏向线程A,线程A检查Mark word为无锁状态,会将无锁状态升级为偏向锁,Mark word标志位01、是否是偏向锁(1)、线程id(线程A的id)

  • 当线程A再次获取这个对象锁时,检查Mark word是否为偏向锁,且线程id是否为线程A,如果是,则直接执行同步代码,此时同一线程多次获取锁的情况下,每次只需要检查标志位即可,效率高

  • 当线程A执行完同步块之后,线程B获取对象锁,检查Mark word是否为偏向锁,且线程id是否为线程A,此时线程id变了,所以有不同的线程来获取锁对象,偏向锁升级为轻量级锁,并由线程B获取该锁

  • 如果线程A还在执行的时候,线程B来获取对象锁,此时需要将线程A撤销偏向锁,并且膨胀为轻量级锁

    线程A撤销偏向锁:

    • 等到全局安全点执行撤销偏向锁,暂停持有偏向锁的线程A并检查程A的状态;

    • 如果线程A不处于活动状态或者已经退出同步代码块,则将对象锁设置为无锁状态,然后再升级为轻量级锁。由线程B获取轻量级锁。

    • 如果线程A还在执行同步代码块,也就是线程A还需要这个对象锁,则偏向锁膨胀为轻量级锁

    线程A膨胀为轻量级锁过程:

    • 在升级为轻量级锁之前,持有偏向锁的线程(线程A)是暂停的

    • 线程A栈帧中创建一个名为锁记录的空间(Lock Record)

    • 锁对象头中的Mark Word拷贝到线程A的锁记录中

    • Mark Word的锁标志位变为00,指向锁记录的指针指向线程A的锁记录地址,Mark word(锁标志位-00,其他位-线程A锁记录的指针)

    • 当原持有偏向锁的线程(线程A)获取轻量级锁后,JVM唤醒线程A,线程A执行同步代码块

    • 线程A持有轻量级锁,线程A执行完同步块代码之后,一直没有线程来竞争对象锁,正常释放轻量级锁。

    释放轻量级锁操作:CAS操作将线程A的锁记录(Lock Record)中的Mark Word替换回锁对象头中。

    • 线程A持有轻量级锁,执行同步块代码过程中,线程B来竞争对象锁。
      Mark word(锁标志位-00,其他位-线程A锁记录的指针)

    • 线程B会先在栈帧中建立锁记录,存储锁对象目前的Mark Word的拷贝

    • 线程B通过CAS操作尝试将锁对象的Mark Word的指针指向线程B的Lock Record,如果成功,说明线程A刚刚释放锁,线程B竞争到锁,则执行同步代码块。

    • 因为线程A一直持有锁,大部分情况下CAS是会失败的。CAS失败之后,线程B尝试使用自旋的方式来等待持有轻量级锁的线程释放锁。

    • 线程B不会一直自旋下去,如果自旋了一定次数后还是失败,线程B会被阻塞,等待释放锁后唤醒。此时轻量级锁就会膨胀为重量级锁。Mark word(锁标志位-10,其他位-重量级锁monitor的指针)

    • 线程A执行完同步块代码之后,执行释放锁操作,CAS 操作将线程A的锁记录(Lock Record)中的Mark Word 替换回锁对象对象头中,因为对象头中已经不是原来的轻量级锁的指针了,而是重量级锁的指针,所以CAS操作会失败。

    • 释放轻量级锁CAS操作替换失败之后,需要在释放锁的同时需要唤醒被挂起的线程B。线程B被唤醒,获取重量级锁monitor

欢迎关注我的其它发布渠道