深入理解 Synchronized
同步
synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础。
synchronized 常见的三种用法如下:
- 普通同步方法,锁是当前实例对象
- 静态同步方法,锁是当前类的class对象
- 同步方法块,锁是括号里面的对象
通过如下代码来分析下synchronized 获取的是哪个对象的锁
public class SynTest {
private static List<String> list = new ArrayList<String>();
//当前实例的锁
public synchronized void add1(String s){
list.add(s);
}
//SynTest.class 锁
public static synchronized void add2(String s){
list.add(s);
}
//SynTest.class 锁
public void add3(String s){
synchronized(SynTest.class){
list.add(s);
}
}
//当前实例的锁
public void add4(String s){
synchronized(this){
list.add(s);
}
}
}
普通同步方法,锁是当前实例对象
add1 方法是synchronized的第一种用法,因为它是普通同步方法,所以获取当前实例的锁。
add2 方法是synchronized的第二种用法,因为它是静态同步方法,所以获取SynTest.class的锁。
add3 方法是synchronized的第三种用法。指定锁:SynTest.class。
add4 方法是synchronized的第三种用法。指定锁:this(当前实例)。
结论:
add1和add4方法的锁都是 当前实例,所以add1和add4 可以实现方法互斥
add2和add3方法的锁都是SynTest.class,所以add2和add3可以实现方法互斥。
注意:add1和add2两个synchronized是不互斥的,因为他们不是同一把锁。只有同一把锁才会互斥。
synchronized块编译成字节码后会在同步块的入口位置和退出位置分别插入monitorenter和monitorexit字节码指令。如下图:
Paste_Image.png
monitorenter和monitorexit指令规则:
- monitorenter在编译后插入到同步代码库的开始位置。
- monitorexit插入在方法结束处和异常处。
- 一个monitorenter必须保证有对应的monitorexit。
- 任何对象都有一个monitor以之关联,当一个monitor被持有后,该对象出于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,尝试获取对象的锁。
静态方法和普通方法在编程成字节码后会在方法的访问标识字段中标识为同步方法。
Paste_Image.png
方法同步的细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。
需要提前了解知识点:java内存模型
内存可见性
synchronized关键字强制实施一个互斥锁,使得被保护的代码块在同一时间只能有一个线程进入并执行。当然synchronized还有另外一个 方面的作用:在线程进入synchronized块之前,会把工作存内存中的所有内容映射到主内存上,然后把工作内存清空再从主存储器上拷贝最新的值。而 在线程退出synchronized块时,同样会把工作内存中的值映射到主内存,但此时并不会清空工作内存。这样一来就可以强制其按照上面的顺序运行,以 保证线程在执行完代码块后,工作内存中的值和主内存中的值是一致的,保证了数据的一致性!
所以由synchronized修饰的set与get方法都是相当于直接对主内存进行操作,不会出现数据一致性方面的问题。
指令重排序
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。
synchronized块对应java程序来说是原子操作,所以说内部不管怎么重排序都不会影响其它线程执行导致数据错误。执行的指令也不会溢出方法。
happens-before
监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
锁优化
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。在Java1.5中,synchronize是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多。相比之下使用Java提供的Lock对象,性能更高一些。但是到了Java1.6,发生了变化。synchronize在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地。
偏向锁
简单的理解,偏向于这个线程。 当一个线程进入同步块,首先测试对象的mark word 中的threadId是否等同当前线程ID,如果成功,则获取锁成功,否则。首先测试mark word中的锁标识是否为1 。如果没有设置。则升级为轻量级锁。否则 通过CAS操作,将自己的线程ID 尝试放入 的mark word 的位置。如果成功则获取锁成功。否则,说明对象存在竞争。将会在适当的地方挂起获取锁的线程。然后升级锁。接着执行代码。
偏向锁默认为开启状态。只是会在应用启动后延迟启动通过参数可以设置不延时XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
轻量级锁
JVM通过CAS修改对象头来获取锁,如果CAS修改成功则获取锁,如果获取失败,则说明有竞争,则通过CAS自旋一段时间来修改对象头,如果还是获取失败,则升级为重量级锁。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
锁消除:
锁消除是指虚拟机在即时编译器在运行时,对于一些在代码上要求同步,但是被检测到不可能存在数据竞争的锁进行消除。比如,一个锁不可能被多个线程访问到,那么在这个锁上的同步块JVM将把锁消除掉。
锁粗化
程序中一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
for(int i=0; i<1000; i++){
synchronized(this){
...
}
}
上面代码JVM将会优化成如下:
synchronized(this){
for(int i=0; i<1000; i++){
...
}
}
想了解更多精彩内容请关注我的公众号