/'sɪŋkrənaɪzd 星可奶(一直记不住这个单词怎么读)
Java平台中任何一个对象都有唯一一个与之关联的锁。这种锁被称为监视器或者内部锁(Intrinsic Lock)。内部锁是一种排他锁,它能够保障原子性、可见性和有序性。在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁是依赖于底层的操作系统Mutex Lock 来实现的,Java的线程池是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程直接的切换时,需要从用户态转换到内核态,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
JDK1.6对锁引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
内部锁是通过synchronized 关键字实现的。synchronized 关键字可以用来修饰方法以及代码块
synchronized 关键字修饰的方法就称为同步方法。
synchronized 关键字修饰的静态方法就被称为同步静态方法
synchronized 关键字修饰的实例方法就被称为同步实例方法
同步方法的整个方法体就是一个临界区。
在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而synchronized关键字则是用来保证线程同步的。
原子性:一个或多个操作要么全部执行成功,要么全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源。可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。执行synchronized时,会对应执行 lock 、unlock原子操作,保证可见性。有序性:程序的执行顺序会按照代码的先后顺序执行。悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。独占锁或者排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。synchronized主要有三种使用方式:
修饰普通同步方法
同一个对象锁——即新建 对象锁,放入到多线程执行。方法是同步的。/**
* @Author: LiangYiFeng
* @Description: 修饰普通方法
* @Date: Create in 2022/8/16 11:18
* @Modified By:
*/
public class SyncTest implements Runnable {
private static int i = 0; //共享资源
private synchronized void add() {
i++;
}
public void run() {
for (int j =0 ; j<10000; j++){
add();
}
}
public static void main(String[] args) throws InterruptedException {
SyncTest syncTest = new SyncTest(); // 锁是同一个对象,放入不同线程执行,
Thread t1 = new Thread(syncTest); //
Thread t2 = new Thread(syncTest);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果:
20000
若两次new syncTest()操作建立的是两个不同的对象:则会出现线程不安全
/**
* @Author: LiangYiFeng
* @Description: 建立的是两个不同的对象
* @Date: Create in 2022/8/16 11:18
* @Modified By:
*/
public class SyncTest implements Runnable {
private static int i = 0; //共享资源
private synchronized void add() {
i++;
}
public void run() {
for (int j =0 ; j<10000; j++){
add();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new SyncTest());
Thread t2 = new Thread(new SyncTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果:出现意想不到的结果——线程不安全导致
19355
因为两次new syncTest()操作建立的是两个不同的对象,也就是说存在两个不同的对象锁,线程t1和t2使用的是不同的对象锁,所以不能保证线程安全。
那这种情况应该如何解决呢?
因为每次创建的实例对象都是不同的,而类对象却只有一个,如果synchronized关键字作用于类对象,即用synchronized修饰静态方法,问题则迎刃而解。
修饰静态同步方法
只需要在add()方法前用static修饰即可,即当synchronized作用于静态方法,锁就是当前的class对象。
/**
* @Author: LiangYiFeng
* @Description: 修饰静态同步方法
* @Date: Create in 2022/8/16 11:18
* @Modified By:
*/
public class SyncTest implements Runnable {
private static int i = 0; //共享资源
private static synchronized void add() {
i++;
}
public void run() {
for (int j =0 ; j<10000; j++){
add();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new SyncTest());//调用方法时,是两个不同对象,同时也是锁当前对象
Thread t2 = new Thread(new SyncTest());//所以线程是安全的
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果:
20000
修饰同步方法块
如果某些情况下,整个方法体比较大,需要同步的代码只是一小部分,如果直接对整个方法体进行同步,会使得代码性能变差,这时只需要对一小部分代码进行同步即可。代码如下:
/**
* @Author: LiangYiFeng
* @Description: 修饰同步方法块
* @Date: Create in 2022/8/16 11:18
* @Modified By:
*/
public class SyncTest implements Runnable {
static int i = 0; //共享资源
public void run() {
synchronized (SyncTest.class){ //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
for (int j =0 ; j<10000; j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
SyncTest syncTest = new SyncTest(); // new
Thread t1 = new Thread(syncTest);
Thread t2 = new Thread(syncTest);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
在jdk1.6之前,synchronized被称为重量锁,在jdk1.6中,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁和轻量级锁。下面先介绍jdk1.6之前的synchronized原理。
在HotSpot虚拟机中,Java对象在内存中的布局大致可以分为三部分:对象头、实例数据和填充对齐。因为synchronized用的锁是存在对象头里的,这里我们需要重点了解对象头。如果对象头是数组类型,则对象头由Mark Word、Class MetadataAddress和Array length组成,如果对象头非数组类型,对象头则由Mark Word和Class MetadataAddress组成。在32位虚拟机中,数组类型的Java对象头的组成如下表:
| 内容 | 说明 | 长度 |
|---|---|---|
| Mark Word | 存储对象的hashCode、分代年龄和锁标记位 | 32bit |
| Class MetadataAddress | 存储到对象类型数据的指针 | 32bit |
| Array length | 数组的长度 | 32bit |
这里我们需要重点掌握的是:Mark Word
在运行期间,Mark Word中存储的数据会随着锁标志位的变化而变化,在32位虚拟机中,不同状态下的组成如下:

其中线程ID表示持有偏向锁线程的ID,Epoch表示偏向锁的时间戳,偏向锁和轻量级锁是在jdk1.6中引入的。
在jdk1.6之前,synchronized只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,所以首先来了解下Monitor,在Hotspot虚拟机中,Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的,首先我们先下载Hotspot的源码,源码下载链接:http://hg.openjdk.java.net/jdk8/jdk8/hotspot,
找到ObjectMonitor.hpp文件,路径是src/share/vm/runtime/objectMonitor.hpp,这里只是简单介绍下其数据结构。
ObjectMonitor() {
_header = NULL;
_count = 0; //锁的计数器,获取锁时count数值加1,释放锁时count值减1,直到
_waiters = 0, //等待线程数
_recursions = 0; //锁的重入次数
_object = NULL; _owner = NULL; //指向持有ObjectMonitor对象的线程地址
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //阻塞在EntryList上的单向线程列表
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
其中 _owner、_WaitSet和_EntryList 字段比较重要,它们之间的转换关系如下图

从上图可以总结获取Monitor和释放Monitor的流程如下:
前面已经了解Monitor的实现细节,而Java虚拟机则是通过进入和退出Monitor对象来实现方法同步和代码块同步的。
synchronized是Java中的一个关键字,在使用的过程中并没有看到显示的加锁和解锁过程。因此有必要通过javap命令,查看相应的字节码文件。
synchronized 同步语句块的情况:
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
target文件下:执行命令
javap -c -v SynchronizedDemo.class

从上述字节码中可以看到同步代码块的实现是由monitorenter 和 monitorexit 指令完成的,
同步代码块开始的位置正常结束同步代码块的指令异常结束时所执行的释放Monitor指令。主要过程:
获取锁,而获取锁的过程就是monitorenter 。释放锁,释放锁就是执行monitorexit指令。为什么会有两个monitorexit呢?
这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。
private synchronized void add(){
i++;
通过javap 反编译,查看字节码文件:
target文件下:执行命令
javap -c -v SynchronizedDemo2.class
public class SynchronizedDemo2 {
private int i =0;
public synchronized void add() {
i++;
}
public static void main(String[] args) {
SynchronizedDemo2 synchronizedDemo = new SynchronizedDemo2();
synchronizedDemo.add();
}
}

发现这个没有monitorenter 和 monitorexit 这两个指令了,而在查看该方法的class文件的结构信息时发现了Access flags后边的acc_synchronized标识,该标识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最后释放Monitor。

原理大概就是这样,最后总结一下,面试中应该简洁地如何回答synchroized的底层原理这个问题。
答:Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorenter 和 monitorexit 指令实现的,而方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。
因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。
在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种,无锁状态,偏向锁状态、轻量级锁状态和重量级锁状态。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级

锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。
引入偏向锁的目的:减少只有一个线程执行同步代码块时的性能消耗,即在没有其他线程竞争的情况下,一个线程获得了锁。
偏向锁的获取流程:
偏向锁的获取流程如下图:

偏向锁的撤销:
只有等到竞争,持有偏向锁的线程才会撤销偏向锁。偏向锁撤销后会恢复到无锁或者轻量级锁的状态。
一句话简单总结偏向锁原理:使用CAS操作将当前线程的ID记录到对象的Mark Word中。
引入轻量级锁的目的:在多线程交替执行同步代码块时(未发生竞争),避免使用互斥量(重量锁)带来的性能消耗。但多个线程同时进入临界区(发生竞争)则会使得轻量级锁膨胀为重量级锁。
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,轻量级锁就会升级为重量级锁。


轻量级锁的解锁:
轻量级的解锁同样是通过CAS操作进行的,线程会通过CAS操作将Lock Record中的Mark Word(官方称为Displaced Mark Word)替换回来。如果成功表示没有竞争发生,成功释放锁,恢复到无锁的状态;如果失败,表示当前锁存在竞争,升级为重量级锁。
一句话总结轻量级锁的原理:将对象的Mark Word复制到当前线程的Lock Record中,并将对象的Mark Word更新为指向Lock Record的指针。
Java锁的几种状态并不包括自旋锁,当轻量级锁的竞争就是采用的自旋锁机制。
很多 synchronized 里面的代码只是一些很简单的代码,执行时间非常快,此时等待的线程都加锁可能是一种不太值得的操作,因为线程阻塞涉及到用户态和内核态切换的问题。既然 synchronized 里面的代码执行得非常快,不妨让等待锁的线程不要被阻塞,而是在 synchronized 的边界做忙循环,这就是自旋。如果做了多次循环发现还没有获得锁,再阻塞,这样可能是一种更好的策略。
什么是自旋锁:当线程A已经获得锁时,线程B再来竞争锁,线程B不会直接被阻塞,而是在原地忙循环 等待,当线程A释放锁后,线程B可以马上获得锁。
引入自旋锁的原因:因为阻塞和唤起线程都会引起操作系统用户态和核心态的转变,对系统性能影响较大,而自旋等待可以避免线程切换的开销。
自旋锁的缺点:自旋等待虽然可以避免线程切花的开销,但它也会占用处理器的时间。如果持有锁的线程在较短的时间内释放了锁,自旋锁的效果就比较好,如果持有锁的线程很长时间都不释放锁,自旋的线程就会白白浪费资源,所以一般线程自旋的次数必须有一个限制,该次数可以通过参数-XX:PreBlockSpin调整,一般默认为10。
自适应自旋锁:JDK1.6引入了自适应自旋锁,自适应自旋锁的自旋次数不在固定,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果对于某个锁对象,刚刚有线程自旋等待成功获取到锁,那么虚拟机将认为这次自旋等待的成功率也很高,会允许线程自旋等待的时间更长一些。如果对于某个锁对象,线程自旋等待很少成功获取到锁,那么虚拟机将会减少线程自旋等待的时间。
重量级锁是synchronized, 是Java虚拟机中最为基础的锁实现。在这种状态下,Java虚拟机会阻塞加锁失败的线程池,并且在目标锁被释放的时候,唤醒这些线程。
| 锁 | 优点 | 缺点 | 实用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在竞争,会额外带来锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
| 重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较慢 |
锁消除
锁消除是指Java虚拟机在即时编译时,通过对运行上下的扫描,消除那些不可能存在共享资源竞争的锁。锁消除可以节约无意义的请求锁时间。
锁粗化
一般情况下,为了提高性能,总是将同步块的作用范围限制到最小,这样可以使得需要同步的操作尽可能地少。但如果一系列连续的操作一直对某个对象反复加锁和解锁,频繁地进行互斥同步操作也会引起不必要的性能消耗。
如果虚拟机检测到有一系列操作都是对某个对象反复加锁和解锁,会将加锁同步的范围粗化到整个操作序列的外部。可以看下面这个经典案例。
for(int i=0;i<n;i++){
synchronized(lock){
}
}
这段代码会导致频繁地加锁和解锁,锁粗化后
synchronized(lock){
for(int i=0;i<n;i++){
}
}
synchronized关键字最主要的三种使用方式:
总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!
下面我以一个常见的面试题为例讲解一下 synchronized 关键字的具体使用。
面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理!”
双重校验锁实现对象单例(线程安全)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。
例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
不能,线程2只能访问该对象的非同步方法。因为执行同步方法时需要获得对象的锁,而线程1在进入sychronized修饰的方A时已经获取到了锁,线程2只能等待,无法进入到synchronized修饰的方法B,但可以进入到其他非synchronized修饰的方法。
volatile主要是保证内存的可见性,即变量在寄存器中的内存是不确定的,需要从主存中读取。synchronized主要是解决多个线程访问资源的同步性。volatile作用于变量,synchronized作用于代码块或者方法。volatile仅可以保证数据的可见性,不能保证数据的原子性。synchronized可以保证数据的可见性和原子性。volatile不会造成线程的阻塞,synchronized会造成线程的阻塞。Lock是显示锁,需要手动开启和关闭。synchronized是隐士锁,可以自动释放锁。Lock可以判断锁的状态,synchronized不可以判断锁的状态。(查看是否成功获取锁)Lock是一个接口,是JDK实现的。synchronized是一个关键字,是依赖JVM实现的。Lock是可中断锁,synchronized是不可中断锁,需要线程执行完才能释放锁。Lock不会主动释放占有的锁,必须通过unlock进行手动释放,因此可能引发死锁。synchronized在发生异常时会自动释放占有的锁,不会出现死锁的情况。Lock实现锁的类型是可重入锁、公平锁。synchronized实现锁的类型是可重入锁,非公平锁。Lock适用于大量同步代码块的场景,synchronized适用于少量同步代码块的场景。相同点:
“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
主要区别如下:
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础: