线程同步锁

synchronized缺点:

1.当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:

  1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;

  2)线程执行发生异常,此时JVM会让线程自动释放锁。

  那么如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,影响程序执行效率。因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。

2.当有多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是采用synchronized关键字来实现同步的话,就会导致一个问题:如果多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。因此就需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突,通过Lock就可以办到。

ReentrantLock与synchonized区别

都是可重入锁。

可重入性实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。举个简单的例子,当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。

1.ReentrantLock可以中断地获取锁(void lockInterruptibly() throws InterruptedException)

如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。

2.ReentrantLock可以尝试非阻塞地获取锁(boolean tryLock())

3.ReentrantLock可以超时获取锁。通过tryLock(timeout, unit),可以尝试获得锁,并且指定等待的时间。

4.ReentrantLock可以实现公平锁。通过new ReentrantLock(true)实现。

公平锁即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。

非公平锁即无法保证锁的获取是按照请求锁的顺序进行的。这样就可能导致某个或者一些线程永远获取不到锁。即先尝试占用,占用失败再公平等待。

5.ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的的wait(), notify(), notifyAll()方法可以实现一个隐含条件,如果要和多于一个的条件关联的对象,就不得不额外地添加一个锁,而ReentrantLock则无需这样做,只需要多次调用newCondition()方法即可。

一、synchronized:

1. 普通同步方法,锁是当前实例对象 
2. 静态同步方法,锁是当前类的class对象,因为 Class数据存在于永久带,因此静态方法锁相当于该类的一个全局锁
3. 同步方法块,锁是括号里面的对象

在JVM中,每个对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁。

monitor监视器

当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:

Contention List:所有请求锁的线程将被首先放置到该竞争队列

Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List

Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set

OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck

Owner:获得锁的线程称为Owner

!Owner:释放锁的线程

下图反映了个状态转换关系:

JVM底层又是如何实现synchronized的

新请求锁的线程将首先被加入到ConetentionList中,当某个拥有锁的线程(Owner状态)调用unlock之后,如果发现 EntryList为空则从ContentionList中移动线程到EntryList。

锁优化

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。 锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

1.自旋锁

那些处于ContetionList、EntryList、WaitSet中的线程均处于阻塞状态,线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。

缓解上述问题的办法便是自旋,所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。其原理是:当发生争用时,若Owner线程能在很短的时间内释放锁,则那些正在争用线程可以稍微等一等(自旋), 在Owner线程释放锁后,争用线程可能会立即得到锁,从而避免了系统阻塞。但Owner运行的时间可能会超出了临界值,争用线程自旋一段时间后还是无法获得锁,这时争用线程则会停止自旋进入阻塞状态(后退)。基本思路就是自旋,不成功再阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有非常重要的性能提高。自旋锁有个更贴切的名字:自旋-指数后退锁,也即复合锁。很显然,自旋在多处理器上才有意义。

线程自旋时其实啥都不做,可以执行几次for循环,可以执行几条空的汇编指令,目的是占着CPU不放,等待获取锁的机会。

那synchronized实现何时使用了自旋锁?答案是在线程进入ContentionList时,也即第一步操作前。线程在进入等待队列时首先进行自旋尝试获得锁,如果不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平。还有一个不公平的地方是自旋线程可能会抢占了 Ready线程的锁。

适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 

2.偏向锁

偏向锁主要解决无竞争下的锁性能问题,现在几乎所有的锁都是可重入的(即已经获得锁的线程可以多次锁住/解锁监视对象;线程可以进入任何一个它已经拥有的锁所同步着的代码块。在外层使用锁之后,在内层仍然可以使用,并且不发生死锁),按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作,CAS操作会延迟本地调用(我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。自旋,重试直到成功为止),因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是置个变量,如果发现为true则无需再走各种加锁/解锁流程。

获取锁 
1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01; 
2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3); 
3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4); 
4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块; 
5. 执行同步代码块

释放锁 
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下: 
1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态; 
2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;

下图是偏向锁的获取和释放流程 
这里写图片描述

3.锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。 

4.锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。 
锁粗化,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

5.轻量级锁

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下: 
获取锁 
1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝;否则执行步骤(3); 
2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3); 
3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

释放锁 
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下: 
1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据; 
2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3); 
3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;

下图是轻量级锁的获取和释放过程 
这里写图片描述

6.重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

二、LOCK

在 java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReentrantLock、 ReadWriteLock(实现类ReentrantReadWriteLock),其实现都依赖 java.util.concurrent.AbstractQueuedSynchronizer类,实现思路都大同小异,因此我们以 ReentrantLock作为讲解切入点。

ReentrantLock的调用过程

经过观察ReentrantLock把所有Lock接口的操作都委派到一个Sync类上,该类继承了AbstractQueuedSynchronizer: 
static abstract class Sync extends AbstractQueuedSynchronizer 

简单说来,AbstractQueuedSynchronizer会把所有的请求线程构成一个CLH队列,当一个线程执行完毕(lock.unlock())时会激活自己的后继节点,但正在执行的线程并不在队列中,而那些等待执行的线程全 部处于阻塞状态,经过调查线程的显式阻塞是通过调用LockSupport.park()完成,而LockSupport.park()则调用 sun.misc.Unsafe.park()本地方法,再进一步,HotSpot在Linux中中通过调用pthread_mutex_lock函数把 线程交给系统内核进行阻塞。

与synchronized相同的是,这也是一个虚拟队列,不存在队列实例,仅存在节点之间的前后关系。令人疑惑的是为什么采用CLH队列呢?原生的CLH队列是用于自旋锁,但Doug Lea把其改造为阻塞锁。 
当有线程竞争锁时,该线程会首先尝试获得锁,这对于那些已经在队列中排队的线程来说显得不公平,这也是非公平锁的由来,与synchronized实现类似,这样会极大提高吞吐量。 如果已经存在Running线程,则新的竞争线程会被追加到队尾,具体是采用基于CAS的Lock-Free算法,因为线程并发对Tail调用CAS可能会 导致其他线程CAS失败,解决办法是循环CAS直至成功。

AbstractQueuedSynchronizer通过构造一个基于阻塞的CLH队列容纳所有的阻塞线程,而对该队列的操作均通过Lock-Free(CAS)操作,但对已经获得锁的线程而言,ReentrantLock实现了偏向锁的功能。 
synchronized 的底层也是一个基于CAS操作的等待队列,但JVM实现的更精细,把等待队列分为ContentionList和EntryList,目的是为了降低线程的出列速度;当然也实现了偏向锁,从数据结构来说二者设计没有本质区别。但synchronized还实现了自旋锁,并针对不同的系统和硬件体系进行了优 化,而Lock则完全依靠系统阻塞挂起等待线程。 
当然Lock比synchronized更适合在应用层扩展,可以继承 AbstractQueuedSynchronizer定义各种实现,比如实现读写锁(ReadWriteLock),公平或不公平锁;同时,Lock对 应的Condition也比wait/notify要方便的多、灵活的多。

猜你喜欢

转载自blog.csdn.net/liyanan21/article/details/81476225