JVM与synchronized

JVM与synchronized

synchronized 关键字

使用:

  • 声明一个 synchronized 代码块

  • 直接标记静态方法或者实例方法

声明 synchronized 代码块

public class T1 {

    public void foo(Object lock) {
        synchronized (lock) {
            lock.hashCode();
        }
    }
}

foo方法对应字节码


//  public void foo(java.lang.Object);
//    descriptor: (Ljava/lang/Object;)V
//    flags: ACC_PUBLIC
//    Code:
//      stack=2, locals=4, args_size=2
//         0: aload_1
//         1: dup
//         2: astore_2
//         3: monitorenter
//         4: aload_1
//         5: invokevirtual #2                  // Method java/lang/Object.hashCode:()I
//         8: pop
//         9: aload_2
//        10: monitorexit
//        11: goto          19
//        14: astore_3
//        15: aload_2
//        16: monitorexit
//        17: aload_3
//        18: athrow
//        19: return

上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁

用 synchronized 标记方法

public class T2 {

    public synchronized void foo(Object lock) {
        lock.hashCode();
    }
}

foo方法对应字节码

//  public synchronized void foo(java.lang.Object);
//    descriptor: (Ljava/lang/Object;)V
//    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
//    Code:
//      stack=1, locals=2, args_size=2
//         0: aload_1
//         1: invokevirtual #2                  // Method java/lang/Object.hashCode:()I
//         4: pop
//         5: return

上面字节码中方法的访问标记包括 ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java 虚拟机需要进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行 monitorexit 操作

原理是通过方法调用指令检查该方法在常量池中是否包含 ACC_SYNCHRONIZED 标记符,如果有,JVM 要求线程在调用之前请求锁

这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例

monitor监视器

  • 每个对象都有一个监视器,在同步代码块中,JVM通过monitorenter和monitorexist指令

  • 实现同步锁的获取和释放功能

  • 当一个线程获取同步锁时,即是通过获取monitor监视器进而等价为获取到锁
    monitor的实现类似于操作系统中的管程

monitorenter指令

  • 每个对象都有一个监视器。当该监视器被占用时即是锁定状态(或者说获取监视器即是获得同步锁)。线程执行monitorenter指令时会尝试获取监视器的所有权,过程如下:

  • 若该监视器的进入次数为0,则该线程进入监视器并将进入次数设置为1,此时该线程即为该监视器的所有者

  • 若线程已经占有该监视器并重入,则进入次数+1

  • 若线程已经占有该监视器并重入,则进入次数+1若其他线程已经占有该监视器,则线程会被阻塞直到监视器的进入次数为0,之后线程间会竞争获取该监视器的所有权只有首先获得锁的线程才能允许继续获取多个锁

monitorexit指令

  • 执行monitorexit指令将遵循以下步骤:

  • 执行monitorexit指令的线程必须是对象实例所对应的监视器的所有者

  • 指令执行时,线程会先将进入次数-1,若-1之后进入次数变成0,则线程退出监视器(即释放锁)

  • 其他阻塞在该监视器的线程可以重新竞争该监视器的所有权

具体实现过程

  • 在同步代码块中,JVM通过monitorenter和monitorexist指令实现同步锁的获取和释放功能

  • monitorenter指令是在编译后插入到同步代码块的开始位置

  • monitorexit指令是插入到方法结束处和异常处

  • JVM要保证每个monitorenter必须有对应的monitorexit与之配对

  • 任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态

  • 线程执行monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁

  • 线程执行monitorexit指令时,将会将进入次数-1直到变成0时释放监视器

  • 同一时刻只有一个线程能够成功,其它失败的线程会被阻塞,并放入到同步队列中,进入BLOCKED状态

对象头

JVM内存中的对象

图示:

图1.png

在JVM中,对象在内存中的布局分成三块区域:对象头、示例数据和对齐填充

  • 对象头: 对象头主要存储对象的hashCode、锁信息、类型指针、数组长度(若是数组的话)等信息

  • 示例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组长度,这部分内存按4字节对齐

  • 填充数据:由于JVM要求对象起始地址必须是8字节的整数倍,当不满足8字节时会自动填充(因此填充数据并不是必须的,仅仅是为了字节对齐)

对象头:Hotspot虚拟机的对象头主要包括两部分数据

  • Mark Word(标记字段)

  • Klass Pointer(类型指针)

其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键

  • synchcronized的锁是存放在Java对象头中的

  • 如果对象是数组类型,JVM用3个子宽(Word)存储对象头,否则是用2个子宽

  • 在32位虚拟机中,1子宽等于4个字节,即32bit;64位的话就是8个字节,即64bit

Mark Word

对象头中的标记字段(mark word)。它的最后两位便被用来表示该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁,11 则跟垃圾回收算法的标记有关。

32位JVM的Mark Word的默认存储结构(无锁状态)

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的hashCode 对象分代年龄 0 01

重量级锁

重量级锁是 Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。

为了尽量避免昂贵的线程阻塞、唤醒操作,Java 虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。

与线程阻塞相比,自旋状态可能会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。

我们可以用等红绿灯作为例子。Java 线程的阻塞相当于熄火停车,而自旋状态相当于怠速停车。如果红灯的等待时间非常长,那么熄火停车相对省油一些;如果红灯的等待时间非常短,比如说我们在 synchronized 代码块里只做了一个整型加法,那么在短时间内锁肯定会被释放出来,因此怠速停车更加合适

自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁

轻量级锁

深夜的十字路口,四个方向都闪黄灯的情况。由于深夜十字路口的车辆来往可能比较少,如果还设置红绿灯交替,那么很有可能出现四个方向仅有一辆车在等红灯的情况。

因此,红绿灯可能被设置为闪黄灯的情况,代表车辆可以自由通过,但是司机需要注意观察

Java 虚拟机也存在着类似的情形:多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

发布了229 篇原创文章 · 获赞 62 · 访问量 19万+

猜你喜欢

转载自blog.csdn.net/Coder_py/article/details/105134175