若是想要透彻的理解Java锁的前因后果,须要先了解如下基础知识。java
锁从宏观上分类,分为悲观锁与乐观锁。linux
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,因此不会上锁,可是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采起在写时先读出当前版本号,而后加锁操做(比较跟上一次的版本号,若是同样则更新),若是失败则要重复读-比较-写的操做。安全
java中的乐观锁基本都是经过CAS操做实现的,CAS是一种更新的原子操做,比较当前值跟传入值是否同样,同样则更新,不然失败。数据结构
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,因此每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。多线程
java的线程是映射到操做系统原生线程之上的,若是要阻塞或唤醒一个线程就须要操做系统介入,须要在户态与核心态之间切换,这种切换会消耗大量的系统资源,由于用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态须要传递给许多变量、参数给内核,内核也须要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工做。并发
synchronized会致使争用不到锁的线程进入阻塞状态,因此说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。框架
明确java线程切换的代价,是理解java中各类锁的优缺点的基础之一。jvm
在介绍java锁以前,先说下什么是markword,markword是java对象数据结构中的一部分,要详细了解java对象的结构能够点击这里,这里只作markword的详细介绍,由于对象的markword和java各类类型的锁密切相关;函数
markword数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,以下表所示:高并发
状态 | 标志位 | 存储内容 |
---|---|---|
未锁定 | 01 | 对象哈希码、对象分代年龄 |
轻量级锁定 | 00 | 指向锁记录的指针 |
膨胀(重量级锁定) | 10 | 执行重量级锁定的指针 |
GC标记 | 11 | 空(不须要记录信息) |
可偏向 | 01 | 偏向线程ID、偏向时间戳、对象分代年龄 |
32位虚拟机在不一样状态下markword结构以下图所示:
了解了markword结构,有助于后面了解java锁的加锁解锁过程;
前面提到了java的4种锁,他们分别是重量级锁、自旋锁、轻量级锁和偏向锁,
不一样的锁有不一样特色,每种锁只有在其特定的场景下,才会有出色的表现,java中没有哪一种锁可以在全部状况下都能有出色的效率,引入这么多锁的缘由就是为了应对不一样的状况;
前面讲到了重量级锁是悲观锁的一种,自旋锁、轻量级锁与偏向锁属于乐观锁,因此如今你就可以大体理解了他们的适用范围,可是具体如何使用这几种锁呢,就要看后面的具体分析他们的特性;
它有多个队列,当多个线程一块儿访问某个对象监视器的时候,对象监视器会将这些线程存储在不一样的容器中。
Contention List:竞争队列,全部请求锁的线程首先被放在这个竞争队列中;
Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
Owner:当前已经获取到所资源的线程被称为Owner;
!Owner:当前释放锁的线程。
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),可是并发状况下,ContentionList会被大量的并发线程进行CAS访问,为了下降对尾部元素的竞争,JVM会将一部分线程移动到EntryList中做为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(通常是最早进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck须要从新竞争锁。这样虽然牺牲了一些公平性,可是能极大的提高系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程,而没有获得锁资源的仍然停留在EntryList中。若是Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻经过notify或者notifyAll唤醒,会从新进去EntryList中。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操做系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,若是获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。
对于synchronized这个关键字,可能以前你们有听过,他是一个重量级锁,开销很大,建议你们少用点。但你们可能也据说过,但到了jdk1.6以后,该关键字被进行了不少的优化,已经不像之前那样不给力了,建议你们多使用。
那么它是进行了什么样的优化,才使得synchronized又深得人心呢?为什么重量级锁开销就大呢?
重量锁在多线程下会致使线程阻塞;
可是阻塞或者唤醒一个线程时,都须要操做系统来帮忙,这就须要从用户态转换到内核态,而转换状态是须要消耗不少时间的,有可能比用户执行代码的时间还要长。
这就是说为何重量级线程开销很大的。
下面我讲一步一步讲解synchronized是如何被优化的,是如何从偏向锁到重量级锁的。
偏向锁是jdk1.6引入的一项锁优化,其中的“偏”是偏爱的偏。它的意思就是说,这个锁会偏向于第一个得到它的线程,在接下来的执行过程当中,假如该锁没有被其余线程所获取,没有其余线程来竞争该锁,那么持有偏向锁的线程将永远不须要进行同步操做。
也就是说:
在此线程以后的执行过程当中,若是再次进入或者退出同一段同步块代码,并再也不须要去进行加锁或者解锁操做
当咱们建立一个对象LockObject时,该对象的部分Markword关键数据以下。
不过,当线程执行到临界区(critical section)时,此时会利用CAS(Compare and Swap)操做,将线程ID插入到Markword中,同时修改偏向锁的标志位。
此时的Mark word的结构信息以下:
bit fields | 是否偏向锁 | 锁标志位 | |
---|---|---|---|
threadId | epoch | 1 | 01 |
此时偏向锁的状态为“1”,说明对象的偏向锁生效了,
总结下偏向锁的步骤:
若是此对象已经偏向了,而且不是偏向本身,则说明存在了竞争。此时可能就要根据另外线程的状况,多是从新偏向,也有多是作偏向撤销,但大部分状况下就是升级成轻量级锁了。
能够看出,偏向锁是针对于一个线程而言的,线程得到锁以后就不会再有解锁等操做了,这样能够省略不少开销。假若有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。
为何要这样作呢?由于经验代表,其实大部分状况下,都会是同一个线程进入同一块同步代码块的。这也是为何会有偏向锁出现的缘由。
在Jdk1.6中,偏向锁的开关是默认开启的,适用于只有一个线程访问同步块的场景。
始终只有一个线程在执行同步块 在有锁的竞争时,偏向锁会多作不少额外操做,尤为是撤销偏向所的时候会致使进入安全点,安全点会致使stw,致使性能降低,这种状况下应当禁用;
查看停顿–安全点停顿日志
要查看安全点停顿,能够打开安全点日志,经过设置JVM参数 -XX:+PrintGCApplicationStoppedTime 会打出系统中止的时间,添加-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 这两个参数会打印出详细信息,能够查看到使用偏向锁致使的停顿,时间很是短暂,可是争用严重的状况下,停顿次数也会很是多;
注意:安全点日志不能一直打开:
1. 安全点日志默认输出到stdout,一是stdout日志的整洁性,二是stdout所重定向的文件若是不在/dev/shm,可能被锁。
2. 对于一些很短的停顿,好比取消偏向锁,打印的消耗比停顿自己还大。
3. 安全点日志是在安全点内打印的,自己加大了安全点的停顿时间。
因此安全日志应该只在问题排查时打开。
若是在生产系统上要打开,再再增长下面四个参数:
-XX:+UnlockDiagnosticVMOptions -XX: -DisplayVMOutput -XX:+LogVMOutput -XX:LogFile=/dev/shm/vm.log
打开Diagnostic(只是开放了更多的flag可选,不会主动激活某个flag),关掉输出VM日志到stdout,输出到独立文件,/dev/shm目录(内存文件系统)。
此日志分三部分:
第一部分是时间戳,VM Operation的类型
第二部分是线程概况,被中括号括起来
total: 安全点里的总线程数
initially_running: 安全点时开始时正在运行状态的线程数
wait_to_block: 在VM Operation开始前须要等待其暂停的线程数
第三部分是到达安全点时的各个阶段以及执行操做所花的时间,其中最重要的是vmop
可见,那些不少但又很短的安全点,全都是RevokeBias, 高并发的应用会禁用掉偏向锁。
刚才说了,当出现有两个线程来竞争锁的话,那么偏向锁就失效了,此时锁就会膨胀,升级为轻量级锁。这也是咱们常常所说的锁膨胀
因为偏向锁失效了,那么接下来就得把该锁撤销,锁撤销的开销花费仍是挺大的,其大概的过程以下:
在代码进入同步块的时候,若是此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的MarkWord的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图13-3所示。
而后,虚拟机将使用CAS操做尝试将对象的Mark Word更新为指向Lock Record的指针。若是这个更新动做成功了,那么这个线程就拥有了该对象的锁,而且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图13-4所示。
若是这个更新操做失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,若是只说明当前线程已经拥有了这个对象的锁,那就能够直接进入同步块继续执行,不然说明这个锁对象已经被其余线程抢占了。若是有两条以上的线程争用同一个锁,那轻量级锁就再也不有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
上面描述的是轻量级锁的加锁过程,它的解锁过程也是经过CAS操做来进行的,若是对象的Mark Word仍然指向着线程的锁记录,那就用CAS操做把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,若是替换成功,整个同步过程就完成了。若是替换失败,说明有其余线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能提高程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。若是没有竞争,轻量级锁使用CAS操做避免了使用互斥量的开销,但若是存在锁竞争,除了互斥量的开销外,还额外发生了CAS操做,所以在有竞争的状况下,轻量级锁会比传统的重量级锁更慢。
总结偏向锁撤销之后升级为轻量级锁的过程:
轻量级锁主要有两种
所谓自旋,就是指当有另一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个得到锁的线程释放锁以后,这个线程就能够立刻得到锁的。
注意,锁在原地循环的时候,是会消耗cpu的,就至关于在执行一个啥也没有的for循环。
因此,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就可以得到锁了。
经验代表,大部分同步代码块执行的时间都是很短很短的,也正是基于这个缘由,才有了轻量级锁这么个东西。
基于这个问题,咱们必须给线程空循环设置一个次数,当线程超过了这个次数,咱们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。
默认状况下,自旋的次数为10次,用户能够经过-XX:PreBlockSpin来进行更改。
自旋锁是在JDK1.4.2的时候引入的
所谓自适应自旋锁就是线程空循环等待的自旋次数并不是是固定的,而是会动态着根据实际状况来改变自旋等待的次数。
其大概原理是这样的:
假如一个线程1刚刚成功得到一个锁,当它把锁释放了以后,线程2得到该锁,而且线程2在运行的过程当中,此时线程1又想来得到该锁了,但线程2尚未释放该锁,因此线程1只能自旋等待,可是虚拟机认为,因为线程1刚刚得到过该锁,那么虚拟机以为线程1此次自旋也是颇有可能可以再次成功得到该锁的,因此会延长线程1自旋的次数。
另外,若是对于某一个锁,一个线程自旋以后,不多成功得到该锁,那么之后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以避免空循环等待浪费资源。
轻量级锁也被称为非阻塞同步、乐观锁,由于这个过程并无把线程阻塞挂起,而是让线程空循环等待,串行执行。
锁优势的比较