文章目录
- Java并发编程实战之互斥锁
- 如何解决原子性问题?
- 锁模型
- Java synchronized 关键字
- Java synchronized 关键字 只能解决原子性问题?
- 如何正确使用Java synchronized 关键字?
- 锁和受保护资源的合理关联关系
- 死锁
- 预防死锁
- 破坏占有且等待条件
- 破坏不可抢占条件
- 破坏循环等待条件
- 总结
- 用 synchronized 实现等待 - 通知机制
- 尽量使用 notifyAll()
- wait() 方法和 sleep() 方法
- 安全性、活跃性以及性能问题
- 参考
Java并发编程实战之互斥锁
之前在《Java并发编程实战基础概要》中提到了原子性这一个概念,一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为“原子性”。 那么原子性问题到底改如何解决呢?
如何解决原子性问题?
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。所以本质来说,解决原子性问题,是要保证中间状态对外不可见。(这一点需要细细品一品)
原子性问题的源头是线程切换,多个线程同时操作同一个变量。这样就会出现线程冲突的问题。所以需要一种机制保持在多核CPU下,同一个时刻只有一个线程更改某个共享变量。(其实没有共享变量,也不会存在并发问题),所以说如果我们能够保证对共享变量的修改是互斥的,那么就能保证原子性了。
锁模型
一谈到互斥,我们很自然就会想到了锁。首先我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁 lock()
,如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁 unlock()
。
这个过程非常像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。
上面的例子虽然挺形象的,但是容易忽略锁的两个很重要的点,分别是我们锁的是什么?和我们想要保护的又是什么?
- 第一个问题,锁的到底是什么?我们单纯锁的是门吗?这么理解也没有错,但是想一想,实际上我们想要锁的是对这个厕所的使用,因为你不可能锁上这个厕所的门,去上另一个厕所,这样没任何意义。所以锁跟你想保护的东西是有一个对应关系的。所以对应到编程世界中,锁的其实是对共享变量的访问。
- 第二个问题,我们想要保护的是什么?我们保护的其实就是我们即将要使用的这个厕所。对应到编程世界中,保护的就是共享变量。
所以对应编程世界中,锁和资源是有一个对应关系的,所以锁的模型如下:
Java synchronized 关键字
锁是一种通用的技术方案,Java 语言提供的 synchronized
关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
我们可以通过synchronized
关键字对我们的临界区进行加锁和解锁。但是我们从代码中并没有看到这个加锁和解锁的动作,这是因为这些操作是由Java编译器为我们加上的。
我们可以利用javap
命令来查看生成的字节码文件,就可以看出来Java编译器会为我们synchronized
修饰的方法或代码块前后自动加上加锁 lock()
和解锁 unlock()
下面通过javap
命令查看下面代码生成的字节码文件
public void method() {
synchronized (this) {
System.out.println("start");
}
}
字节码文件如下:
图中的monitorenter
对应的就是加锁,而monitorexit
对应的就是解锁
至于为什么会有两个monitorexit
指令呢?
是因为对于synchronized关键字而言,javac在编译时,会生成对应的monitorenter
和monitorexit
指令分别对应synchronized
同步块的进入和退出,有两个monitorexit
指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally
,在finally中会调用monitorexit
命令释放锁。
参考: 《Java锁synchronized关键字学习系列之重量级锁》
synchronized
锁的是代码块还是锁的是对象?从上面我们可以总结出synchronized
锁的其实是对象
那 synchronized
里的加锁 lock()
和解锁 unlock()
锁定的对象是什么?
下面的代码我们看到只有 synchronized
修饰代码块的时候,锁定了一个obj
对象,那 synchronized
修饰方法的时候锁定的是什么呢?
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
- 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是
Class X
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}
- 当修饰非静态方法的时候,锁定的是当前实例对象
this
。
class X {
// 修饰非静态方法
synchronized(this) void foo() {
// 临界区
}
}
到这里我们引申出另一个问题,当我们锁住了对象的时候,对象身上发生了什么变化,jvm如何知道这个对象被“锁“住了,关于这个题外话这里不多赘述,可以参考:《Java锁synchronized关键字学习系列之CAS和对象头》
Java synchronized 关键字 只能解决原子性问题?
并发会产生三大问题
- 原子性问题
- 可见性问题
- 有序性问题
前面我们一直在说,锁可以解决原子性问题,Java synchronized
关键字只能解决原子性问题吗?
答案肯定是否定的,前面在《Java并发编程实战基础概要》 说到了Java内存模型规范了Java虚拟机(JVM)如何提供按需禁用缓存和编译优化的方法。这些方法包括:volatile、synchronized和final关键字,以及Java内存模型中的Happens-Before规则
所以synchronized
关键字还可以解决可见性问题(可以参考Happens-Before的锁定规则:对一个锁的解锁操作 Happens-Before于后续对这个锁的加锁操作)。但是以synchronized
关键字不能完全解决有序性问题,因为synchronized
关键字不能避免指令重排,所以我们在之前《Java并发编程实战基础概要》的双重检验的单例模式中,必须加volatile
来避免因为发生指令重排,返回错误实例。
如何正确使用Java synchronized 关键字?
正确使用Java synchronized
关键字主要是关注在synchronized
锁定的对象跟受保护资源的关系。如何理解呢?举一个例子:
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
从上面代码我们可以看出来synchronized
关键字锁定的是两个不同的对象,在之前我们讲过,synchronized
关键字修饰非静态方法的时候,锁定的是当前实例对象 this
。而当修饰静态方法的时候,锁定的是当前类的 Class 对象。所以我们现在相当于用两个锁保护一个资源(一个共享变量value)。
从上图可以看出来,由于get()
方法和addOne()
方法是两把不同的锁,说明执行addOne()
方法的过程中可以执行get()
方法,并发性不能得到保证,所以这两临界区并不是互斥的,临界区 addOne()
对 value 的修改对临界区 get()
也没有可见性保证,这就导致并发问题了。
再举一个例子,下面这个例子是否正确使用synchronized
关键字
class SafeCalc {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}
答案很明显是错误的使用,synchronized
关键字锁定的new object
每次在内存中都是新对象,所以每次锁的都不是同一个对象,怎么做到互斥呢?
所以要真正使用好互斥锁,必须深入分析锁定的对象和受保护资源的关系。
锁和受保护资源的合理关联关系
直接给出结论:受保护资源和锁之间合理的关联关系应该是 N:1 的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源。
我们来举一个用一把锁来保护多个资源的例子。
class Account {
// 账户余额
private Integer balance;
// 账户密码
private String password;
// 取款
synchronized void withdraw(Integer amt) {
if (this.balance > amt){
this.balance -= amt;
}
}
// 查看余额
synchronized Integer getBalance() {
return balance;
}
// 更改密码
synchronized void updatePassword(String pw){
this.password = pw;
}
// 查看密码
synchronized String getPassword() {
return password;
}
}
从上面代码可以看出来,我们是使用当前实例this
来管理Account类中所有的资源。所以会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的,所以不会有并发问题。但是却会产生另一个问题,就是性能太差了。
我们可以稍微修改一下,使用两把锁,让取款和修改密码是可以并行的,因为这两个行为互不干扰。
class Account {
// 锁:保护账户余额
private final Object balLock
= new Object();
// 账户余额
private Integer balance;
// 锁:保护账户密码
private final Object pwLock
= new Object();
// 账户密码
private String password;
// 取款
void withdraw(Integer amt) {
synchronized(balLock) {
if (this.balance > amt){
this.balance -= amt;
}
}
}
// 查看余额
Integer getBalance() {
synchronized(balLock) {
return balance;
}
}
// 更改密码
void updatePassword(String pw){
synchronized(pwLock) {
this.password = pw;
}
}
// 查看密码
String getPassword() {
synchronized(pwLock) {
return password;
}
}
}
用不同的锁对受保护资源进行精细化管理,能够提升性能,这种锁还有个名字,叫细粒度锁。所以我们上锁的时候需要考虑锁的粒度。
所以我们在上锁的时候,应该分析多个资源的关系。如果资源之间没有关系,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。
死锁
前面说到使用细粒度锁可以提高并行度,是性能优化的一个重要手段。但是使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
下面举一个死锁的例子:
public class T {
private Object o1 = new Object();
private Object o2 = new Object();
public void m1() {
synchronized (o1) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("如果出现这句话表示没有死锁");
}
}
}
public void m2() {
synchronized(o2) {
synchronized (o1) {
System.out.println("如果出现这句话表示没有死锁");
}
}
}
public static void main(String[] args) {
T t=new T();
new Thread(t::m1).start();
new Thread(t::m2).start();
}
}
上面这个例子死锁是怎么发生的呢?假设当线程1持有锁对象o1,然后当线程2持有锁对象o2的时候;然后线程1需要对对象o2加锁,但是因为线程2已经对对象o2加锁了,所以线程1需要等待线程2解除锁占用。然后线程2同样需要对对象o1加锁,但是因为线程1已经对对象o1加锁了,所以线程2同样要等待线程1解除锁占用。所以现在就出现了线程1和线程2互相在等待对方解除锁占用,于是就出现了死锁。
预防死锁
那我们如何去预防死锁呢?
那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,只有以下这四个条件都发生时才会出现死锁:
- 互斥:一个资源每次只能被一个进程(或者线程)使用。进程(或者线程)对所分配到的资源不允许其他进程(或者线程)进行访问,若其他进程(或者线程)访问该资源,只能等待,直至占有该资源的进程(或者线程)使用完成后释放该资源
- 占有且等待:进程(或者线程)获得一定的资源之后,又对其他资源发出请求,但是该资源可能被其他进程(或者线程)占有,此时请求阻塞,但又对自己获得的资源保持不放
- 不可抢占:是指进程(或者线程)已获得的资源,在未完成使用之前,不可被剥夺,只能在使用完后自己释放
- 循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待
所以我们想要避免死锁,其实只要破坏掉上面其中一个条件即可。但是第一个条件互斥是没办法破坏的,因为我们用锁的初衷就是为了互斥,所以我们需要从其他三个条件下手。
破坏占有且等待条件
要破坏这个条件,可以一次性申请所有资源。上面的例子一次性申请所有的资源,就相当于一次性加锁了o1和o2对象,解锁的时候也是一次性解锁了o1和o2对象,所以上面的例子可以改成下面这种方式
public class T {
private Object o1 = new Object();
public void m1() {
synchronized (o1) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("如果出现这句话表示没有死锁");
}
}
public void m2() {
synchronized (o1) {
System.out.println("如果出现这句话表示没有死锁");
}
}
public static void main(String[] args) {
T t=new T();
new Thread(t::m1).start();
new Thread(t::m2).start();
}
}
上面这种方式直接使用了一个锁,这种肯定是不会有死锁的。
或者我们还是锁定两个不同的对象,我们还可以这么改造
class M {
private List<Object> list = new ArrayList<>();
public synchronized boolean lock(Object o1, Object o2) {
if (list.contains(o1) || list.contains(o2)) {
return false;
} else {
list.add(o1);
list.add(o2);
}
return true;
}
public synchronized void unlock(Object o1, Object o2) {
list.remove(o1);
list.remove(o2);
}
}
class T {
private Object o1 = new Object();
private Object o2 = new Object();
private M m = new M();
public void m1() {
while (!m.lock(o1, o2)) {
}
try {
synchronized (o1) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("如果出现这句话表示没有死锁");
}
}
} finally {
m.unlock(o1, o2);
}
}
public void m2() {
while (!m.lock(o1, o2)) {
}
try {
synchronized (o2) {
synchronized (o1) {
System.out.println("如果出现这句话表示没有死锁");
}
}
} finally {
m.unlock(o1, o2);
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m1).start();
new Thread(t::m2).start();
}
}
从上面代码可以看出来,我们抽取了一个类M来同时申请多个资源,从而破坏了占有且等待条件
破坏不可抢占条件
破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized
是做不到的。Java 在语言层次确实没有解决这个问题,但是java.util.concurrent
这个包下面提供的 Lock类中的tryLock(long, TimeUnit)
方法,可以帮我们在一段时间尝试获取锁,所以可以轻松解决这个问题的
破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源,这样就不会出现两个线程交错加锁的情况。上面的情况就是因为我们申请资源其实不是顺序的,也就是加锁不是顺序的,T1加锁的是o1然后o2,T2加锁的是o2然后o1。 如果T1和T2都是加锁o1然后o2,其实就不会有这种问题。
class T {
private Object o1 = new Object();
private Object o2 = new Object();
public void m1() {
synchronized (o1) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("如果出现这句话表示没有死锁");
}
}
}
public void m2() {
synchronized(o1) {
synchronized (o2) {
System.out.println("如果出现这句话表示没有死锁");
}
}
}
public static void main(String[] args) {
T t=new T();
new Thread(t::m1).start();
new Thread(t::m2).start();
}
}
总结
但实际上开发过程中的案例肯定不会像我们举例的这么简单,具体问题具体分析,但是我们还是需要从这三个条件出发,去破坏掉我们这三个条件,才能够避免死锁的问题。
用 synchronized 实现等待 - 通知机制
前面我们在讲死锁的破坏占用且等待条件的时候,使用了一个死循环的方式来循环等待
while (!m.lock(o1, o2)) {
}
这种方案,在并发冲突大的场景(也就是可能很久都获取不到锁)不适用,因为这种场景下可能要循环上万次才能获取到锁,太消耗 CPU 了。
那有没有更好的方案呢?那就是使用"等待 - 通知机制"。怎么理解"等待 - 通知机制"呢?你可以类比于医院排队叫号。如果没有排队叫号系统,每个人都需要去问医生是不是轮到我了。而有了排队叫号,病人只需要等着医生把你叫过来,病人和医生是不是都省心省力了。
而在编程世界中,一个完整的“等待 - 通知机制”是这样的:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
那在Java的世界中如何实现的“等待 - 通知机制”? Java 语言内置的 synchronized
配合 wait()
、notify()
、notifyAll()
这三个方法就能轻松实现。
我们看方法名称就可以知道,wait()
顾名思义就是让线程等待,而、notify()
、notifyAll()
就是唤醒线程。
那么wait()
的实现机制是怎样的?
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait()
方法就能够满足这种需求。如上图所示,当调用 wait()
方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。(这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列) 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是 Java 对象的 notify()
和 notifyAll()
方法。当条件满足时调用 notify()
,会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过(因为notify()
只能保证在通知时间点,条件是满足的)。
wait()
、notify()
、notifyAll()
方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized
锁定的是 this
,那么对应的一定是 this.wait()
、this.notify()
、this.notifyAll()
;所以一定是要用锁定的对象去调用这三个方法。
这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()
、notify()
、notifyAll()
都是在 synchronized{}
内部被调用的。如果在 synchronized{}
外部调用,或者锁定的 this
,而用 target.wait()
调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException
尽量使用 notifyAll()
notify()
是会随机地通知等待队列中的一个线程,而 notifyAll()
会通知等待队列中的所有线程。所以实际上使用 notify()
是可能存在风险的,它的风险在于可能导致某些线程永远不会被通知到。
可以参考《Java多线程高并发编程代码笔记(二)》中使用wait和notifyAll方法来实现的例子。
wait() 方法和 sleep() 方法
wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?
主要的区别在于wait会释放所有锁而sleep不会释放锁资源,而且wait只能在同步方法和同步块中使用,而sleep任何地方都可以。
安全性、活跃性以及性能问题
编写并发程序的初衷是为了提升性能,但在追求性能的同时由于多线程操作共享资源而出现了安全性问题(并发出现原子性问题、可见性问题和有序性问题),当然并不是只要是多线程都会有安全性问题,而是有多线程操作共享资源,也就是共享会发生变化的数据,我们也叫数据竞争,在这种情况下才会有安全性问题。
为了解决安全性问题,我们开始用到了锁技术,一旦用到了锁技术就会出现了死锁,还有两种情况,分别是“活锁”和“饥饿”活跃性问题。
活锁:有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁。
可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它。参考来源:极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题
饥饿: 指的是线程因无法访问所需资源而无法执行下去的情况。
如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。参考来源:极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题
但是如果我们不恰当的使用锁,会导致了串行百分比的增加,由此又产生了性能问题。
那我们如何解决性能问题呢?
- 既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好
- 减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的
ConcurrentHashMap
,它使用了所谓分段锁的技术。还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。参考来源:极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题
性能方面的度量指标有很多,有三个指标非常重要,就是:吞吐量、延迟和并发量。
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。
参考来源:极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题
参考
《Java并发编程实战基础概要》
Java多线程高并发编程代码笔记(一)
极客时间 《并发编程实战》—— 互斥锁(上):解决原子性问题
极客时间 《并发编程实战》—— 互斥锁(下):如何用一把锁保护多个资源?
极客时间 《并发编程实战》—— 互斥锁(下):如何用一把锁保护多个资源?
极客时间 《并发编程实战》—— 一不小心就死锁了,怎么办?
极客时间 《并发编程实战》—— 用“等待-通知”机制优化循环等待
极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题