操作系统分为“用户空间”和“内核空间”,JVM是运行在“用户态”的,jdk1.6之前,在使用synchronized锁时需要调用底层的操作系统实现,其底层monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从“用户态”转为“内核态”,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能 带来了很大的压力。
同这个时候CPU就需要从“用户态”切向“内核态”,在这个过程中就非常损耗性能而且效率非常低,所以说jdk1.6之前的synchronized是重量级锁。如下图所示:
简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其底层是通过CAS实现的。无锁无法全方位代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及 ThreadID即可。
一开始无锁状态,JVM会默认开启“匿名”偏向的一个状态,就是一开始线程还未持有锁的时候,就预先设置一个匿名偏向锁,等一个线程持有锁之后,就会利用CAS操作将线程ID设置到对象的mark word 的高23位上【32位虚拟机】,下次线程若再次争抢锁资源的时,多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,只需要在置换ThreadID的时候依赖一次CAS原子指令即可。如图所示:
偏向锁的获取过程:
首先线程访问同步代码块,会通过检查对象头 Mark Word 的锁标志位判断目前锁的状态,如果是 01,说明就是无锁或者偏向锁,然后再根据是否偏向锁 的标示判断是无锁还是偏向锁,如果是无锁情况下,执行下一步。
线程使用 CAS 操作来尝试对对象加锁,如果使用 CAS 替换 ThreadID 成功,就说明是第一次上锁,那么当前线程就会获得对象的偏向锁,此时会在对象头的 Mark Word 中记录当前线程 ID 和获取锁的时间 epoch 等信息,然后执行同步代码块。
全局安全点(Safe Point):全局安全点的理解会涉及到 C 语言底层的一些知识,这里简单理解 SafePoint 是 Java代码中的一个线程可能暂停执行的位置。
等到下一次线程在进入和退出同步代码块时就不需要进行 CAS 操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的。如果用流程图来表示的话就是下面这样:
关闭偏向锁:
偏向锁在Java 6 和Java 7 里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
关于 epoch
偏向锁的对象头中有一个被称为 epoch 的值,它作为偏差有效性的时间戳。
当线程交替执行同步代码块时,且竞争不激烈的情况下,偏向锁就会升级为轻量级锁。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。
其目标就是在只有一个线程执行同步代码块时能够提高性能。当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。
在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
轻量级锁是指当前锁是偏向锁的时候,资源被另外的线程所访问,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能,下面是详细的获取过程。
轻量级锁加锁过程:
在很多场景下,共享资源的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。
如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这就是自旋锁。
当一个线程t1、t2同事争抢同一把锁时,假如t1线程先抢到锁,锁不会立马升级成重量级锁,此时t2线程会自旋几次(默认自旋次数是10次,可以使用参数-XX : PreBlockSpin来更改),若t2自旋超过了最大自旋次数,那么t2就会当使用传统的方式去挂起线程了,锁也升级为重量级锁了。
自旋的等待不能代替阻塞,暂且不说对处理器数量的要求必须要两个核,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,如果锁被占用的时间很长,那自旋的线程只会消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。
自旋锁在jdk1.4中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在jdk1.6之后自旋锁就已经默认是打开状态了。
自适应自旋锁
在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
重量级锁的获取流程:
使用流程图表示,如下图所示:
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
public class SynchRemoveDemo {
public static void main(String[] args) {
stringContact("AA", "BB", "CC");
}
public static String stringContact(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
return sb.append(s1).append(s2).append(s3).toString();
}
}
//StringBuffer 源代码中append()方法源码
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
JVM字节码分析:
StringBuffer的append()是一个同步方法,锁就是this也就是sb对象。虚拟机发现它的动态作用域被限制在stringContact()方法内部。
也就是说, sb对象的引用永远不会“逃逸”到stringContact()方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
“对象的逃逸分析”:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象优先在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。
可以通过下面的例子来看一下:
public class SynchDemo {
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 50; i++) {
sb.append("AA");
}
System.out.println(sb.toString());
}
}
//StringBuffer 源代码中append()方法源码
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
JVM字节码代码分析:
StringBuffer的append()是一个同步方法,通过上面的代码可以看出,每次循环都要给append()方法加锁,这时系统会通过判断将其修改为下面这种,直接将原append()方法的synchronized的锁给去掉直接加在了for循环外。
public class SynchDemo {
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
synchronized(sb){
for (int i = 0; i < 50; i++) {
sb.append("AA");
}
}
System.out.println(sb.toString());
}
}
//StringBuffer 源代码中append()方法源码
@Override
public StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}