带你玩转关键字Synchronized
synchronized关键字是Java并发编程中线程同步的常用手段之一,当多个线程同时访问某个线程间的共享变量时,我们可以使用synchronized来保证线程安全。synchronized可以保证互斥性,可见性和有序性:
- 互斥性:确保线程互斥的访问同步代,锁自动释放,多个线程操作同个代码块或函数必须排队获得锁;
- 可见性:保证共享变量的修改能够及时可见,获得锁的线程操作完毕后会将所数据刷新到共享内存区;
- 有序性:有效解决指令重排问题。
接下来我们一步一步来了解synchronized的底层实现原理。
synchronized使用方式
有如下程序,有两个线程需要对共享变量i进行加1的操作,每个线程都加到10000,最终需要输出20000。
/*** @Author likangmin* @create 2020/12/11 13:36*/ public class Thread4 implements Runnable{//共享资源(临界资源)static int i=0;public void increase(){i++;}public void run() {for(int j=0;j<10000;j++){increase();}}public static void main(String[] args) throws InterruptedException {Thread4 t=new Thread4();Thread t1=new Thread(t);Thread t2=new Thread(t);t1.start();t2.start();t1.join();//主线程等待t1执行完毕t2.join();//主线程等待t2执行完毕System.out.println(i);} }在不使用synchronized关键字的时候,我们看一下最后执行的结果:
发现最终的结果是小于20000的,显然结果是不正确的。这个时候就需要使用synchronized关键字, 一共有三种使用的方法:直接修饰某个实例方法,直接修饰某个静态方法,修饰代码块。每个类都有一个类锁,类的每个对象也有一个内置锁,它们是互不干扰的,也就是说一个线程可以同时获得类锁和该类实例化对象的内置锁,当线程访问synchronzied修饰的方法时,依据修饰的不同类型获取不同的锁。
修饰实例方法
synchronized关键词作用在方法的前面,用来锁定方法,默认锁定的是this对象。synchronized修饰的实例方法,多线程并发访问时,只能有一个线程进入,获得对象内置锁,其他线程阻塞等待,但在此期间线程仍然可以访问其他方法。
我们还是以上面的例子来进行测试,在实例方法上加上synchronized:
看一下输出,的确是20000,符合最终的结果。
修饰静态方法
synchronized修饰在方法上,不过修饰的是静态方法,等价于锁定的是Class对象。synchronized修饰的静态方法,多线程并发访问时,只能有一个线程进入,获得类锁,其他线程阻塞等待,但在此期间线程仍然可以访问其他方法。
我们还是以上面的例子来进行测试,在静态方法上加上synchronized:
看一下输出,的确是20000,符合最终的结果。
修饰代码块
在函数体内部对于要修改的参数区间用synchronized来修饰,相比与锁定函数这个范围更小,可以指定锁定什么对象。synchronized修饰的代码块,多线程并发访问时,只能有一个线程进入,根据括号中的对象或者是类,获得相应的对象内置锁或者是类锁。
我们还是以上面的例子来进行测试,在代码块上加上synchronized:
看一下输出,的确是20000,符合最终的结果。
有的同学可能会问,synchronized修饰方法和修饰代码块有什么区别?这个我们在后面会解答,请你耐心往后看。
synchronized底层原理
synchronized内存模型
讲清 synchronized 关键字的原理前需要理清 Java 对象在内存中的表示方法。我们知道在Java的JVM内存区域中一个对象在堆区创建,创建后的对象由三部分组成:
这三部分功能如下:
- 对象头:主要包括两部分 Klass Point跟 Mark Word,如果是数组,还包括数组的长度;
- 实例变量:存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐;
- 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步,synchronized锁对象是存在对象头Mark Word。
Mark Word 中的某些字段发生变化,就可以代表锁不同的状态。Mark Word状态表示位如下:
其中轻量级锁和偏向锁是Java6对synchronized锁进行优化后新增加的,这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。
每个对象都存在着一个 monitor与之关联,而monitor可以被线程拥有或释放,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):
monitor运行图如下:
依据以上图示我们简单梳理一下执行过程:
因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是早期的synchronized效率低的原因。在Java 6之后Java官方对从JVM层面对synchronized较大优化最终提升显著,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了锁升级的概念。
synchronizedd修改方法与代码块区别
在介绍锁升级之前,我们再回到最开始一个问题,synchronized修饰方法和修饰代码块有什么区别。针对这两种情况,Java 编译时的处理方法并不相同。我们可以通过反汇编看下同步方法跟同步方法块在汇编语言级别是什么样的指令。
对之前的Thread1,即synchronized修改方法的类,在终端执行javap -v Thread1.calss,得到的字节码文件部分如下:
对之前的Thread3,即synchronized修改代码块,在终端执行javap -v Thread1.calss,得到的字节码文件部分如下:
我们可以看到:
- 第一种情况,编译器会为其自动生成了一个 ACC_SYNCHRONIZED 关键字用来标识。在 JVM 进行方法调用时,当发现调用的方法被 ACC_SYNCHRONIZED 修饰,则会先尝试获得锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
- 第二种情况,编译时在代码块开始前生成对应的1个 monitorenter 指令,代表同步块进入。2个 monitorexit 指令,前一个代表同步块正常退出,后一个在同步块异常时退出。每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
CAS算法
在讲其他之前,我们还要给大家介绍一个算法- CAS 算法。CAS 算法全称为 Compare And Swap。顾名思义,该算法涉及到了两个操作,比较(Compare)和交换(Swap)。其基本流程如下图:
在对共享变量进行多线程操作的时候,难免会出现线程安全问题。对该问题的一种解决策略就是对该变量加锁,保证该变量在某个时间段只能被一个线程操作。但是这种方式的系统开销比较大,因此提出了一种新的算法-CAS 算法。算法的思路如下:
当线程运行 CAS 算法时,该运行过程是原子操作,原子操作的含义就是线程开始跑这个函数后,运行过程中不会被别的程序打断。
锁升级
synchronized锁有四种状态,无锁、偏向锁、轻量级锁、重量级锁。这几个状态会随着竞争状态逐渐升级,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态。
偏向锁
HotSpot的作者大量研究发现大多数时候是不存在锁竞争的,经常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,引入偏向锁。以下为偏向锁的 Mark Word 字段。
如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构。当这个线程再次请求锁时,无需再做任何同步操作,即不用再去重复获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。偏向锁的申请流程:
偏向锁使用场景
- 对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁;
- 对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失;
- 偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要高昂的耗时实现CPU从用户态转到内核态的切换,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。如果当前对象是轻量级锁状态,对象的 Mark Word 如下图所示:
该对象头Mark Word分为两个部分。第一部分是指向栈中的锁记录的指针,第二部分是锁标记位,针对轻量级锁该标记位为 00。轻量级锁的申请流程:
重量级锁
在 Java 的早期版本中,synchronized 锁属于重量级锁,此时对象的 Mark Word 如图所示:
该对象头的 Mark Word 分为两个部分,第一部分是指向重量级锁的指针,第二部分是锁标记位。这里所说的指向重量级锁的指针就是 monitor,monitor 是监视器,Java 中每个对象会对应一个监视器,这个监视器其实也就是监控锁有没有释放,释放的话会通知下一个等待锁的线程去获取。monitor 的成员变量比较多,我们可以将 monitor 简单理解成两部分,第一部分表示当前占用锁的线程,第二部分是等待这把锁的线程队列。
如果当前占用锁的线程把锁释放了,那就需要在线程队列中唤醒下一个等待锁的线程。但是阻塞或唤醒一个线程需要依赖底层的操作系统来实现,Java 的线程是映射到操作系统的原生线程之上的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个状态转换需要花费很多的处理器时间,甚至可能比用户代码执行的时间还要长。由于这种效率太低,所以提出了偏向锁,轻量级锁等的改进优化。
总结
最后给大家总结一下各个锁的优缺点以及各自适用的场景:
| 偏向锁 | 加锁解锁无需额外消耗,跟非同步方法时间相差纳秒级别 | 如果竞争线程多,会带来额外的锁撤销的消耗 | 基本没有其他线程竞争的同步场景 |
| 轻量级锁 | 竞争的线程不会阻塞而是在自旋,可提高程序响应速度 | 如果一直无法获得会自旋消耗CPU | 少量线程竞争,持有锁时间不长,追求响应速度 |
| 重量级锁 | 线程竞争不会导致CPU自旋跟消耗CPU资源 | 线程阻塞,响应时间长 | 很多线程竞争锁,切锁持有时间长,追求吞吐量时候 |
最后再奉上锁升级大图(感谢unbelievableme大神的绘制):
想看更多文章,请点击此处
参考文章:
https://www.cnblogs.com/kundeg/p/8422557.html
总结
以上是生活随笔为你收集整理的带你玩转关键字Synchronized的全部内容,希望文章能够帮你解决所遇到的问题。
- 上一篇: Java的TheadLocal使用
- 下一篇: 教你用BitMap排序、查找和存储大量数