深入解析synchronized实现原理,如何保证原子性、有序性和可见性?

世界上并没有完美的程序,但我们并不因此而沮丧,因为写程序本来就是一个不断追求完美的过程。

前言

我在上一篇文章简单的介绍了一些synchronized关键字的知识点和用法(有兴趣的可以点这里,传送门biubiu),而这篇文章主要介绍synchronized底层实现,还有它是如何保证原子性、有序性和可见性的。
在进入正题之前,我先铺垫一下,举个小栗子:
看代码:

public class Demo {
    
    

    public synchronized void method1() {
    
    
        System.out.println("Hello, do method1.");
        
        //模拟网络IO
        try {
    
    
            Thread.sleep(50);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        
        System.out.println("Finish doing method1.");
    }

    public synchronized void method2() {
    
    
        System.out.println("Hey, do method2.");
    }


    public void method3() {
    
    
        System.out.println("Let's do method3.");
    }
}

先说结论:如果一个对象有多个被synchronized修饰的方法,只要有一个线程访问了其中一个synchronized方法,那么其他线程则不能同时访问这个对象中任何一个synchronized方法,但是对于这个对象的其他的非synchronized修饰的方法,其他线程仍旧可以访问到。还有一点需要注意的是,不同对象实例的synchronized方法是互不干预的,其它线程可以同时访问此类下的另一个对象实例中的synchronized方法

验证结论,测试代码-1:

        Demo demo = new Demo();
        ExecutorService executor = Executors.newFixedThreadPool(9);
        for (int i = 0; i< 3; i++) {
    
    
            executor.execute(demo::method1);
        }
        for (int i = 0; i< 3; i++) {
    
    
            executor.execute(demo::method2);
        }
        for (int i = 0; i< 3; i++) {
    
    
            executor.execute(demo::method3);
        }

运行结果-1:
在这里插入图片描述从上面的运行结果可以看出,method3()的执行完全没受到method1()的影响,但是它影响到了method2()的执行,method2()的执行在method1()执行完成释放完锁之后。

测试代码-2:

        ExecutorService executor = Executors.newFixedThreadPool(9);
        for (int i = 0; i< 3; i++) {
    
    
            executor.execute(new Demo()::method1);
        }
        for (int i = 0; i< 3; i++) {
    
    
            executor.execute(new Demo()::method2);
        }
        for (int i = 0; i< 3; i++) {
    
    
            executor.execute(new Demo()::method3);
        }

运行结果-2:
在这里插入图片描述
从上面的运行结果可以看出,method1()method2()method3()的执行没有受到任何影响,这就说明了不同对象实例的synchronized方法是互不干预的

synchronized实现原理

在此之前,需要知道部分JVM知识的,比如HotSpot虚拟机,下面截图来自《深入理解java虚拟机-第二版》:
在这里插入图片描述
在这里插入图片描述
如果还不是很清楚HotSpot虚拟机,用CMD命令输入java -version,就可以看到我们其实一直用的是它:
在这里插入图片描述
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头:用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等,这部分数据官方称它为 “Mark Word”。五种状态(无锁、轻量级锁定、重量级锁定、GC标记、可偏向)下对象头 Mark Word的存储内容:
存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标志
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向
  • 实例数据:对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中的定义顺序的影响,HotSpot虚拟机默认分配策略为longs/doubles、ints、shorts\chars、bytes\booleans、oops(Ordinary Object Pointers),从分配策略中就可以看出,相同宽度的字段总是被分配到一起。
  • 对齐填充:这部分并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

开始进入正题啦,来聊聊synchronized的实现原理,请看下面代码:

public class Demo2 {
    
    

    //同步方法
    public synchronized void method1() {
    
    
        // TODO: 2020/8/30
        System.out.println("Hello, do method1.");
    }

    public void method2() {
    
    
        //同步代码块
        synchronized (this) {
    
    
            // TODO: 2020/8/30
            System.out.println("Let's do method2.");
        }
    }
}

上面是两种最常使用synchronized的方式,执行"javap -c -v"命令进行反汇编,过滤掉其他无用的信息,反汇编结果如下:

public synchronized void method1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello, do method1.
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #5                  // String Let's do method2.
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return

从上面反汇编结果可以看出:JVM对于同步方法和同步代码块的处理方式不同,对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步,而对于同步代码块,JVM则采用 monitorentermonitorexit 这两个指令实现同步。

  • ACC_SYNCHRONIZED标记符

方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED标志,如果有设置,则需要先获得监视器锁,然后开始执行方法方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

  • monitorentermonitorexit 指令

可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

但是细心的你是不是发现了上面出现了两条monitorexit 指令呢?这是为啥嘞?

是这样的,编译器需要确保方法中调用过的每条monitorenter指令都要执行对应的monitorexit 指令。为了保证在方法异常时,monitorentermonitorexit指令也能正常配对执行,编译器会自动产生一个异常处理器,它的目的就是用来执行异常的monitorexit指令。而字节码中多出的monitorexit指令,就是异常结束时被执行用来释放monitor的。

如何保证原子性、有序性和可见性?

  • 原子性: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
    Java内存模型提供了字节码指令monitorentermonitorexit来隐式的使用这两个操作,在synchronized块之间的操作是具备原子性的。

线程1在执行monitorenter指令的时候,会对Monitor进行加锁加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是它并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性

  • 有序性: 程序执行的顺序按照代码的先后顺序执行。
    在并发时,程序的执行可能会出现乱序。给人的直观感觉就是:写在前面的代码,会在后面执行。但是synchronized提供了有序性保证,这其实和as-if-serial语义有关。
    as-if-serial语义是指不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么可以认为是单线程执行的。所以可以保证其有序性。

但是需要注意的是synchronized虽然能够保证有序性,但是无法禁止指令重排和处理器优化的

  • 可见性: 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
    synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁,但在一个变量解锁之前,必须先把此变量同步回主存中,这样解锁后,后续其它线程就可以访问到被修改后的值,从而保证可见性。

非常感谢你能看到最后,如果能够帮助到你,是我的荣幸!

参考文章:

深入理解多线程(一)——Synchronized的实现原理
Java多线程:由浅入深看synchronized的底层实现原理

猜你喜欢

转载自blog.csdn.net/qq_36270361/article/details/107708132