Synchronized关键字
重量级Synchronized底层实现原理
|
java语言层面 |
synchronized |
|
虚拟机字节码 |
monitorenter 和 monitorexit。ACC_SYNCHRONIZED |
|
操作系统 |
mutex |
|
CPU硬件 |
总线锁LOCK#信号 |
synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。
处理器使用总线锁就是解决这个问题的。总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。总线锁把CPU和内存之间通信锁住,使得锁定期间,其他处理器也不能操作其他内存地址的数据,所以总线锁的开销比较大。
此外,两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
我们通过JVM反编译字节码看一下synchronized关键字被解析成了如下命令:
- monitorenter 和 monitorexit(修饰代码块)
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁。
- ACC_SYNCHRONIZED(修饰方法)
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
}
Synchronized锁优化
- 锁膨胀
在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是 为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
如上面实例:
vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。
Vector<String> vector = new Vector<String>();
public void vectorTest(){
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
复制代码
- 锁消除
为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。比如对方法内变量进行同步控制。
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于程序员来说这还不清楚么?在明明知道不存在数据竞争的代码块前加上同步吗?
但是有时候程序并不是我们所想的那样,虽然没有显示使用锁,但是在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
复制代码
在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。
- 自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
所以引入自旋锁,何谓自旋锁?
所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源。所以需要比较CPU空转成本和线程切换带来的成本。阻塞或者唤醒一个JAVA的线程需要操作系统切换CPU状态来完成,这种状态的转换需要耗费处理器时间。如果同步代码块中的内容过于简单,很可能导致状态转换消耗的时间比用户代码执行的时间还要长。所以在短暂的等待之后就可以继续进行的线程,为了让线程等待一下,需要让线程进行自旋,在自旋完成之后,前面锁定了同步资源的线程已经释放了锁,那么当前线程就可以不需要阻塞便直接获取同步资源,从而避免了线程切换的开销。这就是自旋锁。
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
- 适应性自旋锁
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
- 可重入锁
也叫做递归锁,是指在一个线程中可以多次获取同一把锁。
比如:一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法【即可重入】,而无需重新获得锁。
- 偏向锁
适用场景:
无实际竞争,且将来只有第一个申请锁的线程会使用锁。
原理:
当线程请求到锁对象后,将锁对象的状态标志位改为01,即偏向模式。然后使用CAS操作将线程的ID记录在锁对象的Mark Word中。以后该线程可以直接进入同步块,连CAS操作都不需要。但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步。也无需每次加锁解锁都去CAS更新对象头,如果不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候需要锁膨胀为轻量级锁,才能保证线程间公平竞争锁。
资源消耗
- 第一次获取锁时线程需要使用CAS操作将线程ID记录在锁对象的Mark Word中。需要进行一次用户态到内核态的转化。
- 不是总线锁。
参考:
- 轻量锁
适用场景:
追求响应时间,无线程竞争或少量线程竞争。
原理:
轻量级锁由偏向锁升级而来,特点是每次获取和释放轻量级锁的是通过CAS原子操作进行的,失败的线程不会进入阻塞,而是自旋尝试再次CAS去获取锁,若失败的次数过多,则轻量级锁会膨胀为重量级锁。因为自旋也是要消耗CPU的,不能让线程一直自旋下去。根据这些,可以看出轻量级锁最适合场景是追求响应时间的情景,理想的情况是少量线程交替访问同步块、获取锁。<如果出现锁竞争过于激烈导致线程自旋等待失败,则膨胀为重量级锁>
资源消耗:
- 每次线程获取锁和释放锁都要通过CAS操作对对象Mark Word进行更新,和偏向锁相比,多出了多次用户态到内核态的切换成本。
- CPU自旋成本。
- 不是总线锁。
- 重量级锁
适用场景:
重量级锁是轻量级锁受到激烈竞争时,为防止cpu自旋过度浪费膨胀而来,因此重量级锁肯定是应付大量线程同时访问同步块的情景。让申请锁失败的线程阻塞后,cpu会出让执行有效程序,因此数据的吞吐量也就上来了。
资源消耗:
- 线程阻塞和唤醒需要调用内核指令实现,CPU需要进行用户态和内核态切换。
- 获取和释放锁时,需要调用操作系统mutex内核指令实现,CPU用户态和内核态切换资源消耗。
- 使用mutex调用总线锁。锁CPU总线,阻塞其他CPU操作,性能降低明显。
Synchronized用法
无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是类.
参考:www.jianshu.com/p/29854dc7b…
Java的synchronized关键字作用范围:
- 作用于实例方法,锁的是当前实例对象。
- 作用于静态方法,锁的是当前类对象。
- 作用于代码块,锁的是Synchronized里配置的对象,由程序员指定。
Volatile关键字
可见性和有序性实现原理(MESI协议+内存屏障)
volatile关键字在各层面语义:
|
java语言层面 |
volatile关键字 |
|
虚拟机字节码 |
无区别 |
|
操作系统/汇编指令 |
lock指令 |
|
CPU硬件 |
缓存一致性协议,内存屏障 |
有序性:JVM+硬件内存屏障实现,防止JVM层面和CPU层面指令乱序执行。
可见性:CPU缓存数据一致性由CPU缓存一致性协议实现;缓存内存数据一致性由硬件内存屏障语义实现。
适用场景
它保证了可见性和有序性,但是它不保证原子性。
不要将volatile用在getAndOperate场合,仅仅set或者get的场景是适合volatile的。
volatile为什么没有原子性?
AtomicInteger自增,例如你让一个volatile的integer自增(i++),其实要分成3步:
1)读取volatile变量值到local;
2)增加变量的值;
3)把local的值写回,让其它的线程可见。这3步的jvm指令为:
mov 0xc(%r10),%r8d ; Load
inc %r8d ; Increment
mov %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier
注意最后一步是内存屏障。
回到前面的JVM指令:从Load到store到内存屏障,一共4步,其中最后一步jvm让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但**中间的几步(从Load到Store)**是不安全的,中间如果其他的CPU修改了值将会丢失。
参考: