synchroized原理深究
synchroized基本认识
锁的基本使用参考:并发编程04-线程安全解决方案之如何正确使用synchroized关键字
上面的文章介绍了synchroized关键字如何使用,今天我们通过一段代码来一起深入学习下synchroized的原理。
public class Main {
public synchronized void syncMethod() {
System.out.println("**********");
}
public void test() {
synchronized (Main.class) {
System.out.println("----------");
}
}
}
上面的代码编译后的字节码文件如下:
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class Main
2: dup
3: astore_1
4: monitorenter // monitorenter表示进入同步块
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String ----------
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit // monitorexit表示退出同步块
15: goto 23
18: astore_2
19: aload_1
20: monitorexit // monitorexit表示退出同步块,第二个是保证抛异常的情况下也能释放锁,这里有一个隐藏的try...finally...
21: aload_2
// ...省略不重要的
// 修饰方法
public synchronized void syncMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 表示是同步方法,进行方法调用时,会先判断有没有这个标志位,
// 如果有,就会和上面一样获取一个Monitor,通过monitorenter进入同步块
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String **********
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 13: 0
line 14: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this LMain;
}
SourceFile: "Main.java"
针对上面的字节码我们在画一个图看一下。
上述就是synchroized加锁的整个过程,可以看出都是依赖于Monitor(监视器锁)实现的,监视器锁呢在虚拟机中又是根据操作系统的Metux Lock(互斥量)来实现的,这就导致在加锁的过程中需要频繁的在操作系统的内核态和和JVM级别的用户态进行切换,并且涉及到线程上下文的切换,是比较消耗性能的。所以后来有一位大佬Doug Lea基于java实现了一个AQS的框架,提供了Lock锁,性能远远高于synchroized。这就导致Oracle公司很没有面子,因此他们在JDK1.6对synchroized做了优化,引入了偏向锁
和轻量级锁
。存在一个从偏向锁
–》轻量级锁
–》重量级锁
的升级过程,优化后性能就可以和Lock锁的方式持平了。下面我们我们先从对象头说起,探讨一下锁升级。
对象头
java虚拟机中,对象在内存中分为三块区域:对象头、实例数据和对齐填充。
对象头包括两部分:Mark Word
和 类型指针
。类型指针
是指向该对象所属类对象的指针,我们不关注。mark word
用于存储对象的HashCode、GC分代年龄、锁状态等信息。在32位系统上mark word
长度为32bit,64位系统上长度为64bit。他不是一个固定的数据结构,是和对象的状态紧密相关,有一个对应关系的,具体如下表所示:
时间片
我们平时的操作系统可以分为实时操作系统
和分时操作系统
,在分时操作系统
中,会把CPU的时间划分为若干个基本相同的时间片
,通过操作系统的管理,把这些时间片分配给每个线程。如图,假设有两个线程:线程1和线程2,线程1给分配了时间片1(100ms),线程2给分配了时间片2(100ms)。线程1当前获得了CPU使用权,他的运行时间只有100ms,在这期间,如果他还没有完成工作,那么线程1就被暂停,CPU被分配给线程2运行,等线程2达到运行时间后,在切换线程1(这个过程中需要保存线程1的运行状态和相关参数,这个保存
和切换
的过程叫做线程上下文切换
)。再次切换到线程1后,线程1开始执行,假设这次线程1只用了10ms就完成了任务,操作系统也会立刻结束线程1,切换到线程2运行。由于每个时间片的时间间隔非常小,因此虽然同一时间CPU只做了一件事,但是用户感知到好像CPU在同时处理所有工作。
线程上下文切换
接上面当线程1时间片结束,但是线程1的工作仍然未结束,这个时候线程2拿到CPU执行权了,但是线程1执行了个半截怎么办?他执行的状态总的保存吧,所以这个时候就涉及到了线程上下文切换。切换的过程基本分为以下几步:
-
首先从用户态转化到内核态,获取操作内存的特权,
-
将线程1的线程上下文保存在它依赖的进程的进程控制区块,
-
读取线程2的线程栈,开始执行线程2的逻辑,直到他的时间片用完或者他提前运行结束了,也会释放CPU
-
线程1再次获取CPU使用权的时候,CPU在切回线程1的线程栈,同时从内存中加载线程1的线程上下文。
这个过程涉及到往内存中读写线程中间变量,虽然内存很快,但是比起来CPU,那已经是相当慢了。
线程栈
在多线程中,堆区的资源是共享的,所有线程都共用一个堆区。但是栈区是独立的,每个线程都有自己独立的线程栈
,其中保存着程序计数器、寄存器、程序运行的堆栈指令信息等。
内核态和用户态
特权级
: 操作系统中,CPU一共有四个级别,0~3级,0级权限最高,可以控制硬件,操作内存,进行磁盘I/O等操作,3级权限最低。
内核态
:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序,该状态有0级特权。
用户态
:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取,此时特权级别为3级。我们接触到的软件都运行在用户态
为什么要分为内核态和运行态:假设一个没有内核态和用户态的概念,那么一个软件就可以完全控制计算机,改变计算机状态,甚至修改操作系统,这是很危险的事情。
用户态切换内核态的几种方式
-
系统调用:用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,这是用户态进程主动要求切换到内核态的一种方式
-
异常:当cpu在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中
-
外围设备的中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令时用户态下的程序,那么转换的过程自然就会是 由用户态到内核态的切换。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。
PCB
PCB:进程控制块(Processing Control Block)
,是操作系统中一种数据结构,主要表示进程状态,位于主内存占用区的一块连续存储区。存放着操作系统中用于描述进程情况及控制进程运行的所有信息。
- 进程状态:new、ready、running、waiting、blocked
- CPU寄存器:累加器、索引暂存器(Index register)、堆栈指针以及一般用途暂存器、状况代码等,主要用途在于中断时暂时存储数据,以便稍后继续利用
- 程序计数器:程序要执行的下一条指令
- I/O状态信息:保存分配给程序的I/O信息以及文件列表等
- 记账信息:包括处理器时间总和,使用的时钟数总和,时间限制,记账号等
- 上下文:线程和进程之间切换时要保存相关的上下文信息
锁升级
上面提到说synchroized
在JDK1.6之前是重量级锁,性能很差,所以后来引入了锁升级,有了一个无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁的过程。我们下面看看这几个过程。
偏向锁
当一段代码被synchronized
修饰,但其实这段同步代码在很长一段时间只有一个线程使用的时候,那么如果每次都用重量级锁,将会是很大一个开销,所以JDK1.6引入了偏向锁,他的加锁过程如下:
-
当某一线程第一次获得锁的时候,会用CAS指令,将
mark word
中的ThreadID由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。 -
当被偏向的线程再次进入同步块时,发现锁对象对象头中记录的就是当前线程的线程ID,则只需要几个简单的指令就重新获得锁,执行程序,这个过程基本没有性能开销
-
当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在
safepoint
中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word
改为无锁状态(unlocked),之后再升级为轻量级锁。
轻量级锁
轻量级锁适用于锁竞不是很激烈,多个线程交替执行的情况下。可能线程1虽然暂时在占有着CPU,但是也许他很快就用完了,那如果有新的线程2来,线程2不妨等一下线程1,来个死循环,循环50次100次的,通过自旋的方式等待获取锁资源,而不是阻塞当前线程。这就是轻量级锁,他的加锁过程如下:
- 在线程栈中创建一个
Lock Record(锁记录)
,存储锁对象目前的Mark Word的拷贝。 - 通过CAS指令将
Lock Record
的地址存储在对象头的mark word
中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3。 - 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置
Lock Record
第一部分(Displaced Mark Word
)为null,起到了一个重入计数器的作用。然后结束。 - 如果有其他线程进入,则自旋获取锁资源,如果始终获取不到,说明线程竞争激烈,会膨胀为重量级锁。
重量级锁
重量级锁就是我们之前的之前说的那一堆了。
锁消除
锁消除也是锁的一种优化,他使用于这样的场合,比如你在一个方法里定义了一个StringBuffer这种局部变量,那这个变量作为方法中的变量,他是不会有线程安全的问题的,因此他会将StringBuffer的锁消除掉。