Java并发编程实战之互斥锁
Posted c.
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发编程实战之互斥锁相关的知识,希望对你有一定的参考价值。
文章目录
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并发编程实战之互斥锁的主要内容,如果未能解决你的问题,请参考以下文章
转:Java并发编程之七:使用synchronized获取互斥锁的几点说明