首先讲一下原子性以及互斥。
举个例子,在32位CPU上执行long(64位)变量的写操作时,会存在多线程下读写不一致的问题。
因为32位CPU下对其写会拆分成两次操作,一次写高32位和一次写底32位,而这个操作无法保证其原子性所以产生并发问题了。
原子性
指即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行,简单的说就是一个或者多个操作在 CPU 执行的过程中不被中断的特性。
互斥
如果同一时刻只有一个线程执行则被称之为互斥,把一段需要互斥执行的代码称为临界区。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
互斥锁
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是一种互斥锁。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,通过其修饰的临界区是互斥的,使用方法如下:
public class demo{ private final Object monitor = new Object(); // 修饰非静态方法 synchronized void method1() { // 临界区 } // 修饰静态方法 synchronized static void method2() { // 临界区 } // 修饰代码块 void method3() { synchronized(monitor) { // 临界区 } } }
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 demo.class;
当修饰非静态方法的时候,锁定的是当前实例对象 this。
而修饰代码块的锁对象是自定义的对象,注意这个对象需要保证不可变,否则会出现不同步的情况。
其中要注意受保护资源和锁之间的关联关系是 N:1 的关系,意思是用一把锁保护多个资源,比如下例代码就有问题了:
public class demo2 { private static long value = 0L; synchronized long get() { return value; } synchronized static void add() { value ++; } }
此处get方法和add方法的锁对象其实是两个,自然两个临界区就没有互斥关系了,所以存在并发问题。
实现原理
对第一个互斥锁的代码例子使用javap工具进行分析。
public class demo minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #2.#20 // java/lang/Object."<init>":()V #2 = Class #21 // java/lang/Object #3 = Fieldref #4.#22 // demo.monitor:Ljava/lang/Object; #4 = Class #23 // demo #5 = Utf8 monitor #6 = Utf8 Ljava/lang/Object; #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 method1 #12 = Utf8 method2 #13 = Utf8 method3 #14 = Utf8 StackMapTable #15 = Class #23 // demo #16 = Class #21 // java/lang/Object #17 = Class #24 // java/lang/Throwable #18 = Utf8 SourceFile #19 = Utf8 demo.java #20 = NameAndType #7:#8 // "<init>":()V #21 = Utf8 java/lang/Object #22 = NameAndType #5:#6 // monitor:Ljava/lang/Object; #23 = Utf8 demo #24 = Utf8 java/lang/Throwable { public demo(); descriptor: ()V flags: ACC_PUBLIC Code: stack=3, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: new #2 // class java/lang/Object 8: dup 9: invokespecial #1 // Method java/lang/Object."<init>":()V 12: putfield #3 // Field monitor:Ljava/lang/Object; 15: return LineNumberTable: line 1: 0 line 3: 4 synchronized void method1(); descriptor: ()V flags: ACC_SYNCHRONIZED Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 5: 0 static synchronized void method2(); descriptor: ()V flags: ACC_STATIC, ACC_SYNCHRONIZED Code: stack=0, locals=0, args_size=0 0: return LineNumberTable: line 7: 0 void method3(); descriptor: ()V flags: Code: stack=2, locals=3, args_size=1 0: aload_0 1: getfield #3 // Field monitor:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter 7: aload_1 8: monitorexit 9: goto 17 12: astore_2 13: aload_1 14: monitorexit 15: aload_2 16: athrow 17: return Exception table: from to target type 7 9 12 any 12 15 12 any LineNumberTable: line 10: 0 line 11: 17 StackMapTable: number_of_entries = 2 frame_type = 255 /* full_frame */ offset_delta = 12 locals = [ class demo, class java/lang/Object ] stack = [ class java/lang/Throwable ] frame_type = 250 /* chop */ offset_delta = 4 }
javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。
所以先要使用javac demo.java编译,生成字节码文件后用javap -verbose demo进行反编译生成汇编代码。
可以看到method3()同步块的方法中,有两个指令monitorenter和monitorexit:
monitorenter:线程执行monitorenter指令时尝试获取monitor的所有权(当monitor被占用时就会处于锁定状态),而monitor可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
所以synchronized也是一种可重入锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞,这就是通过上述的计数器实现的,当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。
monitorexit:执行monitorexit的线程必须是对象引用所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
为什么会有两个monitorexit指令?查了下stackoverflow,如果同步块正常退出释放锁使用第一个monitorexit,如果同步块中出现Exception或者Error,将使用第二个monitorexit。
ACC_SYNCHRONIZED的标识符,JVM就是根据该标示符来实现方法的同步的:
JAVA对象头
synchronized的锁实际上是存在java对象头中的,而因为Java面向对象的思想,在JVM中需要大量存储对象,存储时为了实现一些额外的功能,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。那么在Hotspot虚拟机中,对象头实际包含如下部分:
Class Pointer(类型指针):存储对象类型数据的指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。它的字段位长度也与JVM有关;
Array Length(数组长度):如果是数组对象,那么还需要有额外的空间用于存储数组的长度。
Mark Word(标记字段):默认存储对象自身的运行时数据,例如HashCode、分代年龄和锁信息等。该字段位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位,如下图所示:
其中1位区分是否偏向锁,最后两位存储了锁的标志位。
为了减少获得锁和释放锁带来的性能消耗,1.6后引入了锁的概念,提供了三种不同的Monitor实现,也就是常说的三种不同的锁:偏向锁(Biased Locking)、轻量级锁和重量级锁,很大程度提升了性能。下面介绍下这三种锁:
偏向锁
由于多数情况下,锁不仅不存在多线程竞争而且总是由同一个线程多次获得,为了减少资源损耗所以引入偏向锁。在HotSpot的虚拟机中,当一个线程访问同步块并尝试获取锁时,会在对象头和栈帧中的记录里存储锁的偏向的线程ID,以后该线程进入和退出同步块的代码时候,不需要通过CAS操作进行加锁解锁 ,只需要检测下MarkWord中的是否存储着指向当前线程的偏向锁。如果检测成功,则说明已经获得了锁,如果检测不成功,则需要测试MarkWord中偏向锁的标识是非设置成1。
轻量级锁
当关闭偏向锁功能(通过JVM参数关闭:-XX:-UseBiasedLocking=false)或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,
加锁步骤如下:线程在执行同步块之前,JVM会现在当前的线程的栈帧中创建用于存储锁记录的空间,并肩对象头的MarkWord复制到锁的记录中,官方称为Displaced Mark Word。因为在申请对象锁时 需要以该值作为CAS的比较条件,同时在升级到重量级锁的时候,能通过这个比较判定是否在持有锁的过程中此锁被其他线程申请过,如果被其他线程申请了,则在释放锁的时候要唤醒被挂起的线程。。然后线程尝试使用CAS操作将对象头MarkWord替换为指向锁记录的指针,如果不成功,说明锁存在竞争,当前线程通过自旋等来获取锁。
解锁:轻量级锁解锁时会通过CAS将Displaced Mark Word替换回对象头,如果成功就表示当前锁没有竞争,失败了就表示存在竞争,会升级成重量级锁。
重量级锁
依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。synchronized是通过Monitor锁来实现的,其本质就是依赖于底层的操作系统的Mutex Lock来实现的,所以synchronized就是重量级锁。而因为Mutex Lock会使用户态切换至核心态,开销大消耗时间较长,这也是其性能不高的原因。
除了上述三种锁之外,还有其他的优化:
自旋锁
参考资料
《JAVA并发编程的艺术》
https://juejin.im/post/5b4eec7df265da0fa00a118f
来源:https://www.cnblogs.com/morph/p/11122040.html