在并发编程中存在线程安全问题,主要原因是在JMM内存模型下,多线程操作共享数据会得到与我们预期不同的结果,而synchronized关键字,可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时synchronized可以保证一个线程的变化可见,即可见性,但是你了解其底层原理吗?今天我们谈一谈java面试必问送分题,synchronized底层原理
一、synchronized的使用
1 synchronize的作用
(1)多线程之间的互斥访问,用于线程之间同步
(2)保证共享变量被修改能及时可见,即可见性
(3)有效解决指令重排序
2 synchronized作用范围
修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
特别注意:
①如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁
②尽量不要使用 synchronized(String s) ,因为JVM中,字符串常量池具有缓冲功能
二、 synchronized底层原理
写了一个小demo,分别作用于代码块,普通方法,静态方法,通过反编译,查看对应字节码指令:
//package com.loy.test.juc;
public class SynchronizedTest {
//修饰代码块
public void method1() {
synchronized (this) {
System.out.println("synchronized daimakuai");
}
}
//修饰普通方法
public synchronized void method2() {
System.out.println("synchronized ordinary function");
}
//修饰静态方法
public static synchronized void method3() {
System.out.println("synchronized static function");
}
public static void main(String[] args) {
SynchronizedTest synchronizedTest = new SynchronizedTest();
new Thread(()->{
synchronizedTest.method1(); //修饰代码块
}).start();
new Thread(()->{
synchronizedTest.method2();//修饰普通方法
}).start();
new Thread(()->{
SynchronizedTest.method3();//修饰静态方法
}).start();
}
}
通过 JDK 自带的 javap 命令查看 SynchronizedTest 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedTest.java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SynchronizedTest.class 得到如下结果:
1 修饰普通方法
synchronized 修饰普通方法包含一个 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用
2 修饰静态方法
通过反编译可以看出,synchronized 修饰静态方法,相比普通方法只是多了个 ACC_STATIC 标识,表明该方法是一个静态方法,其他并无区别
3 修饰代码块
JVM的规范描述了,每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权
而与synchronized相关的另外一个退出指令是monitorexit,JVM中描述了,执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。 其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权
总之,synchronized 同步代码块的实现是通过 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
三、 synchronized优化
JVM在JDK 1.6中引入了分级锁机制来优化synchronized,当一个线程获取锁时,首先对象锁成为一个偏向锁(这是为了避免在同一线程重复获取同一把锁时,用户态和内核态频繁切换),如果有多个线程竞争锁资源,锁将会升级为轻量级锁,此时轻量级锁会结合自旋锁(适用短时间锁占用的情况),避免用户态与内核态切换,如果短时间内大量线程竞争,许多线程自旋失败,轻量级锁又会升级为重量级锁,总之,对于synchronize的优化,作者的意图是尽可能的减少锁粒度,同时为了减少锁竞争,尽可能减少锁的持有时间,避免升级为重量级锁,当然如果此时的场景就是线程竞争非常激烈的情况,那么则建议你禁用偏向锁和禁用自旋锁(使用-XX:-UseBiasedLocking=false
禁用偏向锁),因为竞争激烈且获取到锁的线程占用时间过长,反而引起其他线程一直处于CAS重试,占用CPU资源
特别注意:
JDK1.6 可以通过设置参数手动开启自旋锁
- XX:+UseSpinning 开启自旋锁;
-XX:PreBlockSpin=10设置自旋次数;
JDK1.7,自旋锁的参数被取消,虚拟机不再支持由用户配置自旋锁,自旋锁总是会执行,自旋锁次数也由虚拟机自动调整
四、 总结
本文讲述了synchronized这一关键字实现线程同步的底层机理,它可以同步代码块,通过ACC_STATIC 标识,标识它是一个同步方法,从而执行同步调用,而当它修饰方法时,底层是通过两个指令monitorenter 和 monitorexit 指令,可以认为每个锁对象拥有⼀个锁计数器和⼀个指向持有该锁的线程的指针,当执⾏ monitorenter 时,如果⽬标锁对象的计数器为 0,那么说明它没有被其他线程所持有。Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。在⽬标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机 可以将其计数器加 1,否则需要等待,直⾄持有线程释放该锁。当执⾏ monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。计数器为 0,代表锁已被释放,这就是可重入的实现过程。
一起探索架构技术,拥抱AI,欢迎与我交流!