当前位置:主页 > 查看内容

Java架构师学习之路之并发编程三: Java同步器之synchronized&am

发布时间:2021-06-25 00:00| 位朋友查看

简介:Java架构师学习之路之并发编程三 Java同步器之synchronizedLockAQS Java同步器之synchronizedLockAQS synchronized关键字 1. 什么是synchronized关键字 2. synchronized关键字原理 3. JIT——逃逸分析 4. 锁的粗化 5. 锁的消除 6. 锁的基本知识 一些思考 Jav……

Java同步器之synchronized&Lock&AQS

synchronized关键字

1. 什么是synchronized关键字

syncronized关键字是JVM内置锁,是对象锁。对某一部分代码使用锁后,这段代码将被视为原子操作执行,保证多个线程对该代码块的访问会按照一定顺序先后执行。

2. synchronized关键字原理

假设我们对以下代码进行加锁:

public class MyTest {
    
    public final static Object o = new Object();
    public static int i = 0;

    public static void main(String[] args) {
        synchronized (o) {
            i++;
        }
    }
}

再使用JDK自带的javap命令查看该文件的字节码文件,则会看到以下信息:

public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field o:Ljava/lang/Object;
       3: dup
       4: astore_1
       5: monitorenter				// ~~~~~~~~~~请注意该行~~~~~~~~~~~~
       6: getstatic     #3                  // Field i:I
       9: iconst_1
      10: iadd
      11: putstatic     #3                  // Field i:I
      14: aload_1
      15: monitorexit				// ~~~~~~~~~~请注意该行~~~~~~~~~~~~
      16: goto          24
      19: astore_2
      20: aload_1
      21: monitorexit
      22: aload_2
      23: athrow
      24: return

这里出现了两条指令,分别是monitorentermonitorexit
这两条指令分别标识同步块的开始和结束。而这两个指令,是由JVM进行添加的,所以synchronized是一个JVM内置锁。
JVM在识别synchronized关键字后,会通过传入对象的ObjectMonitor进行锁相关的操作。
这个ObjectMonitor是每个Ojbect被创建的时候都会自动创建到JVM内部的。

加锁解锁的过程中,肯定需要记录锁的状态和信息,因此需要一个内存空间用来记录。
JVM使用对象中的对象头来记录对象加锁解锁的状态。
请看下图:
在这里插入图片描述由图可见,一个java对象的内存结构包含了3部分:

  1. 对象头 ——用来存放各种信息,比如锁状态等
  2. 实际数据 ——存放java对象的实际数据
  3. 对齐填充位 ——规范Java对象的内存空间大小,规定为8的整数倍

紧接着,咱们还可以查看一下JVM的源码,可以从网上下载一份源码,同时找到下面的文件:
/hotspot/src/share/vm/runtime/objectMonitor.hpp
打开看一下,会发现下面的内容:

ObjectMonitor(){
	_header			= NULL;
	_count			= 0+1-1 ;
	_waiters		= 0;
	_WaitSet		= NULL;
	//此处省略一部分...
	_owner			= NULL;
	_EntryList		= NULL;
	//...
}

当然,中间省略了很多参数。但是从上面几个参数来看,我们很简单就能和上面的图联系起来。
_header: 对象头
_count: 记录加锁的次数,重入时用到
_waiters: 当前有多少thread处于wait状态
_WaitSet: wait中的线程会被加入该集合
_owner: 当前哪个线程持有ObjectMonitor
_EntryList: 当前等待加锁的线程会被加入到该集合

假设现在有两个线程t1和t2。如果t1加锁成功,则_owner = t1,且_count += 1
当t1释放锁的时候,_owner = NULL,且_count -= 1
当锁需要重入时,会对_count进行累减到0。

3. JIT——逃逸分析

  1. 逃逸分析是指JIT即时编译器会堆代码进行线程逃逸的分析,并根据分析结果进行指令优化
  2. 通常情况下,JVM的server模式编译器默认开启逃逸分析。(JDK8以后JVM通过hotspot实现,其中包含1个解释器和2个编译器。这2个编译器就可以成为JIT。其中一个是client模式,另一个是server模式。server模式默认开启逃逸分析,client模式相反。)
  3. 可以通过设置JVM参数:-XX:-DoEscapeAnalysis关闭逃逸分析。或者设置-XX:+DoEscapeAnalysis开启逃逸分析。

逃逸分析的作用:

  1. 同步省略。如果一个对象只能被一个线程访问到,那么会省去对该对象的加锁。
  2. 将堆分配转化为栈分配。如果一个对象被创建后,指向该对象的指针永远不会逃逸,那么该对象可能是栈分配的候选,而不是堆分配。
  3. 分离对象或标量替换。. 有的对象不需要作为连续分配的内存存在,也能被访问到,那么该变量的部分或者全部可以不存储在内存,而是存储在寄存器

4. 锁的粗化

什么是锁的粗化:
当一个方法中有连续的加锁操作时,多次加锁操作将被JIT优化为一个锁,减少了频繁申请和释放锁的次数,提高了性能。
示例代码如下:

    public static void main(String[] args) {
        synchronized (LockTest.class){
            System.out.println("111");
        }
        synchronized (LockTest.class){
            System.out.println("222");
        }
        synchronized (LockTest.class){
            System.out.println("333");
        }
    }

来查看一下字节码文件:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=5, args_size=1
         0: ldc           #2                  // class com/fujitsu/smartstore/LockTest
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #4                  // String 111
        10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: ldc           #2                  // class com/fujitsu/smartstore/LockTest
        25: dup
        26: astore_1
        27: monitorenter
        28: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        31: ldc           #6                  // String 222
        33: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        36: aload_1
        37: monitorexit
        38: goto          46
        41: astore_3
        42: aload_1
        43: monitorexit
        44: aload_3
        45: athrow
        46: ldc           #2                  // class com/fujitsu/smartstore/LockTest
        48: dup
        49: astore_1
        50: monitorenter
        51: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        54: ldc           #7                  // String 333
        56: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        59: aload_1
        60: monitorexit
        61: goto          71
        64: astore        4
        66: aload_1
        67: monitorexit
        68: aload         4
        70: athrow
        71: return

指令比较多,可以ctrl + F进行搜索,我们可以找到这两条指令:monitorentermonitorexit。看,前面学习的syncronized原理在这里就用上了。
咱们通过看字节码文件可以发现,这里的锁并没有被粗化成一个锁。
那是因为我们这里查看的是javac编译后的字节码文件。而逃逸分析的优化是JIT将字节码文件翻译为机器码文件过程中进行的。
如果想要查看JIT编译后的文件,可以使用JITWatch等工具查看,此处不再赘述。

5. 锁的消除

锁的消除很显而易见,当某个代码块使用的锁对象只能被一个线程获取到,则JIT会消除该同步操作。
如下:

    public static void main(String[] args) {
        synchronized (new Object()){
            System.out.println("111");
        }
    }

多个线程获取到的锁对象都是不同的Object对象,因此JIT进行逃逸分析时会消除该同步操作。

6. 锁的基本知识

  1. JVM分为32位和64位两种,因此对于对象头的存储位数会有一定区别,但是整体逻辑相同。
  2. 在jvm源码下的/hotspot/src/share/vm/oops/markOop.hpp文件中有对对象头的存储位数进行解释说明。比如,以32位JVM为例,前25位存储hash code,接下来的4位存储age,再往后的1位存储偏向状态,紧接着的2位存储锁的状态。
  3. 锁的状态分为4种:无锁(01),偏向锁(01),轻量级锁(00),重量级锁(10),GC标记(11),并按照下列顺序膨胀升级:
    -无锁:25bit记录对象hashcode,4bit存储对象分代年龄,1bit存储偏向状态(0),2bit存放锁状态(01)
    -偏向锁:23bit存放线程id,2bit存放Epoch,4bit存放对象分代年龄,1bit存放偏向状态(1),2bit存放锁状态(01)
    -轻量级锁:30bit存放线程栈中记录锁的指针,2bit存放锁状态(00)
    -重量锁:30bit存放指向重量级锁monitor的指针(依赖Mutex操作系统的互斥),2bit存放锁状态(10)
    此外还有个 GC状态:30bit置空,2bit存放锁状态(11)

锁升级过程(不可逆):
偏向锁:减少同一个线程去获取锁的成本。在没有竞争线程的情况下,当某个线程短时间内多次获取到锁后,该锁会偏向该线程,Mark Word的结构会转变为偏向锁结构,该线程再次请求该锁时,就不需要重新申请锁了(减少了一些可能会涉及到CAS的操作)。但是当有竞争线程的情况下,不应该使用偏向锁,因为每次获取锁的线程都可能不同。偏向锁失败后就会升级到轻量级锁。

轻量级锁:偏向锁失效后会先升级为轻量级锁(JDK1.6后加入),此时Mark Word转换为轻量级锁的结构。轻量级锁提升性能的依据是:“大部分锁在整个同步周期内都不存在竞争”,但这是经验数据,轻量级锁只适用于线程交替执行同步块的场景。如果同一时间访问同一个锁的话,就会导致轻量级锁升级为重量级锁。

自旋锁:轻量级锁失效后,JVM为了避免线程在操作系统中真正挂起,还会采用自旋锁的形式进行优化。这是基于在大多数情况下,线程持有锁的时间都不会太长而设计的。如果线程直接在操作系统上挂起,就涉及到用户态和内核态之间的切换,这个操作极度消耗时间。由于线程不会持有锁太长时间,因此其他线程自旋中一旦获取到锁,就顺利进入临界区,而不需要进行用户态到内核态的转换了。如果CAS获取锁失败达到一定次数,将升级为重量级锁。

重量级锁:通过操作系统Mutex互斥量将线程挂起,实现同步。

由于本章内容过多,所以将AQS放在下一章来学习~

一些思考:

  1. 在下面代码中,o对象存放在哪里?引用存放在哪里?元数据class存放在哪里?
    public void test(){
        Object o = new Object();
    }
  1. 上述代码中,o一定存放在堆区吗?

下一章:Java架构师学习之路之并发编程四:Java同步器之synchronized&Lock&AQS(下)

欢迎大家一起讨论,让我们一起向着架构师前进~~~

;原文链接:https://blog.csdn.net/qq_44988694/article/details/115589590
本站部分内容转载于网络,版权归原作者所有,转载之目的在于传播更多优秀技术内容,如有侵权请联系QQ/微信:153890879删除,谢谢!

推荐图文


随机推荐