多线程04_线程同步

1 并发和并行

  • 并发指同一个对象被多个线程同时操作

  • 并发是两个队列交替使用一台咖啡机,并行指两个队列同时使用两台咖啡机

    img
  • 并发和并行都可以有多个线程,不同之处在于这些线程是否同时被(多个)CPU执行,如果可以就是并行,并发时多个线程被(一个)CPU轮流切换着执行

2 线程同步

  • 现实生活中会遇到”同一个资源,多个人都想使用“的问题,例如食堂排队打饭
  • 处理多线程问题时,多个线程访问同一个对象,并且某些线程还想要修改这个对象,此时需要线程同步
  • 线程同步其实是一种等待机制,多个需要同时访问此对象的对象进入该对象的等待池形成队列,等待前面线程使用完毕,下一个线程再使用
  • 解决线程同步的关键在于队列
  • 由于同一进程的多个线程共享同一块存储空间,在带来方便的同时,也带来了访问冲突问题,为了保证数据在方法中被访问时的正确性,需要加入锁机制synchronized
  • 当一个线程获得对象的排他锁和独占资源时,其他线程必须等待,使用后释放锁,存在以下问题
    • 一个线程持有锁会导致其他所有需要此锁的线程挂起
    • 在多线程竞争下,加锁和释放锁会导致较多的上下文切换和调度延时,引起性能问题
    • 如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级导致,引起性能问题

3 线程不安全案例

  • 抢火车票

    public class UnsafeBuyTicket {
        public static void main(String[] args) {
            BuyTicket station= new BuyTicket();
    
            new Thread(station, "大黄").start();
            new Thread(station, "小黑").start();
            new Thread(station, "旺财").start();
        }
    }
    
    class BuyTicket implements Runnable {
        private int ticketNums = 10;
        boolean flag = true;
    
        @Override
        public void run() {
            while (flag) {
                try {
                    buy();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        private void buy() throws InterruptedException {
            if (ticketNums <= 0) {
                flag = false;
                return;
            }
    
            // 模拟延时
            Thread.sleep(100);
    
            System.out.println(Thread.currentThread().getName() + "买到了第" + ticketNums-- + "张票");
        }
    }
    

    此时会出现两个线程买到同一张票,或者某个线程买到-1张票的情况。

  • 取钱

    // 两个人去银行取钱
     public class UnsafeBank {
         public static void main(String[] args) {
            Account account = new Account(100, "存款");
     
             Drawing you = new Drawing(account, 50, 0, "你");
             Drawing yourWife = new Drawing(account, 100, 0, "你媳妇");
     
             you.start();
             yourWife.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;        // 现在手中钱数
     
         public Drawing(Account account, int drawingMoney, int nowMoney, String name) {
             super(name);
             this.account = account;
             this.drawingMoney = drawingMoney;
             this.nowMoney = nowMoney;
         }
     
         // 取钱
         @Override
         public void run() {
             try {
                 Thread.sleep(500);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
             
             // 判断钱是否够取出
             if (account.money - drawingMoney < 0) {
                 System.out.println("账户余额不足");
                 return;
             }
     
             account.money -= drawingMoney;        // 卡内余额
             nowMoney += drawingMoney;            // 手中钱数
     
             System.out.println(account.name + "余额为:" + account.money);
             // Thread.currentThread().getName() = this.getName()
             System.out.println(this.getName() + "手中有:" + nowMoney);
         }
     }
    

    此时可能会出现余额不足但是还是能取出钱,导致余额为负数的情况

  • 线程不安全集合

    public class UnsafeList{
        public static void main(String[] args) throws InterruptedException {
            List<String> list = new ArrayList<>();
            
            for (int i = 0; i < 10000; i++) {
                new Thread(() -> {
                    list.add(Thread.currentThread().getName());
                }).start();
            }
            
            Thread.sleep(500);
            System.out.println(list.size());	// 期待值是10000
        }
    }
    

    此时最终输出的结果可能不足10000

4 同步方法

  • 使用synchronized关键字修饰的方法为同步方法

    public synchronized void method() {
       // todo
    }
    
  • synchronized方法控制对“对象”的访问,每个对象有一把锁,每个synchronized方法都必须获得调用该对象的方法的锁才能执行,否则线程会阻塞。

  • 同步方法一旦执行,就独占锁,直到方法返回时释放锁,后续被阻塞的线程才能获得这个锁继续执行

  • 若将一个大的方法声明为synchronized将会影响效率

  • 同步方法相当于锁定了this

5 同步块

  • synchronized可以修饰一个代码块

    synchronized (obj) {
        // todo
    }
    
  • obj称为同步监视器

    • obj可以是任何对象,但推荐使用共享资源作为同步监视器

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

    • public void method() {
          synchronized(this) {
              // todo
          }
      }
      

      此写法与同步方法等价,都是锁定了整个方法中的内容

  • 同步监视器的执行过程

    • 第一个线程访问,锁定同步监视器,执行其中代码
    • 第二个线程访问,发现同步监视器被锁定,无法访问
    • 第一个线程访问完毕,解锁同步监视器
    • 第二个线程访问,发现同步监视器没有锁,然后锁定并访问

6 CopyOnWriteArrayList

import java.util.concurrent.CopyOnWriteArrayList;

public class TestJUC{
    public static void main(String[] args) throws InterruptedException {
        // 线程安全的ArrayList
        CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
        
        for (int i = 0; i < 10000; i++) {
            new Thread(() -> {
                list.add(Thread.currentThread().getName());
            }).start();
        }

		Thread.sleep(500);
        System.out.println(list.size());
    }
}
  • CopyOnWriteArrayList是线程安全的读操作无锁的ArrayList,其中所有的可变操作都是对底层数组进行一次新的复制来实现
  • CopyOnWrite就是对一块内存进行修改时,不直接在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后,再将原来指向的内存指针指到新的内存,原来的内存就可以被回收。
  • CopyOnWriteArrayList适合读操作远大于写操作的场景中,例如缓存
  • CopyOnWriteArrayList不存在扩容的概念,每次写操作都要复制一个副本,性能很差
  • CopyOnWriteArrayList无法保证实时性要求,它拷贝数组、新增元素都需要时间,所以一个写操作后可能读取到的数据还是旧的,但能保证最终一致性

7 死锁

  • 多个线程各自占有一些共享资源,并且互相等待其他线程占有的资源才能运行,导致两个或多个线程都在等待对方释放资源,从而停止执行的场景。某一个同步块中同时拥有两个或多个对象的锁时,可能会发生死锁

  • 死锁可以理解为多个线程互相持有对方需要的资源,然后形成僵持的局面

    public class DeadLock {
        public static void main(String[] args) {
            Makeup girl1 = new Makeup(0, "小丽");
            Makeup girl2 = new Makeup(1, "小红");
    
            // 此时出现死锁
            girl1.start();
            girl2.start();
        }
    }
    
    class Lipstick {}
    
    class Mirror {}
    
    class Makeup extends Thread {
        // 需要的资源只有一份,用static关键字修饰
        static final Lipstick lipstick = new Lipstick();
        static final Mirror mirror = new Mirror();
    
        int choice;
        String girlName;	// 使用化妆品的人
    
        public Makeup(int choice, String girlName) {
            this.choice = choice;
            this.girlName = girlName;
        }
    
        @Override
        public void run() {
            try {
                makeup();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
        // 互相持有对方的锁
        private void makeup() throws InterruptedException {
            if (choice == 0) {
                synchronized (lipstick) {
                    System.out.println(this.girlName + "获得口红");
                    Thread.sleep(500);
                    synchronized (mirror) {
                        System.out.println(this.girlName + "获得镜子");
                    }
                }
            } else {
                synchronized (mirror) {
                    System.out.println(this.girlName + "获得镜子");
                    Thread.sleep(1000);
                    synchronized (lipstick) {
                        System.out.println(this.girlName + "获得口红");
                    }
                }
            }
        }
    }
    
  • 产生死锁的必要条件:

    • 互斥条件:进程要求对所分配的资源进行排他性控制,即在一段时间内某资源仅为一进程所用
    • 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放
    • 不可剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放
    • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源条件
  • 解决死锁的基本方法

    • 预防死锁:资源一次性分配(破坏请求和保持条件);可剥夺资源,即当某进程新的资源未满足时,释放已占有的资源(破坏不可剥夺条件);资源有序分配法,即系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏循环等待条件)
    • 避免死锁:预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得较满意的系统性能。最具代表性的避免死锁算法是银行家算法
    • 检测死锁:允许死锁发生,但会通过一些手段检测出来,JVM自带的 jstack 堆栈跟踪工具和JDK自带的 JConsole 监控工具可用于检测死锁
    • 解除死锁:发现有进程死锁后,便应立即把它从死锁状态中解脱出来

8 Lock对象

  • 从JDK5之后,Java提供了更强大的线程同步机制,通过显式定义同步锁实现同步,使用Lock对象充当同步锁

  • java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象

  • ReentrantLock类实现了Lock接口,它拥有与synchronized相同的并发性和内存语义。在实现线程安全控制时,较常用ReentrantLock,可以显式加锁和释放锁

    import java.util.concurrent.locks.ReentrantLock;
    
    public class TestLock {
        public static void main(String[] args) {
            BuyTicket station= new BuyTicket();
    
            new Thread(station).start();
            new Thread(station).start();
            new Thread(station).start();
        }
    }
    
    class BuyTicket implements Runnable {
        private int ticketNums = 10;
        // 定义锁
        private final ReentrantLock lock = new ReentrantLock();
    
        @Override
        public void run() {
            while (true) {
                lock.lock();	// 加锁
                try {
                    if (ticketNums > 0) {
                        try {
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(ticketNums--);
                    } else {
                        break;
                    }
                } finally {
                    lock.unlock();	// 释放锁
                }
            }
        }
    }
    
  • synchronizedLock的对比

    • Lock是显式锁(手动开启和关闭锁),synchronized是隐式锁,运行出作用域自动释放
    • Lock只有代码块锁,synchronized有代码块锁和方法锁
    • 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好,并且具有更好的扩展性(提供更多的子类)
    • 优先级:Lock > 同步代码块(已经进入了方法体,分配了相应资源) > 同步方法(在方法体之外)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值