xzhuz / blog-gitment

博客备份和comment记录
https://meisen.pro
0 stars 0 forks source link

详细讲一下Java中的锁 #43

Open xzhuz opened 4 years ago

xzhuz commented 4 years ago

https://hshanx.github.io/xiang-xi-jiang-yi-xia-java-zhong-de-suo/

Java锁的分类

Java中的分很多种类,按照场景的不同、特性的不同等分为了很多类,下面就来讲讲Java中锁的概念:

几种重要的锁实现方式:synchronizedReentrantLockReentrantReadWriteLock

可重入锁和不可以重入锁的概念:

如果thread1对一个资源加了一个独享锁(也就是thread1获得了这个锁),此时其他线程无法再对这个资源加锁。

但是如果thread1想要再次获取这个锁,则会有两种不同的表现

可重入锁: 不阻塞,再次拿到锁

不可重入锁: 阻塞

引用一下美团技术团队对锁概念的脑图,觉得写得特别好,特来分享给个各位:

锁导图

悲观锁和乐观锁

再来回顾一些关于悲观锁和乐观锁的概念。

悲观锁在获取资源的时候,认为会有其它线程也要来修改资源(假定会有冲突),于是在获取资源的时候,会将线程先加锁,避免数据被其他线程修改。

乐观锁在获取资源的时候,认为不会有其它线程来资源资源(假定没有冲突),所以在获取资源的时候,不会加锁。其他线程来获取资源的时候,会根据实现的不同采取不同的方式(重试或报错)。

Java中悲观锁和乐观锁的实现

在Java语言中,对于悲观锁和乐观锁有不同的实现。

synchronized实现悲观锁,是通过在对象头中添加一个锁的状态。我们知道synchronized是锁住的对象,明确了这一点,我们再来理解synchronized锁就很简单了。只要一个线程获取到了对象的锁,会修改对象头中的Mark Word状态,同时线程中也会保存对应的状态。

Java中的乐观锁最常采用的是CAS算法,这个算法我们在线程安全之原子性问题这一篇文章中有过详细的介绍。这里不做详细的介绍,而关于AtomicInteger类,这个有很多相关的知识,打算用一篇新的文章来解释。

悲观锁和乐观锁的应用场景

通过上面的分析,我们来看看悲观锁和乐观锁不同应用场景:

自旋锁和适应性自旋锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在很多场景下,同步代码块的执行时间很短,有时候线程挂起和恢复线程的时间花费可能就要比线程切换的时间还要长,这样子做事得不偿失的。所以在这种这种场景下就可以使用自旋锁,比如说CAS。

自旋锁和适应性自旋锁获取锁的场景

场景前提: 在两个线程(线程A、线程B)访问同步资源的时候,线程A先获取同步资源并加锁,线程B这时再来获取同步资源。

自旋锁:线程B发现不能获得锁(获取锁失败),线程B不会放弃CPU时间片,而是会不断自旋获取锁,直到获取锁成功。这就是CAS算法的做法,当然了也会CAS算法的缺点,比如说:一直占用线程,造成CPU使用率过高。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

在自旋锁中 另有三种常见的锁形式:TicketLock、CLHlock和MCSlock,本文中仅做名词介绍,不做深入讲解,感兴趣的同学可以自行查阅相关资料。

无锁、偏向锁、轻量级锁和重量级锁

这四个锁是针对synchronized关键字提出的,所以在说这四个锁之前先来简单介绍一个重要的知识点:Mark Word

Mark Word 是保存在Java对象头中的数据,在HotSpot虚拟机的Java对象头中,有两部分的数据Mark Word(标记字段)、Klass Pointer(类型指针)。Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,今天要介绍的也是Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,今天我们着重关注锁标志位。

Mark Word一共有五个锁标志位:

锁状态 锁标志位 是否偏向锁
无锁态 01 hashCode、分代年龄,是否是偏向锁(0)
偏向锁 01 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)
轻量级锁 00 指向栈中锁记录的指针
重量级锁 10 指向互斥量(重量级锁)的指针

Mark Word保存的不同锁标志对应了不同的锁状态 ,这些状态也都是针对synchronized关键提出,锁之间转换是通过加锁解锁锁升级来实现的。

无锁

无锁的状态就是不会对同步资源加锁,所有线程都能访问并修改同一资源,但只能有一个线程修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

引入偏向锁的目的是为了,当一段同步代码并没有被多个线程同时竞争的时候,降低加锁和解锁带来的性能消耗,提高程序的执行性能。偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态,这种在一定会有多个线程来争抢锁的情况反倒是性能会好很多。

偏向锁加了锁之后就不会解锁了。

轻量级锁

如果有多个线程想要争抢偏向锁时,偏向锁会升级为轻量级锁,只会有一个线程获取到锁,其他线程会通过自旋的形式获取去,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“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中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

简单总结一下:偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

关于偏向锁、轻量级锁和重量级锁的描述见不可不说的Java“锁”事

公平锁和非公平锁

如果等待线程按照争抢锁的顺序获取锁,则为公平锁。否则就为非公平锁。

公平锁的优点在于等待锁的线程不会饿死,但是只有等待队列中的第一个线程被执行,其他线程都会被阻塞,同时CPU唤醒等待线程的消耗比非公平大。

非公平锁的优点在于,如果等待线程中有一个线程刚好可以执行,则CPU在释放锁之后会立即执行该线程,不会再去唤醒其他等到线程,这样会减少唤醒线程的消耗。非公平锁的缺点也正是公平锁的优点,如果有线程一直都没有被执行,则这个线程就可能会被饿死。

Java中synchronized锁为非公平锁,我们也可以通过ReentrantLock来实现公平锁和非公平锁。

对于synchronized有一个等待队列,既然是队列又为什么是非公平锁呢?是因为,争抢锁的顺序和入等待队列的顺序可能不一样,也就是说,A、B、C三个线程去争抢锁,可能入队列的顺序B、C、A。这样即使出队列,也不是争抢锁的顺序。

共享锁和独占锁(排它锁)

这两个锁其实很好理解,锁可能被多个线程获取,则就是共享锁(读锁)。锁如果被一个线程获取了,释放锁之前就不能被其他线程获取,就是独占锁(写)。

独享锁与共享锁是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

Java中synchronized和ReentrantLock都是独占锁。

可重入锁和不可重入锁

如果一个锁能同一个线程多次获取,则这个锁就是一个可重入锁。Java中的ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

如果一个锁不能被同一个线程多次获取,则这个锁就是不可重入锁,不能获取到锁的线程会一直阻塞。

读写锁 (ReadWriteLock)

概念:维护一对关联锁,一个只用于读操作,一个只用一写操作;读锁可以被多个线程同时拥有,写锁是排它锁。同一时间,两把锁不能被不同线程持有

读写锁是互斥的,同一时间两把锁不能被不同线程持有。也就是说:

请注意是不能被不同线程持有,也就是说,有可能被同一线程持有。对的,有一种特殊情况下,同一个线程下可能同时持有读写锁,这种情况就是锁降级。

锁降级

如果当前线程A拥有了同步资源的写锁,之后又想给同步资源加读锁,这时候,是可以加上的。但是线程A 会释放写锁,只占用读锁。

这也就是所谓的锁降级,也只有这种情况下,两把锁会被同一线程同时持有,在其他情况下,读写锁都是互斥的。

参考文章

不可不说的Java“锁”事

深入分析synchronized的实现原理