面试——Synchronized
如何解决线程并发安全问题
在多个线程操作同一共享变量时,在对临界资源操作时,容易出现线程安全问题。因此需要同步机制来解决线程安全问题。
与CAS乐观锁机制相同,Synchronized也能实现上锁,但Synchronized实现的是悲观锁。
Synchronized也称为内置锁或隐式锁,因为其加锁的方式很Lock不同,用了隐式上锁的方式。
学习Synchronized,我们重点关注以下几点:
- Synchronized在jdk1.6版本之前性能较差,1.6及之后使用了锁的膨胀升级
- Synchronized的底层实现逻辑
Synchronized应用场景
Synchronized一般用在以下这几种场景:
- 修饰实例方法,对当前**实例对象(this)**加锁
public synchronized void lockMethod(){
System.out.println("lock method");
}
- 修饰静态方法,对当前**类对象(Class对象)**加锁
public static synchronized void lockStaticMethod(){
System.out.println("lock static method");
}
- 修饰代码块,指定对某个对象进行加锁
public void lockObject(){
synchronized (object){
System.out.println("lock object");
}
}
根据锁的粒度来选择使用哪一种
- 比如使用静态方法上锁,锁的粒度是整个Class对象,如果大量线程都在使用Class对象作为锁对象,那么锁的粒度很大。
- 比如
System.out.println()
这种方式底层是对PrintStream上锁,但PrintStream又是单例的,因此在代码中如果大量使用了System.out.println()
,性能会受影响。
/**
* Prints a String and then terminate the line. This method behaves as
* though it invokes <code>{@link #print(String)}</code> and then
* <code>{@link #println()}</code>.
*
* @param x The <code>String</code> to be printed.
*/
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
Synchronized锁的膨胀升级过程
Synchronized在1.6版本之前性能较差,在并发不严重的情况下,因为Synchronized依然对象上锁,每个对象需要维护一个Monitor管程对象,管程对象需要维护一个Mutex互斥量对象。Mutex是由操作系统内部的pthread线程库维护的。上锁需要通过JVM从用户态切换到内核态来调用底层操作系统的指令,这样操作的性能较差。
AQS框架中的ReentrantLock锁通过Java语言编写,实现了可重入锁和公平锁,且性能比Synchronized要好太多。
JDK1.6版本为了弥补Synchronized的性能缺陷,设计了Synchronized锁的膨胀升级。也就是根据当前线程的竞争激烈程度,设计了不同效果的锁。
对象头
在对象的创建的过程中,涉及到以下过程:
其中为对象设置对象头信息,对象头信息包含以下内容:类元信息、对象哈希码、对象年龄、锁状态标志等。其中锁状态标志,就是当前对象属于哪一种锁。
- 对象头中的Mark Word 字段(32位)
- 对象头中的类型指针(Klass Pointer)
类型指针用于指向元空间当前类的类元信息。比如调用类中的方法,通过类型指针找到元空间中的该类,再找到相应的方法。
开启指针压缩后,类型指针只用4个字节存储,否则需要8个字节存储。
膨胀升级
- 无锁状态:当对象锁被创建出来时,在线程获得该对象锁之前,对象处于无锁状态。
- 偏向锁:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。
偏向锁的核心思想是,⼀旦有线程持有了这个对象,标志位修改为1,就进⼊偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
-
轻量级锁:对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
-
重量级锁:通过自旋,让线程在等待时不会被挂起。自旋次数默认是10次,可以通过
-XX:PreBlockSpin
进行修改。如果自旋失败到达阈值,则将升级为重量级锁。
-
什么是轻量级锁:
- 如果对象是无锁的,JVM会在当前线程的栈帧中建立一个Lock Record(锁记录)的空间,用来存放对象的Mark Work拷贝,然后把Lock Record中的owner属性指向当前对象。
- 接下来JVM会利用CAS尝试把对象原本的Mark Word更新回Lock Record的指针,成功就说明加锁成功,于是改变锁标志位,执行相关同步操作
- 如果失败了,判断当前对象的Mark Word是否指向当前线程的栈帧,如果是就表示当前线程已经持有该对象锁。如果不是,说明当前对象锁被其他线程持有,于是进行自旋。
- 什么是自旋锁:
- 线程通过不断的自旋尝试上锁
- 为什么要自旋?因为如果线程被频繁挂起,也就意味着系统在用户态和内核态之间频繁的切换。——我们所有的程序都在⽤户空间运⾏,进⼊⽤户运⾏状态也就是(⽤户态),但是很多操作可能涉及内核运⾏,⽐如I/O,我们就会进⼊内核运⾏状态(内核态)。
- 通过自旋,让线程在等待时不会被挂起。自旋次数默认是10次,可以通过
-XX:PreBlockSpin
进行修改。如果自旋失败到达阈值,则将升级为重量级锁。
注意,锁的膨胀升级,只能升不能降,也就是说升级过程不可逆。
Synchronized的底层实现逻辑
同步代码块的上锁逻辑
先来看一个Java例子:
package com.qf.intro;
public class LockOnObjectDemo {
public static Object object = new Object();
private Integer count = 10;
public void decrCount(){
synchronized (object){
--count;
if(count <= 0){
System.out.println("count小于0");
return;
}
}
}
}
使用javap -c LockOnObjectDemo.class
命令来看其中的信息:
public void decrCount();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: getstatic #4 // Field object:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter
6: aload_0
7: aload_0
8: getfield #3 // Field count:Ljava/lang/Integer;
11: invokevirtual #5 // Method java/lang/Integer.intValue:()I
14: iconst_1
15: isub
16: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
19: putfield #3 // Field count:Ljava/lang/Integer;
22: aload_0
23: getfield #3 // Field count:Ljava/lang/Integer;
26: invokevirtual #5 // Method java/lang/Integer.intValue:()I
29: ifgt 43
32: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
35: ldc #7 // String count小于0
37: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
40: aload_1
41: monitorexit
42: return
43: aload_1
44: monitorexit
45: goto 53
48: astore_2
49: aload_1
50: monitorexit
51: aload_2
52: athrow
53: return
Exception table:
from to target type
6 42 48 any
43 45 48 any
48 51 48 any
LineNumberTable:
line 14: 0
line 15: 6
line 16: 22
line 17: 32
line 18: 40
line 20: 43
line 21: 53
LocalVariableTable:
Start Length Slot Name Signature
0 54 0 this Lcom/qf/intro/LockOnObjectDemo;
StackMapTable: number_of_entries = 3
frame_type = 252 /* append */
offset_delta = 43
locals = [ class java/lang/Object ]
frame_type = 68 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: new #9 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: putstatic #4 // Field object:Ljava/lang/Object;
10: return
LineNumberTable:
line 9: 0
}
SourceFile: "LockOnObjectDemo.java"
- Synchronized内置锁是一种对象锁,作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
具体的实现逻辑是通过**内部对象Monitor(监视器锁)**来实现。 - 监视器锁的实现依赖底层操作系统的Mutex Lock(互斥锁)实现。互斥锁是一个重量级锁,且性能较低。
- Synchronized关键字被编译成字节码后,会被翻译成monitorenter和monitorexit两条指令。这两条指令中间的代码会被上锁。
Monitor监视器锁
任何一个对象都有一个Monitor与之关联,当对象的Monitor被持有后,该对象处于被锁定状态。具体过程如下:
- 当我们进⼊⼀个⽅法的时候,执⾏monitorenter,就会获取当前对象的⼀个所有权,这个时候monitor进⼊数为1,当前的这个线程就是这个monitor的owner。
- 如果你已经是这个monitor的owner了,你再次进⼊,就会把进⼊数**+1**.
- 当执⾏完monitorexit,对应的进⼊数就**-1**,直到为0,才可以被其他线程持有。
所有的互斥,其实在这⾥,就是看你能否获得monitor的所有权,⼀旦你成为owner,就是获得锁者。
在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现):
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 处于wait状态的线程,加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,加入到_EntryList
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:
- 首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
- 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒;
- 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);
同时,Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
同步方法的上锁逻辑
先看这个例子:
package com.qf.intro;
public class LockOnMethodDemo {
public static Object object = new Object();
private Integer count = 10;
public synchronized void decrCount() {
--count;
if (count <= 0) {
System.out.println("count小于0");
return;
}
}
}
查看代码指令后:
public synchronized void decrCount();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: aload_0
2: getfield #3 // Field count:Ljava/lang/Integer;
5: invokevirtual #4 // Method java/lang/Integer.intValue:()I
8: iconst_1
9: isub
10: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
13: putfield #3 // Field count:Ljava/lang/Integer;
16: aload_0
17: getfield #3 // Field count:Ljava/lang/Integer;
20: invokevirtual #4 // Method java/lang/Integer.intValue:()I
23: ifgt 35
26: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
29: ldc #6 // String count小于0
31: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
34: return
35: return
LineNumberTable:
line 13: 0
line 14: 16
line 15: 26
line 16: 34
line 19: 35
LocalVariableTable:
Start Length Slot Name Signature
0 36 0 this Lcom/qf/intro/LockOnMethodDemo;
StackMapTable: number_of_entries = 1
frame_type = 35 /* same */
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: new #8 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: putstatic #9 // Field object:Ljava/lang/Object;
10: return
LineNumberTable:
line 8: 0
}
在同步方法里有一个标志位ACC_SYNCHRONIZED。
同步⽅法的时候,⼀旦执⾏到这个⽅法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调⽤刚才的两个指令:monitorenter和monitorexit。所以归根究底,还是monitor对象的争夺。