作用
保证在同一时刻最多有一个线程能执行Synchroized修饰的代码,被修饰的代码就会以原子的形式运行,不会存在并发问题,从而达到并发安全的效果。
Synchronized 的具体用法
- Synchronized修饰普通方法时,锁对象默认为this,锁住的是该类的实例对象
public synchronized void testMethod(){}
- Synchronized修饰静态方法时,锁住的类对象
public static synchronized void testStaticMethod(){}
- 同步代码块,this时,锁住的是类的实例
public void test(){
synchronized(this){
}
}
- 同步代码块,Demo.class,锁住的是类对象
public class Demo{
public void test(){
synchronized(Demo.class){
}
}
}
Synchroinzed 原理
synchronized的实现依赖虚拟机, HotSpot虚拟机中,对象的内存布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
对象头中主要包括两部分数据:类型指针和标记字段,
通过类型指针可以知道该对象是什么类型的,
标记字段用来存储对象运行时的数据,其中就有锁对象的指针,
标记字段中的锁对象指针指向了一个monitor对象,每个对象都有一个对应的monitor对象,
代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用ACC_SYNCHRONIZE实现的
我们先通过反编译下面的代码来看看Synchronized是如何实现对代码块进行同步的:
monitorenter/monitorexit
上面说到每个对象都有一个monitor,线程执行monitorenter指令来尝试获取monitor的所有权,
-
如果monitor的进入数为0,则该线程进入monitor,然后将进入数+1,该线程即为monitor的所有者。
-
如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数+1.
-
当一个线程尝试获取monitor时,其他线程占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0时,再尝试获取monitor。
-
monitorexit指令执行时,monitor的进入数-1,如果-1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。 其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
为什么反编译出来有两个monitorexit? 第一个monitorexit指令是同步代码块正常释放锁, 如果同步代码块出现异常,则会调用第二个monitorexit来保证释放锁。
ACC_SYNCHRONIZE
从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成
而常量池中多了ACC_SYNCHRONIZED标示符。
JVM根据这个ACC_SYNCHRONIZE来实现方法的同步的: 方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了, 执行线程将先获取monitor,获取成功之后才能执行方法体, 而在退出该方法时,不论正常返回,还是向调用者抛异常返回,JVM都进行 monitorexit 操作。 方法执行完后再释放monitor。 在方法执行期间,其他任何线程无法获得monitor对象。
monitor和 ACC_SYNCHRONIZE本质上没有区别,ACC_SYNCHRONIZE是一种隐式的方式来实现。
锁的实现原理
JVM 中的同步是基于进入和退出Monitor实现的。每个对象实例都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现
ObjectMonitor() {
_header = NULL;
_count = 0; // 线程获取锁的次数
_waiters = 0,
_recursions = 0; // 递归,锁的重入次数
_object = NULL;
_owner = NULL; // 持有monitor的线程
_WaitSet = NULL; // 处于 wait 状态, 即等待monitor的线程,会被加入到 _WaitSet
_WaitSetLock = 0 ; // 自旋
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁 block 状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
- 当Thread1、Thread2访问同步代码块时Thread1,Thread2会先进入ObjectMonitor的EntryList中等待;
- 接下来当Thread1获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,Thread1申请 Mutex 成功,则持 有该 Mutex,其它线程将无法获取到该 Mutex。ObjectMonitor的owner属性指向Thread1,EntryList中还剩Thread2在等待
- 如果Thread1调用了wait()方法,就会释放当前持有的 Mutex,Thread1进入WaitSet并释放锁,ObjectMonitor的owner属性等于null。 如果Thread1执行完毕,也会释放所持有的Mutex。
- Thread2获取到锁进入同步代码块,ObjectMonitor owner属性指向Thread2,任务执行完退出同步代码之前调用notifyAll, Thread1被唤醒,从WaitSet转到EntryList中等待锁,Thread2退出同步代码块,ObjectMonitor owner属性为null;
所以,Monitor依赖操作系统实现,存在用户态和内核态的切换,增加了性能开销。
锁升级过程
java1.6之后Synchronized进行了一些优化,引入了偏向锁、轻量级锁、重量级锁。 前面说到 Java 对象头由 Mark Word、指向类的指针组成(如果是数组对象,则还有数组长度) Mark Word 记录了对象和锁有关的信息。以64位JVM为例,存储结构如下
| 锁状态 | 存储内容 | 偏向锁标识位(1bit) | 锁标识位(2bit) |
|---|---|---|---|
| 无锁 | hash code(31bit),年龄(4bit),(25bit unused) | 0 | 01 |
| 偏向锁 | 线程ID(54bit),时间戳Epoch(2bit),分代年龄(4bit) | 1 | 01 |
| 轻量级锁 | 指向轻量级锁的指针 | 无 | 00 |
| 重量级锁 | 指向重量级锁的指针 | 无 | 10 |
锁升级就是 Mark Word 中的锁标志位和释放偏向锁标志位的变化,从偏向锁开始的,偏向锁升级到轻量级锁,再升级到重量 级锁的过程。锁只能升级,不能降级。
偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁
在大多情况下,虽然我们加了Synchronized关键字,保证了它的线程安全性, 但是在实际当中多数情况下只有一个线程运行这段代码,并没有其它线程来和它竞争。 这一个线程每次执行代码都需要获取锁和释放锁,每次操作都会发生用户态与内核态的切换,这个开销是没有必要的。 所以引入了偏向锁。 偏向锁的意思就是偏向第一个获得锁的线程。偏向锁在一个线程第一次访问的时候将该线程的id记录下来, 当一个线程再次访问这个同步代码块或方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向它的 ID,如果有,就无需再进入 Monitor 去竞争对象了。 当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是 01,“是否偏向锁”标 志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。 如果有另一个线程也来访问它,说明有可能出现线程并发。此时偏向锁就会升级为轻量级锁。
在高并发场景下,当大量线程同时竞争同一个锁时,偏向锁就会被撤销,可以通过添加 JVM 参数UseBiasedLocking关闭偏向锁来调优性能
参数 -XX:-UseBiasedLocking 关闭偏向锁,默认开启。
偏向锁适用于只有一个线程访问同步块场景。
轻量级锁
如果有其它线程也来访问同步代码时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己,就会进行 CAS 操作获取锁,如果获取成功,就替换Mark Word 中的线程 ID,该锁会保持偏向锁状态; 如果获取锁失败,代表当前锁有一定的竞争,偏向锁会升级为轻量级锁。 轻量级锁是如何加锁的呢? 线程在执行同步块时,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间Lock Record, 并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。 然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。 如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁适用于线程交替执行同步块,不存在长时间竞争的场景
重量级锁
轻量级锁 CAS 抢锁失败,线程将会通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。 这是基于大多数情况下,线程持有锁的时间都不会太长, JDK1.7后,自旋锁默认开启,自旋次数由 JVM 设置决定,如果自旋锁重试之后抢锁还是失败,轻量级锁就会升级至重量级锁。
重量级锁也就是文章开始提到的通过monitor对象来实现的,我们先看下重量级锁的执行过程, 当一段代码被上锁以后同一时间只能有一个线程执行,如果有其他线程也要执行 其他线程都会进入 Monitor,之后会被阻塞在 _WaitSet,等线程执行完后,队列中其他线程还会被唤醒, 这些动作都是操作系统级别的处理,比较消耗资源。
在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。重量级锁适用于同步块执行速度较长的场景。
Synchronized锁升级过程
综上所述,Synchronized锁升级过程
- 检测Mark Word里面是否是当前线程的ID,如果是,表示当前线程处于偏向锁
- 如果不是,则使用CAS将当前线程的ID替换Mark Word,如果成功则表示当前线程获得偏向锁,如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
- 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级状态,如果自旋失败,则升级为重量级锁。
锁优化
除了上面说的锁升级优化,Java 还使用了编译器对锁进行优化。
锁消除
及时编译器在动态编译同步块的时,如果坚持到不可能存在共享数据竞争的问题,就会进行锁消除。锁消除是依赖逃逸分析技术的。在一段代码中,数据不会逃逸出去被其他线程访问到。 例如下代码
public String concat(String s1, String s2, String s3){
return s1 + s2 + s3;
}
JDK5之前,会转换成StringBuffer进行连续的append操作。JDK5之后会转化成StringBuilder的append操作。 对于StringBuffer而言,每次append都会上锁。
public String concat(String s1, String s2, String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
StringBuffer是线程安全的,append()方法是同步方法,但是StringBuffer的作用域在concat方法中,没有发生逃逸, 所以这里的append上的synchronized锁会被消除掉。
锁粗化
锁粗化是在编译器动态编译时,如果发现连续几个相邻的同步块使用的是同一个锁 实例,那么编译器将会把这几个同步块合并为一个大的同步块,比如上面StringBuffer的例子中,连续调用了三次append(), 编译器优化会将同步锁放在第一个append开始和最后一个append结束。 避免一个线程反复获取、释放同一个锁所带来的性能开销。
Synchronized与Lock
JDK1.5 之后,Java 提供了 Lock 同步锁,它与Synchronized有何不同呢?
- 实现方式不同:Synchronized是依赖JVM底层实现,Lock是Java代码底层AQS实现。
- 锁的获取和释放:Synchronized是隐式获取和释放,Lock需要通过调用lock(),tryLock()获取锁,unlock()释放锁,显示的获取和释放。
- 是否可中断,Synchronuzed不可中断,Lock可中断。