文章目录
- 一、死锁
- (1)说明
- (2)案例
- 1、案例1
- 2、案例2
- 3、案例3
- (3)诱发死锁的原因及解决方案
- 1、诱发死锁的原因
- 2、避免死锁
- 二、JDK5.0新特性:Lock(锁)
- (1)介绍
- (2)案例
- (3)synchronized与Lock的对比
一、死锁
(1)说明
不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。
比如线程1拿着同步监视器(锁),它拿着锁1等着锁2;但是线程2拿着锁2等着锁1。两个线程僵持着,互相等待,这就构成了死锁。
我们编写程序时,要避免出现死锁。
一旦出现死锁,整个程序既不会发生异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
(2)案例
1、案例1
StringBuilder
:跟字符串相关的类(后边说,这里看成字符串就可以了,里面没有任何数据)
用它造两个对象:
public class DeadLockTest {
public static void main(String[] args) {
StringBuilder s1=new StringBuilder();
StringBuilder s2=new StringBuilder();
}
}
然后new一个线程:
new Thread(){
@Override
public void run() {
}
}.start();
线程里面调用了run方法,把s1当作同步监视器(锁),然后用s1调用append
方法(用于添加),添加一个“a”,如下:
new Thread(){
@Override
public void run() {
synchronized (s1){
s1.append("a"); //理解为空字符串" "+"a"
}
}
}.start();
然后s2也添加一个“1”,如下:
new Thread(){
@Override
public void run() {
synchronized (s1){
s1.append("a");
s2.append("1");
}
}
}.start();
将s2当作一个锁,给s1添加一个“b”,给s2添加一个“2”。如下:
new Thread(){
@Override
public void run() {
synchronized (s1){
s1.append("a");
s2.append("1");
synchronized (s2){
s1.append("b");
s2.append("2");
System.out.println(s1);
System.out.println(s2);
}
}
}
}.start();
然后在这里sleep
睡一下,便于演示死锁的问题:
new Thread(){
@Override
public void run() {
synchronized (s1){
s1.append("a");
s2.append("1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (s2){
s1.append("b");
s2.append("2");
System.out.println(s1);
System.out.println(s2);
}
}
}
}.start();
说一下大致过程:
线程1进入run,获得锁s1,然后稍微sleep一会,醒来之后再拿着锁s2,执行后面的操作,执行结束后打印s1与s2。
现在我们再写一个线程,原理与上面的类似,只不过这个线程2先获得锁s2,再获得锁s1,如下:
//线程2
new Thread(){
@Override
public void run() {
synchronized (s2){
s1.append("c");
s2.append("3");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (s1){
s1.append("d");
s2.append("4");
System.out.println(s1);
System.out.println(s2);
}
}
}
}.start();
若此时没有sleep,如下:
public class DeadLockTest {
public static void main(String[] args) {
StringBuilder s1=new StringBuilder();
StringBuilder s2=new StringBuilder();
//线程1
new Thread(){
@Override
public void run() {
synchronized (s1){
s1.append("a");
s2.append("1");
synchronized (s2){
s1.append("b");
s2.append("2");
System.out.println(s1);
System.out.println(s2);
}
}
}
}.start();
//线程2
new Thread(){
@Override
public void run() {
synchronized (s2){
s1.append("c");
s2.append("3");
synchronized (s1){
s1.append("d");
s2.append("4");
System.out.println(s1);
System.out.println(s2);
}
}
}
}.start();
}
}
运行如下:
可以发现并没有出现死锁,这就是问题所在,能执行成功不意味着这个程序没有问题。
其实它是存在问题的可能性的。
从输出结果上来看,先执行的是线程1,s1获得a,s2获得1,然后s1获得b,s2获得2。最终s1得到ab,s2得到12。
然后线程2执行,在原有基础上,又添加了数据,最终s1得到abcd,s2得到1234。
也就是说,线程1执行结束,线程2才 开始执行。
当线程1执行结束的时候,s1与s2两个同步监视器都被释放了,所以线程2执行的时候才能顺利拿到s1与s2。
如果先执行的是第2个线程,再执行第1个线程,结果就是:cd,34,cdab,3412。
现在我们加上sleep,让死锁的概率高一点,注意这里只是让它出现的概率变高了,并不是从无到有(只是数量上的,并不是质变)。
🌱代码
package yuyi04.lock;
/**
* ClassName: DeadLockTest
* Package: yuyi04.lock
* Description:
*
* @Author 雨翼轻尘
* @Create 2024/2/1 0001 15:18
*/
public class DeadLockTest {
public static void main(String[] args) {
StringBuilder s1=new StringBuilder();
StringBuilder s2=new StringBuilder();
//线程1
new Thread(){
@Override
public void run() {
synchronized (s1){
s1.append("a");
s2.append("1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (s2){
s1.append("b");
s2.append("2");
System.out.println(s1);
System.out.println(s2);
}
}
}
}.start();
//线程2
new Thread(){
@Override
public void run() {
synchronized (s2){
s1.append("c");
s2.append("3");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (s1){
s1.append("d");
s2.append("4");
System.out.println(s1);
System.out.println(s2);
}
}
}
}.start();
}
}
🍺输出
可以看到,出现了死锁
。
线程1拿着同步监视器s1,在sleep的时候,线程2执行。
然后线程2拿着同步监视器s2往后执行,也碰到了sleep。
线程1醒来之后,去拿同步监视器s2,但是s2在线程2手里。大家就僵持住了。
这就构成了死锁
,如下:
2、案例2
案例1很容易看出来会出现死锁
,现在再来看一个例子:
🌱代码
package yuyi04.lock;
/**
* ClassName: DeadLock
* Package: yuyi04.lock
* Description:
*
* @Author 雨翼轻尘
* @Create 2024/2/1 0001 18:01
*/
class A {
public synchronized void foo(B b) {
System.out.println("当前线程名: " + Thread.currentThread().getName()
+ " 进入了A实例的foo方法"); // ①
try {
Thread.sleep(200);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("当前线程名: " + Thread.currentThread().getName()
+ " 企图调用B实例的last方法"); // ③
b.last();
}
public synchronized void last() {
System.out.println("进入了A类的last方法内部");
}
}
class B {
public synchronized void bar(A a) {
System.out.println("当前线程名: " + Thread.currentThread().getName()
+ " 进入了B实例的bar方法"); // ②
try {
Thread.sleep(200);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("当前线程名: " + Thread.currentThread().getName()
+ " 企图调用A实例的last方法"); // ④
a.last();
}
public synchronized void last() {
System.out.println("进入了B类的last方法内部");
}
}
public class DeadLock implements Runnable { //用实现Runnable接口的方式创建多线程
A a = new A();
B b = new B();
public void init() {
Thread.currentThread().setName("主线程");
// 调用a对象的foo方法
a.foo(b);
System.out.println("进入了主线程之后");
}
public void run() {
Thread.currentThread().setName("副线程");
// 调用b对象的bar方法
b.bar(a);
System.out.println("进入了副线程之后");
}
public static void main(String[] args) {
DeadLock dl = new DeadLock();
new Thread(dl).start(); //分线程创建,并调用start()方法,即调用run()方法
dl.init(); //主线程调用init()方法
}
}
🍰分析
可以看到,类A与类B里面定义了两个方法,都加了synchronized
,就是同步方法,同步监视器就是当前类的对象。
如下:
然后在实现类里面声明了a与b两个成员变量,还有两个方法,如下:
在main方法种,创建了当前实现类DeadLock的对象dl,然后将dl当作形参传入Thread()
中,用实现Runnable接口的方式创建一个线程,调用start()
方法,于是这个分线程就去执行run()
方法。
然后又使用dl调用init()
方法,这就是主线程的代码。就将dl当作普通的对象,它调用init()
方法。
现在就是,主线程调用init()方法,分线程调用run()方法。
分线程调用run
方法,主要执行b.bar(a);
:
public void run() {
Thread.currentThread().setName("副线程");
// 调用b对象的bar方法
b.bar(a);
System.out.println("进入了副线程之后");
}
主线程调用init
方法,主要执行a.foo(b);
:
public void init() {
Thread.currentThread().setName("主线程");
// 调用a对象的foo方法
a.foo(b);
System.out.println("进入了主线程之后");
}
可以得出主线程与分线程分别调用的方法如下:
分线程调用bar方法,需要拿着A类的一个对象a作为锁,这个锁就是B的对象。
然后执行a.last()
,如下:
注意锁是当前对象this,用b调用bar(a),this就是b,也就是拿着锁b进入了同步方法bar中,顺便将参数a带进来了。
在执行最后有一个a.last(),就是用对象a调用last()方法,last
方法也是一个同步方法,a
是传进来的A的对象,相当于又需要握着a这个同步监视器。如下:
也就是说,分线程握着B的对象,还要握A的对象。
主线程正好相反,握着A的对象a又需要b的同步监视器。
🍺输出结果
可以看见输出结果在这里出现了死锁
,僵持不动了。
3、案例3
public class TestDeadLock {
public static void main(String[] args) {
Object g = new Object();
Object m = new Object();
Owner s = new Owner(g,m);
Customer c = new Customer(g,m);
new Thread(s).start();
new Thread(c).start();
}
}
class Owner implements Runnable{
private Object goods;
private Object money;
public Owner(Object goods, Object money) {
super();
this.goods = goods;
this.money = money;
}
@Override
public void run() {
synchronized (goods) {
System.out.println("先给钱");
synchronized (money) {
System.out.println("发货");
}
}
}
}
class Customer implements Runnable{
private Object goods;
private Object money;
public Customer(Object goods, Object money) {
super();
this.goods = goods;
this.money = money;
}
@Override
public void run() {
synchronized (money) {
System.out.println("先发货");
synchronized (goods) {
System.out.println("再给钱");
}
}
}
}
(3)诱发死锁的原因及解决方案
1、诱发死锁的原因
①互斥条件(一定会出现)
比如线程1握着同步监视器(锁),另外一个线程就无法获得这个锁。(必然的,这是同步机制,加同步的目的就是为了某个线程能获得锁而另一个线程握不住)
②占用且等待
比如线程1握着同步监视器(锁),然后又等待另外一个锁。另外一个线程又拿着这个锁。
③不可抢夺(或不可抢占)
某线程等待的时候不能将其他线程拥有的锁抢过来。
④循环等待
这种情况会一直僵持着,解不开。
以上4个条件,同时出现就会触发死锁。
2、避免死锁
死锁
一旦出现,基本很难人为干预,只能尽量规避。
可以考虑打破上面的任意一个诱发条件。
①针对条件1:互斥条件基本上无法被破坏。因为线程需要通过互斥解决安全问题。
②针对条件2:可以考虑一次性申请所有所需的资源,这样就不存在等待的问题。
③针对条件3:占用部分资源的线程在进一步申请其他资源时,如果申请不到,就主动释放掉已经占用的资源,让别人先来,这样其他的线程不需要等待,不会僵持了。
④针对条件4:可以将资源改为线性顺序。申请资源时,先申请序号较小的,这样避免循环等待问题(按顺序获取,先获取序号小的,才能获取后边大的序号)。
二、JDK5.0新特性:Lock(锁)
(1)介绍
以前说的“同步机制”,其实就是synchronized
的使用方式。
除了使用synchronized同步机制
处理线程安全问题之外,还可以使用jdk5.0提供的Lock锁
的方式。
- 除了
synchronized
的方式,JDK5.0还提供了Lock锁
的方式(另外一种解决线程安全的方式)。 - JDK5.0的新增功能,保证线程的安全。
- 与采用
synchronized
相比,Lock可提供多种锁方案,更灵活、更强大。Lock通过显式定义同步锁对象来实现同步。同步锁使用Lock对象充当。 java.util.concurrent.locks.Lock
接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。- 在实现线程安全的控制中,比较常用的是
ReentrantLock
,可以显式加锁、释放锁。ReentrantLock
类实现了 Lock 接口,它拥有与synchronized
相同的并发性和内存语义,但是添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。
- Lock锁也称同步锁,加锁与释放锁方法,如下:
public void lock()
:加同步锁。public void unlock()
:释放同步锁。
- 代码结构
class A{
//1. 创建Lock的实例,必须确保多个线程共享同一个Lock实例
private final ReentrantLock lock = new ReenTrantLock();
public void m(){
//2. 调动lock(),实现需共享的代码的锁定
lock.lock();
try{
//保证线程安全的代码;
}
finally{
//3. 调用unlock(),释放共享代码的锁定
lock.unlock();
}
}
}
注意:如果同步代码有异常,要将unlock()写入finally语句块。
【举例】
import java.util.concurrent.locks.ReentrantLock;
class Window implements Runnable{
int ticket = 100;
//1. 创建Lock的实例,必须确保多个线程共享同一个Lock实例
private final ReentrantLock lock = new ReentrantLock();
public void run(){
while(true){
try{
//2. 调动lock(),实现需共享的代码的锁定
lock.lock();
if(ticket > 0){
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(ticket--);
}else{
break;
}
}finally{
//3. 调用unlock(),释放共享代码的锁定
lock.unlock();
}
}
}
}
public class ThreadLock {
public static void main(String[] args) {
Window t = new Window();
Thread t1 = new Thread(t);
Thread t2 = new Thread(t);
t1.start();
t2.start();
}
}
(2)案例
【步骤】
步骤1:创建Lock的实例,需要确保多个线程共用同一个Lock实例。
需要考虑将此对象声明为static final
。
步骤2.:执行lock()
方法,锁定对共享资源的调用。
步骤3.:unlock()
的调用,释放对共享数据的锁定。
以下面代码(继承的方式)为例:
🌱代码
package yuyi04.lock;
/**
* ClassName: WindowTest2
* Package: yuyi04.lock
* Description:
* 使用继承Thread类的方式,实现卖票
* @Author 雨翼轻尘
* @Create 2024/2/2 0002 9:54
*/
public class WindowTest2 {
public static void main(String[] args) {
//3.创建3个窗口 创建当前Thread的子类的对象
Window w1=new Window();
Window w2=new Window();
Window w3=new Window();
//命名
w1.setName("窗口1");
w2.setName("窗口2");
w3.setName("窗口3");
//4.通过对象调用start(): 1.启动线程 2.调用当前线程的run()方法
w1.start();
w2.start();
w3.start();
}
}
class Window extends Thread{ //卖票 1.创建一个继承于Thread类的子类
//票
static int ticket=100;
//2.重写Thread类的run() —>将此线程要执行的操作,声明在此方法体中
@Override
public void run() {
while (true){
if(ticket>0){ //如果票数大于0就可以售票
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//哪个窗口卖票了,票卖了多少
System.out.println(Thread.currentThread().getName() + "售票,票号为:" + ticket); //最开始票号为100
ticket--;
}else{
break;
}
}
}
}
🍺输出
上面代码有线程安全问题。
现在我们用Lock
来解决线程安全问题。
“concurrent
”就是并发的意思,Java的并发编程其实就是说这个包里面的API。
现在我们就可以看到这个包里面的一个API,叫Lock
,它是一个接口,目前已知的实现类如下:
我们需要使用ReentrantLock
(可重入锁)这个实现类,接下来创建这个实现类的对象。
如下:
class Window extends Thread{ //卖票 1.创建一个继承于Thread类的子类
ReentrantLock lock=new ReentrantLock();
//...
}
①权限修饰
我们需要先考虑权限修饰。
首先不希望在外部可以被访问,因为这纯粹是为了保证线程安全的,所以加一个private
。
其次这个锁有要求,需要保证线程的安全,多个线程需要共用同一个lock,所以还要再加一个static
。
最后再加一个final
,保证给lock赋值之后不要再修改了,是唯一的不能改变的。
如下:
private static final ReentrantLock lock=new ReentrantLock();
Runnable接口一般不需要static的,一般是把这个接口的一个实例作为多个线程对象的形参,一般情况只会有一个接口的实例。
②用方法限制
这个锁定操作,不像同步代码块,有一个大括号,里面是需要被同步的代码。
lock比较灵活,它只需要用两个方法去限制,两个方法执行当中的代码就是需要被同步的代码。
针对案例,需要被同步的代码如下:(蓝色部分)
在这一段代码之前,调用一个方法lock()
;然后在代码执行结束,调用unlock()
方法。如下:
两个方法中间的代码就是之前我们放到同步代码块当中的代码。
🌱代码
package yuyi04.lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* ClassName: WindowTest2
* Package: yuyi04.lock
* Description:
* 使用继承Thread类的方式,实现卖票
* @Author 雨翼轻尘
* @Create 2024/2/2 0002 9:54
*/
public class WindowTest2 {
public static void main(String[] args) {
//3.创建3个窗口 创建当前Thread的子类的对象
Window w1=new Window();
Window w2=new Window();
Window w3=new Window();
//命名
w1.setName("窗口1");
w2.setName("窗口2");
w3.setName("窗口3");
//4.通过对象调用start(): 1.启动线程 2.调用当前线程的run()方法
w1.start();
w2.start();
w3.start();
}
}
class Window extends Thread{ //卖票 1.创建一个继承于Thread类的子类
//票
static int ticket=100;
private static final ReentrantLock lock=new ReentrantLock();
//2.重写Thread类的run() —>将此线程要执行的操作,声明在此方法体中
@Override
public void run() {
while (true){
lock.lock();
if(ticket>0){ //如果票数大于0就可以售票
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//哪个窗口卖票了,票卖了多少
System.out.println(Thread.currentThread().getName() + "售票,票号为:" + ticket); //最开始票号为100
ticket--;
}else{
break;
}
lock.unlock();
}
}
}
🍺输出
可以看见线程安全了。
🗳️这里可能出现了没有解锁的场景:
如果死锁的话就执行不到unlock,虽然关闭了程序,但是如果这些资源是指向别的资源的话就无法释放了。
unlock()
方法一定要保证会被执行,可以考虑写入finally
中。
现在我们在这里加一个try
,将下面蓝色部分移入try里面:
然后再写一个finally
,将unlock
写入,确保它一定会被执行。如下:
🌱代码
package yuyi04.lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* ClassName: WindowTest2
* Package: yuyi04.lock
* Description:
* 使用继承Thread类的方式,实现卖票
* @Author 雨翼轻尘
* @Create 2024/2/2 0002 9:54
*/
public class WindowTest2 {
public static void main(String[] args) {
//3.创建3个窗口 创建当前Thread的子类的对象
Window w1=new Window();
Window w2=new Window();
Window w3=new Window();
//命名
w1.setName("窗口1");
w2.setName("窗口2");
w3.setName("窗口3");
//4.通过对象调用start(): 1.启动线程 2.调用当前线程的run()方法
w1.start();
w2.start();
w3.start();
}
}
class Window extends Thread{ //卖票 1.创建一个继承于Thread类的子类
//票
static int ticket=100;
//①创建Lock的实例(Lock是一个接口,现在用的是可重入锁ReentrantLock)
// 需要确保多个线程共用同一个Lcok实例,需要考虑将此对象声明为static final
//若没有加static,Window一共造了3个对象,相当于现在就有3把锁,每一个对象锁自己的,就不好使
private static final ReentrantLock lock=new ReentrantLock();
//2.重写Thread类的run() —>将此线程要执行的操作,声明在此方法体中
@Override
public void run() {
while (true){
try {
//②执行lock()方法,锁定对共享资源的调用
lock.lock();
if(ticket>0){ //如果票数大于0就可以售票
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//哪个窗口卖票了,票卖了多少
System.out.println(Thread.currentThread().getName() + "售票,票号为:" + ticket); //最开始票号为100
ticket--;
}else{
break;
}
}finally{
//③unlock()的调用,释放对共享数据的锁定,解锁之后其他线程就可以来操作了
lock.unlock();
}
}
}
}
🍺输出
可以看到,程序结束了,而且没有线程安全问题。如下:
(3)synchronized与Lock的对比
synchronized与Lock的对比
1、Lock
是显式锁(手动开启和关闭锁,别忘记关闭锁),synchronized
是隐式锁,出了作用域、遇到异常等自动解锁。
2、Lock
只有代码块锁,synchronized
有代码块锁和方法锁。
3、使用Lock
锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类),更体现面向对象。
4、(了解)Lock
锁可以对读不加锁,对写加锁,synchronized
不可以。
5、(了解)Lock
锁可以有多种获取锁的方式,可以从sleep
的线程中抢到锁,synchronized
不可以。
说明:
开发建议中处理线程安全问题优先使用顺序为:
Lock ----> 同步代码块 ----> 同步方法
【面试题】
🎲synchronized
同步的方式 与Lock
的对比 ?
synchronized
不管是同步代码块还是同步方法,都需要在结束一对{}
之后,释放对同步监视器的调用。Lock
是通过两个方法控制需要被同步的代码,更灵活一些。Lock
作为接口,提供了多种实现类(也就是很多锁),适合更多更复杂的场景,效率更高。
对于Lock,JUC里面再详细说,这里不做深入。