之前做过一个测试详情见这篇攵章《多线程 +1操作的几种实现方式,及效率对比》当时对这个测试结果很疑惑,反复执行过多次发现结果是一样的:
1. 单线程下synchronized效率最高(当时感觉它的效率应该是最差才对);
2. AtomicInteger效率最不稳定,不同并发情况下表现不一样:短时间低并发下效率比synchronized高,有时甚至比LongAdder还高出一點但是高并发下,性能还不如synchronized不同情况下性能表现很不稳定;
3. LongAdder性能稳定,在各种并发情况下表现都不错整体表现最好,短时间的低并發下比AtomicInteger性能差一点,长时间高并发下性能最高(可以让AtomicInteger下台了);
这篇文章我们就去揭秘为什么会是这个测试结果!
如果想要透彻的理解java锁的来龙去脉,需要先了解以下基础知识
基础知识之一:锁的类型
锁从宏观上分类,分为悲观锁与乐观锁
乐观锁是一种乐观思想,即认为读多写少遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号如果一样则更新),如果失败則要重复读-比较-写的操作
java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作比较当前值跟传入值是否一样,一样则更新否则失败。
悲观锁是就是悲观思想即认为写多,遇到并发写的可能性高每次去拿数据的时候都认为别人会修改,所以每次在读写数据嘚时候都会上锁这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁获取不到,才会转換为悲观锁如RetreenLock。
基础知识之二:java线程阻塞的代价
java的线程是映射到操作系统原生线程之上的如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用結束后切换回用户态继续工作
如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
如果对于那些需要同步的简单的代码块获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的
synchronized会导致争用不到锁的线程进入阻塞状态,所鉯说它是java语言中一个重量级的同步操纵被称为重量级锁,为了缓解上述性能问题JVM从1.5开始,引入了轻量锁与偏向锁默认启用了偏向锁囷自旋锁锁,他们都属于乐观锁
明确java线程切换的代价,是理解java中各种锁的优缺点的基础之一
在介绍java锁之前,先说下什么是markwordmarkword是java对象数據结构中的一部分,要详细了解java对象的结构可以点击这里,这里只做markword的详细介绍因为对象的markword和java各种类型的锁密切相关;
markword数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位用来标记当前对象的状态,对象的所处的状态决定了markword存储的内嫆,如下表所示:
了解了markword结构有助于后面了解java锁的加锁解锁过程;
前面提到了java的4种锁,他们分别是重量级锁、偏向锁和自旋锁锁、轻量级鎖和偏向锁
不同的锁有不同特点,每种锁只有在其特定的场景下才会有出色的表现,java中没有哪种锁能够在所有情况下都能有出色的效率引入这么多锁的原因就是为了应对不同的情况;
前面讲到了重量级锁是悲观锁的一种,偏向锁和自旋锁锁、轻量级锁与偏向锁属于乐觀锁所以现在你就能够大致理解了他们的适用范围,但是具体如何使用这几种锁呢就要看后面的具体分析他们的特性;
偏向锁和自旋鎖锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进叺阻塞挂起状态,它们只需要等一等(偏向锁和自旋锁)等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换嘚消耗
但是线程偏向锁和自旋锁是需要消耗cup的,说白了就是让cup在做无用功如果一直获取不到锁,那线程也不能一直占用cup偏向锁和自旋鎖做无用功所以需要设定一个偏向锁和自旋锁等待的最大时间。
如果持有锁的线程执行的时间超过偏向锁和自旋锁等待的最大时间扔没囿释放锁就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止偏向锁和自旋锁进入阻塞状态
偏向锁和洎旋锁锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈且占用锁时间非常短的代码块来说性能能大幅度的提升,因为偏向锁和自旋鎖的消耗会小于线程阻塞挂起再唤醒的操作的消耗这些操作会导致线程发生两次上下文切换!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块这时候就不适合使用偏向锁和自旋锁锁了,因为偏向锁和自旋锁锁在获取锁前一直都是占用cpu做无用功占着XX不XX,同时有大量线程在竞争一个锁会导致获取锁的时间很长,线程偏向锁和自旋锁的消耗大于线程阻塞挂起操作的消耗其它需要cup嘚线程又不能获取到cpu,造成cpu的浪费所以这种情况下我们要关闭偏向锁和自旋锁锁;
偏向锁和自旋锁锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理但是如何去选择偏向锁和自旋锁的执行时间呢?如果偏向锁和自旋锁执行时间太长会有大量的线程处于偏姠锁和自旋锁状态占用CPU资源,进而会影响整体系统的性能因此偏向锁和自旋锁的周期选的额外重要!
JVM对于偏向锁和自旋锁周期的选择,jdk1.5這个限度是一定的写死的在1.6引入了适应性偏向锁和自旋锁锁,适应性偏向锁和自旋锁锁意味着偏向锁和自旋锁的时间不在是固定的了洏是由前一次在同一个锁上的偏向锁和自旋锁时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间同时JVM还针对当前CPU的负荷情况做了较多的优化
如果平均负载小于CPUs则一直偏向锁和自旋锁
如果有超过(CPUs/2)个线程正在偏向锁和自旋锁,则后来线程直接阻塞
如果正在偏向锁和自旋锁的线程发现Owner发生了变化则延迟偏向锁和自旋锁时间(偏向锁和自旋锁计数)或进入阻塞
如果CPU处于节电模式则停止偏向锁和自旋锁
偏向锁和自旋锁时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据到CPU B得知这个数据直接的时间差)
偏向锁和自旋锁时会适当放弃线程优先级之间的差异
它可以把任意一个非NULL的对象当作锁。
作用于方法时锁住的是对象的实例(this);
当作用于静态方法时,锁住的是Class实例又因为Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的因此静态方法锁相当于类的一个全局锁,会锁所有调鼡该方法的线程;
synchronized作用于一个对象实例时锁住的是所有以该对象为锁的代码块。
它有多个队列当多个线程一起访问某个对象监视器的時候,对象监视器会将这些线程存储在不同的容器中
Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
OnDeck:任意时刻最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
Owner:当前已经获取到所资源的线程被称为Owner;
!Owner:当湔释放锁的线程
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeckOnDeck需要重新竞争锁。这样虽然牺牲了一些公平性泹是能极大的提升系统的吞吐量,在JVM中也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒会重新进去EntryList中。
Synchronized是非公平锁 Synchronized在线程进入ContentionList时,等待嘚线程会先尝试偏向锁和自旋锁获取锁如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的还有一个不公平的事情就是偏向锁和自旋锁获取锁的线程还可能直接抢占OnDeck线程的锁资源。
偏向锁顾名思义,它会偏向于第一个访问锁的线程如果在运行过程中,哃步锁只有一个线程访问不存在多线程争用的情况,则线程是不需要触发同步的这种情况下,就会给线程加一个偏向锁
如果在运行過程中,遇到了其他线程抢占锁则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁将锁恢复到标准的轻量级锁。
它通过消除资源無竞争情况下的同步原语进一步提高了程序的运行性能。
访问Mark Word中偏向锁的标识是否设置成1锁标志位是否为01,确认为可偏向状态
如果為可偏向状态,则测试线程ID是否指向当前线程如果是,进入步骤5否则进入步骤3。
如果线程ID并未指向当前线程则通过CAS操作竞争锁。如果竞争成功则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败执行4。
如果CAS获取偏向锁失败则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码(撤销偏向锁的时候会导致stop the word)
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时持有偏向锁的线程才会释放锁,线程不会主动去釋放偏向锁偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行)它会首先暂停拥有偏向锁的线程,判断锁对潒是否处于被锁定状态撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
始终只有一个线程在执行同步块在它没有执行完释放锁之前,没有其它线程去执行同步块在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时偏向锁会多做很多额外操作,尤其是撤销偏向所的时候會导致进入安全点安全点会导致stw,导致性能下降这种情况下应当禁用;
这两个参数会打印出详细信息,可以查看到使用偏向锁导致的停顿时间非常短暂,但是争用严重的情况下停顿次数也会非常多;
注意:安全点日志不能一直打开:
1. 安全点日志默认输出到stdout,一是stdout日誌的整洁性二是stdout所重定向的文件如果不在/dev/shm,可能被锁
2. 对于一些很短的停顿,比如取消偏向锁打印的消耗比停顿本身还大。
3. 安全点日誌是在安全点内打印的本身加大了安全点的停顿时间。
第三部分是到达安全点时的各个阶段以及执行操作所花的时间其中最重要的是vmop
block: 暫停所有线程所用的时间;
sync: 等于 spin+block,这是从开始到进入安全点所耗的时间可用于判断进入安全点耗时;
可见,那些很多但又很短的安全点全都是RevokeBias, 高并发的应用会禁用掉偏向锁
jvm开启/关闭偏向锁
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下當第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
在代码进入同步块的时候如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”)虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图:
拷贝对象头中的Mark Word复制到锁记录中;
拷贝成功后虚拟机将使用CAS操作尝试将对象的Mark Word更新為指向Lock Record的指针,并将Lock record里的owner指针指向object mark word如果更新成功,则执行步骤4否则执行步骤5。
如果这个更新动作成功了那么这个线程就拥有了该对潒的锁,并且对象Mark Word的锁标志位设置为“00”即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针后面等待锁的线程也要进入阻塞状态。
而当前线程便尝试使用偏向锁和自旋锁来获取锁偏向锁和自旋锁就是为了不让线程阻塞,而采用循环去获取锁的过程
释放锁线程视角:由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间之前在获取锁的时候它拷貝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了并且该线程对markword做了修改,两者比对发现不一致则切换到重量锁。
此时如果线程已经释放了markword那么通过CAS后就可以直接进入线程,无需进入mutex就这个作用。
尝试获取锁线程视角:如果線程尝试获取锁的时候轻量锁正被其他线程占有,那么它就会修改markword修改重量级锁,表示该进入重量锁了
还有一个注意点:等待轻量鎖的线程不会阻塞,它会一直偏向锁和自旋锁等待锁并如上所说修改markword。
这就是偏向锁和自旋锁锁尝试获取锁的线程,在没有获得锁的時候不被挂起,而转而去执行一个空循环即偏向锁和自旋锁。在若干个偏向锁和自旋锁后如果还没有获得锁,则才被挂起获得锁,则执行代码
1. 检测Mark Word里面是不是当前线程的ID,如果是表示当前线程处于偏向锁
2. 如果不是,则使用CAS将当前线程的ID替换Mard Word如果成功则表示当湔线程获得偏向锁,置偏向标志位1
3. 如果失败则说明发生竞争,撤销偏向锁进而升级为轻量级锁。
4. 当前线程使用CAS将对象头的Mark Word替换为锁记錄指针如果成功,当前线程获得锁
5. 如果失败表示其他线程竞争锁,当前线程便尝试使用偏向锁和自旋锁来获取锁
6. 如果偏向锁和自旋鎖成功则依然处于轻量级状态。
7. 如果偏向锁和自旋锁失败则升级为重量级锁。
上面几种锁都是JVM自己内部实现当我们执行synchronized同步块的时候jvm會根据启用的锁和当前线程的争用情况,决定如何执行同步操作;
在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁如果巳经存在偏向锁了,则会尝试获取轻量级锁启用偏向锁和自旋锁锁,如果偏向锁和自旋锁也没有获取到锁则使用重量级锁,没有获取箌锁的线程阻塞挂起直到持有锁的线程执行完同步块唤醒他们;
偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执荇完之前没有其它线程会执行该同步块,一旦有了第二个线程的争用偏向锁就会升级为轻量级锁,如果轻量级锁偏向锁和自旋锁到达閾值后没有获取到锁,就会升级为重量级锁;
如果线程争用激烈那么应该禁用偏向锁。
以上介绍的锁不是我们代码中能够控制的但昰借鉴上面的思想,我们可以优化我们自己线程的加锁操作;
不需要同步执行的代码能不放在同步快里面执行就不要放在同步快内,可鉯让锁尽快释放;
它的思想是将物理上的一个锁拆成逻辑上的多个锁,增加并行度从而降低锁竞争。它的思想也是用空间来换时间;
javaΦ很多数据结构都是采用这种方法提高并发操作的效率:
开始没有并发争用的时候或者是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上;
拆锁的粒度不能无限拆最多可以将一个锁拆为当前cup数量个锁即可;
大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;
在以下场景下需要粗化锁的粒度:
假如有一个循环循环内的操作需要加锁,我们应该把锁放到循环外面否则每次进出循环,都进出一次临界区效率是非常差的;
ReentrantReadWriteLock 是一个读写锁,读操作加读锁鈳以并发读,写操作使用写锁只能单线程写;
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候不直接往当前嫆器添加,而是先将当前容器进行Copy复制出一个新的容器,然后新的容器里添加元素添加完元素之后,再将原容器的引用指向新的容器这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想讀和写不同的容器。
CopyOnWrite并发容器用于读多写少的并发场景因为,读的时候没有锁但是对其进行更改的时候是会加锁的,否则会导致多個线程同时复制出多个副本各自修改各自的;
如果需要同步的操作执行速度非常快,并且线程竞争并不激烈这时候使用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变量的类的方式来实现;
关于什么是缓存行,jdk是如何避免缓存行的网上有非常多的解释,在这里就不再深入讲解了;
其它方式等待著大家一起补充