Java中的锁事

Java中提供了种类丰富的锁,定义这些锁可以在适当的场景中发挥更好的作用。

乐观锁&悲观锁

乐观锁

每次去拿取数据的时候认为不会有人进行修改,所以不会去添加锁。只是会在更新数据时去判断是否有其他线程对这个数据进行了修改。通过判断版本号检测是否发生了更新,未发生变化直接写入新数据;发生了变化,就需要重复执行‌读版本号-比较有无发生变化-写入新数据操作。
在Java中一般通过CAS算法实现。例如Atomic*类内部都是通过CAS实现的。
乐观锁适合读操作多的场景,不加锁可以大大提升读操作的效率。

CAS算法

compare and swap(比较与交换),是一种无锁算法(在不使用锁的情况下实现线程间的变量同步)。

CAS算法涉及了三个操作数:

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 要写入的新值 B

当且仅当V的值等于A时,CAS通过原子方式更新V的值为B。否则不会执行任何操作(比较与更新为一个原子操作),一般情况下为一个自旋操作,需要不断进行重试。

乐观锁缺点

ABA问题

CAS需要在操作值的时候检查内存值是否发生了变化,没有发生变化才会去更新值。但有一种特殊情况,内存中的值发生了A->B->A这类变化,在检查时得到的结果就是没有发生变化,这显然是不合理的。

解决该问题的方法有两种:

  • 通过在变量前面添加版本号,每次变量更新时进行版本号增加操作,可以保证监听到值的变化
  • 通过AtomicStampedReference类解决,需要检查当前引用与预期引用,当前标记与预期标记是否相同,相同则更新。
循环时间长开销大

CAS操作不成功时,默认会进行自旋操作(直到成功为止),会一直占用CPU资源造成极大的消耗。

可以通过处理器的pause指令进行解决。

pause指令有两个作用:

  • 延迟流水线执行
  • 避免退出循环时因为内存顺序冲突引起CPU流水线被清空
只能保证一个共享变量的原子操作

CAS只对单个变量有效,无法对多个变量同时生效。

可以通过AtomicReference来保证引用对象之间的原子性,把多个变量放于同一个对象里进行CAS操作。

悲观锁

每次去拿取数据的时候都认为别人会进行修改,所以每次在拿数据的时候都会进行上锁操作,确保数据不会被其他线程修改。在其他线程想要操作该数据时,就会被阻塞直到得到锁(共享资源每次只给一个线程使用,其他线程被阻塞,等到当前线程使用完毕后,其他线程才可以获取锁)。

可以通过Thread.holdsLock()来获取当前线程是否持有锁。

悲观锁适合写操作多的场景,可以保证进行写操作时的数据正确。

其中Java中的synchronizedLock就是悲观锁的具体实现。

锁的状态

在JVM中锁的状态分为四种:

  • 无锁:例如CAS操作
  • 偏向锁
  • 轻量级锁
  • 重量级锁:synchronized

锁的进化状态为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁不可退化

对象头

锁的每个状态都是有标记的,他们都存储于对象头的Mark Word中。

对象头分为两部分:Mark WordKlass Pointer

Mark Word:存储对象的HashCode,分代年龄、线程ID以及锁标志信息。

Mark Word

其中四种锁状态主要对应在锁标志位上

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

Klass Pointer:对象指向它的类元数据指针,虚拟机通过该指针确定实例。

无锁

不会对资源进行锁定,所有线程都能访问并修改同一个资源,但是只能有一个线程修改成功在同一时间内。

无锁的特点就是修改操作在循环中进行,线程会不断的去尝试修改共享资源。如果修改成功就直接退出,否则继续循环尝试。

在已存在线程修改共享资源时,其他线程会进入自旋状态直至修改成功。

自旋锁

如果持有锁的线程能在很短的时间内就释放锁,其他需要等待竞争锁的线程就不需要在内核态和用户态之间进行切换,导致进入阻塞状态,其他线程只有执行自旋,等待执行操作的线程释放锁之后就可以去直接获取锁,避免线程切换的开销。

优缺点

自旋锁尽可能的减少阻塞发生,对于锁的竞争不激烈且不会占用锁事件过长的操作性能提升明显,自旋的消耗相对线程的切换小很多。在线程阻塞和唤醒的过程中会发生两次上下文切换过程。

上下文切换:当CPU执行从一个线程切换到另一个线程时,需要先存储当前线程的本地数据、程序指针等。然后载入另一线程的本地数据、程序指针等,然后开始执行另一个线程。

自旋锁本身是有缺点的,无法代替阻塞功能。自旋虽然避免了线程切换的开销,但是需要占用CPU。如果锁被占用的时间很短,自旋锁的效果就会非常好。

但是,如果锁被占用的时间很长,其他自旋等待的线程就会一直占用CPU资源,导致极大的浪费。这时就需要去关闭自旋锁。

默认设置自旋次数超过10次-XX:PreBlockSpin进行修改就会自动关闭并挂起当前线程。

自适应自旋锁

在1.6之前,自旋次数上限是写死的,在1.6之后引入了自适应自旋锁,意味着自旋上限不再是固定的,而是根据 上一次同一个锁上的自旋时间以及锁拥有者的状态进行决定的

偏向锁

一段同步代码一直被同一个线程访问,那么该线程自动获取锁,降低获取锁的代价。

适用场景

始终只有一个线程在执行同步代码块,即使没有执行完,也不会有其他线程去执行同步代码块。为了在只有一个线程执行同步代码块时提高性能。

在高并发场景下会直接禁用偏向锁,通过设置-XX:-UseBiasedLocking。关闭后,会进入轻量级锁

获取过程

  1. 当线程访问同步块代码并获取锁时,会在Mark Word中存入当前线程的ID并设置偏向锁标识为1
  2. 再次有线程访问该代码块时,先去判断Mark Word中偏向锁标识是否为1且线程ID是否一致
  3. 若一致,则执行同步代码
  4. 不一致时,需要通过CAS去获取锁,如果竞争成功,修改Mark Word中的线程ID为当前线程
  5. 若竞争失败,说明当前还有其他线程在竞争锁。那么就需要释放偏向锁。

释放过程

偏向锁只有遇到其他线程竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。

偏向锁的释放,需要等待全局安全点(safe-point)-当前时间点没有字节码在执行,会首先暂停拥有偏向锁的线程,并判断该锁对象是否处于被锁定状态。

释放偏向锁后恢复无锁状态或进化到轻量级锁(标记00)状态。

轻量级锁

轻量级锁是由偏向锁升级来的,当偏向锁被另一个线程访问时,偏向锁就会升级为轻量锁。

在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁并不是用来替代传统重量级锁。

加锁过程

  1. 代码进入同步块的时候,如果同步对象锁状态为无锁状态(标志位01,是否为偏向锁0),在当前线程的栈帧中建立一个Lock record用于存储Mark Word,名为Displaced Mark Word
  2. 拷贝当前线程对象头的Mark WordLock Record
  3. 拷贝成功后,虚拟机使用CAS操作尝试将对象的Mark Word更新为指向Lock Record指针,并将Lock Record指针指向Mark Word
  4. 更新成功,该线程就拥有了对象的锁,并且设置Mark Word的锁标志位为00,表明当前处于轻量级锁定状态。

如果更新失败,虚拟机首先检查Mark Word

释放锁过程

重量级锁

阻塞

锁优化

锁消除

锁粗化

其他锁分类

公平锁&非公平锁

可重入锁&非重入锁

独占锁&共享锁

读写锁

死锁

两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,他们会互相阻塞。

产生死锁的条件有4个:

  • 互斥条件:一个资源每次只能被一个线程占用
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:线程已获得的资源,未使用完之前不得强行剥夺
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

内容引用

JAVA中锁的深入理解与解析

不可不说的Java“锁”事


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!