Fork me on GitHub

JVM 锁优化

之前做过一个测试,详情见这篇文章《多线程 +1操作的几种实现方式,及效率对比》,当时对这个测试结果很疑惑,反复执行过多次,发现结果是一样的:
  1. 单线程下synchronized效率最高(当时感觉它的效率应该是最差才对);
  2. AtomicInteger效率最不稳定,不同并发情况下表现不一样:短时间低并发下,效率比synchronized高,有时甚至比LongAdder还高出一点,但是高并发下,性能还不如synchronized,不同情况下性能表现很不稳定;
  3. LongAdder性能稳定,在各种并发情况下表现都不错,整体表现最好,短时间的低并发下比AtomicInteger性能差一点,长时间高并发下性能最高(可以让AtomicInteger下台了);
  这篇文章我们就去揭秘,为什么会是这个测试结果!

锁的基础知识

锁的类型

  锁从宏观上分类,分为悲观锁与乐观锁。
乐观锁
  乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
  java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

悲观锁
  悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。

java线程阻塞的代价

  java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
  1.如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
  2.如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。

  synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。
  明确java线程切换的代价,是理解java中各种锁的优缺点的基础之一


jvm内部锁优化

重量级锁Synchronized

自旋锁与自适应自旋

1.自旋锁
  定义:让后面请求锁的那个线程执行一个忙循环(自旋),线程只是等待并不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
为什么会有这个技术?
  1.线程互斥同步对性能最大的影响是阻塞的实现,挂起和恢复线程的操作都需要转入到内核态中完成,这些操作给并发带来了很大的压力。
  2.许多应用的共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得
  自旋等待的时间必须要有一定的限度,自旋次数默认是10次,可以使用参数更改。如果超过限定次数仍然还没有获得锁,就应当使用传统的方式去挂起线程

2.自适应自旋
  定义:自旋时间不再固定了,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
为什么会有这个技术?
  明知道自旋会成功或失败,就没必要白白浪费自旋时所占的处理器资源
情况分析
  1.如果在同一个锁对象上,自旋等待刚刚获得过锁,并且持有的线程正在运行中,那么JVM就认为这次自旋也可能成功,将允许自旋等待持续相对更长的时间。如100个循环。
  2.如果某个锁,自旋很少成功获得过,那在以后获得这个锁时间将可能省略自旋过程,以避免浪费处理器资源

  JVM对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁,JDK1.7后,去掉开启参数,由jvm控制;


销消除

  定义:指JVM限时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除
  判断依据来源于逃逸分析的数据支持:如果判断在一段代码中,堆上的所有数据都不会逃逸出上数据去从而被其他线程访问到,那就可能把它们当做栈上数据对待,认为它们是线程私有的的,同步自然就无须进行。
  程序员明知道不存在数据争用的情况下为什么还要用同步?不是自己加入的,Java很多类都加了同步,如StringBuffer类

1
2
3
4
5
6
public String concatString() {
StringBuffer sb= new StringBuffer()
sb.append('a')
sb.append('b')
return sb.toString();
}

StringBuffer类的append()都有一个同步块,锁就是sb对象。jvm会发现sb的动态作用域被限制在方法内部,也就是说sb的所有引用不会“逃逸”到方法之外,其他线程也就无法访问到。
因此,虽然这里有锁,但是可以被安全消除掉,在即时编译后,这段代码就会忽略所有的同步块而直接执行。

销粗化

  如果jvm探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
  实际运用:如StringBuffer类的连续多个append方法,会优化到只在第一个append操作之前直到最后一个append操作之后 加锁一次


轻量级锁

  JDK1.6之后加入的新型锁机制, 名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的, 传统的锁机制就称为“重量级”锁。
  轻量级锁并不是用来代替重量级锁的,本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗.

markword

  在介绍java锁之前,先说下什么是markword,markword是java对象数据结构中的一部分,对象头详情了解:JVM自动内存管理机制
  markword数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,如下表所示:

加锁和解锁

加锁过程
  1.在代码进入同步块的时候,如果同步对象锁状态为 无锁状态 (锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图:

  2.虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。
    a).如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示 此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。

    b).如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,
      如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。
      否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
      而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

解锁过程
  解锁过程也是通过CAS操作来进行的,
  如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,
    如果替换成功,整个同步过程就完成了。
    如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

优缺点

  轻量级锁能提升程序同步性的依据是” 对于绝大部分的锁,在整个同步周期内都是不存在竞争的 “, 这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。


偏向锁

  Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。
  定义:偏向锁,它会偏向于第一个访问锁的线程,如果在运行过程中,该锁没有被其它的线程获取,则持有偏向锁的线程将永不需要进行同步。
    如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
  目的:消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

偏向锁与轻量级锁
  轻量级锁:在无竞争的情况下使用CAS操作去消除同步使用的互斥量
  偏向锁 :在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

原理

  偏向锁的原理跟轻量级锁中关于对象头Mark Word与线程之间的操作过程类似。
  假设当前JVM启用了偏向锁(-XX:+UseBiasedLocking, JDK1.6默认值),
  加锁过程:当锁对象第一次被线程获取的时候,JVM将会把对象头中的标志位设为”01”,即偏向模式。同时使用CAS操作把苑到这个锁的线程的ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,JVM都可以不再进行任何同步操作。
  解锁过程:当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

优缺点

  偏向锁可以提高带有同步但无竞争的程序性能,
  如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就多余了,需要关闭。


总结

  偏向锁、轻量级锁的状态转化及对象Mark Word的关系图
  
  synchronized的执行过程:
  1.检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2.如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3.如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4.当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5.如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6.如果自旋成功则依然处于轻量级状态。
  7.如果自旋失败,则升级为重量级锁。
  上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;

  在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;
  偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁;
  如果线程争用激烈,那么应该禁用偏向锁


程序锁优化

  以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作;

减少锁的时间

  不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;

减少锁的粒度

  思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;
  拆锁的粒度不能无限拆,最多可以将一个锁拆为当前cup数量个锁即可;
  java中很多数据结构都是采用这种方法提高并发操作的效率:
1.ConcurrentHashMap
  java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组
  Segment< K,V >[] segments
  Segment继承自ReenTrantLock,所以每个Segment就是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

2.LongAdder
  LongAdder 实现思路也类似ConcurrentHashMap,LongAdder有一个根据当前并发状况动态改变的Cell数组,Cell对象里面有一个long类型的value用来存储值;
  开始没有并发争用的时候或者是cells数组正在初始化的时候,会使用cas来将值累加到成员变量的base上,在并发争用的情况下,LongAdder会初始化cells数组,在Cell数组中选定一个Cell加锁,数组有多少个cell,就允许同时有多少线程进行修改,最后将数组中每个Cell中的value相加,在加上base的值,就是最终的值;cell数组还能根据当前线程争用情况进行扩容,初始长度为2,每次扩容会增长一倍,直到扩容到大于等于cpu数量就不再扩容,这也就是为什么LongAdder比cas和AtomicInteger效率要高的原因,后面两者都是volatile+cas实现的,他们的竞争维度是1,LongAdder的竞争维度为“Cell个数+1”为什么要+1?因为它还有一个base,如果竞争不到锁还会尝试将数值加到base上;

3.LinkedBlockingQueue
  LinkedBlockingQueue也体现了这样的思想,在队列头入队,在队列尾出队,入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高;

锁粗化

  大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;
  在以下场景下需要粗化锁的粒度:
  假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

使用读写锁

  ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;

读写分离

  CopyOnWriteArrayList 、CopyOnWriteArraySet
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
  CopyOnWrite并发容器用于读多写少的并发场景,因为,读的时候没有锁,但是对其进行更改的时候是会加锁的,否则会导致多个线程同时复制出多个副本,各自修改各自的;

使用cas

  如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择;

消除缓存行的伪共享

  除了我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。
  在多核cup的处理器中,每个cup都有自己独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了提高性能,cpu读写数据是以缓存行为最小单元读写的;32位的cpu缓存行为32字节,64位cup的缓存行为64字节,这就导致了一些问题。
  例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁;
  为了防止伪共享,不同jdk版本实现方式是不一样的:
  1.在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行;
  2.在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现;
  3.在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数: -XX:-RestrictContended
  sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离;
  关于什么是缓存行,jdk是如何避免缓存行的,网上有非常多的解释,在这里就不再深入讲解了;


参考文章:
  java 中的锁 – 偏向锁、轻量级锁、自旋锁、重量级锁
  JVM 虚拟机字节码指令表

-----------------本文结束,感谢您的阅读-----------------