一、定义
如果某一个资源被多个线程共享,为了避免因为资源抢占导致资源数据错乱,需要对线程进行同步,也就是让他们阻塞等待。synchronized就是实现线程同步的关键字。也被称为同步锁。
synchronized的作用是保证在同一时刻,被其修饰的代码块或方法只会有一个线程执行,已达到并发安全的效果。
二、特性
- 原子性:所谓原子性就是多个操作要么全部执行成功,要么都不执行成功。
- 可见性:可见性是指多个线程访问同一个资源的时候,该资源的状态,值等信息其他线程是可见的。(通过“在执行unlock之前,必须先把变量同步回主内存”实现)
- 有序性:保证代码块和方法体执行顺序和出现顺序一致。(通过“一个变量在同一时刻只允许一个线程对其进行lock操作”实现)
三、用法
从语法上看,synchronized可以将任何非null对象作为“锁”。在HotSpot JVM实现中,锁有个专门的名字:对象监视器(Object Monitor)。
三种用法:(1)修饰静态方法(2)修饰成员函数(3)直接修饰一个代码块
// (1)修饰静态方法
public synchronized static void syncTest(){
System.out.println("hello synchronized");
}
// (2)修饰成员函数
public synchronized void syncTest(){
System.out.println("hello synchronized");
}
// (3)修饰代码块
public void syncTest(){
synchronized(this) {
System.out.println("hello synchronized");
}
}
四、锁的实现
synchronized上锁有两种形式,一是在方法上上锁,二是在代码块上上锁。他们的上锁策略都是一样的,都是在同步代码执行前获取锁,获取成功后在计数器上+1,同步代码执行完后计数器-1,获取失败则阻塞等待锁释放。只是在同步代码块的识别方式不一样,从class字节码文件可以看出来,一个是通过方法flags标志,一个是通过monitorenter和monitorexit指令操作的。
同步代码块:
关于monitorenter:
每个对象都有一个监视器锁(monitor)。当monitor被占用就会处于锁定状态,线程执行monitorenter指令的时候会尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后进入数设置为1,该线程即为monitor所有者。
2、如果线程占有monitor,只是重新进入,则进入数+1。(重入)
3、如果其他线程占用了monitor,则该线程进入阻塞等待,知道monitor的进入数为0,再重新获取monitor的所有权。
关于monitorexit:
执行monitorexit指令的线程必须是objectref对应的monitor所有者。
执行指令时monitor的进入数-1,如果-1后monitor的进入数为0,那么线程退出monitor,不在所有monitor。此时被阻塞的线程可以尝试去获取这个monitor的所有权了。
synchronized的寓意底层是通过一个monitor对象来完成的,其实wait/notify等方法也依赖于monitor对象。这也是为什么只有在被synchronized修饰的方法内或者代码块内才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
同步方法:
从字节码文件上看出当synchronized修饰方法的时候,会使用一个ACC_SYNCHRONIZED常量标识。JVM就是通过这个标识符实现方法同步的:
当方法调用时,调用指令会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,线程会先获取相应的monitor,获取成功则执行方法,执行完后则释放monitor,获取失败的话,怎进行阻塞等待。
两种方式在本质上都是基于monitor实现同步控制的,只是方法的同步是基于标识符ACC_SYNCHRONIZED隐式控制,无需字节码来完成。两个monitor指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
synchronized在修饰静态方法的时候锁对象就是类的class对象。
五、锁的原理和Monitor
(一)、对象内存布局
- 当一个对象的大小不足8*n的时候,会对其进行内存填充以达到8的整数倍字节。
- 类型指针占4字节是因为默认开启了指针压缩,如果不开启指针压缩则占8字节。
synchronized用的锁就是存放在Java对象头里面的,什么是Java对象头呢?HotSpot虚拟机的对象头主要包括两部分:mark word(标记字段)、class pointer(类型指针)。其中class pointer是对象指向他的类元数据的指针,通过这个指针确定对象属于哪个类的实例,mark word用于存储对象自身的运行时数据,是实现轻量级锁和偏向锁的关键。
mark word用于存储对象自身的运行时数据,如:hashCode,GC分带年龄,锁状态标志,线程持有的锁,偏向线程id,偏向时间戳等。
mark word的后三位被设定成了一个跟锁相关的标志位,其中两位是锁标志位,一位是偏向锁标志位。
(二)、监视器(Monitor)
任何一个对象都有一个Monitor与之相关联,当该对象的Monitor被持有后,它将处于锁定状态。synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体细节不一样,但是通过成对的monitorenter和monitorexit指令来实现。
monitorenter: 插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取monitor的所有权,即尝试获得该对象的锁。
monitorexit:插入在同步代码块结束处和异常处,JVM八正每个monitorenter必须有对应的monitorexit。
每一个Java对象在被创建出来就有一个monitor对象,通常称为内部锁或者monitor锁。
通常说的synchronized的对象锁也就是说的monitor锁,在C++源码实现中,Monitor是由ObjectMonitor实现的。
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
在这个构造函数中,主要关注两个变量:_WaitSet 和 _EntryList,这两个容器,一个用来存放wait的线程,一个用来存放阻塞中的线程。当多个线程访问同一个同步代码的时候:
1、首先会进入_EntryList集合,当线程获取到对象的Monitor对象的时候,将_owner 设置为当前线程,计数器_count+1。
2、若线程调用了wait()方法,将释放当前持有的Monitor,_owner = NULL,_count-1,同时将线程put到_WaitSet 中。
3、若当前线程执行完毕,也将释放Monitor锁,复位_count的值,以便其他线程进入获取。
Monitor对象存在于每一个对象的对象头mark word中,也就是任意对象都能作为锁的原因,勇士notify/notifyAll/wait等方法会使用到Monitor对象,所以必须在同步代码中使用。
(三)、锁优化
之前有说过synchronized在字节码层面是monitorenter和monitorexit来实现的,但是真正实现互斥的锁其实是依赖于操作系统底层原语Mutex Lock 来实现的,这是一个重量级锁,由操作系统主导,要想使用它,必须将线程挂起并从用户态切换到内核态来执行,这种代价非常昂贵。
在JDK1.6以前,每次获取到的都是重量级锁,在很多场景下性能很低,所以JDK1.6对synchronized锁进行了优化,目的就是减少重量级锁的使用。
整体的锁升级过程如下:
锁升级(锁膨胀)
锁有四种状态,其中锁会由实际情况发生膨胀,膨胀方向:无锁-->偏向锁-->轻量级锁-->重量级锁,其中锁的膨胀是不可逆的。
偏向锁
在大多数情况下,锁不存在竞争,总是由同一个线程获得,此时就是偏向锁。
轻量级锁
轻量级锁是由偏向锁升级来的,当存在两个线程同时申请同一个锁的时候,偏向锁会立即升级为轻量级锁,这个时候两个线程并不竞争线程,而是前后交替执行同步代码。(或者理解为少量线程竞争时升级为轻量级锁)
重量级锁
重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁会升级为重量级锁,此时申请锁的开销增大。
重量级锁一般在追求吞吐量,同步块或者同步方法执行时间较长的场景。
六、面试题
记录几个关于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;
}
}
// 其中 volatile 关键保证变量uniqueInstance的可见性,主要是禁止JVM指令重排,保证多线程下的正常运行。
说一下synchronized底层实现原理?
1.synchronized的底层是就一个Monitor对象(监视器锁)来实现的。
2.每个对象都有一个监视器锁(Monitor)。每个被synchronized修饰的代码块或者方法在被执行的时候都会预先去获取锁对象的Monitor,如果能够获取到则执行同步代码,如果失败则进入阻塞等待,具体过程:
1.如果Monitor的进入数为0,则线程进入Monitor,计数器+1,线程获得Monitor所有权。
2.如果线程已经占有Monitor,只是重新进入,那么计数器+1 ,
3.如果monitor被其他线程占有,那么该线程进入阻塞状态,知道monitor进入数为0,再次重新获取monitor的所有权。
synchronized的可重入原理?
重入锁是指一个线程获取到该锁后,该线程可以继续获取该锁。底层原理就是monitor维护了一个计数器,每当线程获取到monitor后,计数器+1,同一线程再次获取后,计数器+1,释放一次,计数器-1。计数器为0时,表示线程不被占有,其他线程可以竞争。
什么是自旋?
我们知道线程阻塞会涉及到用户态和内核态切换问题,那么我们考虑在线程试图获取一个已经被占有的monitor的时候,让其在synchronized之前做忙循环,执行一个空循环,让他循环尝试获取,这时候不让出CPU,这样也就避免了线程切换带来的性能损耗。
synchronized的锁升级原理是什么?
原理:每个锁对象的对象头里面有一个threadid字段,在第一次访问的时候,threadid为空,此时JVM让其持有偏向锁,并将threadid设置为线程id,在此进入的时候会先判断threadid与线程id是否一致,如果一致则可以使用这个锁,如果不一致,就将锁升级为轻量级锁,通过自旋循环一定次数来获取锁,如果执行一定次数的自旋后,还是没有获取到锁,那么锁升级为重量级锁。
当线程T1进入到对象O的一个使用synchronized修饰的方法A,那么线程T2能不能进入到线程O中同样用synchronized修饰的方法B呢?
当然不行。当synchronized修饰对象方法的时候,锁对象的提供者都为this,是通的monitor都是同一个monitor,那么显然线程B是无法获取到monitor对象的,也就无法调用同步方法了。