Java锁详解

synchronized锁实现原理

Java对象头:
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?

我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针),数组长度(只有数组对象才有)。

Mark Word

:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
在这里插入图片描述
JVM一般是这样使用锁和Mark Word的:

1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞,这个操作需要转入内核态进行,这样做会带来很大的性能开销。关于阻塞的解释可以看下我的这篇文章线程和进程/阻塞和挂起

Klass Point

:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Java对象的类数据保存在方法区。。

Monitor

Mark Word 有一个字段指向 monitor 对象。
monitor 中记录了锁的持有线程,等待的线程队列等信息。前面说的每个对象都有一个锁和一个等待队列,就是在这里实现的。 monitor 对象由 C++ 实现。其中有三个关键字段:

_owner 记录当前持有锁的线程
_EntryList 是一个队列,记录所有阻塞等待锁的线程
_WaitSet 也是一个队列,记录调用 wait() 方法并还未被通知的线程。

Monitor的操作机制如下:
多个线程竞争锁时,会先进入 EntryList 队列。竞争成功的线程被标记为 Owner。其他线程继续在此队列中阻塞等待。
如果 Owner 线程调用 wait() 方法,则其释放对象锁并进入 WaitSet 中等待被唤醒。Owner 被置空,EntryList 中的线程再次竞争锁。
如果 Owner 线程执行完了,便会释放锁,Owner 被置空,EntryList 中的线程再次竞争锁。

JVM 对 synchronized 的处理

上面了解了 monitor 的机制,那虚拟机是如何将 synchronized 和 monitor 关联起来的呢?分两种情况:
如果同步的是代码块,编译时会直接在同步代码块前加上 monitorenter 指令,代码块后加上 monitorexit 指令。这称为显示同步。
如果同步的是方法,虚拟机会为方法设置 ACC_SYNCHRONIZED 标志。调用的时候 JVM 根据这个标志判断是否是同步方法。

JVM 对 synchronized 的优化自旋锁与自适应自旋
在许多应用中,锁定状态只会持续很短的时间,为了这么一点时间去挂起恢复线程,不值得。我们可以让等待线程执行一定次数的循环,在循环中去获取锁。这项技术称为自旋锁,它可以节省系统切换线程的消耗,但仍然要占用处理器。在 JDK1.4.2 中,自选的次数可以通过参数来控制。 JDK 1.6又引入了自适应的自旋锁,不再通过次数来限制,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

锁消除
虚拟机在运行时,如果发现一段被锁住的代码中不可能存在共享数据,就会将这个锁清除。

锁粗化
当虚拟机检测到有一串零碎的操作都对同一个对象加锁时,会把锁扩展到整个操作序列外部。如 StringBuffer 的 append 操作。

轻量级锁
对绝大部分的锁来说,在整个同步周期内都不存在竞争。如果没有竞争,轻量级锁可以使用 CAS 操作避免使用互斥量的开销。

偏向锁
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作,即可获取锁。
synchronized 是重量级锁,由于消耗太大,虚拟机对其做了一些优化。

CAS

操作模型
CAS 是 compare and swap 的简写,即比较并交换。它是指一种操作机制,而不是某个具体的类或方法。在 Java 平台上对这种操作进行了包装。在 Unsafe 类中,调用代码如下:
unsafe.compareAndSwapInt(this, valueOffset, expect, update);
代码
它需要三个参数,分别是内存位置 V,旧的预期值 A 和新的值 B。操作时,先从内存位置读取到值,然后和预期值A(自己线程保存的值)比较。如果相等,则将此内存位置的值改为新值 B,返回 true。如果不相等,说明和其他线程冲突了,则不做任何改变,返回 false。
这种机制在不阻塞其他线程的情况下避免了并发冲突,比独占锁的性能高很多。 CAS 在 Java 的原子类和并发包中有大量使用。

重试机制(循环 CAS)
有很多文章说,CAS 操作失败后会一直重试直到成功,这种说法很不严谨。
第一,CAS 本身并未实现失败后的处理机制,它只负责返回成功或失败的布尔值,后续由调用者自行处理。只不过我们最常用的处理方式是重试而已。
第二,这句话很容易理解错,被理解成重新比较并交换。实际上失败的时候,原值已经被修改,如果不更改期望值,再怎么比较都会失败。而新值同样需要修改。
所以正确的方法是,使用一个死循环进行 CAS 操作,成功了就结束循环返回,失败了就重新从内存读取值和计算新值,再调用 CAS。看下 AtomicInteger 的源码就什么都懂了:

public final int incrementAndGet () {
     
 for (;;) {
         
 int current = get();     
 int next = current + 1;     
 if (compareAndSet(current, next))            
return next;    }}

底层实现
CAS 主要分三步,读取-比较-修改。其中比较是在检测是否有冲突,如果检测到没有冲突后,其他线程还能修改这个值,那么 CAS 还是无法保证正确性。所以最关键的是要保证比较-修改这两步操作的原子性。
CAS 底层是靠调用 CPU 指令集的 cmpxchg 完成的,它是 x86 和 Intel 架构中的 compare and exchange 指令。在多核的情况下,这个指令也不能保证原子性,需要在前面加上 lock 指令。lock 指令可以保证一个 CPU 核心在操作期间独占一片内存区域。那么 这又是如何实现的呢?
在处理器中,一般有两种方式来实现上述效果:**总线锁和缓存锁。**在多核处理器的结构中,CPU 核心并不能直接访问内存,而是统一通过一条总线访问。总线锁就是锁住这条总线,使其他核心无法访问内存。这种方式代价太大了,会导致其他核心停止工作。而缓存锁并不锁定总线,只是锁定某部分内存区域。当一个 CPU 核心将内存区域的数据读取到自己的缓存区后,它会锁定缓存对应的内存区域。锁住期间,其他核心无法操作这块内存区域。关于总线锁和缓存锁的详细可以看下这篇文章:
CAS 就是通过这种方式实现比较和交换操作的原子性的。 值得注意的是, CAS 只是保证了操作的原子性,并不保证变量的可见性,因此变量需要加上 volatile 关键字。关于总线锁和缓存锁可以参考我的这篇文章JMM模型(内存模型)、总线锁、缓存锁

CAS的ABA 问题
上面提到,CAS 保证了比较和交换的原子性。但是从读取到开始比较这段期间,其他核心仍然是可以修改这个值的。如果核心将 A 修改为 B,CAS 可以判断出来。但是如果核心将 A 修改为 B 再修改回 A。那么 CAS 会认为这个值并没有被改变,从而继续操作。这是和实际情况不符的。解决方案是加一个版本号。
聚个例子:

我们假设一个提款机的例子。假设有一个遵循CAS原理的提款机,小灰有100元存款,要用这个提款机来提款50元。
由于提款机硬件出了点问题,小灰的提款操作被同时提交了两次,开启了两个线程,两个线程都是获取当前值100元,要更新成50元。

理想情况下,应该一个线程更新成功,一个线程更新失败,小灰的存款值被扣一次。

线程1首先执行成功,把余额从100改成50.线程2因为某种原因阻塞。这时,小灰的妈妈刚好给小灰汇款50元
线程2仍然是阻塞状态,线程3执行成功,把余额从50改成了100。
线程2恢复运行,由于阻塞之前获得了“当前值”100,并且经过compare检测,此时存款实际值也是100,所以会成功把变量值100更新成50。
原本线程2应当提交失败,小灰的正确余额应该保持100元,结果由于ABA问题提交成功了。

怎么解决呢,一个是每次操作通过一个版本号进行对比而不是实际值,如Java提供了两种带版本戳的原子引用类型:

AtomicStampedReference:带版本戳的原子引用类型,版本戳为int类型。
AtomicMarkableReference:带版本戳的原子引用类型,版本戳为boolean类型。
详细可以看这篇为文章Atomic系列-ABA问题-带版本戳的原子引用类型AtomicStampedReference与AtomicMarkableReference

示例:

同步代码块:

我们在代码块加上synchronized关键字

public void synSay() {
   
    synchronized (object) {
   
        System.out.println("synSay----" + Thread.currentThread().getName());
    }
}

编译之后,我们利用反编译命令javap -v xxx.class查看对应的字节码,这里为了减少篇幅,我就只粘贴对应的方法的字节码。

  public void synSay();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: getfield      #2                  // Field object:Ljava/lang/String;
         4: dup
         5: astore_1
         6: monitorenter
         7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: new           #4                  // class java/lang/StringBuilder
        13: dup
        14: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        17: ldc           #6                  // String synSay----
        19: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        22: invokestatic  #8                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        25: invokevirtual #9                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        28: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        31: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        34: invokevirtual #11                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        37: aload_1
        38: monitorexit
        39: goto          47
        42: astore_2
        43: aload_1
        44: monitorexit
        45: aload_2
        46: athrow
        47: return
      Exception table:
         from    to  target type
             7    39    42   any
            42    45    42   any
      LineNumberTable:
        line 21: 0
        line 22: 7
        line 23: 37
        line 24: 47
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      48     0  this   Lcn/T1;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 42
          locals = [ class cn/T1, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

可以发现synchronized同步代码块是通过加monitorenter和monitorexit指令实现的。
每个对象都有个**监视器锁(monitor) **,当monitor被占用的时候就代表对象处于锁定状态,而monitorenter指令的作用就是获取monitor的所有权,monitorexit的作用是释放monitor的所有权,这两者的工作流程如下:
monitorenter:
如果monitor的进入数为0,则线程进入到monitor,然后将进入数设置为1,该线程称为monitor的所有者。
如果是线程已经拥有此monitor(即monitor进入数不为0),然后该线程又重新进入monitor,则将monitor的进入数+1,这个即为锁的重入。
如果其他线程已经占用了monitor,则该线程进入到阻塞状态,直到monitor的进入数为0,该线程再去重新尝试获取monitor的所有权。
monitorexit:执行该指令的线程必须是monitor的所有者,指令执行时,monitor进入数-1,如果-1后进入数为0,那么线程退出monitor,不再是这个monitor的所有者。这个时候其它阻塞的线程可以尝试获取monitor的所有权。

同步方法
在方法上加上synchronized关键字

synchronized public void synSay() {
   
    System.out.println("synSay----" + Thread.currentThread().getName());
}

编译之后,我们利用反编译命令javap -v xxx.class查看对应的字节码,

  public synchronized void synSay();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: new           #3                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
        10: ldc           #5                  // String synSay----
        12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        15: invokestatic  #7                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        18: invokevirtual #8                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        21: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        30: return
      LineNumberTable:
        line 20: 0
        line 21: 30
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      31     0  this   Lcn/T1;

从字节码上看,加有synchronized关键字的方法,常量池中比普通的方法多了个ACC_SYNCHRONIZED标识,JVM就是根据这个标识来实现方法的同步。
当调用方法的时候,调用指令会检查方法是否有ACC_SYNCHRONIZED标识,有的话线程需要先获取monitor,获取成功才能继续执行方法,方法执行完毕之后,线程再释放monitor,同一个monitor同一时刻只能被一个线程拥有。
两种同步方式区别
synchronized同步代码块的时候通过加入字节码monitorenter和monitorexit指令来实现monitor的获取和释放,也就是需要JVM通过字节码显式的

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值