世界上并没有完美的程序,但我们并不因此而沮丧,因为写程序本来就是一个不断追求完美的过程。
我在上一篇文章简单的介绍了一些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方法
是互不干预的。
在此之前,需要知道部分JVM知识的,比如HotSpot虚拟机,下面截图来自《深入理解java虚拟机-第二版》:
如果还不是很清楚HotSpot虚拟机,用CMD命令输入java -version
,就可以看到我们其实一直用的是它:
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标志 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
开始进入正题啦,来聊聊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标志
。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED标志
,如果有设置,则需要先获得监视器锁
,然后开始执行方法,方法执行之后再释放监视器锁
。这时如果其他线程来请求执行方法,会因为无法获得监视器锁
而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁
会被自动释放。
monitorenter
和monitorexit
指令可以把执行
monitorenter
指令理解为加锁
,执行monitorexit
理解为释放锁
。 每个对象维护着一个记录着被锁次数的计数器
。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter
)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit
指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
但是细心的你是不是发现了上面出现了两条monitorexit
指令呢?这是为啥嘞?
是这样的,编译器需要确保方法中调用过的每条
monitorenter
指令都要执行对应的monitorexit
指令。为了保证在方法异常时,monitorenter
和monitorexit
指令也能正常配对执行,编译器会自动产生一个异常处理器
,它的目的就是用来执行异常的monitorexit指令
。而字节码中多出的monitorexit
指令,就是异常结束时,被执行用来释放monitor
的。
monitorenter
和monitorexit
来隐式的使用这两个操作,在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的底层实现原理