废话不多说,先上锁的分类图
1、乐观锁&悲观锁
悲观锁
悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,把别的线程阻塞住,最终确保数据不会被别的线程修改。
所以悲观锁保证了数据的原子性。
Java中,synchronized关键字和Lock的实现类都是悲观锁。(读写锁中的读锁好像是例外?)
传统的关系型数据库里边也用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。悲观锁的实现往往依靠数据库本身的锁功能实现。
乐观锁
乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
如果这个数据没有被更新,当前线程将自己修改的数据成功写入。
如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
java中常用的原子类就是乐观锁实现的
mysql中常用版本号或者时间戳来实现乐观锁
两种锁的使用场景
悲观锁适合写多读少的场景
乐观锁适合读多写少的场景
乐观锁的实现方式
乐观锁一般有两种实现方式:采用版本号机制 和 CAS
(Compare-and-Swap,即比较并替换
)算法实现。
CAS算法:
Java 从 JDK1.5 开始支持,java.util.concurrent
包里提供了很多面向并发编程的类;
一些以 Atomic
为开头的一些原子类都使用 CAS 作为其实现方式,使用这些类在多核CPU 的机器上会有比较好的性能。
CAS 中涉及三个要素:
- 需要读写的内存值(V)
- 进行比较的预期原值(A)
- 拟写入的新值(B)
CAS源码实现方式:AtomicInteger
源码定义
类属性
- unsafe: 获取并操作内存的数据。
- valueOffset: 存储value在AtomicInteger中的偏移量。
- value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。
AtomicInteger的自增函数incrementAndGet()底层调用的是unsafe.getAndAddInt()
// Unsafe.java public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v + delta)); return v; }
源码解读:根据源码我们可以看出,getAndAddInt() 循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。
如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。
底层拓展:CAS操作,在JNI里是借助于一个CPU指令 cmpxchg 完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。
JDK通过CPU的 cmpxchg 指令,去比较寄存器中的 A 和 内存中的值 V。
如果相等,就把要写入的新值 B 存入内存中。
如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置成功为止。
AtomicInteger测试
我们以 java.util.concurrent 中的 AtomicInteger
为例,看一下在不用锁的情况下是如何保证线程安全的
public class AtomicCounter {
private AtomicInteger integer = new AtomicInteger(); public AtomicInteger getInteger() { return integer; } public void setInteger(AtomicInteger integer) { this.integer = integer; } public void increment(){ integer.incrementAndGet(); }
public void decrement(){ integer.decrementAndGet(); } } public class AtomicProducer extends Thread{ private AtomicCounter atomicCounter; public AtomicProducer(AtomicCounter atomicCounter){ this.atomicCounter = atomicCounter; } @Override public void run() { for(int j = 0; j < AtomicTest.LOOP; j++) { System.out.println("producer : " + atomicCounter.getInteger()); atomicCounter.increment(); } } } public class AtomicConsumer extends Thread{ private AtomicCounter atomicCounter; public AtomicConsumer(AtomicCounter atomicCounter){ this.atomicCounter = atomicCounter; } @Override public void run() { for(int j = 0; j < AtomicTest.LOOP; j++) { System.out.println("consumer : " + atomicCounter.getInteger()); atomicCounter.decrement(); } } }
// 测试类 public class AtomicTest { final static int LOOP = 10000; public static void main(String[] args) throws InterruptedException { AtomicCounter counter = new AtomicCounter(); AtomicProducer producer = new AtomicProducer(counter); AtomicConsumer consumer = new AtomicConsumer(counter); // 启动线程开始循环加减 producer.start(); consumer.start(); producer.join(); consumer.join(); System.out.println(counter.getInteger()); } }
经测试可得,不管循环多少次最后的结果都是0,也就是多线程并行的情况下,使用 AtomicInteger 可以保证线程安全性。
也就是 incrementAndGet 和 decrementAndGet 都是原子性操作。
乐观锁的缺点
ABA问题
中间数据可能被人修改过,但是最后又改回来了
解决方法一:JDK 1.5 以后的 AtomicStampedReference
类就提供了此种能力,其中的 compareAndSet 方法
就是首先检查当前引用是否等于预期引用,
并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
解决方法二:采用CAS的一个变种DCAS来解决这个问题。DCAS,是对于每一个V增加一个引用的表示修改次数的标记符。
对于每个V,如果引用修改了一次,这个计数器就加1。然后在这个变量需要update的时候,就同时检查变量的值和计数器的值。
循环开销大
我们知道乐观锁在进行写操作的时候会判断是否能够写入成功,如果写入不成功将触发等待 -> 重试机制,这种情况是一个自旋锁,简单来说就是适用于短期内获取不到,
进行等待重试的锁,它不适用于长期获取不到锁的情况,另外,自旋循环对于性能开销比较大,自旋循环时也会占用cpu时间,在特殊情况下甚至会GC有影响。
只能保证一个共享变量的原子操作
对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
- Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
CAS和Synhronized的使用场景
简单的来说 CAS 适用于读多写少,synchronized 适用于写多读少
cas缺点是自旋的时候会占用cpu资源,如果线程较多,争抢不到锁就会一直自旋,影响其它线程处理。
synchronized缺点是线程会在用户态和内核态进行转换,有时候锁住的代码块内逻辑很简单时,这个切换甚至比处理代码逻辑的时候更久;另外线程每次阻塞和唤醒也很消耗性能
- 对于资源竞争较少(线程冲突较轻)的情况,使用 Synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 cpu 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,自旋的时候也会占用cpu时间,从而浪费更多的 CPU 资源,效率低于 synchronized。
2、自旋锁和适应性自旋锁
自旋锁的提出背景
由于在多处理器环境中某些资源的有限性,有时需要 互斥访问(mutual exclusion)
,这时候就需要引入锁的概念,只有获取了锁的线程才能够对资源进行访问,由于多线程的核心是CPU的时间分片,所以同一时刻只能有一个线程获取到锁。那么就面临一个问题,那么没有获取到锁的线程应该怎么办?
通常有两种处理方式:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING)
;还有一种处理方式就是把自己阻塞起来,等待重新调度请求,这种叫做互斥锁
。
补充一点,分布式锁是在多进程环境中操作同一个资源时的有限性,此时加上的互斥访问锁;redisson实现的trylock就是实现了lock接口,从概念上来说是一种自旋锁。
什么是自旋锁
当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人持有,那么此线程就无法获取到这把锁,将会循环等待获取,此时不会中断线程,不会释放cpu时间片。
简单理解: 循环加锁 - > 然后等待重试
简单应用:Lock实现类 和 CAS 应用类。
自旋锁的原理
在操作系统的内核中经常使用自旋锁,
采用CAS实现
自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。
但是如何去选择自旋时间呢?
如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。
因此自旋的周期选的额外重要!
JDK在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,
基本认为一个线程上下文切换的时间是最佳的一个时间。
自旋锁的优缺点
优点:减少线程上下文切换:
如果持有锁的线程能在短时间内释放锁资源,那么等待的线程就不需要去中断或阻塞操作,此时线程会一直持有cpu时间片,减少恢复现场导致的消耗。
线程的中断和唤醒会做用户态和内核态之间的切换操作,发送两次上下文切换,产生不必要的时间浪费,自旋就是为了解决这个问题
缺点:会长时间占用cpu时间片,如果线程竞争激烈或锁住的同步代码块执行逻辑久,其它线程可能就竞争不到cpu使用权,影响总体性能
自旋锁的实现
简单的自旋锁实现
public class SpinLockTest { private AtomicBoolean available = new AtomicBoolean(false); public void lock(){ // 循环检测尝试获取锁 while (!tryLock()){ // 获取成功就进行自己的逻辑... } } public boolean tryLock(){ // 尝试获取锁,成功返回true,失败返回false return available.compareAndSet(false,true); } public void unLock(){ if(!available.compareAndSet(true,false)){ throw new RuntimeException("释放锁失败"); } } }
简单自旋锁无法保证线程的执行顺序,可能产生饥饿问题,就是一个线程长时间获取不到锁;解决方式是排队自旋,讲究个先来后到。
TicketLock
每个线程拿着票据,先进先出(FIFO) 的队列机制
设计原则:TicketLock 中有两个 int 类型的数值,开始都是0,第一个值是队列 ticket(队列票据)
, 第二个值是 出队 ticket(票据)
。
队列票据是线程在队列中的位置,而出队票据是现在持有锁的票证的队列位置。
可能有点模糊不清,简单来说,就是队列票据是你取票号的位置,出队票据是你距离叫号的位置。现在应该明白一些了吧。
缺点
TicketLock 虽然解决了公平性的问题,
但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量queueNum ,
每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
为了解决这个问题,MCSLock 和 CLHLock 应运而生。
CLHLock
CLH 是一种基于链表的可扩展,高性能,公平的自旋锁,申请线程只能在本地变量上自旋,它会不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
MCSLock
MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。
CLHLock 和 MCSLock
- 都是基于链表,不同的是CLHLock是基于隐式链表,没有真正的后续节点属性,MCSLock是显示链表,有一个指向后续节点的属性。
- 将获取锁的线程状态借助节点(node)保存,每个线程都有一份独立的节点,这样就解决了TicketLock多处理器缓存同步的问题。
适应性自旋锁
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
3、无锁、偏向锁、轻量级锁、重量级锁
背景
在jdk6之前,synchronized 是重量级锁,线程中断唤醒的代价很大,需要内核态和用户态的转换。
ReentrantLock 可以看做 synchronized 的超集,在jdk1.5推出,性能远远超出原来的 synchronized ,为此提出了 四种锁状态 来优化同步锁的使用效率
锁状态的分类
Java 语言专门针对 synchronized
关键字设置了四种状态,它们分别是:无锁、偏向锁、轻量级锁和重量级锁
,但是在了解这些锁之前还需要先了解一下 Java 对象头和 Monitor。
java对象头
我们知道 synchronized 是悲观锁,在操作同步之前需要给资源加锁,这把锁就是对象头里面的,而Java 对象头又是什么呢?
我们以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段) 和 class Pointer(类型指针)
。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。
这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。
它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
class Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
锁状态 | 存储内容 | 存储内容 |
---|---|---|
无锁 | 对象的hashCode(25bit)、对象分代年龄(4bit)、是否是偏向锁(0)(1bit) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
对象头内存解释
- age_bits 就是我们说的分代回收的标识,占用4字节
- lock_bits 是锁的标志位,占用2个字节
- biased_lock_bits 是是否偏向锁的标识,占用1个字节
- max_hash_bits 是针对无锁计算的hashcode 占用字节数量,如果是32位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以64位的 hashcode 占用 31 byte
- hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取31,否则取真实的字节数
- cms_bits 我觉得应该是不是64位虚拟机就占用 0 byte,是64位就占用 1byte
- epoch_bits 就是 epoch 所占用的字节大小,2字节。
synchronized锁
synchronized用的锁是存在Java对象头里的。
JVM基于进入和退出 Monitor 对象来实现方法同步和代码块同步。
代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应地,在执行 monitorexit 指令时会将锁计数器减1,当计数器被减到0时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
monitor监视器
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁
。
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁
和轻量级锁
:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
所以锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6/7中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁。
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
偏向锁获取过程
1、首先线程访问同步代码块,会通过检查对象头 Mark Word 的锁标志位判断目前锁的状态,如果是 01,说明就是无锁或者偏向锁,然后再根据是否偏向锁 的标示判断是无锁还是偏向锁,如果是无锁情况下,执行下一步
2、线程使用 CAS 操作来尝试对对象加锁,如果使用 CAS 替换 ThreadID 成功,就说明是第一次上锁,那么当前线程就会获得对象的偏向锁,此时会在对象头的 Mark Word 中记录当前线程 ID 和获取锁的时间 epoch 等信息,然后执行同步代码块。
偏向锁的存在意义:
我们知道,偏向锁的目标是减少昂贵的原子指令cas等的使用以及互斥量的开销,轻量锁的目标是减少互斥量的开销;
偏向锁在不考虑重偏向这种情况下,似乎只有第一次加锁才起作用,那么这个动作似乎有些多余,我们需要对没有竞争的代码加上同步吗?
答案:是需要的,场景举例
1.类加载其实是加锁的,我们可以尝试并发地进行类加载,尽管大多情况下这由main线程完成.
2.一些旧版本的库,如使用 Vector、HashTable 、Collections.synchronize系列,在绝对不会出现 线程逃逸 的情况下使用 StringBuffer 拼接字符串,单线程使用了某些库中加了同步的代码等.
3.默认的情况下在jvm启动的前几秒偏向锁是不可用的,可以使用-XX:BiasedLockingStartupDelay=0进行配置.
偏向锁的设计疑问,为什么只在对象头中保存线程id?
偏向锁退出同步块其实是无操作的,偏向锁标记依旧存在,所以自然恢复,规避了昂贵的原子指令和屏障的开销;
但是轻量锁就不同了,需要在设置标记时保存锁记录的指针,同时还要将原来的信息存放到栈桢;
在升级为轻量级锁时,会在安全点先进行偏向锁消除,然后再进行锁升级,升级完毕后还给原来的线程使用。这样在写副本信息时,可以使用cas恢复原值.
轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
轻量级锁加锁过程
1、紧接着偏向锁的上一步,如果 CAS 操作替换 ThreadID 没有获取成功,执行下一步
2、如果使用 CAS 操作替换 ThreadID 失败(这时候就切换到另外一个线程的角度)说明该资源已被同步访问过,这时候就会执行锁的撤销操作,撤销偏向锁,然后等原持有偏向锁的线程到达全局安全点(SafePoint)
时,会暂停原持有偏向锁的线程,然后会检查原持有偏向锁的状态,如果已经退出同步,就会唤醒持有偏向锁的线程,执行下一步
3、检查对象头中的 Mark Word 记录的是否是当前线程 ID,如果是,执行同步代码,如果不是,执行偏向锁获取流程 的第2步。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
重量级锁的获取流程
1. 接着上面偏向锁的获取过程,由偏向锁升级为轻量级锁,执行下一步
2. 会在原持有偏向锁的线程的栈中分配锁记录,将对象头中的 Mark Word 拷贝到原持有偏向锁线程的记录中,然后原持有偏向锁的线程获得轻量级锁,然后唤醒原持有偏向锁的线程,从安全点处继续执行,执行完毕后,执行下一步,当前线程执行第4步
-
执行完毕后,开始轻量级解锁操作,解锁需要判断两个条件
拷贝在当前线程锁记录的 Mark Word 信息是否与对象头中的 Mark Word 一致。
判断对象头中的 Mark Word 中锁记录指针是否指向当前栈中记录的指针 - 在当前线程的栈中分配锁记录,拷贝对象头中的 MarkWord 到当前线程的锁记录中,执行 CAS 加锁操作,会把对象头 Mark Word 中锁记录指针指向当前线程锁记录,如果成功,获取轻量级锁,执行同步代码,然后执行第3步,如果不成功,执行下一步
- 当前线程没有使用 CAS 成功获取锁,就会自旋一会儿,再次尝试获取,如果在多次自旋到达上限后还没有获取到锁,那么轻量级锁就会升级为 重量级锁
-
关于 epoch
偏向锁的对象头中有一个被称为 epoch
的值,它作为偏差有效性的时间戳。
锁消除、锁粗化、重偏向、锁撤销
锁消除
JIT编译器在编译的时候,进行逃逸分析。
分析 synchronized 锁对象是不是只可能被一个线程加锁,不存在其他线程来竞争加锁的情况。这时消除该锁,编译就不用加入monitorenter和monitorexit指令。
锁粗化
JIT编译时,发现一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁。
重偏向
1.HotSpot虚拟机仅支持"粗放"的重偏向(bulk rebias),用以在承受单队列重偏向过程的开销同时保留优化的收益.
2.粗放的偏向锁重偏向和移除这两件事共享了同一个安全点操作名:RevokeBias.
3.如果满足这几个条件:偏向锁撤消次数超过了 BiasedLockingBulkRebiasThreshold 并且小于 BiasedLockingBulkRevokeThresholdand,且最后一次撤消偏向不晚于BiasedLockingDecayTime ,且所有逃逸的变量都限定于 jvm 的属性,则后续的偏向锁粗放重偏向是可用的.
4.使用 -XX:+PrintSafepointStatistics 可打印安全点事件,与偏向锁有关的可重点可关注 EnableBiasedLocking 、 RevokeBias 和 BulkRevokeBias.
选项-XX:+TraceBiasedLocking可以帮助生成一个详细描述jvm做出的偏向锁决策的日志.
安全点
全局安全点(Safe Point):全局安全点的理解会涉及到 C 语言底层的一些知识,这里简单理解 SafePoint 是 Java 代码中的一个线程可能暂停执行的位置。
openJdk中定义:安全点是在程序执行期间的所有GC Root已知并且所有堆对象的内容一致的点。
个人理解:安全点是 jvm 在 gc、偏向锁移除 的时候 stw 就等于用户线程停止的地方
关于安全点的三个术语:
安全点状态:java线程可以按相应的轮询机制轮询是否进入此状态,但一旦进入,就只能在安全点操作结束后才可离开了.
安全点轮询:java线程询问是否需要进入安全点状态的机制.
安全点操作:出于各种原因,一定要等所有线程到达安全点才可以执行的操作.
安全点与锁的关系
我们知道,从java6开始,自带的synchronized锁进行了大量的优化,有一个膨胀的过程,从无锁-偏向锁-轻量锁-重量锁依次膨胀,第一次加锁时,允许线程将该监视器偏向自己,直到发生其他线程争抢(偏向锁持有线程在退出同步块时不移除偏向,此种情况可以重偏向),此时偏向锁被移除,并膨胀为轻量锁.
这个过程可以简单理解为其他线程请求锁,虚拟机要所有线程在最近的安全点阻塞,vm线程伪造一个displaced mark word到持有者线程的栈桢,更改监视器的标记位,然后让所有线程继续执行。此时持有锁的线程会因此自视为轻量锁,竞争者也将按照轻量锁的规则去竞争.
从 JDK10 起出现了一个新的功能"线程局部握手",它能帮助我们做若干事情,其中一件就是由vm线程和java线程在单独线程的安全点移除偏向锁,而不需要等待全局安全点,同时在握手期间,会阻止进入全局安全点.
安全点的经典例子
thread.sleep(0);
666 此代码真是超神!用在int循环时!如果是long以上的话,jvm会默认加安全点进行中断然后gc回收
安全点和JIT
编译器我也不懂,复制过来没事就看看
JIT有client和server模式,其中server模式是高度优化的,甚至于可以用"过度优化"来形容,在"54个java官方文档术语"这篇文章中甚至提过一个"不常见的陷阱",发生时会反优化并退回解释执行.
JIT高度编译优化的代码和字节码解释执行不同,可能会进行一些安全点的消除,并且编译代码要在全局安全点进行一次"栈上替换"(OSR),然后才能生效.
public class TestBlockingThread { private static final Logger LOGGER = LoggerFactory.getLogger(TestBlockingThread.class); public static final void main(String[] args) throws InterruptedException { Runnable task = () -> { int i = 0; while (true) { i++; if (i != 0) { boolean b = 1 % i == 0; }}}; new Thread(new LogTimer()).start(); Thread.sleep(2000); new Thread(task).start(); } public static class LogTimer implements Runnable { @Override public void run() { while (true) { long start = System.currentTimeMillis(); try { Thread.sleep(1000); } catch (InterruptedException e) { // do nothing } LOGGER.info("timeElapsed={}", System.currentTimeMillis() - start); }}}}
//打印日志
[Thread-0] INFO c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
[Thread-0] INFO c.m.c.concurrent.TestBlockingThread - timeElapsed=1003
[Thread-0] INFO c.m.c.concurrent.TestBlockingThread - timeElapsed=13331
[Thread-0] INFO c.m.c.concurrent.TestBlockingThread - timeElapsed=1006
[Thread-0] INFO c.m.c.concurrent.TestBlockingThread - timeElapsed=1003
[Thread-0] INFO c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
[Thread-0] INFO c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
注意前面提到的前提,JIT编译的高度优化代码需要在全局安全点进行栈上替换,也就是说,它需要要求所有线程到最近的一个安全点阻塞.
正常情况下,每一个JAVA线程会轮询一个安全点标记(safepoint flag)来询问是否要进入安全点,当观察到去安全点标记(go to safepoint flag)时,会赶去最近的安全点.但是,大量地进行安全点标记的轮询是耗费性能的,因此C1C2编译器做了相应的优化,消除了过于频繁的安全点轮询
1.使用解释器执行时任意两个字节码之间.
2.C1C2编译器生成的代码的非计数循环的"回边"(参考了深入理解java虚拟机的回边计数器,方法调用计数器的翻译).
3.在C1C2编译器的方法的退出(OpenJDK虚拟机)和进入(Zing),但当方法已经被内联时,编译器将移除这个安全点的轮询点.
注意示例代码的task线程,它进行的是一个计数的循环,因为计数的循环会让编译器认为是一个"有限"的循环,因此每个回边不会插入相应的安全点轮询.
故此,JIT在试图将编译优化的代码进行OSR时,其他线程已赶到安全点阻塞,但是task线程却依旧未能及时到达安全点,直到JIT最终放弃了等待并判定为无限循环为止.
安全点的放置位置
方法调用处
循环跳转处、循环结束处
异常跳转处
所有的 native 函数处 (在调用结束返回java代码时会进入安全点)
为什么要设置安全点的放置位置
这里引入R大的结论,括弧(我的偶像榜样啊) https://www.zhihu.com/question/29268019/answer/43762165
根据 R 大的说法:正在执行 native 函数的线程看作“已经进入了safepoint”,或者把这种情况叫做“在safe-region里”。
4、公平锁、非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
公平锁和非公平锁的实现
公平锁
new ReentrantLock(true)
我们创建了一个 ReetrantLock,并给构造函数传了一个 true,我们可以查看 ReetrantLock 的构造函数
public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
根据 JavaDoc 的注释可知,如果是 true 的话,那么就会创建一个 ReentrantLock 的公平锁,然后并创建一个 FairSync
,FairSync
其实是一个 Sync
的内部类,它的主要作用是同步对象以获取公平锁。
/** * Sync object for fair locks */ static final class FairSync extends Sync { private static final long serialVersionUID = -3000897897090466540L;
final void lock() { acquire(1); } /** * Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first. */ protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
}
而 Sync 是 ReentrantLock 中的内部类,Sync 继承 AbstractQueuedSynchronizer
类,AbstractQueuedSynchronizer 就是我们常说的 AQS ,它是 JUC(java.util.concurrent) 中最重要的一个类,通过它来实现独占锁和共享锁。
非公平锁
ReentrantLock 创建默认就是非公平锁
sychronized 是非公平锁
ReentrantLock介绍
可重入、等待可中断、公平锁实现、锁绑定多个条件
使用方法:
class MyFairLock { private final ReentrantLock lock = new ReentrantLock(); public void m() { lock.lock();// 建议放在外部,防止在内部时加锁失败抛异常时 释放锁也会报错 try { // sth... } finally { lock.unlock();//释放锁建议放到finally 代码块中 } } }
注意:tryLock()
方法不支持公平性;
reentrantLock 锁通过同一线程最多支持2147483647个递归锁。尝试超过此限制会导致锁定方法引发错误。
ReentrantLock是如何实现锁公平性
hasQueuedPredecessors() 也是 AQS 中的方法,它主要是用来 查询是否有任何线程在等待获取锁的时间比当前线程长;
AQS排队问题
5、可重入锁、不可重入锁
可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
示例代码:
public class Widget { public synchronized void doSomething() { System.out.println("方法1执行..."); doOthers(); } public synchronized void doOthers() { System.out.println("方法2执行..."); } }
在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
如果 synchronized 是不可重入锁的话,那么在调用 doSomethingElse() 方法的时候,必须把 doSomething() 的锁丢掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
补充问题:为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?待解
不可重入锁
代表:非可重入锁NonReentrantLock
为什么非可重入锁在重复调用同步资源时会出现死锁?
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
6、共享锁、独占锁
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
ReentrantReadWriteLock的部分源码:
在 ReentrantReadWriteLock 里面,读锁和写锁的锁主体都是 Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
ReadLock 和 WriteLock 是靠内部类 Sync 实现的锁。Sync 是继承于 AQS 子类的,AQS 是并发的根本,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
那读锁和写锁的具体加锁方式有什么区别呢?
在了解源码之前我们需要回顾一下其他知识。 在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。
在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。如下图所示:
了解了概念之后我们再来看代码,先看写锁的加锁源码:
protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); // 取到当前锁的个数 int w = exclusiveCount(c); // 取写锁的个数w if (c != 0) { // 如果已经有线程持有了锁(c!=0) // (Note: if c != 0 and w == 0 then shared count != 0) if (w == 0 || current != getExclusiveOwnerThread()) // 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败 return false; if (w + exclusiveCount(acquires) > MAX_COUNT) // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。 throw new Error("Maximum lock count exceeded"); // Reentrant acquire setState(c + acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。 return false; setExclusiveOwnerThread(current); // 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者 return true; }
- 这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w = exclusiveCount©; ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
- 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
- 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
- 如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
- 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
tryAcquire()除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,
原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。
因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。
接着是读锁的代码:
protected final int tryAcquireShared(int unused) { Thread current = Thread.currentThread(); int c = getState(); if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) return -1; // 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态 int r = sharedCount(c); if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) { if (r == 0) { firstReader = current; firstReaderHoldCount = 1; } else if (firstReader == current) { firstReaderHoldCount++; } else { HoldCounter rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) cachedHoldCounter = rh = readHolds.get(); else if (rh.count == 0) readHolds.set(rh); rh.count++; } return 1; } return fullTryAcquireShared(current); }
可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。
如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。
读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。
所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
此时,我们再回头看一下互斥锁ReentrantLock中公平锁和非公平锁的加锁源码:
我们发现在ReentrantLock虽然有公平锁和非公平锁两种,但是它们添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。
而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。所以可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。
标签:java,CAS,相见,获取,对象,线程,自旋,偏向 From: https://www.cnblogs.com/dreamzy996/p/16837116.html