JVM对synchronized的优化
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
自旋锁与自适应自旋
synchronized 属于重量级锁,本质上是通过互斥同步来实现线程安全,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
总结来说:synchronized 会阻塞其他线程的执行,另外线程切换开销大,导致性能低下。
Java 虚拟机开发人员为此进行了如下优化:如果机器能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁在 JDK 1.4.2 中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning 参数来开启,在 JDK 6中就已经改为默认开启了。 但自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次, 用户也可以使用参数-XX:PreBlockSpin 来自行更改。
自旋次数不管是采用默认值还是自己设置,并不能应对所有的锁情况,无法起到好的效果。因此 JDK6 引入了自适应的自旋锁。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
锁消除
锁消除是指 JIT 编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。 具体来说就是,JIT 编译器可以借助逃逸分析来判断,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks
(默认开启)可以开启同步消除。
来看一个经典案例:
public String concatString(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); } 复制代码
而 StringBuffer 的 append 方法定义如下:
public synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this; } 复制代码
也就是说在 concatString()方法中涉及了同步操作。但是可以观察到 sb 对象它的作用域被限制在方法的内部,也就是 sb 对象不会“逃逸”出去,其他线程无法访问。因此,虽然这里有锁,但是可以被安全的消除,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
锁粗化
在上文讲述 synchronized 的三种实现方式时,推荐我们使用同步代码块,将可能出现线程安全问题的代码圈起来即可,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。
以上原则大多数情况下是正确的,但特殊情况特别对待,如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如下案例,我们暂时不考虑锁消除的情况,连续的 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(); } 复制代码
所以我们可以将把加锁同步的范围扩展(粗化) 到整个操作序列的外部,如下代码所示:
public String concat(String s1, String s2, String s3) { StringBuilder sb = new StringBuilder(); synchronized (this){ sb.append(s1); sb.append(s2); sb.append(s3); } return sb.toString(); } 复制代码
无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。无锁本质上是基于 CAS 原理实现的,后续我们会详细介绍这一原理。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当某个线程第一次访问同步代码块并获取锁时,使用 CAS 操作在 Mark Word 里存储锁偏向的线程 ID,将会把对象头中的标志位设置为“01”、 把偏向模式设置为“1”, 表示进入偏向模式。持有偏向锁的线程之后再进入和退出同步块时,不需要再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
一旦有其他线程尝试竞争偏向锁时,持有偏向锁的线程会释放锁,它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在 JDK 6及以后的 JVM 里是默认启用的。可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁
轻量级锁是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
轻量级锁升级为重量级锁的场景:
1、若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
2、另外在轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时 Mark Word 中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
整体的锁状态升级流程如下:
锁的差异
synchronized其他特性
synchronized是非公平锁
首先我们来看一下公平锁与非公平锁的描述,如下图所示:
而 synchronized 关键字则是非公平锁,加上 synchronized 是依赖于 JVM 实现的,具体实现我们无法查看,这里就不探究了。未来学习 Lock 锁时会深入源码来学习公平锁与非公平锁的实现。
synchronized的可重入性
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:
public class Widget { public synchronized void doSomething() { System.out.println("方法1执行..."); doOthers(); } public synchronized void doOthers() { System.out.println("方法2执行..."); } } 复制代码
在上面的代码中,类中的两个方法都是被内置锁 synchronized 修饰的,doSomething()方法中调用 doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用 doOthers()时可以直接获得当前对象的锁,进入 doOthers()进行操作。
注意由于 synchronized 是基于 monitor 实现的,因此每次重入,monitor 中的计数器(_recursions)仍会加1。
线程中断与synchronized
Java 的中断是一种协作机制。也就是说调用线程对象的 interrupt 方法并不一定就中断了正在运行的线程,它只是要求线程自己在合适的时机中断自己。每个线程都有一个 boolean 的中断状态(这个状态不在Thread的属性上),interrupt 方法仅仅只是将该状态置为 true。
关于中断的讲解,推荐阅读Thread的中断机制(interrupt)和 Java Thread的interrupt详解,这两篇文章讲的非常详细。
这里只介绍一下中断与 synchronized 之间的关联:synchronized 在获锁的过程中是不能被中断的,意思是说如果产生了死锁,则不可能被中断。
public class SynchronizedAndInterrupt implements Runnable { boolean stop = false; public SynchronizedAndInterrupt() { new Thread(() -> foo(),"thread-A").start(); } public synchronized void foo() { while (!stop) { System.out.println(Thread.currentThread() + "" + System.currentTimeMillis()); // 让该循环持续一段时间,使上面的话打印次数少点 long time = System.currentTimeMillis(); while ((System.currentTimeMillis() - time < 1000)) { } } } @Override public void run() { while (!stop) { if (Thread.interrupted()) { stop = true; break; } else { foo(); } } } public static void main(String[] args) throws InterruptedException { SynchronizedAndInterrupt obj = new SynchronizedAndInterrupt(); Thread thread = new Thread(obj); thread.start(); Thread.sleep(5000); System.out.println(thread.getName() + " Interrupting thread..."); thread.interrupt(); System.out.println(thread.getName() + " 线程" + thread + "是否中断:" + thread.isInterrupted()); } } 复制代码
执行结果如下所示,并且会一直打印日志。
Thread[thread-A,5,main]1658298529537 Thread[thread-A,5,main]1658298530537 Thread[thread-A,5,main]1658298531537 Thread[thread-A,5,main]1658298532537 Thread[thread-A,5,main]1658298533537 Thread[thread-A,5,main]1658298534537 Thread-0 Interrupting thread... Thread-0 线程Thread[Thread-0,5,main]是否中断:true Thread[thread-A,5,main]1658298535537 Thread[thread-A,5,main]1658298536537 ...... 复制代码
根据结果可知:当我们在 SynchronizedAndInterrupt 构造函数中创建一个新线程并启动获取调用 foo()获取到当前实例锁,由于 SynchronizedAndInterrupt 自身也是线程,启动后在其 run方法中也调用了 foo(),但由于对象锁被其他线程占用,导致线程 Thread-0 只能等到锁,此时我们调用了thread.interrupt();
但并不能中断线程 thread-A。
等待唤醒机制
线程通信
多线程意味着线程间存在交互问题,各线程在执行过程中会相互通信。所谓通信就是指相互交换一些数据或者发送一些控制指令,比如一个线程给另一个暂停执行的线程发送一个恢复执行的指令。
线程通信需要考虑很多问题:共享变量的内存可见性问题、原子性问题以及指令重排序问题。Java提供了volatile 和 synchronized 的同步手段来保证通信内容的正确性。
wait 和 notify/notifyAll 就是线程通信的一种方式。
wait、notify/notifyAll方法
当一个线程获取到锁之后,因为某些原因可能暂时释放锁,然后该线程就会进入等待队列里等待去,等到其他线程通知某个线程把这个条件完成后,就通知等待队列里的线程他们等待的条件满足了,可以继续运行了。
当一个线程获取到锁之后,如果因为某个条件不满足,需要主动让出锁,该线程就会被放到一个等待队列里等待去,等到某个线程把这个条件完成后,就通知等待队列里的线程它们等待的条件满足了,可以继续运行了。
注意:Java 规定每个锁(Monitor)对应一个等待队列(_WaitSet)。
synchronized 可以与 wait 和 notify/notifyAll 结合使用,不过在使用这三个方法时,必须处于 synchronized 代码块或者 synchronized方法中,否则就会抛出 IllegalMonitorStateException 异常。
原因如下:因为调用这几个方法前必须拿到当前对象的监视器 monitor 对象,也就是说 notify/notifyAll 和 wait 方法依赖于 monitor 对象,在前面的分析中,我们知道 monitor 存在于对象头的 Mark Word 中(存储monitor引用指针),而 synchronized 关键字可以获取 monitor。
另外,这也是 wait 和 notify/notifyAll 是顶级对象 Object 的方法的原因,在 monitor 对象中有对应的方法实现。
注意事项
1、与 sleep 方法不同的是,wait 方法调用完成后,线程将被暂停,但 wait 方法将会释放当前持有的监视器锁(monitor),直到有线程调用 notify/notifyAll 方法后方能继续执行;而 sleep 方法只让线程休眠并不释放锁。
2、notify/notifyAll 方法调用后,并不会马上释放监视器锁,而是在被 synchronized 修饰的代码或方法执行结束后才自动释放锁。
3、wait()必须在同步(Synchronized)方法/代码块中调用,因为调用 wait()就是释放锁,释放锁的前提是必须要先获得锁,先获得锁才能释放锁。
生产消费者案例
/** * @author hresh * @date 2020/2/16 21:19 * @description * 线程之间的通信问题:生产者和消费者问题 * 传统解决方法,Sychronized,wait,notify三者结合使用 */ public class A { public static void main(String[] args) { Data data = new Data(); new Thread(()->{ for (int i=0;i<20;i++){ try { data.increment(); } catch (InterruptedException e) { e.printStackTrace(); } } },"A").start(); new Thread(()->{ for (int i=0;i<10;i++){ try { data.decrement(); } catch (InterruptedException e) { e.printStackTrace(); } } },"B").start(); new Thread(()->{ for (int i=0;i<10;i++){ try { data.decrement(); } catch (InterruptedException e) { e.printStackTrace(); } } },"C").start(); } } class Data { private int num = 0; //判断等待,业务,通知 public synchronized void increment() throws InterruptedException { //注意这里使用的是while判断,而非if判断,防止虚假唤醒 while (num != 0){ //等待 this.wait(); } num++; System.out.println(Thread.currentThread().getName()+"=>"+num); //通知其他线程,我+1完毕了 this.notifyAll(); } public synchronized void decrement() throws InterruptedException { while (num == 0){ this.wait(); } num--; System.out.println(Thread.currentThread().getName()+"=>"+num); //通知其他线程,我-1完毕了 this.notifyAll(); } } 复制代码
关于 wait 方法的判断,必须使用 while 条件,官方文档对此是这样描述的。
参考文献
《Java并发编程的艺术》
《深入理解Java虚拟机》