前言
目前正在出一个Java多线程专题
长期系列教程,从入门到进阶含源码解读
, 篇幅会较多, 喜欢的话,给个关注❤️ ~ 本篇内容纯理论一点
相信很多同学对synchronized
的使用上不陌生,之前也给大家讲解过它的使用。本篇主要带大家深入了解一下它,大家也可以自己试着总结一下,这也是面试
中常常问到的,单纯的回答它的基本使用,是惊艳不到面试官的~
synchronized 介绍
从字面意思翻译过来就是同步
的意思,所以它也叫同步锁
,我们通常会给某个方法或者某块代码加上Synchronized
锁来解决多线程中并发带来的问题,它也是最常用,最简单的一种方法
在Java中,锁
基本上都是基于对象而言的,所以又称为对象锁
, 一个类通常只有一个class
对象和n个实例
对象,它们共享class对象
,而我们有时候会对class对象
加锁,所以又称为class对象锁
这里大家要注意的是对象需要是一个非null
的对象,我们通常也叫做对象监视器(Object Monitor)
重量级锁
在JDK 1.5
之前,它是一个重量级锁
,我们通常都会使用它来保证线程同步。在1.5的时候还提供了一个Lock
接口来实现同步锁
的功能,我们只需要显式
的获取锁和释放锁。
重在哪❓
在1.5的时候,Synchronized
它依赖于操作系统底层的Mutex Lock
实现,每次释放锁和获取锁都会导致用户态和内核态
的切换,从而增加系统性能的开销,当出现大并发的情况下,锁竞争会比较激烈,性能显得非常糟糕,所以称为重量级锁
,所以大家往往会选择Lock
锁。
锁优化
但是Synchronized
又是那么的简单好用,又是官方自带的,怎么可能放弃呢?所以在1.6之后,引入了大量的锁优化,比如自旋锁
,轻量级锁
, 偏向锁
等,下面我们逐个看一下~
synchronized 实现原理
我们了解锁
优化之前,我们先看一下它的实现原理。
首先我们看下同步块
中,因为它是关键字,我们看不到源码实现,所以只能反编译
看一下,通过 javap -v **.class
public static void main(String[] args) { synchronized(Demo.class) { System.out.println("hello"); } } 复制代码
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: ldc #2 // class com/thread/base/Demo 2: dup 3: astore_1 4: monitorenter 5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 8: ldc #4 // String hello 10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 13: aload_1 14: monitorexit 15: goto 23 18: astore_2 19: aload_1 20: monitorexit 21: aload_2 22: athrow 23: return 复制代码
我们重点关注monitorenter
和monitorexit
,那么他俩是什么意思呢❓
monitorenter
,如果当前 monitor 的进入数为 0 时,线程就会进入 monitor,并且把进入数 + 1,那么该线程就是 monitor 的拥有者 (owner)。如果该线程已经是 monitor 的拥有者,又重新进入,就会把进入数再次 + 1。也就是可重入。
monitorexit
,执行 monitorexit 的线程必须是 monitor 的拥有者,指令执行后,monitor 的进入数减 1,如果减 1 后进入数为 0,则该线程会退出 monitor。其他被阻塞的线程就可以尝试去获取 monitor 的所有权。指令出现了两次,第 1 次为同步
正常退出释放锁;第2次为发生异步
退出释放锁;
我们再来看一下, 修饰实例方法
中的表现:
class Demo { public synchronized void hello() { System.out.println("hello"); } } 复制代码
public synchronized void hello(); 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 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 25: 0 line 26: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 this Lcom/thread/base/Demo; } 复制代码
我们重点关注ACC_SYNCHRONIZED
,它作用就是一旦执行到这个方法时,就会先判断是否有标志位,如果有,就会先尝试获取 monitor,获取成功才能执行方法,方法执行完成后再释放 monitor。在方法执行期间,其他线程都无法获取同一个 monitor。归根结底还是对 monitor 对象的争夺,只是同步方法是一种隐式的方式来实现。
synchronized 在 JVM 里的实现就是基于进入和退出 monitor 来实现的,底层则是通过成对的 MonitorEnter 和 MonitorExit 指令来实现
有了以上的认识,下面我们就看看锁优化
Synchronized中的锁优化
自适应自旋锁
自旋锁
,之前我们讲FutureTask
源码的时候,有一个内部方法awaitDone()
,给大家有介绍过,就是基于它实现的,今天再给大家总结一下。
它的目的是为了避免阻塞和唤醒的切换,在没有获得锁的时候就不进入阻塞,不断地循环检测锁是否被释放。但是,它也有弊端,我们通常来讲,一个线程占用锁
的时间相对较短,但是万一占用很长时间怎么办?这样会占用大量cpu时间,这样会导致性能变差,所以在1.6引入了自适应自旋锁
来满足这样的场景。
那么什么是自适应自旋锁
呢❓自旋的次数不是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果此次自旋成功了,很有可能下一次也能成功,于是允许自旋的次数就会更多,反过来说,如果很少有线程能够自旋成功,很有可能下一次也是失败,则自旋次数就更少。这样一来,就能够更好的利用系统资源。
锁消除
锁消除是一种锁的优化策略,这种优化更加彻底,在 JVM 编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。这种优化策略可以消除没有必要的锁,去除获取锁的时间。
锁粗化
如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。意思是将多个连续加锁、解锁的操作连接在一起,扩展成为一个范围更大的锁, 这个应该很好理解
偏向锁
偏向锁
是JDK 1.6引入的,它解决的场景是什么呢❓我们大部分使用锁都是解决多线程
场景下的问题,但有时候往往一个线程也会存在这样的问题,偏向锁是在单线程执行代码块时使用的机制。
锁的争夺实际上是 Monitor 对象的争夺,还有每个对象都有一个对象头,对象头是由 Mark Word 和 Klass pointer
组成的。一旦有线程持有了这个锁对象,标志位修改为 1,就进入偏向模式,同时会把这个线程的 ID 记录在对象的 Mark Word
中,当同一个线程再次进入时,就不再进行同步操作,大大减少了锁获取的时间,从而提高了性能。
轻量级锁
我们上边提到的偏向锁
,在多线程情况下如果偏向锁失败就会升级为轻量级锁
, Mark Word 的结构也变为轻量级锁的结构。
执行同步代码块之前,JVM 会在线程的栈帧中创建一个锁记录(Lock Record),并将 Mark Word 拷贝复制到锁记录中。然后尝试通过 CAS 操作将 Mark Word 中的锁记录的指针,指向创建的 Lock Record。如果成功表示获取锁状态成功,如果失败,则进入自旋获取锁
状态。
如果自旋锁
失败,就会升级为重量级锁
,也就是我们之前讲的,会把线程阻塞,需等待唤醒。
重量级锁
它又称为悲观锁
, 升级到这种情况下,锁竞争比较激烈,占用时间也比较长,为了减少cpu的消耗,会将线程阻塞,进入阻塞队列。
synchronized
就是通过锁升级策略
来适应不同的场景,所以现在synchronized
被优化的很好,也是我们项目中往往都会使用它的理由。
结束语
本节的内容比较多,大家好好理解,特别是锁
的升级策略。本节我们提到了Lock
锁,下一节,带大家深入学习一下Java的Lock
~