大家好,我是南橘,从接触java到现在也有差不多两年时间了,两年时间,从一名连java有几种数据结构都不懂超级小白,到现在懂了一点点的进阶小白,学到了不少的东西。知识越分享越值钱,我这段时间总结(包括从别的大佬那边学习,引用)了一些平常学习和面试中的重点(自我认为),希望给大家带来一些帮助
这是之前的几篇文章,有兴趣的同学可以看看(暗搓搓给自己打广告)
在Java中,我们能接触到各种各样的锁,而每种锁因其特性的不同,在不同的的场景下有着不同的效果,这篇文章,就是为和大家一起学习这些锁的知识点、原理和使用范围。
这篇文章很多内容学习自 不可不说的Java“锁”事,也正是看了美团技术团队很多文章,才让自己的技术更加纯熟
第一件事还是把思维导图贴给大家,因为用的是免费版,所以有水印,如果需要原始版本的话,可以加我的微信:
第一件事还是把思维导图贴给大家,因为用的是免费版,所以有水印,如果需要原始版本的话,可以加我的微信,记得备注一下是一起学习的同学
悲观锁和乐观锁大概是大家听到最多的两种锁了,这两种锁的区分更多的是思想上。
对于一个操作,悲观锁认为自己在操作过程中,一定有别的线程也要来修改这个数据,所以一定会加锁。而乐观锁则不认为会有别的线程来干扰自己,所以不需要加锁。
在Java中,synchronized关键字和Lock的实现类都是悲观锁,而乐观锁一般采用无锁编程,也就是CAS算法来实现的。
首先说一说悲观锁
通过图片和文字,我们能看出悲观锁适合写操作多的场景,加锁可以确保数据的安全,但是会影响一些操作效率。
这两张图是从这位大佬的文章中引用的:不可不说的Java“锁”事 - 美团技术团队
乐观锁的流程:
和悲观锁不同,乐观锁明显不适合经常进行修改,因为谁也不能保证不会出现数据安全的问题,所以乐观锁适合读操作的场景。对于读操作来说,加锁只会影响效率。
上文说到了,乐观锁一般采用CAS算法来实现,那么我们就来讲讲什么是CAS算法
CAS的英语是【Compare and Swap】,比较和交换,单单从这一个词组来看,我们就已经能Get到CAS算法的核心了。
感觉已经讲完了,就像之前去面菜鸟的时候,面试官哥哥问我:“TCP和UDP的区别是什么”。我下意识地说了一句,一个是单工通信,一个是双工通信。停顿了一下,准备继续洋洋洒洒的时候,面试官严肃地直接打断了我:“可以了。”
CAS的算法涉及三个操作数: 内存位置(V)、预期原值(A)和新值(B)。
如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。
换一种说法,当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。
在JDK1.5 中新增java.util.concurrent(J.U.C)就是通过实现CAS来实现乐观锁的。
我们可以看一下它的重点:
在没有锁的机制下需要字段value要借助volatile原语,保证线程间的数据是可见的。这样在获取变量的值的时候才能直接读取,这就是内存的可见性。
从上面这三个图可以看出,CAS每次从内存中读取数据然后将此数据修改+1后的结果进行CAS操作比较,如果成功就返回结果,否则重试直到成功为止,compareAndSet利用JNI来完成CPU指令的操作。
是不是很复杂?其实一点也不复杂,我们可以这样理解:CPU去更新一个值,但如果想改的值和原来的值,操作就失败(因为有其它操作先改变了这个值),然后可以去再次尝试。如果想改的值和原来一样,那么就修改之。
一个线程X1从内存位置V中取出A,这时候另一个线程Y1也从内存中取出A,并且Y1进行了一些操作变成了B,然后Y1又将V位置的数据变成A,这时候线程X1进行CAS操作发现内存中仍然是A,然后X1操作成功。尽管线程X1的CAS操作成功,但是不代表这个过程就是没有问题的。
解决办法:
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中,利用JNI来检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
循环时间长开销大
CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
只能保证一个共享变量的原子操作
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
Java中的线程安全问题至关重要,要想保证线程安全,就需要用到乐观锁与悲观锁。悲观锁是独占锁,阻塞锁。乐观锁是非独占锁,非阻塞锁。什么情况选择什么样的锁,就是我们开发人员需要思考的问题了。
我们之前提到了CAS操作如果长时间不成功,会导致其一直自旋,非常浪费性能。但是,自旋是非常有用的。
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
自旋锁不会放弃CUP时间片,而是通过自旋等待锁释放。
为什么要自旋,?获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁不是会造成busy-waiting吗?
因为在我们的程序中,如果存在着大量的互斥同步代码,当出现高并发的时候,系统内核态就需要不断的去挂起线程和恢复线程,频繁的上下文切换会对我们系统的并发性能有一定影响。在程序的执行过程中锁定“共享资源“的时间片是极短的,如果仅仅是为了这点时间而去不断挂起、恢复线程的话,消耗的时间可能会更长,那就“捡了芝麻丢了西瓜”了。
自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源
于是乎,自适应的自旋锁出现了。
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应自旋锁的出现使得自旋操作变得聪明起来,不再跟之前一样死板。所谓的“自适应”意味着对于同一个锁对象,线程的自旋时间是根据上一个持有该锁的线程的自旋时间以及状态来确定的。例如对于A锁对象来说,如果一个线程刚刚通过自旋获得到了锁,并且该线程也在运行中,那么JVM会认为此次自旋操作也是有很大的机会可以拿到锁,因此它会让自旋的时间相对延长。但是如果对于B锁对象自旋操作很少成功的话,JVM甚至可能直接忽略自旋操作。
因此,自适应自旋锁在一定程度上能强化自旋锁的性能。
可是,出现了多个线程同时争抢锁资源,我们也不能总是自旋啊!
于是,java团队又进行了进化。
学习这四个锁之前,我们先来了解一下java对象头和Monitor的概念。
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表,每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步
为了了解这几个概念,我们可以通过两个代码来看一个:
第一块代码很简单,看一看字节码,非常清楚,一眼就能看出它做了什么。
再看第二个代码,看看java代码,非常简单,和HelloWorld相比只是多了一个synchronize的代码块,但是字节码却大不一样,可以看出在加锁的代码块, 多了个 monitorenter , monitorexit。
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
执行monitorexit的线程必须是objectref所对应的monitor的所有者:
通过这两个图,大家大概就能理解之前的那两个概念了。
我们知道,高并发的情况,不断地争抢锁,系统内核态就需要不断的去挂起线程和恢复线程,频繁的上下文切换会对我们系统的并发性能有一定影响。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
这是四种锁状态对应的的:Mark Word(标记字段)内容:
锁状态 | 存储内容 | Mark Word |
---|---|---|
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。
如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功,CAS原理及应用即是无锁的实现。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
公平锁的优点是:等待锁的线程不会饿死,人人有饭吃,人人有书读
公平锁的缺点是:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。如果此时此刻锁刚好可用,那么这个线程就可以插队,无阻塞地获取锁。
非公平锁的优点是:可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程
非公平锁的缺点是:处于等待队列中的线程可能会饿死,或者等很久才会获得锁
我们可以通过一些源码来看一看公平锁和非公平锁在java中的应用。
从结构中来看,ReentrantLock里面有一个内部类Sync,Sync继承自AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
公平锁FairSync | 非公平锁NonfairSync |
---|---|
我们用软件比较一下:
是不是很清晰了?公平锁和非公平锁只有一个地方不一样
阅读一下注释:是否返回true取决于头是否在尾部之前初始化以及头是否准确(如果当前线程在队列中)
意思就是这个方法主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false
由此可得,公平锁通过同步队列来实现顺序获取锁,而非公平锁加锁时不考虑先后顺序,直接尝试去获取锁,所以存在后申请却先获得锁的情况。
可重入锁这个概念也比较好理解,在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法能自动获取锁(前提锁对象得是同一个对象或者class)就是可重入锁,不能自动获取那么这个锁就是不可重入锁。
在JAVA中,我们最熟悉的ReentrantLock和synchronized都是可重入锁。
为什么可重入锁可以自动获得锁呢?
我们还是看看源码吧~!!!(源码真是好用2/10000)
可重入锁ReentrantLock | 不可重入锁NonReentrantLock |
---|---|
这两个图是不是很明显?
由图可知,当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
独享锁和共享锁这个概念,可以类比为读写锁。
举个例子,A线程获得数据ZZZ的锁,如果加锁后其他的线程不能再对ZZZ加任何形式的锁,也不能对它进行读写,那么说明ZZZ上的是排他锁。
如果线程A获得数据ZZZ上的锁以后,则其他线程还能对ZZZ再加共享锁,获得共享锁的线程还能读数据,只是不能修改数据,那么说明ZZZ上的是共享锁。
我们可以看看读写锁ReentrantReadWriteLock
读写锁里面有两把锁,一把是ReadLock,一把是WriteLock,现在我们不知道里面是什么样子的,于是我选择看源码(源码真是太厉害了!3/10000)
ReadLock | WriteLock |
---|---|
我们惊讶的发现了一个老熟人state,我们总是能看到他。
在独享锁中state这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。
再借大佬的图片
从写锁的这一段我们可以看出,它首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败。
从读锁中又冷发现,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。
Java的锁这一章总算肝完了…因为周末没有放假(加班),基本上是把休息的时间全用上了,有点累,但是写完后蛮兴奋的,毕竟又分享了新东西嘛!
好了,今天就到这里,see you!
同时需要思维导图的话,可以联系我,毕竟知识越分享越香!