Java线程锁机制是怎样的?
- JAVA的锁就是在对象的Markword中记录一个锁状态。无锁,偏向锁,轻量级锁,重量级锁对应不同的锁状态。
- JAVA的锁机制就是根据资源竞争的激烈程度不断进行锁升级的过程。
锁的分类
1. 乐观锁与悲观锁
- 乐观锁
- 对共享数据进行访问时,乐观锁总是认为不会有其他线程修改数据修改数据。
- 于是直接执行操作,只是在更新时检查数据是否已经被其他线程修改。
- 如果没有被修改,则操作执行成功;否则,添加其他补偿措施。
- 常见的补偿措施是不断尝试,直到成功。
- Java中的非阻塞同步都是采用这种乐观的并发策略,乐观锁在Java中是通过使用无锁编程来实现,最常使用的CAS操作。
- 比如,线程安全的原子类的自增操作,就是通过循环的CAS操作实现的。
- 悲观锁
- 对共享数据进行访问时,悲观锁总是认为一定会有其他线程修改数据。如果不加锁,肯定会出问题。
- 因此,悲观锁无论是否出现共享数据的争用,在访问数据时都会先加锁。
- Java中同步互斥都是采用这种悲观的并发策略,synchronized关键字和Lock接口的实现类都是悲观锁。
2. 独占锁和共享锁
- 独占锁
- 又叫排它锁,同一个锁对象,同一时刻只允许一个线程获取到锁。
- 如果线程T对数据A加上独占锁后,其他线程不能对该数据再加任何类型的锁(包括独占锁和共享锁),自己可以对数据进行读操作或者写操作。
- 独占锁允许线程对数据进行读写操作。
Java中的 synchronized关键字、Mutex、ReentrantLock、ReentrantReadWriteLock 中写锁,都是独占锁。
共享锁
- 同一个所对象,同一时刻允许多个线程获取到锁。
- 线程T对数据A加上共享锁,则其他线程只能对数据A加共享锁,不能加独占锁。
- 共享锁只允许对数据进行读操作。
- java中ReentrantReadWriteLock中读锁是共享锁。
- ReentrantReadWriteLock读写锁的获取:
- 同步状态不为0,如果有其他线程获取到读锁或者当前线程不是持有写锁的线程,则获取写锁失败进入阻塞状态;否则,当前线程是持有写锁的线程,直接通过setState()方法增加写状态。
- 同步状态为0,直接通过compareAndSetState()方法实现写状态的CAS增加,并将当前线程设置为持有写锁的线程。
- 如果有其他线程获取到了写锁,则获取读锁失败进入阻塞状态。
- 如果写锁未被获取或者该线程为持有写锁的线程,则获取读锁成功,通过compareAndSetState()方法实现读状态的CAS增加
- 独占锁和共享锁都是通过AQS实现的,tryAcquire()或者tryAcquireShared()方法支持独占式或者共享式的获取同步状态。
3. 公平锁和非公平锁
- 公平锁
- 当锁被释放,按照阻塞的先后顺序获取锁,即同步队列头节点中的线程将获取锁。
- 公平锁可以保证锁的获取按照FIFO原则,但需要进行大量的线程切换,导致吞吐率较低。
- 非公平锁:
- 当锁被释放,所有阻塞的线程都可以争抢获取锁的资格,可能导致先阻塞的线程最后获取锁。
- 非公平锁虽然可能造成线程饥饿,但极少进行线程的切换,保证了更大的吞吐量。
- Java中ReentrantLock和ReentrantReadWriteLock支持公平和非公平访问,而synchronized关键字只支持非公平访问。
- 公平与非公平可以通过构造函数的fair参数进行指定,默认是false,即默认为非公平的获取锁。
- 公平和非公平都是依靠AQS实现的,公平使用FairSync同步器,非公平使用NoFairSync同步器。
1
2
3public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}4. 可重入锁和非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:
- 可重入锁:
- 已经获取锁的线程再次获取该锁而不被锁所阻塞,需要解决线程再次获取锁和锁的最终释放两个问题。
- 可重入锁可以一定程度的避免死锁。
- 非可重入锁:
- 已经获取锁的线程再次获取该锁,会因为需要等待自身释放锁而被阻塞。
- 非可重入锁容易造成当前线程死锁,从而使整个队列中线程永久阻塞。
- Java中的synchronized关键字、ReentrantLock锁和ReentrantReadWriteLock锁都支持重进入,其中ReentrantReadWriteLock的读锁是支持重进入的共享锁,写锁是支持重进入的独占锁。
5.无锁VS偏向锁VS轻量级锁VS重量级锁
- synchronized关键字实现同步的基础是每个对象都是一个锁,它依靠对象头存储锁。
- 无锁、偏向锁、轻量级锁、重量级锁都是专门针对synchronized关键字设计的、级别从低到高的4种状态。
- 注意: 锁状态只能升级,不能降级。
- 对象头中的第一个字宽叫做Mark Word,用于存储对象的hashCode、分代年龄、锁等信息。
- 其中最后2 bit的标志位,用于标记锁的状态。根据标志位的不同,可以有如下几种状态:
无锁
- 不对资源进行锁定,所有的线程都可以访问并修改同一资源,但同一时刻只有一个线程能修改成功。
- 无锁的修改操作依靠循环实现: 如果没有争用,修改成功并退出循环;否则,循环尝试修改操作,直到成功。
- 无锁无法全面代替有锁,但在某些场景下具有非常高的性能。
- 无锁的经典实现: CAS操作。
偏向锁
- 出现的原因:
- 在无竞争的情况下,同一线程可能多次进入同一个同步块,即多次获取同一个锁。
- 如果进入和退出同步块都使用CAS操作来加锁和解锁,则会消耗一定的资源。
- 于是通过CAS操作将线程ID存储到Mark Word中,线程再次进入或退出同步块时,直接检查Mark Word中是否存储指向当前线程的偏向锁。 如果存储了,则直接进入或退出同步块。
偏向锁可以在无竞争的情况下,尽量减少不必要的轻量级锁执行路径。轻量级锁的加锁和解锁都需要CAS操作,而偏向锁只有将线程ID存储到Mark Word中时才执行一次CAS操作。
偏向锁的释放:
- 当有其他线程竞争偏向锁时,持有偏向锁的线程会释放锁偏向锁。
- 释放时,会根据锁对象是否处于锁定状态而恢复到不同的状态。
- 如果锁对象处于未锁定状态,撤销偏向后恢复到无锁的状态(0 + 01 );如果锁对象处于锁定状态,撤销偏向后恢复到轻量级锁的状态(00)。
- 偏向锁在JDK1.6及以后,默认是启用的,即-XX:+UseBiasedLocking。可以通过-XX:-UseBiasedLocking关闭偏向锁。
偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
轻量级锁
多个线程竞争同步资源时,没有获取到资源的线程自旋等待锁的释放。
加锁过程:
- 线程进入同步块时,如果同步对象处于无锁状态(0 + 01),JVM 首先在当前线程的栈帧中开辟一块叫做锁记录(Lock Record)的空间,用于存储同步对象的Mark Word的拷贝。这个拷贝加了一个前缀,叫Displaced Mark Word。
- 然后通过CAS操作将同步对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向同步对象的Mark Word。
- 如果这个更新动作成功,则当前线程拥有了该对象的锁,Mark Word中的标志位更新为00,表示对象处于轻量级锁定状态。
- 如果更新动作失败,JVM首先会检查同步对象的Mark Word是否指向当前线程的栈帧。如果是,说明当前线程已经持有了该对象的锁,可以直接进入同步块继续执行;否则,说明存在多线程竞争锁。
轻量级锁升级为重量级锁
- 若当前只有一个线程在等待,则通过自旋进行等待。自旋超过一定的次数,轻量级锁升级为重量级锁。
- 若一个线程持有锁,一个线程自旋等待锁,又有第三个线程想要获取锁,轻量级锁升级为重量级锁。
- 锁的释放:
- 通过CAS操作,将Lock Record中的Displaced Mark Word与对象中的Mark Word进行替换。
- 替换成功,同步状态完成;替换失败,说明有其他线程尝试获取过该锁,释放锁的同时需要唤醒被挂起的线程。
重量级锁
- 多线程竞争同步资源时,没有获取到资源的线程阻塞等待锁的释放。
- 轻量级锁升级为重量级锁,锁的标志位变成10,Mark Word中存储的是指向重量级锁的指针。
- 所有等待锁的线程都会进入阻塞状态。
锁升级过程
锁升级的顺序为:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,且锁升级的顺序是不可逆的。
线程第一次获取锁获时锁的状态为偏向锁,如果下次还是这个线程获取锁,则锁的状态不变,否则会升级为CAS轻量级锁;如果还有线程竞争获取锁,如果线程获取到了轻量级锁没啥事了,如果没获取到会自旋,自旋期间获取到了锁没啥事,超过了10次还没获取到锁,锁就升级为重量级的锁,此时如果其他线程没获取到重量级锁,就会被阻塞等待唤起,此时效率就低了。
升级图解
什么是锁降级?
一个线程执行写操作,先获取写锁,再获取读锁,完成写操作后先释放写锁,接下来的程序里可能要依赖写操作后的变量值,待程序全部执行完后再释放读锁。先释放了写锁,只剩下了读锁,称之为“锁降级”。
为什么需要锁降级?
一句话:为了保证数据可见性。假设线程A修改了数据,释放了写锁,这个时候线程T获得了写锁,修改了数据,然后也释放了写锁,线程A读取数据的时候,读到的是线程T修改的,并不是线程A自己修改的,那么在使用修改后的数据时,就会忽略线程A之前的修改结果。因此通过锁降级来保证数据每次修改后的可见性。
6. 自旋锁与自适应自旋锁
- 自旋锁:
- 阻塞或唤醒一个线程都需要从用户态切换到内核态去完成,会对性能造成很大影响。
- 有时一个线程持有锁的时间很短,如果在很短的时间内让后续获取锁的线程都进入阻塞态,这是很不值得。
- 可以让后续线程持有CPU时间等待一会,这个等待需要执行忙循环(自旋) 来实现。
- 自旋等待的时间由自旋次数来衡量,默认为10,可以使用-XX:PreBlockSpin来进行设置。
- 如果在自旋等待中,持有锁的线程释放该锁,当前线程可以不必阻塞直接获取同步资源。
- 如果超过自旋次数仍未获取成功,则使用传统的方法将其阻塞。
- 自旋锁的实现原理: 循环的CAS操作
- 自旋锁的缺点:
- 自旋锁虽然避免了线程的切换开销,但是会占用CPU时间。
- 如果每个等待获取锁的线程总是自旋规定的次数,却又没有等到锁的释放,这样就白白浪费了CPU时间。
- 自旋锁在JDK1.4.2中引入,默认是关闭的;在JDK1.6中变成默认开启,并为了解决自旋锁中浪费CPU资源的问题,而引入了自适应自旋锁。
- 自适应自旋锁:
- 自适应意味着自旋的次数不再固定,而是根据上一次在同一个锁自旋的次数和锁的拥有者的状态来决定。
- 如果在同一个锁对象上自旋刚刚成功获取过锁,并持有锁的线程处于运行状态,则可以认为这一次自旋也很可能成功,允许它自旋更长的时间。
- 如果在一个锁上,自旋很少成功,则下一次可以省略自旋过程,直接阻塞线程,避免浪费处理器资源。
一些问题
轻量级锁一定比重量级锁快吗?
在回答这个问题之前,我们先来了解一下:什么是轻量级锁?什么是重量级锁?
锁概念
轻量级锁是 JDK 1.6 新增的概念,是相对于传统的重量级锁而已的一种状态,在 JDK 1.5 时,synchronized 是需要通过操作系统自身的互斥量(mutex lock)来实现,然而这种实现方式需要通过用户态与和核心态的切换来实现,但这个切换的过程会带来很大的性能开销,所以在 JDK 1.6 就引入了轻量级锁来避免此问题的发生。
轻量级锁执行过程
再讲轻量级锁执行过程之前,要先从虚拟机的对象头开始说起,HotSpot 的对象头(Object Header)分为两部分:
Mark Word 区域,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分带年龄等;
用于存储指向方法区对象类型数据的指针(如果是数组对象的话,还有一个存储数组长度的额外信息)。
Mark Word 在 32 位系统中,有 32bit 空间,其中:
25bit 用来存储 HashCode;
4bit 用来存储对象的分带年龄;
2bit 用来存储锁标志位,01=可偏向锁、00=轻量级锁、10=重量级锁;
1bit 固定为 0。
再说会轻量级锁的执行过程,在代码进入同步块的时候,如果此对象没有被线程所占用,虚拟机会先将此线程的栈帧拷贝一份存储在当前对象的 Lock Record (锁记录) 区域中。
然后虚拟机再使用 CAS (Compare and Swap, 比较并交换) 将本线程的 Mark Word 更新为指向对象 Lock Record 区域的指针,如果更新成功,则表示这个线程拥有了该对象,轻量级锁添加成功,如果更新失败,虚拟机会先检查对象 Mark Word 是否指向了当前线程的线帧,如果是则表明此线程已经拥有了此锁,如果不是,则表明该锁已经被其他线程占用了。如果有两条以上的线程在争抢死锁,那么锁就会膨胀为重量锁,Mark Word 中存储的就是指向重量级锁的互斥量指针,后面等待锁的线程也会进入阻塞状态。
从以上的过程,我们可以看出轻量级锁可以理解为是通过 CAS 实现的,理想的情况下是整个同步周期内不存在锁竞争,那么轻量锁可以有效的提高程序的同步性能,然而,如果情况相反,轻量级锁不但要承担 CAS 的开销还要承担互斥量的开销,这种情况下轻量级锁就会比重量级锁更慢,这就是我们本文的答案。
总结
轻量级锁不是在任何情况下都比重量级锁快的,要看同步块执行期间有没有多个线程抢占资源的情况,如果有,那么轻量级线程要承担 CAS + 互斥量锁的性能消耗,就会比重量锁执行的更慢。
打开偏向锁是否效率一定会提升?为什么?
偏向锁(不太需要竞争的,一般一个线程)
未必会提高,尤其是当你知道一定会有大量线程去竞争的时候。
打开偏向锁偏向锁还有一个锁撤销的过程(把ID撕下来)。
为什么要延迟4s?
偏向锁默认是在JVM启动4s后再初始化偏向锁,可用如下参数修改启动时间,设为0则表示立即启用。之所以这么设计是因为JVM启动的时候,如果立即启动偏向,有可能会因为线程竞争太激烈导致产生太多安全点挂起。-XX:BiasedLockingStartupDelay=0