Synchronized原理

881 阅读8分钟

知识点:
1)Synchronized用法
2)Synchronized底层原理
3)锁优化

1 synchronized关键字最主要的三种使用方式的总结

1.1 修饰实例方法

作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

1.2 修饰静态方法

作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁 。也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static表明这是该类的一个静态资源,不管new了多少个对象,只有一份,所以对该类的所有对象都加了锁)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

1.3 修饰代码块

指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。 和 synchronized 方法一样,synchronized(this)代码块也是锁定当前对象的。synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。这里再提一下:synchronized关键字加到非 static 静态方法上是给对象实例上锁。另外需要注意的是:尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓冲功能!

2 Synchronized底层原理

package com.allen.test;

public class testSychronized {
	public void method(){
		synchronized(this){
			System.out.println("Sychronized code...");
		}
	}
}

定位到相应的位置,通过
javac testSychronized.java 把这个类编译成class文件
然后通过 javap -c -s -v -l testSychronized.class查看到如下内容

从上面可以看到,synchronized同步语句块实现采取的是moniterenter和moniterexit指令,前者为起始后者为终结。
当执行moniterenter的时候,线程尝试获取moniter对象监视器【moniter对象存在于每个java对象的对象头,synchronized就是通过这种方式来获取锁的。】
要了解一个概念,synchronized本身是属于可重入锁,这是什么意思呢,当线程A获取到Synchronized锁之后,计数器则+1,如果是不同对象尝试获取锁时候,发现计数器不为0的情况就要阻塞等待,而线程A在未释放锁的情况下再次尝试获取锁会让这个计数器数字+1,当然释放也释放多次。

值得一提的是当synchronized不是作为修饰代码块而是修饰方法,它并没有moniterenter和moniterexit指令,如下:

package com.allen.test;

public class testSynchronized {
	
	public synchronized void test(){
		System.out.println("Hello Allen");
	}
}

ACC_SYNCHRONIZED标志位代表此方法属于同步方法,JVM通过这种方式来辨识方法声明是否是同步方法,从而执行相应的同步调用。

3 锁优化

在java早期的版本【1.6之前】,synchronized属于重量级锁,效率比较低。因为监视锁是依赖底层操作徐彤的MutexLock来实现的,java的线程需要映射到操作系统的原生线程来处理,挂机和恢复线程都需要操作系统来辅助完成,而操作系统挂起和恢复线程需要从用户态转化成内核态,这个状态耗时很大,时间成本比较大,这也是为什么早期的synchronized效率不高的原因。

这边补充下内核态和用户态的概念,辅助来理解。 对于操作系统而言,创建一个新的进程属于核心功能,需要做很多底层的工作,比如消耗系统物理资源,分配物理内存,从父进程拷贝相关信息,拷贝设置目录页表等等,这些自然不能让随便一个程序就能去做,所以引入了特权级别的概念,关键的权利必须由高等特权的程序来执行,这样可以集中管理,减少有限资源的访问和使用冲突。 就Intel X86架构的CPU来说,它具备0~3四个特权,0最高,3最低。 当程序运行在特权3上,可以称之为用户态,因为是最低特权,也就是普通用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态的,反之运行在特权级之前的,就成为运行在内核态。

JDK1.6对Synchronized做了大量的优化,如偏向锁,轻量级锁,自旋锁,自适应自旋锁,锁消除,锁粗化等等技术。 锁主要存在四种状态:无锁,偏向锁,轻量级锁,重量级锁。 这四种状态会随着竞争激烈而逐步提升,这个方向是不可逆的。【只能逐级膨胀,不能坍塌】

1)偏向锁

引入偏向锁的目的是消除数据在无竞争情况下同步,提高程序的运行量。
偏向锁的核心在于偏字,它会偏向于第一个获取锁的线程,如果接来下没有其他线程尝试获取锁,那么持有偏向锁的线程永远不会再进行同步。
执行过程:
假如当前jvm启动了偏向锁,那么锁对象第一次被线程获取的时候,虚拟机会把对象头的标志位设置为01,即偏向模式,同时使用CAS操作把获取到这锁的线程ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进去这个锁相关的同步块,虚拟机都不再进行任何同步操作。

2)轻量级锁

当持有偏向锁的时候,另外一个线程也来请求锁,此时锁变会膨胀为轻量级锁(对象头标志位00)
轻量级锁不是为了代替重量级锁的,目的是在没有多线程竞争下,减少传统重量级索使用操作系统互斥量产生的性能损耗,由于使用轻量级锁,所以不需要申请互斥量,加锁和解锁采取CAS操作。
执行过程:
代码进入到同步块,如果此时对象没有被锁定(01状态),jvm将当前线程的栈帧中建立一个锁记录Lock Record,用来存储对象目前的Mark Word的副本集。
JVM会尝试用CAS操作把对象的Mark Word更新为指向Lock Record。
如果更新成功,这个线程就拥有该对象的锁,并且锁标志位转变成00,标识这个锁处于轻量级锁状态。
如果更新失败,那么JVM先会检查对象的Mark Word是否指向当前线程的栈帧,如果有那么代表当前线程已经拥有这个对象的锁了,如果没有,代表这个锁已经被其他线程占有了。如果有俩个以上的线程争用同一把锁,那么轻量级锁会失效,膨胀为重量级锁。
轻量级锁提升性能开销是在没有竞争的情况下,采取CAS操作避免了使用互斥量,但是在竞争情况下,除了互斥量之外还多了CAS操作,比传统的重量锁更加慢。

3)自旋锁/自适应自旋锁

在轻量级锁失败之后,JVM为了避免线程在操作修通上挂起还会进行自旋锁的优化。 对于互斥而言,性能影响最大的就是阻塞的实现,正如上文说的,线程的挂起和恢复线程接借助操作系统的话会从用户态转为内核态,很耗时间。通常来说线程持有锁的时间都不会太长,仅仅为了这点时间来挂起和恢复线程是得不偿失的。JVM的开发团队考虑,能不能让后面的线程等待一会而不挂起,看看持有锁的线程是否很快释放锁,为了让一个线程等待,就让它做一个忙循环,这就是所谓的自旋锁。自旋次数默认是10次,可以通过--XX:PreBlockSpin修改
自旋的时间不固定,跟前一次同一个锁的自旋时间来决定。

4)锁消除

如果检测共享数据不可能存在竞争,那么执行锁消除,可以节省无意义的锁请求时间

5)锁粗化

一系列对同一个对象反复的加锁解锁,会带来很多没必要的开销,所以JVM会扩大作用域,粗化到整个操作序列的外部,那么只需要加锁一次即可。

参考内容《深入理解java虚拟机第13章》