Java 并发-互斥锁
本文介绍 Java 并发编程中的互斥锁,如原子性和互斥的关系,Java synchronized 原语的使用,以及死锁的产生条件和如何避免死锁

# Java 并发-互斥锁

# 原子性与互斥

  1. 原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性

  2. 线程切换是原子性问题的源头

    在单核机器上,可以通过禁止线程切换来保证一个线程一直持有 CPU 使用权,多次操作具有原子性

    在多核机器上,多线程可以同时执行,禁止线程切换并不能保证原子性

  3. 互斥锁模型

    同一时刻只有一个线程执行,即互斥

    image-20220117160426629

    需要注意的是,锁和受保护资源是相关联的

    原子性的本质是加锁和解锁操作的中间状态对外不可见

# synchronized

  1. 锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字是锁的一种实现

  2. synchronized 修饰方法和代码块

    class X { 
        // 修饰非静态方法 
       public synchronized void a() { 
            // 临界区 
        } 
        // 修饰静态方法 
        public synchronized static void b() { 
            // 临界区 
        } 
        // 修饰代码块 
        Object o = new Object();
        public void c() { 
            synchronized(o) { 
                // 临界区 
            } 
        }
    }
    

    synchronized 是 JVM 层面的锁,编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁和解锁操作

  3. synchronized 锁的粒度

    修饰静态方法:对类对象(class)加锁

    修饰普通方法:对当前对象(this)加锁

  4. synchronized 保证可见性

    Happens-Before 规则中有一条是一个线程的解锁对另一个线程的加锁是可见的

    配合传递性规则可得,一个线程对加锁资源的修改对另一个线程是可见的

# 锁和受保护资源

  1. 必须用同一把锁保护一个资源,否则不能实现操作资源的互斥性

    class Calc { 
        static long value = 0L; 
        synchronized long get() { 
            return value; 
        } 
        synchronized static void addOne() { 
            value += 1; 
        }
    }
    

    addOne 方法是类对象的锁, get 方法是当前对象的锁,两个线程执行 addOne 和 get 是不互斥的

  2. 一把锁保护多个资源

    class Account {
      private int balance;
      // 转账
      synchronized void transfer(Account target, int amt){
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      } 
    }
    

    transfer 方法加了当前对象的锁,所以不同 Account 对象加的锁是不一样的,无法保证互斥访问

    解决的方法是提供一个 Account 类各个对象共有的锁,如构造传入相同的对象用于加锁、对静态变量加锁或者对 Account.class 加锁

# 死锁

  1. 死锁产生的背景

    保护多个资源的互斥访问,可以选择一个公共的对象进行加锁,但是这样锁的粒度太大,往往会降低并发度

    优化的方案是选择若干个范围更小的锁,一个线程获取所有锁之后才能对资源进行访问

    如果线程 A 获取了锁 1,急需获取锁 2,但线程 B 已经持有锁 2,也在等待锁 1,这样就一直陷入等待

    image-20220118094354718

  2. 死锁的示例代码

    class Account {
      private int balance;
      // 转账
      void transfer(Account target, int amt){
        // 锁定转出账户
        synchronized(this) {              
          // 锁定转入账户
          synchronized(target) {           
            if (this.balance > amt) {
              this.balance -= amt;
              target.balance += amt;
            }
          }
        }
      } 
    }
    

    当两个线程操作两个 Account 对象互相转账的时候,可能出现各自锁住了自己的账户对象,一直等待另一个,产生死锁

  3. 产生死锁的条件

    • 互斥:资源是互斥访问的
    • 请求与保持:请求资源并且保持等待
    • 不可剥夺:线程获取锁后,不可被强行剥夺
    • 环路:线程之间获取的锁和需要等待的锁,构成一个闭环
  4. 避免死锁的方式

    • 破坏请求与保持条件

      避免请求一个锁后等待另一个锁,可以将多个锁的获取过程优化为一次性获取

      class Allocator {
          private Set<Object> locks = new HashSet<>();
          // 一次性申请所有锁
          synchronized boolean lock(Object from, Object to) {
              if (locks.contains(from) || locks.contains(to)) {
                  return false;
              } else {
                  locks.add(from);
                  locks.add(to);
              }
              return true;
          }
          // 释放锁
          synchronized void release(Object from, Object to) {
              locks.remove(from);
              locks.remove(to);
          }
      }
      

      使用一个单例的 Allocator 对象,在获取锁前循环等待

      while (!allocator.lock(this, target)) {}
      
    • 破坏不可剥夺条件

      JDK 并发包内提供了高级的锁 API,可以设置等待锁的时间,时间到了自动放弃,避免一直等待

    • 破坏环路条件

      可以对资源进行排序,例如转账示例代码中,对两个 Account 对象可以先排序再加锁,这样多个对象执行转账方法时,按顺序获取锁,不存在循环等待

Comment here, be cool~

Copyright © 2020 CadeCode

Theme 2zh powered by VuePress

本页访问次数 0

Loading