20-并发锁

nobility 发布于 2021-06-12 803 次阅读


并发锁

锁是用于对于共享资源访问控制的工具,常见的有Locksynchronized,都是可以达到线程安全的目的,但是使用上和功能上有所不同

Lock接口

Lock不会想synchronized那样在异常时自动释放锁,获取锁后的try语句块儿中编写业务逻辑,在finally语句块儿中有释放锁的操作,以避免死锁的发生

Lock的加锁解锁和synchronized有同样的内存语义,也就是说遵守happens-before原则,下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作

方法名 描述
void lock() 获取锁,等锁期间不会被中断,永久等待
void lockInterruptibly() 获取锁,等锁期间会被中断,永久等待
boolean tryLock() 尝试获取锁,立刻返回,获取到返回true,获取不到返回false
boolean tryLock(long time, TimeUnit unit) 尝试获取锁,指定时间内获取到锁返回true,超时获取不到返回false
void unlock() 释放锁

锁的分类

锁的分类是从不同角度去看,这些分类不是互斥的,就可以共存的,也就是说同一个锁可以属于多种类型

  • 线程释放锁住同步资源
    • 锁住:悲观锁
    • 不锁住:乐观锁
  • 多线程能否共享一把锁
    • 可以:共享锁
    • 不可以:独占锁
  • 多线程竞争时是否允许插队
    • 不允许插队:公平锁
    • 允许插队:不公平锁
  • 同一个线程是否可以重复获取同一把锁
    • 可以:可重入锁
    • 不可以:不可重入锁
  • 等锁期间是否可中断
    • 可以:可中断锁
    • 不可以:不可中断锁
  • 等锁过程中线程是否阻塞
    • 线程不停的尝试获取锁,也就是自旋:自旋锁
    • 阻塞:非自旋锁

乐观锁和悲观锁

乐观锁和悲观锁也称为非互斥同步锁和互斥同步锁

乐观锁认为:自己在处理操作时不会有其他线程来感染,所以并不会锁住被操作对象,在更新数据时,去对比在修改期间数据是否被别的线程修改过,若没有被修改过则正常执行,若被修改过则可选择放弃修改、报错、重试等策略

Java中乐观锁实现就是原子类、并发容器等,乐观锁执行流程如下:

  1. 多个线程直接获取资源并各自计算,并未给资源加锁
  2. 优先计算完的线程判断资源是否被修改过,若没有则将计算的结果写入资源中
  3. 之后计算玩的线程判断资源已经被修改,则会执行放弃修改、报错、重试等设置的策略

悲观锁认为:若不锁住资源,别的线程就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,将数据锁住,别的线程就无法访问该数据,这样就可以保证数据的正确性

Java中的悲观锁实现就是synchronizedLock相关类,悲观锁的执行流程如下:

  1. 多个线程同时抢一把锁,只有一个线程能抢到,未抢到的线程就需要等待
  2. 抢到锁的线程将锁释放后,后续线程接着抢
  3. 直到所有线程都执行完毕

乐观锁和悲观锁对比

悲观锁会带来以下问题:所以适用于并发写入少,大部分是读取场景,不加锁就能让读取性能大幅度提高

  • 若自旋时间很长或不停重试,那么消耗资源也会越来越多

悲观锁会带来以下问题:所以适用于并发写入多的情况,适用于临界区持锁时间比较长(IO操作、代码复杂循环量大、竞争激烈)的情况,悲观锁可以避免长时间无用自旋等消耗

  • 性能损耗:被悲观锁锁住后,其他想获取相同资源就必须等待,线程阻塞和唤醒会带来性能问题,比如上下文切换、操作系统用户态和心态切换、检查是否有阻塞线程要被唤醒等
  • 可能永久阻塞:若持有锁的线程因为陷入死循环、死锁等活跃性问题,无法释放锁,那么等待的线程就会永久阻塞
  • 优先级反转:若持有锁的线程优先级低,未持有锁的优先级高,若持有锁优先级低的线程长时间不释放锁,就会导致优先级高的线程也得不到执行

可重入锁和不可重入锁

可重入锁又称为递归锁

可重入性质:多个线程可多次获取同一把锁

  • 可重入锁原理:获取锁时先判断,若当前线程已经占有该锁,则获取锁该锁的次数加1;释放说时也是先判断,若当前线程获取该锁的次数若等于0,才真正的释放
  • 不可重入锁原理:获取锁时直接将该锁设置为已被占有状态,当再次获取时就无法获取;释放锁时也是直接设置为未被占有状态

Java中可重入锁有synchronizedReentrantLock;Java中不可重入锁有ThreadPoolExecutor.Worker

private static ReentrantLock reentrantLock = new ReentrantLock();

private static void accessResource() {
  reentrantLock.lock();  //加锁
  try {
    if (reentrantLock.getHoldCount() < 5) {
      System.out.println("加锁次数:" + reentrantLock.getHoldCount());
      accessResource();  //递归调用
      System.out.println("加锁次数:" + reentrantLock.getHoldCount());
    }
  } finally {
    reentrantLock.unlock();  //解锁
  }
}

public static void main(String[] args) {
  accessResource();
}

公平锁和非公平锁

公平是指按照线程请求顺序来分配锁;非公平是指不完全按照线程请求顺序来分配锁,在一定情况下允许插队

  • 公平锁的原理:获取锁之前先判断是否有其他线程在排队,若没有才尝试获取锁
  • 非公平锁的原理:不管是否有是否有其他线程在排队,都尝试获取锁

对比公平锁和非公平锁

  • 公平锁
    • 优点:每个线程等待一段时间后,总有执行的集汇
    • 缺点:存在空档期,更慢,吞吐量小
  • 不公平锁
    • 优点:不存在空档期,更快,吞吐量大
    • 缺点:有可能产生线程饥饿现象,比如某些线程长时间被其他线程插队而得不到执行

Java中的锁默认就是非公平锁,之所以这样设计是因为,这样可以避免唤醒线程带来的空档期,从而提高效率,具体插队的策略从下面代码可以看出

tryLock()方法不遵守设定的公平规则,当线程执行tryLock()方法时,一旦有线程释放了锁,那么该线程就能立刻获得到锁,即使已经有其他线程在等待队列中了

/**
 * 公平锁的情况下:同一个线程在第二次获取锁时也会被排到最后
 * 非公平锁的情况下:同一个线程在第二次获取锁时会直接获取,也就是插队现象
 */
//private static ReentrantLock reentrantLock = new ReentrantLock();  //默认是非公平锁
private static ReentrantLock reentrantLock = new ReentrantLock(true);  //传入true是公平锁

public static void main(String[] args) throws InterruptedException {
  for (int i = 0; i < 5; i++) {
    new Thread(() -> {
      Random random = new Random();
      reentrantLock.lock();  //第一次上锁
      try {
        System.out.println(Thread.currentThread().getName() + "第一次上锁");
        Thread.sleep(random.nextInt(3000));  //等一会再释放锁
      } catch (InterruptedException e) {
        e.printStackTrace();
      } finally {
        reentrantLock.unlock();
      }

      reentrantLock.lock();  //第二次上锁
      try {
        System.out.println(Thread.currentThread().getName() + "第二次上锁");
        Thread.sleep(random.nextInt(3000));  //等一会再释放锁
      } catch (InterruptedException e) {
        e.printStackTrace();
      } finally {
        reentrantLock.unlock();
      }
    }).start();
    Thread.sleep(10);  //保证线程的启动顺序
  }
}

共享锁和排他锁

排他锁:又称为独占锁、独享锁,获取该锁后,既能读又能写,但是其他线程无法获取排他锁

共享锁:又称为读锁,获取该锁后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,同时也可以查看但无法修改和删除

若在读的地方使用读锁,在写的地方使用写锁,灵活控制,在没有写锁的情况下,读是无阻塞的,可大大提高程序的执行效率,共同使用共享锁和排他锁具有如下规则:简单的说要么同时读,要么一个线程写,但两者不会同时出现(要么多读,要么一写)

  • 多个线程只申请读锁都可以申请到
  • 若有一个线程已经占有读锁,则此时其他线程申请写锁需要等待读锁释放
  • 多有一个线程已经占有写锁,则此时其他线程如果申请写锁或读锁需要等待写锁释放

Java中共享锁和排他锁就是ReentrantReadWriteLock,其中读锁是共享锁,写锁是排他锁

private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();  //该方法返回读写锁的读锁
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();  //该方法返回读写锁的写锁

private static void read() {
  readLock.lock();
  try {
    System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
    Thread.sleep(1000);
  } catch (InterruptedException e) {
    e.printStackTrace();
  } finally {
    System.out.println(Thread.currentThread().getName() + "释放读锁");
    readLock.unlock();
  }
}

private static void write() {
  writeLock.lock();
  try {
    System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
    Thread.sleep(1000);
  } catch (InterruptedException e) {
    e.printStackTrace();
  } finally {
    System.out.println(Thread.currentThread().getName() + "释放写锁");
    writeLock.unlock();
  }
}

public static void main(String[] args) {
  new Thread(() -> read()).start();
  new Thread(() -> read()).start();
  new Thread(() -> write()).start();
  new Thread(() -> write()).start();
}

读线程插队性质

公平的读写锁:不允许读锁写锁插队

非公平的读写锁:若允许读锁插队不允许写锁插队虽然可提高程序执行效率,但是可能会造成写锁线程饥饿,所以Java中的ReentrantReadWriteLock使用下面的插队策略

  • 写锁随时可插队,之所以这样是因为读锁可以共存,写锁不能共存,就不容易插队,就不容易造成饥饿现象
  • 读锁仅在等待队列头节点不是想获取写锁的线程时才可以插队,之所以这样是因为可以减少空档期的时间浪费
public class Main {
  private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();  //默认是非公平锁
  //private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);  //传入true是公平锁
  private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();  //该方法返回读写锁的读锁
  private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();  //该方法返回读写锁的写锁

  private static void read() {
    readLock.lock();
    try {
      System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    } finally {
      System.out.println(Thread.currentThread().getName() + "释放读锁");
      readLock.unlock();
    }
  }

  private static void write() {
    writeLock.lock();
    try {
      System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    } finally {
      System.out.println(Thread.currentThread().getName() + "释放写锁");
      writeLock.unlock();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    /**
     * 公平锁的情况下:不允许任何锁插队
     * 非公平锁的情况下:若头节点是读线程时允许读线程插队;写线程可随时插队
     */
    //writeBarge();  //写线程插队
    readBarge();  //读线程插队
  }

  public static void readBarge() {
    Thread[] thread = new Thread[1000];
    for (int i = 0; i < 1000; i++) {
      thread[i] = new Thread(() -> read(), "子线程" + i);  //将子线程创建放在最外面,以防对象创建影响线程执行顺序
    }

    new Thread(() -> write()).start();
    new Thread(() -> read()).start();
    //此处空隙有一瞬间等待队列中头节点是读线程,也就是这里直接可以有子线程插队
    new Thread(() -> read()).start();
    new Thread(() -> write()).start();  //多运行几次,让这个写线程在后面更明显
    new Thread(() -> {
      for (int i = 0; i < 1000; i++) {
        thread[i].start();
      }
    }).start();  //子线程中启动很多读线程,可插队到写线程之前
  }

  public static void writeBarge() {
    Thread[] thread = new Thread[1000];
    for (int i = 0; i < 1000; i++) {
      thread[i] = new Thread(() -> read(), "子线程" + i);  //将子线程创建放在最外面,以防对象创建影响线程执行顺序
    }
    new Thread(() -> write()).start();
    new Thread(() -> {
      for (int i = 0; i < 1000; i++) {
        thread[i].start();
      }
    }).start();  //子线程中创建很多读线程,下面写线程可以插队到读子线程之前
    new Thread(() -> write()).start();
  }
}

锁的升降级

写锁可以降级为读锁,读锁不可以升级为写锁,之所以这样设计是为了避免升级造成的死锁,比如:要知道必须等所有的读锁都释放才能进行升级成写锁,若多个线程都想升级成写锁,都不释放自己的读锁,就会造成死锁

public class Main {
  private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
  private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
  private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

  private static void readUpgrading() {
    readLock.lock();
    try {
      System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
      Thread.sleep(1000);
      System.out.println("升级会带来阻塞");
      writeLock.lock();
      System.out.println(Thread.currentThread().getName() + "获取到了写锁,升级成功");
    } catch (InterruptedException e) {
      e.printStackTrace();
    } finally {
      System.out.println(Thread.currentThread().getName() + "释放写锁");
      writeLock.unlock();
      System.out.println(Thread.currentThread().getName() + "释放读锁");
      readLock.unlock();
    }
  }

  private static void writeDowngrading() {
    writeLock.lock();
    try {
      System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
      Thread.sleep(1000);
      readLock.lock();
      System.out.println("在不释放写锁的情况下,直接获取读锁,成功降级");
    } catch (InterruptedException e) {
      e.printStackTrace();
    } finally {
      System.out.println(Thread.currentThread().getName() + "释放读锁");
      readLock.unlock();
      System.out.println(Thread.currentThread().getName() + "释放写锁");
      writeLock.unlock();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    //readUpgrading();
    writeDowngrading();
  }
}

自选锁和阻塞锁

阻塞或唤醒一个线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费CPU的时间,若同步代码块中的内容过于简单,状态切换消耗时间有可能比用户代码执行时间还要长,也就是同步资源锁定时间很短

自旋锁:若物理机有多个CPU,且并发低,同步资源锁定时间很短,能够让两个或以上的线程同时并行执行,就可以让后面那个请求锁的线程不进入阻塞状态,而是一直自旋检测持有锁的线程是否释放了锁,这个线程就可以不必阻塞,此后直接获取同步资源,从而避免切换线程的开销;但是若锁占用时间很长,那么自旋线程只会白白浪费CPU资源

阻塞锁:与自旋锁相反,在没拿到锁的情况下会直接将线程阻塞,直到被唤醒

下面代码只是为了模拟,大致思路是这样,后面可使用原子类实现

public class SpinLock {
  private volatile Thread thread = null;  //volatile保证线程可见性
  //private AtomicReference<Thread> sign = new AtomicReference<>();  //引用类型原子类

  public void lock() {
    Thread current = Thread.currentThread();
    do {
      //if (sign.compareAndSet(null, current)) break;  //判断并赋值的原子操作
      if (thread == null) {  //线程不安全,判断和赋值应该是原子操作,只是为了模拟,大致思路是这样
        thread = current;
        break;
      }
      System.out.println(current.getName() + "自旋获取失败,再次尝试");
    } while (thread != current);
  }

  public void unlock() {
    Thread current = Thread.currentThread();
    //sign.compareAndSet(current, null);  //判断并赋值的原子操作
    if (thread == current) {  //只有是当前线程上的锁才能解锁,线程不安全,判断和赋值应该是原子操作,只是为了模拟,大致思路是这样
      thread = null;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SpinLock spinLock = new SpinLock();
    Runnable runnable = () -> {
      spinLock.lock();
      System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
      try {
        Thread.sleep(10);  //等一会在释放自旋锁
      } catch (InterruptedException e) {
        e.printStackTrace();
      } finally {
        System.out.println(Thread.currentThread().getName() + "释放了自旋锁");
        spinLock.unlock();
      }
    };
    new Thread(runnable).start();
    new Thread(runnable).start();
  }
}

可中断锁和不可中断锁

若一个线程正在等锁期间,可以响应中断信号,这种锁就是可中断锁

在Java中,synchronized就是不可中断锁,Lock就是可中断锁因为tryLock(time)和lockInterruptibly()都能响应中断

public class SpinLock {
  private volatile Thread thread = null;  //volatile保证线程可见性

  public void lock() {
    Thread current = Thread.currentThread();
    do {
      if (thread == null) {  //线程不安全,只是为了模拟,大致思路是这样
        thread = current;
        break;
      }
      //System.out.println(current.getName() + "自旋获取失败,再次尝试");
    } while (thread != current);
  }

  public void unlock() {
    thread = null;
  }

  public static void main(String[] args) throws InterruptedException {
    SpinLock spinLock = new SpinLock();
    Runnable runnable = () -> {
      spinLock.lock();
      System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
      try {
        Thread.sleep(10);  //等一会在释放自旋锁
      } catch (InterruptedException e) {
        e.printStackTrace();
      } finally {
        System.out.println(Thread.currentThread().getName() + "释放了自旋锁");
        spinLock.unlock();
      }
    };
    new Thread(runnable).start();
    new Thread(runnable).start();
  }
}

锁优化

JVM对锁的优化

  • 自旋锁和自适应锁:在自旋时,若长时间获取不到锁,就会转换为阻塞锁
  • 锁消除:在编译代码时,检测到根本不存在共享数据竞争,也就无需同步加锁了,但是一旦存在逸出,就意味着不能轻易将锁消除,无法知道外部是如何使用这些资源的
  • 锁粗化:通常需要将同步块尽可能的小,但一系列连续对一个对象反复的加锁和解锁,这会导致不必要的新能损耗,这种情况JVM会将锁的范围扩大到整个操作序列

编写代码对锁的优化

  • 缩小锁的范围,尽量使用同步代码块,而不是同步方法
  • 尽量锁中不再包含锁,以免造成死锁
  • 减少请求锁的次数(比如将所有需要锁的操作汇集到一起提交一个任务,批量操作)
  • 选择合适的锁类型或合适的工具类
此作者没有提供个人介绍
最后更新于 2021-06-12