这篇文章主要介绍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);
}
从上面的运行结果可以看出,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);
}
从上面的运行结果可以看出,method1()、method2()和method3()的执行没有受到任何影响,这就说明了不同对象实例的synchronized方法是互不干预的。
在此之前,需要知道部分JVM知识的,比如HotSpot虚拟机,下面截图来自《深入理解java虚拟机-第二版》:
如果还不是很清楚HotSpot虚拟机,用CMD命令输入java -version,就可以看到我们其实一直用的是它:
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头:用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等,这部分数据官方称它为 “Mark Word”。五种状态(无锁、轻量级锁定、重量级锁定、GC标记、可偏向)下对象头 Mark Word的存储内容:
实例数据:对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在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则采用 monitorenter和monitorexit 这两个指令实现同步。
方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED标志,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
但是细心的你是不是发现了上面出现了两条monitorexit 指令呢?这是为啥嘞?
是这样的,编译器需要确保方法中调用过的每条monitorenter指令都要执行对应的monitorexit 指令。为了保证在方法异常时,monitorenter和monitorexit指令也能正常配对执行,编译器会自动产生一个异常处理器,它的目的就是用来执行异常的monitorexit指令。而字节码中多出的monitorexit指令,就是异常结束时,被执行用来释放monitor的。
Java内存模型提供了字节码指令monitorenter和monitorexit来隐式的使用这两个操作,在synchronized块之间的操作是具备原子性的。
线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是它并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。
在并发时,程序的执行可能会出现乱序。给人的直观感觉就是:写在前面的代码,会在后面执行。但是synchronized提供了有序性保证,这其实和as-if-serial语义有关。
as-if-serial语义是指不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么可以认为是单线程执行的。所以可以保证其有序性。
但是需要注意的是synchronized虽然能够保证有序性,但是无法禁止指令重排和处理器优化的。
被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁,但在一个变量解锁之前,必须先把此变量同步回主存中,这样解锁后,后续其它线程就可以访问到被修改后的值,从而保证可见性。
非常感谢你能看到最后,如果能够帮助到你,是我的荣幸!
参考文章:
原文链接:https://blog.csdn.net/qq_36270361/article/details/107708132