前言
在上一篇讲解CAS的文章里提到
synchronized
加锁开销很大,那么为什么会开销大呢?这篇文章主要讲解的内容是synchronized的底层原理
以及锁升级
的过程。
并发编程的三大问题
首先我们要知道为什么要使用
synchronized
加锁,那是因为在并发编程中会出现原子性问题
、可见性问题
、有序性问题
,导致结果不是我们希望的,所以需要进行同步操作,而使用synchronized
加锁是一种保证同步性的方法。下面我们来讲解一下并发编程的这三大问题。
原子性问题
原子性问题指的是在一次或者多次操作中,要么所有操作都执行,要么所有操作都不执行。通过下面的例子你可以发现原子性问题。
public class Demo {
static int count;//记录用户访问次数
public static void request() throws InterruptedException {
//模拟请求耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
count++;
}
public static void main(String[] args) throws InterruptedException {
//开始时间
long startTime = System.currentTimeMillis();
int threadSize = 100;
//CountDownLatch类就是要保证完成100个用户请求之后再执行后续的代码
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
for (int i = 0; i < threadSize; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
//模拟用户行为,访问10次网站
try{
for (int j = 0; j < 10; j++)
request();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName()+"耗时:"+(endTime-startTime)+",count="+count);
}
}
同时又100个线程,每个线程执行了10次request()方法,对count变量自增,结果却不是1000,这是因为count++并不是一个原子性操作,通过反编译可以知道count++包含了四条字节码指令,所以多个线程同时操作的时候,线程执行count++会收到其他线程的干扰。
可见性问题
可见性问题指的是一个线程在访问一个共享变量的时候,其他线程对该共享变量的修改对于第一个线程来说是不可见的,下面通过一个例子可以发现可见性问题。
public class Visable {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()-> {
while(flag) {
}
}).start();
Thread.sleep(2000);
new Thread(() -> {
flag = false;
System.out.println("修改了共享变量flag的值");
}).start();
}
}
在这份代码中,声明了一个共享变量flag
,然后声明了一个线程一直在读这个共享变量,另一个线程修改了共享变量,我们运行发现,当另一个线程修改了共享变量之后,第一个线程仍然在循环运行,所以这就是并发编程中的可见性问题。
有序性问题
有序性问题指的就是JVM在编译器和运行期会对执行进行一个重排序,导致最终程序代码运行的顺序与开发者一开始编写的顺序不一致,导致出现有序性问题。
synchronized的使用
synchronized可以修饰方法,也可以修饰代码块。
修饰方法
静态方法
public class Demo{
public static synchronized void request(){
.....
}
}
synchronized
修饰静态方法的时候其实是锁定了Demo
的类对象。
非静态方法
public class Demo{
public synchronized void request(){
.....
}
}
synchronized
修饰非静态方法的时候其实是锁定了Demo
类的实例对象(this)。
修饰代码块
public class Demo{
private Object o = new Object();
public static void request(){
synchronized(o) {
...
}
}
public static void request1(){
synchronized(this) {
...
}
}
}
synchronized(o)
锁定的其实是o对象
,而synchronized(this)
锁定的其实是this
对象。
synchronized的特性
synchronized主要有两大特性,分别是
不可中断特性
和可重入特性
。
不可中断特性
不可中断特性指的是,当线程在竞争共享资源的时候,如果资源已经上锁了,那么线程会阻塞等待,直到锁释放,这个阻塞等待过程是不能被中断的。通过下面的例子可以证明
synchronized
的不可中断特性。
public class UnBlocked {
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
synchronized (this) {
try {
TimeUnit.MILLISECONDS.sleep(8888);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
Thread.sleep(1000);
t2.start();
System.out.println("t2中断前");
t2.interrupt();
System.out.println("t2中断后");
System.out.println("t1线程的状态:"+t1.getState());
System.out.println("t2线程的状态:"+t2.getState());
}
}
这段代码中声明了两个线程,线程t1一直占用着锁对象,然后线程t2一直处于阻塞状态,调用中断函数也不可中断线程t2的阻塞状态,所以这就是synchronized的不可中断性
。
Q:synchronized与Lock的区别
提到synchronized的不可中断特性,不得不提到Lock,对于Lock来说默认是不可中断的。但是可以调用Lock对象的trylock()方法,可以在线程阻塞等待一段时间之后,自动中断阻塞等待状态,这也是synchronized与Lock的一个区别。其次还有Lock可以返回锁的状态,而synchronized是一个无状态锁,你是不知道线程的锁定状态的。
可重入性
synchronized
的可重入性指的是同一个线程可以多次获取同一把锁,synchronized
的锁对象关联的monitor对象中会有一个可重入计数器,当同一个线程访问该锁对象,可重入计数器会加一,然后释放锁的时候,可重入计数器会减一。
synchronized的底层原理
synchronized
的底层原理与Java对象头
和Monitor对象
息息相关,所以先了解一下Java对象头与Monitor对象的结构,然后再分别讲解一下synchronized修饰方法和代码块的底层原理。
Java对象头
Java对象在JVM内存结构的布局分为三部分:对象头
、实例数据
和对齐填充
。
结构 | 说明 |
---|---|
对象头 | 对象头又分为Mark Word 、类型指针 和数组长度 (可选) |
实例数据 | 类中的属性数据信息,包括父类的属性数据信息 |
对齐填充 | JVM要求对象的起始地址是8的倍数,如果不满足,使用对齐填充 |
关于synchronized的锁信息是存放在对象头中的Mark Word
中,所以重点讲解一下对象头,对象头的结构如下:
对象头结构 | 说明 |
---|---|
Mark Word | 对象的运行时数据:hashcode、分代年龄、锁信息以及GC标记等信息 |
类型指针 | 通过类型指针可以知道该对象属于哪个类 |
数组长度(可选) | 如果是数组对象,就代表数组的长度 |
其中与synchronized锁对象相关的信息是在Mark Word(运行时数据)
中的,那我们来看看Mark Word的结构如下:
可以看到在JDK1.6之前,还没有锁升级的概念,synchronized是重量级锁,锁标识是10
,Mark Word中存放了指向重量级锁对象monitor的指针
。
Monitor对象
每一个对象都会有一个关联的Monitor对象
,synchronized的锁对象实际上是Monitor对象,所以synchronized
可以锁任何对象,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 ;
}
其中几个重要的属性是:
属性名 | 说明 |
---|---|
_recursions | 代表一个线程的重入次数 |
_owner | 指向获取到Monitor对象的线程 |
_WaitSet | 处于wait状态的线程,会被加入到_WaitSet |
_EntryList | 处于等待锁block状态的线程,会被加入到_EntryList |
_object | 指向关联的锁对象 |
当多个线程访问同一个同步代码的时候,会先进入_EntryList区域
,获取ObjectMonitor对象的线程会进入_Owner区域
,并且将ObjectMonitor对象的_owner指向当前线程,计数器_count自增1,若线程调用wait()方法,线程会释放持有的monitor,owner变量恢复为Null,_count自减1,同时该线程进入_WaitSet
集合中等待被唤醒。若当前线程执行完毕也将释放monitor对象并复位变量的值,以便其他线程可以进入获取monitor锁。如下图所示:
synchronized代码块底层原理
通过反汇编的方式了解synchronized代码块的底层原理
首先我们反编译用synchronized修饰的代码块,看看被synchronized修饰的代码块的字节码指令有什么不同?
public class Demo1 {
private static Object object = new Object();
private static int count = 0;
public static void main(String[] args) {
synchronized (object) {
count++;
}
}
}
我们发现在count++的四个字节码指令外面包裹了一个monitorenter
和monitorexit
字节码指令。
monitorenter指令
当JVM执行到某个线程中某个方法的monitorenter字节码指令的时候,会尝试去获取当前对象的
monitor
对象的所有权,具体过程如下:
- 若monitor的进入计数器为0,则该线程可以获得monitor的所有权,将monitor的计数器置为1,当前线程成为monitor的拥有者(owner);
- 若该线程已经拥有了monitor的所有权,可以直接重入,然后monitor的重入计数器自增1;
- 若monitor对象已经被其他线程拥有了,当前尝试获取monitor对象的线程会被阻塞,直到monitor的进入数为0时,才能重新尝试获取monitor对象。
monitorexit指令
monitorexit指令的执行过程如下:
- 首先执行
monitorexit
指令的线程一定是拥有了monitor
对象的所有权的。- 执行
monitorexit
指令会将monitor
对象的可重入数减一,如果可重入数为0,当前线程就会释放monitor对象,不再拥有monitor对象的所有权。此时其他阻塞等待获取这个monitor对象的线程就会被唤醒,重新尝试获取monitor对象。
Q:为什么会有两个monitorexit指令?
这是因为当执行同步块代码过程中发生异常了,会自动释放锁,所以第二个monitorexit指令是用于
异常释放锁
的。
synchronized方法底层原理
public synchronized void test(){
System.out.println("synchronized修饰方法");
}
通过反编译可以看到被synchronized修饰的方法test()的字节码指令如下:
可以看到被synchronized修饰的方法并没有monitorenter
和monitorexit
字节码指令,取而代之的是红色框里面的ACC_SYNCHRONIZED标识,标识了这是一个同步方法。当方法被调用时,会先检测同步标识ACC_SYNCHRONIZED是否被设置了,如果设置了,则执行线程先获取monitor对象,然后再执行方法,最后方法完成时会释放monitor对象。在方法执行期间,执行线程持有了monitor对象,其他线程是无法获取同一个monitor对象的。
Q:为什么说在JDK1.6之前synchronized是重量级锁?
JDK1.6访问synchronized修饰的代码块或者方法,都会加锁,首先加锁的过程就是获取monitor对象的过程,
涉及到一些系统调用,所以会有内核态和用户态切换,就会消耗资源
。其次,线程的阻塞和唤醒过程涉及到了线程上下文切换,也是涉及了内核态和用户态的切换
,所以整体来说JDK1.6之前synchronized是一个重量级锁。
synchronized的锁升级过程
synchronized在JDK1.6之后进行了一个优化,根据并发量的不同,经历了
无锁
->偏向锁
->轻量级锁
->自旋锁
->重量级锁
这么一个锁升级的过程。
偏向锁
偏向锁,顾名思义就是偏向第一个获取到锁对象的线程,并且在运行过程中,只有一个线程会访问同步代码块,不会存在多线程竞争,这种情况下加的就是
偏向锁
。
偏向锁的获取过程
- 判断对象的MarkWord。
- 判断MarkWord中是否开启偏向锁模式。
- 如果为可偏向状态,判断MarkWord中的ThreadID是否为空,如果为空,则通过CAS操作设置成当前线程的线程ID,然后执行步骤5。否则执行步骤4。
- 如果不为空,则判断是否是当前线程ID,如果是则直接执行同步代码块,如果不是,则存在锁竞争,等到全局安全点的时候撤销偏向锁,将锁标记设置为无锁或者轻量级锁状态。
- 执行同步代码块。
偏向锁撤销
- 偏向锁的撤销必须等到全局安全点,指的是没有字节码执行执行的时刻。
- 暂停持有偏向锁的线程,判断锁对象是否处于被锁状态。
- 撤销偏向锁,恢复到无锁或者升级到轻量级锁。
偏向锁撤销的过程中,在全局安全点的时候,会STW(STOP THE WORLD),如果确定应用程序中存在锁竞争,可以通过设置参数-XX:-UseBiasedLocking=false来关闭偏向锁模式,减少额外的开销
。
轻量级锁
在偏向锁状态下,当有另一个线程尝试进入同步代码块的时候,就会存在锁竞争,此时偏向锁就会升级为轻量级锁。
轻量级锁的获取过程
- 获取MarkWord对象。如果同步对象锁的状态为无锁状态,首先在当前线程的栈帧中创建一个锁记录(Lock Record)。这时候栈帧和MarkWord的状态如图:
- 拷贝对象头的MarkWord复制到锁记录中的displaced hdr字段。
- 拷贝成功之后通过CAS操作让MarkWord更新为指向Lock Record的指针,并将Lock Record中的owner指针指向MarkWord对象。如果更新成功则执行步骤4,否则执行步骤5。
- 如果更新成功,则线程拥有了锁对象,执行同步代码块
- 如果更新不成功,判断MarkWord中是否指向当前栈帧中的Lock Record,如果是则直接执行同步代码块,如果不是,则存在锁同时竞争,轻量级锁就要升级为重量级锁。
自旋锁
如果存在多个线程同时竞争轻量级锁的时候,会膨胀升级为重量级锁,但是对于当前线程来说,会尝试自旋来获取锁,而不会立刻就阻塞等待,自旋锁其实就是采用循环去获取锁的一个过程。
可以通过参数-XX:+UseSpinning
来开启自旋锁。如果一直自旋的话,就会消耗完CPU资源,所以一般是自旋超过一定次数就会退出自旋模式,而进入重量级锁。可以通过设置参数-XX:PreBlockSpin
来更改自旋默认次数。
自适应自旋
在JDK1.6中对自旋锁进行了优化,如果一个在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么就会延长自旋的次数。如果对于某个锁,自旋很少成功获得过锁,则会直接忽略掉自旋过程,避免浪费CPU资源。
重量级锁
重量级锁指的就是获取monitor对象的过程,需要调用操作系统的底层函数,所以涉及到内核态和用户态的切换,切换成本非常高。
使用场景
偏向锁:适用于没有线程竞争资源的场景。
轻量级锁:适用于多个线程不同时刻竞争资源的场景。
重量级锁:适用于多个线程同时竞争资源的场景。
小结
所以synchronized的锁升级过程为
- 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
- 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
- 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
- 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级状态。
- 如果自旋失败,则升级为重量级锁。
锁优化
上面介绍了synchronized在JDK1.6之后的优化过程,进行了一个锁升级过程。那么我们在写多线程代码的时候也可以借鉴synchronized锁优化中的一些思想来优化我们的代码
减少锁的时间
不需要同步执行的代码,不要放到同步代码块中,可以让锁尽快释放。
减少锁的粒度
减少锁的粒度的思想就是将一把锁拆分成多把锁,增加了并发度,减少了锁竞争,ConcurrentHashMap在JDK1.8之前就是采用了”分段锁“的思想,还有LongAddr也是采用了”分段锁“的思想,降低了锁的粒度,提高了并发度。
锁粗化
如果同步代码块中包含循环,应该将锁放在循环以外,不然循环每次都会进入共享资源,效率会降低。
锁消除
锁消除其实就是对于同步代码块执行时间不长,并且并发量不大的情况下,可以采用CAS等乐观锁来进行同步操作,减少了加锁释放锁带来的开销。
读写分离
读写分离指的是,写的时候可以复制一份数据,然后写操作可以加锁,读操作就读原来的数据,比如CopyOnWriteArrayList集合类采用的就是读写分离的方式来解决并发安全的问题。