谈谈Synchronized底层及其优化
Synchronized的引出
同步问题的引出:由于多个线程对共享资源的操作而导致的同步问题
- Synchronized是JDK1.0提供的一种同步手段,来处理同步问题
Synchronized保证了可见性与原子性
可见性:确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程可见(即获得锁就同时获得了最新共享变量的值)
原子性:保证在临界区中,只有一个线程去操控修改共享变量
Synchronized关键字处理有两种模式:
- 同步代码块
- 同步方法
获取Sychronized锁的方式:
- 获取对象锁
- 获取类锁
注意:获取类锁的本质也是获取对象锁,相当于获取class对象锁,它的所有类实例共享一个类
关于对象锁与类锁:
- 当有线程访问对象的同步代码块时,其余线程可以访问该对象的非同步代码块。
- 若锁的是同一个对象时,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞
- 若锁的是同一个对象时,一个线程在访问对象的同步方法时,另一个访问对象的同步方法的线程会被阻塞
- 若锁的是同一个对象时,一个线程在访问对象的同步代码块时,另一个访问对象的同步方法的线程会被阻塞,反之成立
- 同一个类的不同对象锁互不干扰
- 类锁是一种特殊的对象锁,与上述基本一致。由于一个类只能有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的
- 类锁与对象锁互不干扰
Synchronized的实现原理
对象锁机制(monitor)
Synchronized修饰对象
public class Main {
private static Object object= new Object();
public static void main(String[] args) {
synchronized (object){
System.out.println("This is synchronized");
}
}
}
通过Javap -verbose查看字节码如下(截取重要的一段):
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 // Field object:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter <----- Look
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: ldc #4 // String This is synchronized
11: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Str
ing;)V
14: aload_1
15: monitorexit <----- Look
16: goto 24
19: astore_2
20: aload_1
21: monitorexit <----- Look
22: aload_2
23: athrow
24: return
根据上面会发现:
执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。通过分析之后可以看出,使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。不知道大家是否注意到上述字节码中包含一个monitorenter指令以及多个monitorexit指令。这是因为Java虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁,所以在Hotspot底层相当于加了一个try_finally异常处理。
下面我们再看看Hotspot底层源码
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
- WaitSet:等待队列 EntryList:锁池 用来保存ObjectMonitor对象列表
- owner字段:是指向持有ObjectMonitor的线程,当多个线程同时进入到同步列表时会先进入到EntryList中,当线程获取到对象的monitor之后就会进入到object区域并把owner指向当前线程,同时计数器count会+1,若对象调用wait方法会释放当前线程的monitor,onwer就会被恢复成null,count-1,并且该对象的实例就会被放入到WaitSet集合中等待被唤醒
- count字段则是用来支持可重入
Monitor锁的竞争,获取与释放:
Synchronized修饰同步方法
public class Main {
public synchronized void foo(){
System.out.println("Meth");
}
}
同样观察字节码:
public synchronized void foo();
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 Meth
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
- 我们会发现有一个ACC_SYNCHRONIZED标记,该标记表示在进入同步方法时,虚拟机要进行monitorenter操作,而当退出方法时不论是否正常返回都会执行monitorexit。这里的monitorenter与monitorexit都是隐式的操作
Synchronized的优化
CAS操作(compare and swap)
由于在大多数情况下,锁都会被一个线程去获取,这时没必须要去阻塞其他线程后,这样会导致频繁的上下文切换,进而性能会降低很多,所以JDK1.5之后引入CAS来优化Synchronized。CAS采用循环等待的方法,当线程没有获取到锁时,会进一步循环尝试,不会果断的挂起阻塞,当CAS操作失败后由程序员自己判断是否将其挂起还是执行其他线程。
- 在使用Synchronized锁时,相当于是一种悲观锁,在获取到锁的线程角度理解就是在任何时刻都会有别的线程会和我去竞争锁,所以当一个线程获取到锁时就会阻塞其余线程,锁的粒过大。而CAS则是一种乐观锁策略(无锁),表示在任何情况下都不会有其他线程与我去竞争锁资源,既然没有冲突自然不需要阻塞其他线程
- CAS思想:在CAS中有三个标记 :内存中的实际值(V) 预期的值(O 旧值) 更新的值(N);
- 当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。
CAS的缺点:
- 若循环时间过长,则开销很大 解决方法:自适应自旋
- 只能保证一个共享变量的原子操作 解决方法:当要操作多个共享变量时,需要使用锁
- ABA问题 解决方法:添加版本号,在JDK1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题
- 公平性:自旋状态还带来另外一个副作用,不公平的锁机制。处于阻塞状态的线程,无法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。因此Synchronized是非公平锁
Java对象头
在同步的时候是获取对象的monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在Java对象的对象头。Java对象头里的Mark Word里默认的存放的对象的Hashcode,分代年龄和锁标记位。
Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
- 偏向锁:线程第一次通过CAS成功获取锁之后,将Mark Word中的线程ID替换成当前线程ID,并且进入到偏向模式。当线程在此申请锁时,只需要检查Mark Word线程ID是否与当前线程ID是否一致,一致直接进入不需要进行同步操作,不一致表明存在锁竞争,这时升级为偏轻量级锁。
- 轻量级锁:线程交替执行同步块。当同一个时刻有多个线程申请锁时机会膨胀为重量级锁
轻量级的加锁:
- 首先会先去检查Mark Word中锁标记是否为01。如果是JVM虚拟机就会在该线程的栈帧中创建一个Lock Record(锁记录)内存空间,用于存储Mark Word的拷贝。
- 将Mark Word拷贝到锁记录中
- 尝试将锁对象中的标记字段替换成一个指针,该指针指向该线程的锁记录空间,并将锁记录中owner指针指向当前线程的对象头。如果更新成功执行步骤4,否则执行步骤5.
- 更新成功表明该线程拥有了该对象锁,并且锁记录变为00
- 更新失败,先检查Mark Word中的指针是否指向当前线程的栈帧,如果是,表明线程已经获取了该对象锁。否则表明,存在多个线程竞争,这时膨胀为重量级锁,锁记录变为10,后面等待的线程进入到阻塞状态,并且当前线程自旋尝试获取锁。
轻量级锁申请成功后,堆栈指针相互指向:
轻量级的解锁:
- 通过CAS操作尝试将线程栈帧中的锁记录替换掉堆中Mark Word
- 成功,锁释放成功
- 失败,表明此时锁已经升级为重量级锁,在释放锁的同时需要唤醒被挂起的线程。
- 重量级锁:重量级锁是JVM中最为基础的锁实现。在这种状态下,JVM虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。Java线程的阻塞以及唤醒,都是依靠操作系统来完成的。这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,其开销非常之大,所以一般采用自旋操作,避免直接将线程阻塞。
为什么要替换到当前的Mark Word?
这就要谈谈JMM与锁的内存语义,锁的内存语义:当线程释放锁时,JMM会把线程对应的本地内存中的共享变量刷新到主 内存中,当线程获取到锁时,JMM会把该线程的本地内存置位无效,直接从主内存中读取共享变量。
锁消除:在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁 举例:在一个方法中存在被StringBuffer修饰的成员变量,由于该变量只会在该方法中使用,不能被其他线程锁共享,所以JVM会自动消除其内部的锁。
锁粗化:如果存在反复加锁操作,通过扩大加锁范围,避免重复加锁操作。
自旋锁,自适应自旋