写在前面:
锁是多线程并发访问共享资源产生的概念。假如没有多线程,没有并发,那要锁干吗?不纯粹浪费时间吗。就好比,世界上没有小偷,每家每户还需要上锁吗(杠精请走开)?
一,synchronized
synchronized 关键字是一把经典的锁,属于隐式锁,也是我们平时用得最多的。在 JDK1.6 之前, syncronized 是一把重量级的锁,不过随着 JDK 的升级,也在对它进行不断的优化,如今它变得不那么重了,甚至在某些场景下,它的性能反而优于轻量级锁。在加了 syncronized 关键字的方法、代码块中,一次只允许一个线程进入特定代码段,从而避免多线程同时修改同一数据。
首先,synchronized可以修饰类、方法(实例方法和静态方法)和代码块(修饰代码块实现同步),区别就是作用范围的不同:
*修饰类的时候和修饰静态方法是一样的,都是给所有的对象加了同一把锁; *修饰实例方法时作用范围就是整个函数,给当前实例加锁; *修饰代码块时作用范围就是大括号内的内容,对给定的对象加锁。
我们先来看看Java 对象在内存中的布局。
①,mark word: 对象自身的运行时数据。存储 hashCode、GC 分代年龄、锁类型标记、偏向锁线程 ID 、 CAS 锁指向线程 LockRecord 的指针等, synconized 锁的机制与这个部分( markwork )密切相关,用 markword 中最低的三位代表锁的状态,其中一位是偏向锁位,另外两位是普通锁位。下图是64位JVM mark word 示意图:
②,对象类型指针:对象指向它的类元数据的指针、 JVM 就是通过它来确定是哪个 Class 的实例。
③,实例数据区域:此处存储的是对象真正有效的信息,比如对象中所有字段的内容。
④,对齐填充区域:JVM 的实现 HostSpot 规定对象的起始地址必须是 8 字节的整数倍,换句话来说,现在 64 位的 OS 往外读取数据的时候一次性读取 64bit 整数倍的数据,也就是 8 个字节,所以 HotSpot 为了高效读取对象,就做了"对齐",如果一个对象实际占的内存大小不是 8byte 的整数倍时,就"补位"到 8byte 的整数倍。所以对齐填充区域的大小不是固定的。
清楚了java对象的你存布局,下面看看synchronized 锁的几个特点:
1.1,锁升级
引申一下:不知你有没有注意到 用户态/内核态 。当线程运行的时候处于用户态,当线程阻塞挂起的时候处于内核态。具体可了解我的另一篇文章《线程用户态与内核态》
如上图所示, synchronized 锁升级的顺序为:偏向锁->轻量级锁->重量级锁,每一步触发锁升级的情况如下:
1.1.1,偏向锁
在 JDK1.8 中,其实默认是轻量级锁,但如果设定了 -XX:BiasedLockingStartupDelay = 0 ,那在对一个 Object 做 syncronized 的时候,会立即上一把偏向锁。当处于偏向锁状态时, markwork 会记录当前线程 ID 。
①,偏向锁的场景
大多数情况下,锁不仅不存在多线程竞争状态,而且通常由同一个线程多次获得,因此,我们有必要减少同一个线程多次获得同一个锁的性能消耗。
②,偏向锁的原理
当锁对象第一次被线程获取的时候,虚拟机在对象的对象头中标志为偏向模式,同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象头的 Mark Word 数据中。(关于CAS原理的相关问题)
只要 CAS 操作获取成功,该锁对象便 “偏向” 了这个线程,只要不出现第二个线程,这个锁对象的对象头就会一直记录着该线程的 id。
这时,获得偏向锁的线程以后每次进入这个锁的时候都不再需要进行同步操作,一路畅通。
那如果出现了第二个线程会发生什么呢?我们继续往下看。
1.1.2,轻量级锁
①,轻量级锁的原理
当一个线程企图持有一个锁的时候,倘若这个锁已经是偏向状态,那么这个时候会将偏向状态解除,然后在竞争这个锁的线程的栈帧中建立一个锁记录的空间(Lock Record),并把锁对象的 Mark Word 拷贝到里面来,记作 Displaced Mark Word。然后,JVM 再使用 CAS 操作将锁对象的 Mark Word 更新为指向其中一个线程的 Lock Record 的指针,当这个操作成功,这个线程也就持有了该轻量锁。当然,轻量锁的持有和释放,都需要 CAS 操作进行。释放锁的时候,只需要把栈帧里的 Displaced markd word 使用 CAS 复制回去即可。如果 CAS 操作获取锁失败,JVM 会首先检查一下锁对象的 Mark Word 是否指向当前线程,是则可以直接通行,否则先自旋一下吧。
②,轻量级锁的场景
这个锁适应的是没有竞争或是只有轻度竞争的情况,若是发送了轻度的竞争,只需要进行几次自旋即可。但是一旦发生长时间的竞争,轻量级锁就会升级为重量级锁,这时候就变成了传统的通过阻塞来进行同步,并使用 monitor 对象来管理锁的持有和释放的方式(不要忘了 monitorenter 和 monitorexit 这两个指令)。
1.1.3,重量级锁
①,重量级锁的原理
如果锁竞争加剧(如线程自旋次数或者自旋的线程数超过某阈值, JDK1.6 之后,由 JVM 自己控制该规则),就会升级为重量级锁。此时没有获取到锁的线程就会向操作系统申请资源,线程挂起,进入到操作系统内核态的等待队列中,等待操作系统调度,然后映射回用户态。在重量级锁中,由于需要做内核态到用户态的转换,而这个过程中需要消耗较多时间,也就是"重"的原因之一。
1.2,可重入
synchronized 拥有强制原子性的内部锁机制,是一把可重入锁。因此,在一个线程使用 synchronized 方法时调用该对象另一个 synchronized 方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。在 Java 中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。每一个锁关联一个线程持有者和计数器。synchronized 锁的对象头的 markwork 中会记录该锁的线程持有者和计数器,当一个线程请求成功后, JVM 会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个 synchronized 方法/块时,计数器会递减,如果计数器为 0 则释放该锁锁。
1.3,悲观锁
synchronized 是一把悲观锁(独占锁),当前线程如果获取到锁,会导致其它所有需要锁该的线程等待,一直等待持有锁的线程释放锁才继续进行锁的争抢。
二,Lock-ReentrantLock
ReentrantLock是lock的实现类。 从字面可以看出是一把可重入锁,这点和 synchronized 一样,但实现原理也与 syncronized 有很大差别,它是基于经典的 AQS(AbstractQueueSyncronizer) 实现的, AQS 是基于 volitale 和 CAS 实现的,其中 AQS 中维护一个 valitale 类型的变量 state 来做一个可重入锁的重入次数,加锁和释放锁也是围绕这个变量来进行的。ReentrantLock 也提供了一些 synchronized 没有的特点,因此比 synchronized 好用。
2.1,AQS模型
AbstractQueuedSynchronizer,抽象队列同步器,是一个用于编写锁的框架,由 CLH 变形而来。
2.1.1,CLH
CLH 是实现公平自旋锁的理论模型,它大概是这么工作的:当线程进入时先查看有没有人等待,即队列为不为空,如果没有人等待,则直接拿到锁,所谓的拿到锁就是将头节点的标志设置成占用。而如果发现有人等待则加入队列尾部,然后不断地循环监视前一个节点的标志,一旦前一个节点的标志从占用变成了空闲就说明前一个线程释放了锁,然后自己就会推出循环,成为队列头。大概的过程可以描述为下图
此时蓝色节点就是拿到锁的线程,其他三个线程卡在循环检测前一个节点。当蓝色节点调用方法使得标志位变化为 0 时后一个节点将退出循环,然后使得自己成为头部节点,就像这样
AQS 由 CLH 变化而来,其共同之处在于,队列中的节点总会依次的获取到锁,不过如果它源码中不说确实感觉不到。
2.1.2,AQS
AQS同样维护了一个队列,以下简称队列,相对要复杂一些。队列中存在两种节点,分别为共享节点和互斥节点,分别对应两种锁的模式,互斥模式和共享模式,每种模式又对应两种方法,分别是获取锁和释放锁。注意,下面对这四个操作的阐述中,请区分获取与尝试获取,释放与尝试释放
- acquire 当一个线程以互斥模式获取锁的时候,如果尝试获取成功,就会立刻拿到锁,否则将进入队列的尾部(此时加入的就是互斥模式的节点),然后给前一个节点作上标记,而后挂起自身。这里的问题时,为什么一定要一个标志位呢?难道不可以当前一个节点释放锁后,直接唤醒下一个节点吗?我认为的答案是,如果后一个节点还没有阻塞,那么此时唤醒不但没有意义,反而会产生异常,所以多一事不如少一事。我执行完成了,我变成空闲状态了,下面的小伙子请自便。
- release 当一个线程以互斥模式释放锁的时候,其实很简单,首先尝试释放锁,如果成功就查看标志选择唤醒后继节点,然后后继节点就会继续争夺锁,并在拿到锁后成为新的头节点
- acquireShared 当一个线程以共享模式获取锁的时候,首先尝试获取锁,不过尝试获取的结果是一个整数,如果是负数说明获取失败,如果是 0 则表示获取成功但是仅此而已,如果是正数则说明后续的线程也可以获得锁,而在尝试获取之后,AQS会根据结果选择策略。如果获取失败,线程进入队尾等待,此时进入的节点就是共享节点,然后进入阻塞。如果获取成功,那么他将成为新的头节点,而且如果尝试获取的返回值是正数,则它会在获取成功后继续唤醒下一个节点告诉他可以开始获取锁了,这个过程被称作唤醒的传播,要注意的是,传播会在遇到互斥节点时停止,这是两种节点唯一的区别
- releaseShared 当一个线程以共享模式释放锁的时候,会先尝试释放,如果释放后返回 true 表示可以有新的线程来获取锁,则会去唤醒队列的第一个节点
2.2,ReentrantLock优点
2.2.1,可重入
ReentrantLock 和 syncronized 关键字一样,都是可重入锁,不过两者实现原理稍有差别, RetrantLock 利用 AQS 的的 state 状态来判断资源是否已锁,同一线程重入加锁, state 的状态 +1 ; 同一线程重入解锁, state 状态 -1 (解锁必须为当前独占线程,否则异常); 当 state 为 0 时解锁成功。syncronized 则是利用对象内存结构中的头部markword中的计数器去记录线程数。
2.2.2,手动加锁/释放锁
synchronized 关键字是自动进行加锁、解锁的,而 ReentrantLock 需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成,来手动加锁、解锁。
2.2.3,支持公平/非公平锁
synchronized 关键字是一种非公平锁,先抢到锁的线程先执行。而 ReentrantLock 的构造方法中允许设置 true/false 来实现公平、非公平锁,如果设置为 true ,则线程获取锁要遵循"先来后到"的规则,每次都会构造一个线程 Node ,然后到双向链表的"尾巴"后面排队,等待前面的 Node 释放锁资源。
2.2.4,可中断
ReentrantLock 中的 lockInterruptibly() 方法使得线程可以在被阻塞时响应中断,比如一个线程 t1 通过 lockInterruptibly() 方法获取到一个可重入锁,并执行一个长时间的任务,另一个线程通过 interrupt() 方法就可以立刻打断 t1 线程的执行,来获取t1持有的那个可重入锁。而通过 ReentrantLock 的 lock() 方法或者 Synchronized 持有锁的线程是不会响应其他线程的 interrupt() 方法的,直到该方法主动释放锁之后才会响应 interrupt() 方法。
看一下java代码:
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(()->{
try {
System.out.println("t1 begin lock");
lock.lockInterruptibly();
Thread.sleep(10000);
System.out.println("t1 lock end");
} catch (InterruptedException e) {
System.out.println("响应中断了");
}finally {
lock.unlock();
}
});
t1.start();
Thread.sleep(2000);
Thread t2 = new Thread(()->{
System.out.println("t2 begin");
t1.interrupt();
System.out.println("t2 end");
});
t2.start();
}
结果如下:
t1 begin lock t2 begin t2 end 响应中断了
三,ReadWriteLock - ReentrantReadWriteLock
ReentrantReadWriteLock (读写锁)其实是两把锁,一把是 WriteLock (写锁),一把是读锁, ReadLock 。读写锁的规则是:读读不互斥、读写互斥、写写互斥。在一些实际的场景中,读操作的频率远远高于写操作,如果直接用一般的锁进行并发控制的话,就会读读互斥、读写互斥、写写互斥,效率低下,读写锁的产生就是为了优化这种场景的操作效率。一般情况下独占锁的效率低,来源于高并发下对临界区的激烈竞争导致线程上下文切换。因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率高,因此需要根据实际情况选择使用。
ReentrantReadWriteLock 的原理也是基于 AQS 进行实现的,与 ReentrantLock 的差别在于 ReentrantReadWriteLock 锁拥有共享锁、排他锁属性。读写锁中的加锁、释放锁也是基于 Sync (继承于 AQS ),并且主要使用 AQS 中的 state 和 node 中的 waitState 变量进行实现的。实现读写锁与实现普通互斥锁的主要区别在于需要分别记录读锁状态及写锁状态,并且等待队列中需要区别处理两种加锁操作。ReentrantReadWriteLock 中将 AQS 中的 int 类型的 state 分为高 16 位与第 16 位分别记录读锁和写锁的状态,如下图所示:
3.1,写锁是悲观锁(排他锁、互斥锁)
3.2,ReadLock(读锁)是共享锁(乐观锁)
3.3,总结:
在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。
四,LongAdder/DoubleAdder
在高并发的情况下,我们对一个 Integer 类型的整数直接进行 i++ 的时候,无法保证操作的原子性,会出现线程安全的问题。为此我们会用 juc 下的 AtomicInteger ,它是一个提供原子操作的 Interger 类,内部也是通过 CAS 实现线程安全的。但当大量线程同时去访问时,就会因为大量线程执行 CAS 操作失败而进行空旋转,导致 CPU 资源消耗过多,而且执行效率也不高。Doug Lea 大神应该也不满意,于是在 JDK1.8 中对 CAS 进行了优化,提供了 LongAdder ,它是基于了 CAS 分段锁的思想实现的。
线程去读写一个 LongAdder 类型的变量时,流程如下:
LongAdder 也是基于 Unsafe 提供的 CAS 操作 +valitale 去实现的。在 LongAdder 的父类 Striped64 中维护着一个 base 变量和一个 cell 数组,当多个线程操作一个变量的时候,先会在这个 base 变量上进行 cas 操作,当它发现线程增多的时候,就会使用 cell 数组。比如当 base 将要更新的时候发现线程增多(也就是调用 casBase 方法更新 base 值失败),那么它会自动使用 cell 数组,每一个线程对应于一个 cell ,在每一个线程中对该 cell 进行 cas 操作,这样就可以将单一 value 的更新压力分担到多个 value 中去,降低单个 value 的 “热度”,同时也减少了大量线程的空转,提高并发效率,分散并发压力。比如说对10 的原子操作就变成{5,5}两个变量的原子操作。这种分段锁需要额外维护一个内存空间 cells ,不过在高并发场景下,这点成本几乎可以忽略。分段锁是一种优秀的优化思想, juc 中提供的的 ConcurrentHashMap 也是基于分段锁保证读写操作的线程安全。
cells数组是LongAdder高性能实现的必杀器: AtomicInteger只有一个value,所有线程累加都要通过cas竞争value这一个变量,高并发下线程争用非常严重; 而LongAdder则有两个值用于累加,一个是base,它的作用类似于AtomicInteger里面的value,在没有竞争的情况不会用到cells数组,这时使用base做累加,有了竞争后cells数组就上场了,第一次初始化长度为2,以后每次扩容都是变为原来的两倍,直到cells数组的长度大于等于当前服务器cpu的数量为止就不在扩容(CPU能够并行的CAS操作的最大数量是它的核心数),每个线程会通过线程对cells[threadLocalRandomProbe%cells.length]位置的Cell对象中的value做累加,这样相当于将线程绑定到了cells中的某个cell对象上。
五,悲观锁与乐观锁
5.1,悲观锁
当要对共享数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。
之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。悲观锁的实现:
- 传统的关系型数据库使用这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
- Java 里面的同步 synchronized 关键字的实现。
悲观锁主要分为共享锁和排他锁:
- 共享锁【shared locks】又称为读锁,简称S锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
- 排他锁【exclusive locks】又称为写锁,简称X锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据行读取和修改。
说明 悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个线程如果锁定了某行数据,其他线程就必须等待该线程处理完才可以处理数据。
5.2,乐观锁
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。乐观锁适用于读操作多的场景,这样可以提高程序的吞吐量。
乐观锁机制采取了更加宽松的加锁机制。乐观锁是相对悲观锁而言,也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:
CAS 实现:
Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
ABA的问题
就是A线程拿着期望值A取更新数据的时候。这个数据可能已经被其他线程更新两次了,里程为A-B-A。这个时候期望值相等,但是已经被修改过。不是最开始期望的。
一般的解决方案是使用递增的版本号或者直接使用时间戳。期望值与期望版本号同时满足时,才进行更新操作。
CAS的缺点:
1.CPU开销较大 在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
2.不能保证代码块的原子性 CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
本篇文章介绍了jvm常见的几种锁。
下一篇文章《JVM的锁(2) - 锁优化》