文章目录
简介
synchronized这个关键字相信大家都知道,并且都用过,基本上写过java的人应该都用过synchronized这个关键字;
java本身是支持多线程的,再多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这种资源统称为临界资源,这个资源可能是:对象、变量等;
- 共享:资源可以由多个线程同时访问
- 可变:资源可以再其生命周期内被修改
引出的问题
- 由于线程再执行的过程中是不可控的,所以会出现线程并发安全问题;
10个线程同时执行,每个线程对临界资源TOTAL进行1000次++操作,如果等于10000,则证明不会出现线程并发安全问题,测试三次,每次都小于10000,证明再多个线程同时对临界资源进行读/写操作,尤其是写操作时,是会出现线程并发安全问题的
public class NoSynchronizedDemo {
private static int TOTAL = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
// 使当前线程处于休眠状态,直至所有的线程都创建完
// 尽可能的模仿多个线程并行竞争执行,从而出现线程安全问题
countDownLatch.await();
for (int x = 0; x < 1000; x++) {
TOTAL++;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
// 确保10个线程都能创建完
Thread.sleep(1000);
// 让所有的线程同一时间从countDownLatch.await()处一起开始跑
countDownLatch.countDown();
// 确保10个线程都能跑完
Thread.sleep(2000);
log.info("TOTAL期望值:{},结果:{}",10*1000,TOTAL);
}
}
//TOTAL期望值:10000,结果:7578
//TOTAL期望值:10000,结果:6850
//TOTAL期望值:10000,结果:7244
如何解决线程并发安全问题?
- 再多线程编程中,要想避免线程并发安全问题,只能序列化的访问临界资源;即保证再同一时间,只能有一个线程对临界资源进行访问,也成为同步互斥访问;
- 实现序列化访问临界资源本质就是加锁,java提供了两种锁(synchronized和Lock)
- synchronized:隐式锁;内置锁;JVM控制加锁解锁
- Lock: 显示锁,自己使用时手动加锁解锁
synchronized加锁:
public class NoSynchronizedDemo {
private static int TOTAL = 0;
private static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
// 使当前线程处于休眠状态,直至所有的线程都创建完
// 尽可能的模仿多个线程并行竞争执行,从而出现线程安全问题
countDownLatch.await();
for (int x = 0; x < 1000; x++) {
// 加锁保证序列化访问
synchronized (object){
TOTAL++;
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
// 确保10个线程都能创建完
Thread.sleep(500);
// 让所有的线程同一时间从countDownLatch.await()处一起开始跑
countDownLatch.countDown();
// 确保10个线程都能跑完
Thread.sleep(2000);
log.info("TOTAL期望值:{},结果:{}",10*1000,TOTAL);
}
}
//TOTAL期望值:10000,结果:10000
//TOTAL期望值:10000,结果:10000
//TOTAL期望值:10000,结果:10000
Lock加锁
public class NoSynchronizedDemo {
private static int TOTAL = 0;
private static Object object = new Object();
private static ReentrantLock reentrantLock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
// 使当前线程处于休眠状态,直至所有的线程都创建完
// 尽可能的模仿多个线程并行竞争执行,从而出现线程安全问题
countDownLatch.await();
for (int x = 0; x < 1000; x++) {
try {
// 加锁
reentrantLock.lock();
TOTAL++;
} finally {
// 解锁动作放在finally确保一定可以解锁
reentrantLock.unlock();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
// 确保10个线程都能创建完
Thread.sleep(500);
// 让所有的线程同一时间从countDownLatch.await()处一起开始跑
countDownLatch.countDown();
// 确保10个线程都能跑完
Thread.sleep(2000);
log.info("TOTAL期望值:{},结果:{}",10*1000,TOTAL);
}
}
//TOTAL期望值:10000,结果:10000
//TOTAL期望值:10000,结果:10000
//TOTAL期望值:10000,结果:10000
synchronized原理
synchronized内置锁是一种对象锁:锁的是对象而非引用,锁粒度是对象,可以用来实现对临界资源的序列化访问,是可以重入的
加锁的方式 | 锁粒度 |
---|---|
同步实例方法 | 当前实例对象 |
同步类方法 | 当前类对象 |
同步代码块 | 括号里面的对象 |
注意:当多个线程执行一个方法时,该方法内部的局部变量不属于临界资源,这些局部变量都是储存在每个线程的私有栈中,不具有共享性,不会出现线程并发安全问题
synchronized的前世今生
JDK1.6之前
- 再jdk1.6之前,synchronized的效率是非常慢的
- synchronized一上来就会依赖于我们的java对象obj
- 对象依赖于Monitor,Monitor是每个对象再创建之后JVM天然会维护一个Monitor
- Monitor会依赖于底层的操作系统OS中的Mutex(互斥量)
- Mutex(互斥量)是由操作系统的Pthread(线程库)维护的,Pthread涉及大量的阻塞,互斥量等等操作;
- JVM是运行再用户态上面,Pthread再内核态上面,JVM每次调用底层的Pthread时,我们的CPU都会进行一轮状态的切换,这个状态的切换是一个重型的操作,
- 所以synchronized再jdk1.6之前性能是非常低的
JDK1.6之后
- jdk1.6之前,synchronized的使用,上来就是重量级锁,性能较低,只适合于竞争非常激烈的场景;
- 再jdk1.6对synchronized做了优化,有一个锁膨胀升级的过程(无锁->偏向锁->轻量级锁->重量级锁),不会一上来就是重量级锁;
- 通过锁升级的方式,对比jdk1.6之前上来就使用重量级锁的方式性能是有很大提升的
synchronized底层原理
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块的同步,监视器锁的实现依赖操作系统的Mutex lock(互斥锁) 实现,它是一个重量级锁性能较低.当然,经过1.6版本做了重大优化(锁升级)减少锁操作的开销;内置锁的并发性能已经基本与Lock持平.
synchronized关键字被编译成字节码后会被翻译成monitorenter和monitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置.
package com.fanqiechaodan.synch;
/**
* @Classname SynchronizedDemo
* @Description
* @Date 2021/8/18 20:40
* @slogan:
*/
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class){
System.out.println("假装这是业务代码");
}
}
}
反编译结果:
我们在字节码可以看到有对应的monitorenter和monitorexit,而且monitorexit有两个,monitorexit是释放锁的指令,两个monitorexit是因为涉及到异常的处理.一个是代码块正常执行完毕释放锁,另一个是当代码出现异常的时候释放锁.
什么是Monitor
Monitor是一个同步工具,也可以理解成一种同步机制,它通常被描述为一个对象,在JAVA的设计中,每个对象被new出来以后,JVM天然会为这个对象维护一个Monitor对象,也可以这么说,每一个java对象从娘胎里出来就带了一把看不见的锁,它叫内部锁或者Monitor锁.也就是常说的Synchronized的对象锁,在Java虚拟机中,Monitor是由ObjectMonitor实现的,源码如下:
ObjectMonitor() {
_header = NULL;
// 记录个数
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
// 处于wait状态的线程,会被加入到_WaitSet
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
// 处于等待锁block状态的线程,会被加入到该列表
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,_WaitSet和_EntryList,用来保存ObjectWaiter对象列表,每个等待锁的线程都会被封装成ObjectWaiter对象.
- 首先会进入_EntryList集合,当线程获取到对象的monitor后,进入_owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1
- 若线程调用wait()方法,将释放当前持有的monitor,_owner变量回复为null,_count减1,同时该线程进入_WaitSet集合中等待被唤醒;
- 若当前线程执行完毕,将释放monitor(锁)并对_count进行减一,方便其他线程进入获取monitor
同时,Monitor对象存在与每个Java对象的对象头Mark Word中(储存着指针的指向),Synchronized锁便是通过这种方式获取锁的,
Mark Word
Mark Word是用来储存对象自身的运行时数据,如HashCode、GC分代年龄、锁状态标志、偏向线程Id、偏向时间戳、线程持有的锁等等;是实现轻量级锁和偏向锁的关键,这部分数据的长度在32位和64位的虚拟机(不考虑指针压缩的情况)中分别位32和64个Bits.官方称为Mark Word,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外储存成本,考虑到空间效率,MarkWord被设计成一个非固定的数据结构以便于在极小的空间储存尽量多的信息.
MarkWord被设计成了一个非固定的数据结构,它会根据对象状态服用自己的储存空间,也就是说Mark Word会随着程序的运行发生变化,变化如下:
32位虚拟机:
可以看出来,Mark Word用在最后2Bits来记录锁的状态,可是无锁状态和偏向锁最后两位都是01,并不能做一个很好的区分,Mark Word用倒数第三位来记录是否偏向锁,如果最后三位Bits是001就是无锁状态,如果是101那就是偏向锁
64位虚拟机:
现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。
锁的膨胀升级过程
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁.随着锁的竞争愈发激烈,锁就可以从偏向锁升级到轻量级锁,最后在升级成重量级锁,从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
偏向锁
经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价在JDK1.6的时候引入了新锁:偏向锁;
偏向锁的核心思想:如果一个线程获得了锁,那么锁就进入了偏向模式,此时Mark Word的结构也变为偏向锁结构,当这个线程再次请求锁时,无需在做任何同步操作,即获取锁的过程,这样做的好处就是省去了大量有关锁申请的操作,从而提升了程序的性能.所以,对于没有锁竞争的场合,偏向锁能有很好的性能提升,毕竟大多数情况下都是同一线程多次获得锁.但是随着锁的竞争愈发激烈,偏向锁就失效了,因为这样的场景下,大多数情况下每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,不会马上升级为重量级锁,而是会先升级为轻量级锁;
- JDK1.6开始默认开启偏向锁
- 关闭偏向锁:-XX:-UseBiasedLocking
- 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
轻量级锁
每次申请锁的线程都是不相同的,此时偏向锁就失效了,虽然此时是有竞争的,但是竞争并没有那么激烈.所以在JDK1.6还加入了轻量级锁;
在偏向锁失效后,虚拟机为了避免线程真实的在操作系统层面挂起,推出了轻量级锁,而且还会进行自旋的优化手段,这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接升级为重量级锁在操作系统层面挂起可能会得不偿失,毕竟操作系统实现线程之间的切换时需要进行一轮用户态与核心态之间的切换,这是一个重量级的操作,时间成本较高.
自旋的前提就是:在不久的将来,当前线程可以获取锁,因为虚拟机会让没有获取想要获取的线程做自旋获取锁的动作,一般不会太久.如果得到锁,就可以执行同步代码块,这就是自旋的优化方式,如果自旋失败次数达到指定次数或者自旋时间达到指定时间阈值时,最后没办法也只能升级为重量级锁,将想要获取锁的线程正在操作系统层面挂起.
注意:在自旋的过程中一直占用着cpu,不会让出cpu,虽然会浪费一点cpu的资源,但是会比线程马上阻塞,等其他线程来唤醒的性能快的多
注意:锁升级的过程是不可逆的,锁只能升级,不能降级,因为出现锁升级的时候,就证明我们的程序是会出现竞争比较激烈的场景的,此时再对锁进行降级,当下次竞争比较激烈的时候,我们的锁还是会进行升级,频繁进行锁升级也是会对自身性能有一定影响的;
锁消除
锁消除是另一种锁的优化手段,这种优化更彻底,JVM在即时编译(当为某段代码即将第一次被执行时进行编译)时,通过对运行上下文的扫描,发现这段代码不可能存在对临界资源竞争,JVM会自动将其锁消除,通过这种方式消除没有必要的锁,可以节省毫无意义请求锁释放锁的时间.锁消除依赖于逃逸分析的数据支持,所以锁消除的前提就是开启逃逸分析
- -XX:+DoEscapeAnalysis 开启逃逸分析
- -XX:+EliminateLocks 表示开启锁消除
总结
锁级别 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
重量级锁 | 线程竞争不使用自旋,节省CPU资源 | 线程阻塞,在操作系统层面挂起,响应时间慢 | 适用于竞争激烈,锁占用时间较长的场景 |
轻量级锁 | 竞争的线程不会挂起,响应时间快 | 在自旋的过程中,会占用CPU资源,浪费CPU资源 | 适用于追求响应时间,锁占用时间很短的场景 |
偏向锁 | 加锁和解锁都没有额外的消耗,与非同步代码块相比几乎没有差距 | 如果线程间存在竞争,会带来锁撤销的消耗 | 适用于只有一个线程访问同步代码块的场景 |