8种常用的设计模式(3-1) —— 单例模式之线程同步

    科技2024-12-26  5

    目录

    1.回顾多线程的队列和🔒小结 2.同步方法&同步块3.死锁4.Lock锁5.synchronized与Lock的对比


    1.回顾多线程的队列和🔒

    为什么要有队列?因为资源只有一个,如果开启多线程,多个线程同时对于一个资源进行操作,这样得到的各个线程操作之后的资源的值是不可靠、不可用的;并且线程的执行顺序我们是不能控制的,所以最后得到什么结果我们是不能控制的;为了解决这个问题,队列就诞生了,就是让多线程进行排队,一个一个的进行操作,这就解决了哪一个线程先使用资源的问题

    有队列了还要🔒干嘛?因为线程执行的过程我们是不可控的,所以我们不能保证线程执行起来它真的就乖乖地在队列里面等着,而不会干扰正在执行地线程,比如当前A线程正在使用资源,B线程也去使用资源,这就会导致最后资源的计算结果出现错误;所以为了保证正在执行地线程地安全,就有了🔒的概念;我们可以把资源想象成一个单间中的马桶,线程就是一长溜等待上厕所的人,每个人都想先去上厕所,所以就会挣厕所,就会造成大家都上不了厕所的情况,所以就出现了队列;虽然排了队,但是你在上厕所的时候可能有一个老大哥来了,强行要抢你的厕所,那么这就让你不得不暂停,这个时候就体现出了🔒的必要性,你就去之后把门锁上,那么外面的人就只能乖乖等你出来之后才能再进去了

    线程同步就是需要队列+🔒才能实现,线程同步就是让多线程安全的访问同一资源

    🔒在Java中有一个关键词叫synchronized

    线程同步显然就会降低性能,即虽然我们开了多线程,但是我们又让每个线程都排队,并且去给每个线程都配上了一把🔒,让每个线程都把自己的事情做完了才开🔒,这就破坏了高效的异步执行,变为了同步执行;但是这是高效和安全执行的一种权衡和妥协,相对于高性能不可靠的结果,我们更愿意选择低性能可靠的结果

    【代码举例】不可靠的多线程

    public class UnsafeBuyTicket { public static void main(String[] args) { BuyTicket ticket = new BuyTicket(); new Thread(ticket).start(); new Thread(ticket).start(); new Thread(ticket).start(); } } class BuyTicket implements Runnable{ private int ticket = 10;//总票数 private boolean flag = true; @Override public void run() { //买票 while (flag){ buyTicket(); } } private void buyTicket(){ if (this.ticket>0){ System.out.println(Thread.currentThread().getName()+"买到了一张票,现在余票数 = "+ --ticket); } else { this.flag = false; System.out.println("票卖完了!"); } try { Thread.sleep(100); //线程休眠,可以放大问题的发生可能性 } catch (InterruptedException e) { e.printStackTrace(); } return; } }

    上图中的异常就是多线程开启但是没有开启线程同步的时候容易出现的错误,究其原因都是因为多个线程同时访问了同一个资源,而线程对于资源的操作又是通过拷贝之后在自己的内存空间中进行操作,操作完成之后再将操作结果返回资源,对资源原来的值进行覆盖造成了

    倒序异常&重复出现异常:A线程和B线程同时访问资源变量i,此时资源的值为9,因为线程执行是异步的,且执行顺序我们不能人为的干预,所以A和B谁先执行完都有可能,但是不管谁先执行完,它们返回的资源i的值都应该是8;假设A线程先执行完,资源i变成了8,但是在线程B返回自己的执行结果8之前来了线程C,它也执行了资源i减1的操作,并且先于线程B结束,那么它返回的i资源的数值应该是 i = 7;等到线程B执行完了,返回的资源 i = 8,就会对线程C刚刚修改的资源 i的值产生覆盖,所以资源数又变成了8

    上面的解析中8重复出现,7出现之后又出现了8,这还只是同时有3个线程对于同一资源进行没有线程同步的访问,如果线程数增多,出错的情况可能更多

    所以需要排队和+🔒实现线程同步,保证同一个资源被多个线程访问的时候的安全

    注意:我们只是在线程访问同一个资源的时候才上锁,线程执行其他的东西的时候还是异步的,所以即使性能减弱,也不会完全退化为同步操作

    小结

    由于同一进程的多个线程共享同一块存储空间,在带来方便的同时,也带来了访问冲突问题,为了保证数据在方法中被访问时的正确性,在访问时加入锁机制synchronized,当一个线程获得对象的排它锁,独占资源,其他线程必须等待,使用后释放锁即可存在以下问题: 一个线程持有锁会导致其他所有需要此锁的线程挂起,在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引起性能问题如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能问题

    2.同步方法&同步块

    由于我们可以通过private关键字来保证数据对象只能被方法访问,所以我们只需要针对方法提出—套机制,这套机制就是synchronized关键字,它包括两种用法:synchronized方法和synchronized块(关键字synchronized的实现原理就是队列+🔒)同步方法同步方法:public synchronized void method(int args){} synchronized方法控制对“对象”的访问,每个对象对应有一把锁🔒,每个synchronized方法都必须获得调用该方法的对象的锁才能执行,否则线程会阻塞,方法一旦执行,就独占该锁,直到该方法返回才释放锁,后面被阻塞的线程才能获得这个锁,继续执行 缺陷:若将一个大的方法申明为synchronized将会影响效率 同步块:synchronized (obj){ } obj称为同步监视器 Obj可以是任何对象,但是推荐使用共享资源(就是被多个线程同时访问的那个资源所属的类的对象)作为同步监视器同步方法中无需指定同步监视器,因为同步方法的同步监视器就是this,就是这个对象本身,或者是class [反射中讲解] 同步监视器的执行过程 第一个线程访问,锁定同步监视器,执行其中代码第二个线程访问,发现同步监视器被锁定,无法访问第一个线程访问完毕,解锁同步监视器第二个线程访问,发现同步监视器没有锁,然后锁定并访问 【代码举例】同步方法:使用synchronized改造上面的买票程序public class UnsafeBuyTicket { public static void main(String[] args) { BuyTicket ticket = new BuyTicket(); new Thread(ticket).start(); new Thread(ticket).start(); new Thread(ticket).start(); } } class BuyTicket implements Runnable{ private int ticket = 10;//总票数 private boolean flag = true; @Override public void run() { //买票 while (flag){ buyTicket(); } } private synchronized void buyTicket(){ //同步方法 if (this.ticket>0){ System.out.println(Thread.currentThread().getName()+"买到了一张票,现在余票数 = "+(--this.ticket)); } else { this.flag = false; System.out.println("票卖完了!"); } try { Thread.sleep(100); //线程休眠,可以放大问题的发生可能性 } catch (InterruptedException e) { e.printStackTrace(); } return; } }

    解析:线程A进入类BuyTicket调用买票方法,由于买票方法加上了关键字synchronized ,所以线程A需要先去获取类BuyTicket的🔒,由于类的🔒只有一把,一个线程进入调用同步方法的时候获取了,后面来的线程由于不能获取类的🔒而不能调用执行使用了关键字synchronized的方法,所以它们就只能等待,等到线程A调用买票方法完毕之后再去调用使用了关键字synchronized的买票方法,后面不管多少线程来都是这样,这就保障了线程同步

    【代码例子】同步块:这里需要举一个新的、麻烦一点的例子,银行取钱

    public class UnsafeBank { public static void main(String[] args) { Account account = new Account(100,"100W的钱"); Draw draw1 = new Draw(account,50,"张三"); Draw draw2 = new Draw(account,100,"李四"); draw1.start(); draw2.start(); } } class Account{ //银行账户 int balance;//账户余额 String cardID;//钱用来干什么/这笔钱的名称 public Account(int balance, String cardID) { this.balance = balance; this.cardID = cardID; } } class Draw extends Thread{ //银行取钱这件事 Account account;//银行中的账户 int drawingMoney;//取出的钱的金额 public Draw(Account account, int drawingMoney, String name){ //构造 super(name); //父类为Thread,所以调用的是为线程取名字的构造,name用于标识当前是谁在去钱 this.account = account; this.drawingMoney = drawingMoney; } @Override public void run() { if (account.balance - drawingMoney <0){ System.out.println("取款金额大于余额,取款失败"); return; } try { Thread.sleep(100); //使用sleep()放大多线程出错的效果 } catch (InterruptedException e) { e.printStackTrace(); } account.balance = account.balance - drawingMoney; //当前余额 = 原来的金额 - 取出的金额 System.out.println(Thread.currentThread().getName()+"取走了"+this.drawingMoney +",当前余额为 = "+account.balance); } }

    从上面的执行结果来看,显然不是我们想要的,因为银行账户中只有100w,但是两个人却取出了150w

    上面3种情况就是在运行的时候出现的3种结果,并且没有一次是正常的结果,这就是多线程访问同一资源不使用线程同步的后果

    结果 = 0,因为张三和李四线程同时发现银行余额为100,所以都通过了判断,然后张三线程先执行完毕,所以balance余额变为50,但是还没有执行到输出;李四后执行完,将自己的执行结果0返回获取修改了balance的值,这就造成了最后输出的都是0

    结果 = 50:上面同理,但是是张三后执行完

    结果 = -50:因为张三和李四线程同时发现银行余额为100,所以都通过了判断,然后李四线程在执行减余额之前,张三线程就已经执行完毕,并且将余额修改为50w,但是还有没有输出;李四线程读取到的余额就是50,减去自己的100,就是-50,然后李四后执行完,将自己的执行结果-50返回获取修改了balance的值,这就造成了最后输出的都是-50

    使用同步方法尝试解决问题 使用同步方法没有效果的原因:因为我们使用的关键字synchronized获取的🔒是类的🔒,也就是说它🔒的是当前执行取钱这个过程的Draw,但是并多个线程同时访问的资源balance并不是类Draw的成员变量,所以我们在类Draw的run()上加上关键字synchronized是没有意义的,因为我们没有锁住被多个线程访问的资源,它是另一个类中的成员属性,它才是问题的核心,所以我们需要锁得时Account类的balance属性但是balance是一个属性,我们怎么锁?记住:锁来自于对象,所以我们不需要锁属性balance,只需要锁住使用的Account对象的代码块即可,所以我们需要使用到同步代码块将Account对象锁起来

    注意:同步代码块🔒的是被操作的那个资源所属的对象,虽然同步监视器obj可以是任何的对象,但是如果不🔒进行增删改的那个资源,还是会出现多线程竞争同一个资源的错误情况

    3.死锁

    多个线程各自占有一些共享资源,并且互相等待其他线程占有的资源才能运行,而导致两个或者多个线程都在等待对方释放资源,都停止执行的情形,某一个同步块同时拥有“两个以上对象的锁”时,就可能会发生“死锁”的问题产生死锁的四个必要条件: 互斥条件:一个资源每次只能被一个进程使用请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放不可剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

    上面列出了死锁的四个必要条件,即要出现死锁必须满足上面的4个条件,所以我们只要想办法破其中的任意一个或多个条件就可以避免死锁发生

    【代码举例】死锁

    public class DeadLock { public static void main(String[] args) { MakeUp makeUp1 = new MakeUp(0,"张三"); MakeUp makeUp2 = new MakeUp(2,"李四"); makeUp1.start(); makeUp2.start(); } } class LipStick{} //口红资源 class Mirror{} //镜子资源 class MakeUp extends Thread{ //化妆这件事 static Mirror mirror = new Mirror(); static LipStick lipStick = new LipStick(); int choice; //选择获取什么资源 String name; //获取资源的线程名称 public MakeUp(int choice, String name) { this.choice = choice; this.name = name; } @Override public void run() { makeup(); } public void makeup(){ if (choice == 0){ synchronized (lipStick){ System.out.println(name+"选择了口红的锁"); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (mirror){ System.out.println(name+"选择了镜子的锁"); } } } else { synchronized (mirror){ System.out.println(name+"选择了镜子的锁"); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lipStick){ System.out.println(name+"选择了口红的锁"); } } } } }

    上面的代码中,都是先获取一种资源的🔒,然后在此基础上再去获取另一个资源的🔒,这就出现了死锁的条件,所以下面的结果出现了死锁

    【解决】破坏死锁条件:不让线程在获取资源1的基础上再去获取资源2

    在程序中一定要避免死锁的出现,做法就是破坏死锁形成的条件


    4.Lock锁

    从JDK 5.0开始,Java提供了更强大的线程同步机制——通过显式(相对于使用关键字synchronized隐式的锁)定义同步锁对象来实现同步,同步锁使用Lock对象充当java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象ReentrantLock(可重入锁)类实现了Lock接口,它拥有与synchronized相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加🔒、释放🔒其实Lock就和关键字synchronized效果一样【代码举例】还是使用我们买票的例子,下面的没有使用Lock锁public class TestLock { public static void main(String[] args) { BuyTicket1 ticket = new BuyTicket1(); new Thread(ticket).start(); new Thread(ticket).start(); new Thread(ticket).start(); } } class BuyTicket1 implements Runnable{ private int ticket = 10;//总票数 private boolean flag = true; @Override public void run() { //买票 while (flag){ buyTicket(); } } private void buyTicket(){ if (this.ticket>0){ System.out.println(Thread.currentThread().getName()+"买到了一张票,现在余票数 = "+(--this.ticket)); } else { this.flag = false; System.out.println("票卖完了!"); } try { Thread.sleep(100); //线程休眠,可以放大问题的发生可能性 } catch (InterruptedException e) { e.printStackTrace(); } return; } } 加上Lock锁import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class TestLock { public static void main(String[] args) { BuyTicket1 ticket = new BuyTicket1(); new Thread(ticket).start(); new Thread(ticket).start(); new Thread(ticket).start(); } } class BuyTicket1 implements Runnable{ private int ticket = 10;//总票数 private boolean flag = true; //加入lock锁 private final Lock lock = new ReentrantLock(); @Override public void run() { //买票 while (flag){ buyTicket(); } } private void buyTicket(){ try { lock.lock(); if (this.ticket>0){ System.out.println(Thread.currentThread().getName()+"买到了一张票,现在余票数 = "+(--this.ticket)); } else { this.flag = false; System.out.println("票卖完了!"); } try { Thread.sleep(100); //线程休眠,可以放大问题的发生可能性 } catch (InterruptedException e) { e.printStackTrace(); } } finally { lock.unlock(); } return; } }


    5.synchronized与Lock的对比

    Lock是显式锁(手动开启和关闭锁,别忘记关闭锁))synchronized是隐式锁,出了作用域自动释放Lock只有代码块锁,synchronized有代码块锁和方法锁使用Lock锁,JVM将花费较少的时间来调度线程,性能更好,并且具有更好的扩展性(提供更多的子类)优先使用顺序: Lock >同步代码块(已经进入了方法体,分配了相应资源)>同步方法(在方法体之外)
    Processed: 0.139, SQL: 8