java高并发程序设计:JUC

java高并发程序设计:JUC


前言


本文为作者学习笔记,内容包括了自己的一些理解,所以可够准确,希望路过的小伙伴可以指出来。

一、多线程的团队协作:同步控制

1.1重入锁

  • 重温一下造成死锁的条件
    1.互斥共享资源 X 和 Y 只能被一个线程占用
    2.占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X
    3.不可抢占,其他线程不能强行抢占线程 T1 占有的资源
    4.循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
  • 前面在介绍死锁问题的时候,提出了一个破坏不可抢占条件方案,但是这个方案 synchronized 没有办法解决。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。但我们希望的是:对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了
    问:如果我们重新设计一把互斥锁去解决这个问题,那该怎么设计呢?
    答:三种方案。1.能够响应中断。**synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。**但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。2.支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。3.非阻塞地获取锁如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这三个方案就是“重复造轮子”的主要原因,**体现在 API 上,就是 Lock 接口的三个方法。**详情如下:

// 支持中断的API
void lockInterruptibly() 
  throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit) 
  throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
  • 由以上结论可以知道,重入锁由JDK实现主要应用于解决死锁问题。与synchroized由jvm实现的方式不一样。有一个概念先说清楚,不然后面混淆,重入锁和可重入锁是两个概念,重入锁指的是由jdk实现的reentrantLock锁,可重入锁指的是一种可重入概念,是一种现象,synchroized也是可重入锁,与可重入相对的就是不可重入锁,指的是当拿到过一次锁后再次拿锁会被阻塞。重入锁的写法参考如下:
    在这里插入图片描述
    可以明显的看到重入锁的加锁释放锁的过程是显示的,这也意味着重入锁的逻辑控制灵活性相对synchroized较高。

  • 重入锁允许一个线程连续重复的获得同一把锁,只需要也释放同样数量的锁就可以了,如果释放的锁数量不够,会造成死锁。
    在这里插入图片描述

1.1.1 中断响应(重入锁的特点)

与synchroized不同,重入锁ReentrantLock提供中断功能,不同于synchroized锁在线程等待锁时只有两种可能:1.获得锁继续执行,2.保持等待。ReentrantLock可以根据需要取消对锁的等待去做其他的事。比如当发生死锁的时候就需要有人先释放出琐资源才能解决,这时候利用中断就非常的重要了。
注意:重入锁的加锁过程是由开发人员操作的,除了lock()方法申请锁外还有其他的,比如这里要申请可以中断的锁,就需要用lockInterruptibly()方法申请可中断锁。

1.1.2 锁申请等待限时(重入锁的特点)

  • 使用重入锁的tryLock()方法设置申请锁的等待时间,如果超过了等待的时间就会放弃申请锁。显然,这又是一个解决死锁的好方法。
  • 当tryLock()方法不带参数时(也就是没设置等待时间)如果没获取到锁该线程会立即放弃等待锁并返回false(tryLock()是布尔类型)。

1.1.3 公平锁(重入锁的特点)

  • 公平锁会按照时间的先后顺序,保证先到的先得到锁资源,最大的特点就是公平锁不会产生饥饿现象
  • 重入锁设置参数可以控制锁是否为公平锁,而synchroized锁只能是非公平的。使用方法如下:
    在这里插入图片描述
    在这里插入图片描述

1.1.4 重入锁实现的三大要素

  • 原子状态使用CAS技术来存储当前锁的状态,判断锁是否被别的线程占用。
  • 等待队列没请求到锁的线程会被放到等待队列中进行等待
  • 阻塞原语parl()和unpark():用于挂起和恢复,将没有得到锁的线程挂起。

1.2重入锁的搭档:Condition条件

  • Condition条件是专门和重入锁搭配的。
  • Condition是一个接口,最常用最基本的方法是await()和signal()方法
  • Condition的实现依赖于Lock接口,由lock.newCondition()可以生成一个Condition对象。
  • Condition的await()方法和signal()方法都必须在拿到锁资源的情况下(代码必须在lock.lock()和lock.unlock()之间)才能执行,这个和object的wait和notify方法一样。
  • Condition主要也是用来解决线程的调度问题。

下面是lock接口搭配condition(condition实现的reentrantlock锁和lock的方法)实现的另一套管程(监视器)代码:


public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满
        notFull.await();
      }  
      // 省略入队操作...
      //入队后,通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        notEmpty.await();
      }  
      // 省略出队操作...
      //出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

这里需要注意,Lock 和 Condition 实现的管程,线程等待和通知需要调用 await()、signal()、signalAll(),它们的语义和 wait()、notify()、notifyAll() 是相同的。但是不一样的是,Lock&Condition 实现的管程里只能使用前面的 await()、signal()、signalAll(),而后面的 wait()、notify()、notifyAll() 只有在 synchronized 实现的管程里才能使用。如果一不小心在 Lock&Condition 实现的管程里调用了 wait()、notify()、notifyAll(),那程序可就彻底玩儿完了。

1.3允许多个线程同时访问:信号量(Semaphore)设计限流器

  • 属于多线程功能的扩展,本质上是对锁的扩展
  • 总之,信号量是一个允许同时多个线程拿到锁的多线程控制工具,其构造函数如下:
public Semaphore(int permits)
public Semaphore(int permits,boolean fair) //第二个参数用于指定是否为公平锁。
  • Semaphore 可以允许多个线程访问一个临界区。现实中的应用情况。
    1.比较常见的需求就是我们工作中遇到的各种池化资源,例如连接池对象池线程池等等。其中,你可能最熟悉数据库连接池,在同一时刻,一定是允许多个线程同时使用连接池的,当然,每个连接在被释放前,是不允许其他线程使用的。
    2.所谓对象池呢,指的是一次性创建出 N 个对象,之后所有的线程重复利用这 N 个对象,当然对象在被释放前,也是不允许其他线程使用的。对象池,可以用 List 保存实例对象,这个很简单。但关键是限流器的设计,这里的限流,指的是不允许多于 N 个线程同时进入临界区。那如何快速实现一个这样的限流器呢?信号量的解决方案。信号量的计数器,在上面的例子中,我们设置成了 1,这个 1 表示只允许一个线程进入临界区,但如果我们把计数器的值设置成对象池里对象的个数 N,就能完美解决对象池的限流问题了。代码如下:

class ObjPool<T, R> {
  final List<T> pool;
  // 用信号量实现限流器
  final Semaphore sem;
  // 构造函数
  ObjPool(int size, T t){
    pool = new Vector<T>(){};
    for(int i=0; i<size; i++){
      pool.add(t);
    }
    sem = new Semaphore(size);
  }
  // 利用对象池的对象,调用func
  R exec(Function<T,R> func) {
    T t = null;
    sem.acquire();
    try {
      t = pool.remove(0);
      return func.apply(t);
    } finally {
      pool.add(t);
      sem.release();
    }
  }
}
// 创建对象池
ObjPool<Long, String> pool = 
  new ObjPool<Long, String>(10, 2);
// 通过对象池获取t,之后执行  
pool.exec(t -> {
    System.out.println(t);
    return t.toString();
});

1.4 读写锁(ReadWriteLock)

  • 读写锁面向的是多线程性能问题,用于在一定条件先提升多线程性能的。
  • 因为读和读操作是不存在可见性、原子性、有序性等问题的,所以当在有大量读操作的应用中,不同线程的读操作却是阻塞的,这样非常不合理。所以读写锁遵循一下阻塞约束:
    在这里插入图片描述
  • 读锁不能升级为写锁:本线程在释放读锁之前,想要获取写锁是不一定能获取到的,因为其他线程可能持有读锁(读锁共享),可能导致阻塞较长的时间,所以java干脆直接不支持读锁升级为写锁。
    写锁可以降级为读锁:本线程在释放写锁之前,获取读锁一定是可以立刻获取到的,不存在其他线程持有读锁或者写锁(读写锁互斥),所以java允许锁降级。
  • 这也是lock接口下面的,所以支持重入,也支持条件变量等

1.5 读写锁的子集:比读写锁更快的StampedLock

  • ReadWriteLock 支持两种模式:一种是读锁,一种是写锁
    StampedLock 支持三种模式,分别是:写锁、悲观读锁和乐观读。其中,写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。
  • StampedLock 的性能之所以比 ReadWriteLock 还要好,其关键是 StampedLock 支持乐观读的方式。ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。注意这里,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,乐观读这个操作是无锁的,所以相比较 ReadWriteLock 的读锁,乐观读的性能更好一些。
  • 注意事项:对于读多写少的场景 StampedLock 性能很好,简单的应用场景基本上可以替代 ReadWriteLock,但是 StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。
    1.StampedLock 是不可重入的
    2.StampedLock 的悲观读锁、写锁都不支持条件变量
    3.使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。

1.6 倒计时器:CountDownLatch

  • 控制调用线程在指定步骤完成后才能继续执行。也可以说是必须等待指定数量的线程完成了任务他才可以继续执行。

1.7 循环栅栏:CyclicBarrier

  • 简单的描述,这就是个增强版倒计时器,CountDownLatch是等待一批任务完成后才能继续执行,那么循环栅栏就是等待一批批的执行。比如说,他等待了一批指定数量的线程完成了任务后,他执行了,然后他还可以继续又等指定数量的一批线程,然后继续执行…
  • 当然了只是这样那就没意义了,比倒计时器多个功能,那就是CyclicBarrier可以接收一个参数作为barrierAction,是在计数完成一次(等待完了一批)后系统会执行的动作。如下:
public CyclicBarrier(int parties,Runnable barrierAction)

第二个参数就是传入的动作。

  • CountDownLatch和CyclicBarrier总结
    CountDownLatch 主要用来解决一个线程等待多个线程的场景,可以类比旅游团团长要等待所有的游客到齐才能去下一个景点;而 CyclicBarrier 是一组线程之间互相等待,更像是几个驴友之间不离不弃。除此之外 CountDownLatch 的计数器是不能循环利用的,也就是说一旦计数器减到 0,再有线程调用 await(),该线程会直接通过。但 CyclicBarrier 的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到 0 会自动重置到你设置的初始值。除此之外,CyclicBarrier 还可以设置回调函数,可以说是功能丰富。

1.8 线程阻塞工具类:LockSupport

  • 是用于提供线程阻塞得工具类。
  • 主要的方法是park() unpark()方法,他们与最开始提到的suspend() 和resume() 是一样的,都是挂起和唤醒操作
  • park和unpark方法与Object的wait 和notify相比不需要获得锁才能执行,与suspend和resume相比,弥补了resume发生在前导致线程无法继续执行的情况,并且park和unpark不会抛出中断异常

总结

其实是这样串起来的:

  • 监视器(管程),是一个抽象的概念,而他的实现分为操作系统层面和java层面,其中操作系统层面和java层面的区别是条件等待队列的数量不同,java层面只有一个(这里的只有一个是讲的使用synchronized那一套的管程只有一个条件阻塞队列,java语言内置的管程里只有一个条件变量,而 Lock&Condition实现的管程是支持多个条件变量的,这是二者的一个重要区别。)而os有多个。
  • 实际上监视器才是真正保证临界区资源的最小单位,并不能狭隘的只是认为synchronized或者什么重入锁。而监视器由synchronized、wait、notify、notifyall这一套组成,本文呢描述了另一套监视器组成,那就是reentrantLock、park、unpark,其实准确的说是由lock和condition组成的,因为reentrantlock以及后面的方法都是由lock接口实现的,目的是解决synchronized面对的死锁问题。
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值