Java 多线程开发 05 —— synchronized、Lock、死锁

系列文章目录

Java 多线程开发 01 —— 线程创建
Java 多线程开发 02 —— 静态代理模式
Java 多线程开发 03 —— Lambda表达式
Java 多线程开发 04 —— 线程状态控制、优先级、守护线程
Java 多线程开发 05 —— synchronized、Lock、死锁
Java 多线程开发 06 —— 管程法、信号灯法



Java 多线程 三大不安全案例

案例一:模拟抢票的情况,黄牛党A、B、C同时去持续抢票,直到票被抢完。这会出现A、B、C抢到同一张票,甚至出现负票。

package lessen07_Thread;

public class UnsafeBuyTicket  {
    
    
    public static void main(String[] args) {
    
    
        BuyTicket buyTicket = new BuyTicket();

        new Thread(buyTicket, "黄牛党A").start();
        new Thread(buyTicket, "黄牛党B").start();
        new Thread(buyTicket, "黄牛党C").start();
    }
}

class BuyTicket implements Runnable{
    
    

    private int ticketNum = 10;
    private boolean flag = true;

    void buy(){
    
    
        //判断能否买票
        if(ticketNum <= 0){
    
    
            flag = false;
            return;
        }
        //模拟网络延时(放大问题发生性)
        try {
    
    
            Thread.sleep(100);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        //抢到一张票
        System.out.println(Thread.currentThread().getName()+"——>买到第 "+ticketNum--+" 票");
    }

    @Override
    public void run() {
    
    
        //每个人都一直抢票
        while(flag){
    
    
            buy();
        }
    }
}


结果:

黄牛党A——>买到第 9 票
黄牛党C——>买到第 10 票
黄牛党B——>买到第 8 票
黄牛党C——>买到第 6 票
黄牛党A——>买到第 5 票
黄牛党B——>买到第 7 票
黄牛党B——>买到第 4 票
黄牛党A——>买到第 3 票
黄牛党C——>买到第 2 票
黄牛党A——>买到第 1 票
黄牛党B——>买到第 -1 票
黄牛党C——>买到第 0


原因:线程不同步。同一进程的多个线程A、B、C共享同一片存储空间,它们同时看见票仅剩一张,它们同时去抢,当某个线程抢到时,其他线程已经过了if判断票数的代码,于是最后一张被抢走时,票已经变为-1,而此时其它线程就抢到-1票。
抢票过程图片

案例二:模拟取钱,你和你女朋友同时取结婚基金的钱出来。假设基金共有100万,你取50万,你女朋友取100万。理想情况是你先取的50万,你女朋友取不了,或者你女朋友取得100万,你取不了。然而真实情况是出现你和你女朋友都取出,结婚基金变为-50万。

package lessen07_Thread;

public class UnsafeBank {
    
    
    public static void main(String[] args) {
    
    
        //基金账户,你和你女朋友共同享有100万的结婚基金账户
        Account account = new Account(100, "结婚基金");

        //你想取50万,你女朋友想取100万
        Drawing you = new Drawing(account, 50, "you");
        Drawing girlFriend = new Drawing(account, 100, "girlFriend");

        //开始取钱
        you.start();
        girlFriend.start();
    }
}
//账户类
class Account{
    
    
    int money;//账户余额
    String name;//账户名
    public Account(int money, String name) {
    
    
        this.money = money;
        this.name = name;
    }
}
//取钱类
class Drawing extends Thread{
    
    
    Account account;//账户
    int drawingMoney;//取了多少钱
    int nowMoney;//手里有多少钱

    /**
     * 取钱类的构造方法
     * @param account 哪个账户取钱
     * @param drawingMoney 取多少钱
     * @param name 谁取钱
     */
    public Drawing(Account account, int drawingMoney, String name){
    
    
        super(name);//线程的名字 Thread(String : name)
        this.account = account;
        this.drawingMoney = drawingMoney;
    }

    //开始取钱
    @Override
    public void run() {
    
    
        //判断能否取钱,账户余额必须比待取的钱多
        if (account.money - drawingMoney < 0){
    
    
            System.out.println(Thread.currentThread().getName() + "钱不够,取不了");
            return;
        }

        //取钱都会有一个时间,模拟延时,放大问题发生性
        try {
    
    
            Thread.sleep(1000);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }

        //卡内余额 = 余额 - 取的钱
        account.money -= drawingMoney;
        //手里的钱 = 手里的钱 + 取的钱
        nowMoney += drawingMoney;

        System.out.println(account.name+"的余额:"+account.money);
        System.out.println(this.getName()+"手里的钱:"+nowMoney);
    }
}

结果:

结婚基金的余额:-50
you手里的钱:50
结婚基金的余额:-50
girlFriend手里的钱:100

案例三:线程本身不安全,用 ArraryList < String> 来存储大量线程名,看是否有预定的数量。

package lessen07_Thread;

import java.util.ArrayList;
import java.util.List;

public class UnsafeList {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 50000; i++) {
    
    
            //这里是Lambda表达式
            new Thread(()->{
    
    
                list.add(Thread.currentThread().getName());
            }).start();
        }
        Thread.sleep(5000);
        System.out.println(list.size());
    }
}

我这里结果只有49995,因为大量线程同一瞬间操作了同一片存储空间,可能就把两个数组添加到同一个位置,造成了覆盖,元素就少了。

同步方法和同步代码块

类似与我们通过 private 关键字来限制一个类的数据被外部访问,使得只能通过get和set来访问数据。同理,我们通过 synchronized 关键字来帮助线程对数据上锁。

同步方法:

public synchronized void method(int args){
    
    }

synchronized 方法控制对”对象“的访问,每个线程中执行到该方法时,会对拥有该方法的对象上锁直到该方法结束,而其他线程必须等待锁解除才能执行该方法。

抢票过程02

例如:A抢票时,把抢票类的对象 buyTicket 上锁,此时B、C线程无法访问,而A抢完票后解锁,B再访问发现没有票,C同理。

缺陷:若将一个大的方法声明为 synichronized 将会影响效率。例如我们同步方法中有部分是只读的,对于其他线程来说访问该部分完全没问题,而此时加上锁,则降低了效率。
synichronized影响效率


同步块:

synichronized(Obj){
    
    }
  • Obj 称为同步监视器,它可以是任何对象,但一般是共享资源。

  • 同步方法中无需指定同步监视器,因为同步方法的同步监视器就是这个对象本身或者是class。

    【例如 有class A,在class A中有同步方法,而在A的同步方法是中的 this 就是同步监视器】

  • 同步监视器执行过程:

    • 第一个线程访问,锁定同步监视器,执行{ }中的代码
    • 第二个线程访问,发现同步监视器被锁定,无法访问
    • 第一个线程执行结束,解锁同步监视器
    • 第二个线程访问,同步监视器未锁,然后锁定斌执行{ }其代码

下面对上面三个不安全案例进行修改。

案例一:使用 synchronized 设置同步方法。在buy()方法前加入关键字 synchronized 即可。

synchronized void buy(){
    
    ....}

运行结果如下:

黄牛党A——>买到第 10 票
黄牛党C——>买到第 9 票
黄牛党C——>买到第 8 票
黄牛党B——>买到第 7 票
黄牛党C——>买到第 6 票
黄牛党A——>买到第 5 票
黄牛党A——>买到第 4 票
黄牛党C——>买到第 3 票
黄牛党B——>买到第 2 票
黄牛党C——>买到第 1

注意:这里对 buy() 方法加 synchronized,锁的是 BuyTicket 这个类,因为票这个变量是 BuyTicket 的属性,所以锁住这个类即可,下一个案例有区别。

案例二:账户取钱问题,此时我们先采用同步方法,对取钱的方法即 run() 加上关键字 synchronized,会发现结果依然是错误的。然后我们用同步代码块,如下代码。

package lessen07_Thread;

public class UnsafeBank {
    
    
    public static void main(String[] args) {
    
    
        //基金账户,你和你女朋友共同享有100万的结婚基金账户
        Account account = new Account(100, "结婚基金");

        //你想取50万,你女朋友想取100万
        Drawing you = new Drawing(account, 50, "you");
        Drawing girlFriend = new Drawing(account, 100, "girlFriend");

        //开始取钱
        you.start();
        girlFriend.start();
    }
}
//账户类
class Account{
    
    
    int money;//账户余额
    String name;//账户名
    public Account(int money, String name) {
    
    
        this.money = money;
        this.name = name;
    }
}
//取钱类
class Drawing extends Thread{
    
    
    Account account;//账户
    int drawingMoney;//取了多少钱
    int nowMoney;//手里有多少钱

    /**
     * 取钱类的构造方法
     * @param account 哪个账户取钱
     * @param drawingMoney 取多少钱
     * @param name 谁取钱
     */
    public Drawing(Account account, int drawingMoney, String name){
    
    
        super(name);//线程的名字 Thread(String : name)
        this.account = account;
        this.drawingMoney = drawingMoney;
    }

    //开始取钱
    @Override
    public void run() {
    
    
        //这里是 同步代码块
        synchronized (account){
    
    
            //判断能否取钱,账户余额必须比待取的钱多
            if (account.money - drawingMoney < 0){
    
    
                System.out.println(Thread.currentThread().getName() + "钱不够,取不了");
                return;
            }

            //因为一定有延时,模拟延时,放大问题发生性
            try {
    
    
                Thread.sleep(1000);
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }

            //卡内余额 = 余额 - 取的钱
            account.money -= drawingMoney;
            //手里的钱 = 手里的钱 + 取的钱
            nowMoney += drawingMoney;

            System.out.println(account.name+"的余额:"+account.money);
            System.out.println(this.getName()+"手里的钱:"+nowMoney);
        }
    }
}

也就是将之前run()里的内容放在 synchronized(account){ } 中,在这里面,对account对象锁住了。

选择同步方法或是同步代码块主要看我们增删改的对象是谁,同步方法默认锁的是this,即这个类的对象本身,当我们把 run() 变为同步方法时,实际上就是 synchronized(this){ } 的效果,而线程真正修改的是account对象。

案例三:把List锁住即可。

package lessen07_Thread;

import java.util.ArrayList;
import java.util.List;

public class UnsafeList {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < 50000; i++) {
    
    
            new Thread(()->{
    
    
                //这里是同步代码块
                synchronized (list){
    
    
                    list.add(Thread.currentThread().getName());
                }
            }).start();
        }
        Thread.sleep(5000);
        System.out.println(list.size());
    }
}

此时结果就是50000。

注意:我们的同步代码块锁的位置在是线程的方法里,这里用了Lambda表达式,线程的方法是 ( )->{ }的花括号里,因此我们要锁 list 对象应该在 ( )->{ }的花括号里。

这里也可以选择用 JUC 并发包里的 CopyOnWriteArrayList,它是实现了安全线程的,直接替换 ArrayList 即可。【Callable接口也是属于 JUC 包里的】

Lock上锁

前面使用 synchronized 的同步方法和同步代码块都是隐式上锁和解锁,JDK 5.0开始,Java 就提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步。

实现方法:

class A{
    
    
    private final ReentrantLock lock = new ReentrantLock();
    public void method(){
    
    
        lock.lock();
        try{
    
    
            //需上锁的代码
        }finally{
    
    
            //上锁代码中可能出现异常时,把unlock写在finally中,
            //一般都直接这么写
            lock.unlock();
        }
    }
}

下面来重新实现之前的抢票模拟:

package lessen07_Thread;

import java.util.concurrent.locks.ReentrantLock;

public class TestLock {
    
    
    public static void main(String[] args) {
    
    
        BuyTicket2 buyTicket = new BuyTicket2();

        new Thread(buyTicket, "黄牛党A").start();
        new Thread(buyTicket, "黄牛党B").start();
        new Thread(buyTicket, "黄牛党C").start();
    }
}

class BuyTicket2 implements Runnable {
    
    
    private int ticketNum = 10;
    private boolean flag = true;
    private final ReentrantLock lock = new ReentrantLock();

    @Override
    public void run() {
    
    
        //每个人都一直抢票
        while (true) {
    
    
            //上锁
            lock.lock();
            try{
    
    
                //判断能否买票
                if (ticketNum <= 0) break;
                //模拟网络延时(放大问题发生性)
                try {
    
    
                    Thread.sleep(100);
                }catch (Exception e){
    
    
                    e.getStackTrace();
                }
                //抢到一张票
                System.out.println(Thread.currentThread().getName() + "——>买到第 " + ticketNum-- + " 票");
            }finally {
    
    
                lock.unlock();//解锁
            }
        }
    }
}

synchronized与Lock对比:

  1. Lock是显式锁(手动打开、关闭),synchronized是隐式锁,出了作用域自动关闭。
  2. Lock只有代码块锁,synchronized有代码块锁和方法锁。
  3. 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好,且有更好的扩展性。
  4. 优先使用:Lock > 同步代码块 > 同步方法

死锁

即多个线程相互持有对方需要的资源,然后形成僵持。

例如女生化妆需要口红和镜子,女生A有口红,女生B有镜子,而女生A和女生B都不愿意把持有的东西给对方,导致她们都无法化妆。若这时女生A先用完了口红就放桌上,女生B就可以使用口红然后化完妆,释放镜子,女生A就也能化完妆。

产生死锁的四个必要条件:

  1. 互斥条件:一个资源每次只能被一个进程使用
  2. 请求与保持条件:一个进程因请求资源而被阻塞,对已获得的资源保持不放
  3. 不可剥夺条件:进程已经获得的资源,在未使用完前不能被强行剥夺
  4. 循环等待条件:若干进程之间形成一种首尾相连的循环等待资源关系

对比上面的例子:

  1. 口红和镜子只能被一个女生使用。
  2. 女生A有口红,却因没有镜子被阻塞,但仍持有口红不放。
  3. 女生A的口红在没用完前不能被强行剥夺。
  4. 女生A和女生B形成一种循环等待对方资源的关系。

代码如下:

package lessen07_Thread;

public class DeadLock {
    
    
    public static void main(String[] args) {
    
    
        new Makeup(0, "灰姑娘").start();
        new Makeup(1, "白雪公主").start();
    }
}

//口红
class Lipstick{
    
    }
//镜子
class Mirror{
    
    }
//化妆
class Makeup extends Thread{
    
    
    //static关键字保证Makeup类的口红和镜子资源只有一份
    static Lipstick lipstick = new Lipstick();
    static Mirror mirror = new Mirror();

    int choice;//选择用镜子还是口红
    String name;//化妆者姓名

    public Makeup(int choice, String name){
    
    
        this.choice = choice;
        this.name = name;
    }

    @Override
    public void run() {
    
    
        try {
    
    
            makeup();
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }

    //化妆
    private void makeup() throws InterruptedException {
    
    
        //用镜子
        if (choice == 1){
    
    
            synchronized (mirror){
    
    
                System.out.println(this.name+"拥有镜子");
                sleep(1000);
                synchronized (lipstick){
    
    
                    System.out.println(this.name+"拥有口红");
                }
            }
        }else if(choice == 0){
    
    //用口红
            synchronized (lipstick){
    
    
                System.out.println(this.name+"拥有口红");
                sleep(2000);
                synchronized (mirror){
    
    
                    System.out.println(this.name+"拥有镜子");
                }
            }
        }
    }
}

结果:

灰姑娘拥有口红
白雪公主拥有镜子

结果发现灰姑娘和白雪公主就一直卡死在那。这是因为在白雪公主拥有了镜子后,延时1秒想去获得口红时,口红仍被延时2秒中的灰姑娘上锁,而灰姑娘延时结束后想获得镜子,发现镜子也仍被白雪公主上锁。

解决方法:

 if (choice == 1){
    
    
     synchronized (mirror){
    
    
         System.out.println(this.name+"拥有镜子");
         sleep(1000);
     }
     synchronized (lipstick){
    
    
         System.out.println(this.name+"拥有口红");
     }
 }else if(choice == 0){
    
    
     synchronized (lipstick){
    
    
         System.out.println(this.name+"拥有口红");
         sleep(2000);
     }
     synchronized (mirror){
    
    
         System.out.println(this.name+"拥有镜子");
     }
 }

把里面嵌套的同步代码块都拿出来,这样变破环了产生死锁的请求和保持条件,这样拥有镜子的灰姑娘用完镜子(灰姑娘1秒延时结束)后就释放了镜子,然后等待白雪公主用完口红(灰姑娘会等待1秒)。

猜你喜欢

转载自blog.csdn.net/qq_39763246/article/details/112973581