Java中的锁.归类概览

1.乐观锁 与 悲观锁

  • 乐观锁:乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

    java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

  • 悲观锁:悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁

    java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如Lock的实现类RetreenLock。

2.自旋与适应性自旋

自旋:尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

自旋原理:如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。但是线程自旋是需要消耗cup的,所以需要设定一个自旋等待的最大时间,即自旋锁时间阈值。

  • 优点:锁的竞争不激烈且占用锁时间非常短的代码块来说时,自旋锁可尽可能的减少线程的阻塞,性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换。CPU消耗代价小于两次上下文切换。

  • 缺点:锁出现激烈竞争,或者持有锁的线程需要长时间占用锁执行同步块时,自旋锁将不是一个好选择,因为自旋锁在获取锁前持续消耗CPU去等待,且同时大量线程竞争一个锁,会导致获取锁的时间拉长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要CPU的线程又无法及时获取CPU,造成CPU的浪费。CPU消耗代价大于两次上下文切换。

自旋锁时间阈值:JVM对于自旋周期的选择,JDK1.5这个周期限度是写死的,在JDK1.6引入了适应性自旋锁,适应性自旋锁其自旋周期不固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态共同决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化,因此存在一个自旋周期的确定过程。

  • 如果平均负载小于CPUs则一直自旋
  • 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  • 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  • 如果CPU处于节电模式则停止自旋
  • 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  • 自旋时会适当放弃线程优先级之间的差异

3.公平锁与非公平锁

  • 公平锁(Fair) :等待锁的线程统一进入队列排队,于队尾等待获取锁;

  • 非公平锁(Nonfair) :加锁时不考虑排队等待问题,先直接尝试获取锁,获取不到则自动到队尾等待;

    1. 非公平锁性能比公平锁高 5~10 倍,因为公平锁需要在多核的情况下维护一个队列。
    2. Java 中的 synchronized 是非公平锁ReentrantLock 默认lock()方法是非公平锁,但是可启用公平锁。

4.共享锁 与 独占锁

J.U.C. 提供的加锁模式分为共享锁和独占锁

  • 共享锁(读锁)

共享锁允许多个线程同时获取锁,并发访问共享资源,如:ReadWriteLock。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。

  1. AQS 的内部类 Node 定义了两个常量 SHARED 和 EXCLUSIVE,他们分别标识 AQS 队列中等待线程的锁获取模式。
  2. java 的并发包中提供的 ReadWriteLock,读-写锁。它允许一个资源可以被多个读操作访问, 或者被一个写操作访问,但两者不能同时进行。
  • 独占锁/排他锁(写锁)

独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock 和sychronized是以独占方式实现的互斥锁

独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。

5.可重入锁

线程内的多个流程支持获取同一把锁。

同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁,也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块

可重入锁最大的作用是避免死锁

  • sychronized和ReentrantLock

6.多线程 锁的状态

多线程竞争锁的状态总共有四种:无锁状态、偏向锁、轻量级锁、重量级锁。

锁升级/锁膨胀:单向升级

Synchronized

独占式的悲观锁、可重入锁。

  • 实现原理

    synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。
    ​ 也就是说当两个线程同时对一个对象的一个方法进行操作,只有一个线程能够抢到锁。因为一个对象只有一把锁,一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,就不能访问该对象的其他synchronized实例方法,但是可以访问非synchronized修饰的方法

  • 作用范围

    • 修饰类:锁住的是Class实例,类同静态方法,类的一个全局锁,会锁所有调用该类的线程;
    • 修饰静态方法:锁住的是Class实例,又因为Class的相关数据存储在永久代(jdk1.8 则是 元空间),永久代是全局共享的,因此静态方法锁相当于类的一个全局锁, 会锁所有调用该方法的线程;
    • 修饰非静态方法/成员变量:锁住的是调用该静态方法的对象实例(this对象);
    • 修饰代码块:锁住的是调用该代码块的对象实例(this对象);
  • 局限性

    • 当线程尝试获取锁的时候,如果获取不到锁会一直阻塞。
    • 如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁必须一直等待。

Java对象头

Java对象头由三部分组成:

  • Mark Word

    存储对象的hashCode或锁信息等

  • Class Metadata Address

    存储到对象类型数据的指针

  • Array length

    数组的长度(当对象为数组时)

Java对象头中Mark Word存储结构如图所示(以32位JVM虚拟机为例)。

6.1 无锁

不锁住资源,多线程只能有一个修改成功,其余线程会重试。

6.2 偏向锁

同一个线程执行不同流程同步资源时,自动获取资源。大多数情况,锁不仅不存在多线程竞争,而且总是由同一线程多次获取,因而由此优化。

当一个线程访问同步块并获取锁时,会在Java对象头和帧栈中的锁记录里面存储锁偏向的线程id,该线程再次访问同步块时不需要进行CAS操作来进行加锁、解锁,只需测试Java对象头中Mark Word里是否存储指向当前线程的偏向锁。

偏向锁获取过程:

  1. 访问Java对象头中Mark Word中偏向锁的标志位是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则测试Mark Word中线程ID是否指向当前线程,如果是,进入步骤5获取锁执行同步代码块,否则进入步骤3继续尝试。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,则证明该偏向锁指向的线程存在,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,设置锁标志位为00(变为轻量级锁),偏向锁标识位为0,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
  5. 执行同步代码。
场景:线程2竞争线程1持有的偏向锁 导致锁升级过程
1、线程1持有偏向级锁;
2、线程2来竞争锁对象;
3、判断当前对象头是否是偏向锁;
4、判断拥有偏向锁的线程1是否还存在;
	线程1不存在,直接设置偏向锁标识为0(线程1执行完毕后,不会主动去释放偏向锁)。使用cas替换偏向锁线程ID为线程2,锁不升级,仍为偏向锁;
	线程1仍然存在,暂停线程1。设置锁标志位为00(变为轻量级锁),偏向锁为0;
5、从线程1的空闲monitor record中读取一条,放至线程1的当前monitor record中;
6、更新mark word,将mark word指向线程1中monitor record的指针;
7、继续执行线程1的代码;
8、锁升级为轻量级锁;
9、线程2自旋来获取锁对象;

偏向锁的特点:无竞争不锁,有竞争挂起,转为轻量锁。

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

偏向锁的开启:Java6、7均默认开启,应用程序启动几秒后才激活,可配置JVM关闭延迟或直接关闭偏向锁。

  • 开启偏向锁:-XX:+UseBiasedLocking
  • 关闭偏向锁延迟:-XX:BiasedLockingStartupDelay=0
  • 关闭偏向锁:-XX:-UseBiasedLocking

6.3 轻量级锁

多线程竞争同步资源时,未获取资源的线程自旋等待锁释放。

轻量级锁适应的场景:线程通过自旋等待获取锁,实现线程交替执行同步块。

锁升级:如果在自旋一定次数后仍未获得锁,那么轻量级锁将会升级成重量级锁。

在jdk1.6以前,默认轻量级锁自旋次数是10次,如果超过这个次数或自旋线程数超过CPU核数的一半,就会升级为重量级锁。jdk1.6以后加入了自适应自旋锁Adapative Self Spinning),自旋的次数不再固定,由jvm自己控制,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

6.4 重量级锁

多线程竞争同步资源时,未获取资源的线程阻塞自旋等待被唤醒。

Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高。

JDK 中对 Synchronized 做的种种优化,其核心都是为了减少这种重量级锁的使用。 JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入上文的“轻量级锁”和 “偏向锁”。

6.5 锁的对比

优点缺点适用场景
偏向锁加锁与解锁无额外消耗多线程锁竞争时存在偏向锁的撤销,产生额外消耗只有一个线程会访问同步块
轻量级锁多线程竞争通过CAS自旋实现,线程交替执行同步块,提高程序响应速度始终获取不到锁的线程会持续自旋消耗CPU追求响应时间,同步块执行速度块
重量级锁多线程竞争不会自旋消耗CPU线程阻塞,响应慢追求吞吐量,同步块执行慢(周期长)

最后,内容推荐。