JAVA基础复习(二):并发

  • JAVA基础复习(二):并发
    • 背景知识
      • 进程与线程
      • Java中的线程
    • Java并发机制的底层实现原理
      • 原子性、可见性与有序性
        • 原子性
        • 可见性
        • 有序性
      • volatile和synchronized的应用与实现
        • 应用与实现原理
      • 锁的底层实现原理
        • java对象头中有锁
        • 锁的升级与对比
      • 原子操作的实现原理
    • Java内存模型
      • java内存模型的基础
        • 两个问题与两种模型
        • java内存模型的抽象结构
        • java中重排序
        • happens-before
        • 顺序一致性
      • 内存语义
        • volatile的意义
        • 内存语义
        • 锁的内存语义
        • final的内存语义
      • 双重检查锁定与延迟初始化
      • JSR-133的语义增强
    • 并发编程基础
      • 理解线程
        • 线程状态
        • Daemon线程
        • Thread部分源码分析
        • wait/notify,等待/通知机制
        • 管道输入、输出流
    • 显式锁
      • 显式锁的接口:Lock接口
      • 显式锁的基础:队列同步器
        • AQS干了一件是什么事儿?
        • AQS中的同步队列
        • AQS部分源码分析
        • LockSupport工具类
        • Condition接口
      • 显式锁的一种常见实现:重入锁,ReentrantLock
      • 显式锁的另一种常见实现:读写锁,ReentrantReadWriteLock
    • Java并发容器和框架
      • ConcurrentHashMap
        • 概述
        • 方法分析
      • ConcurrentLinkedQueue,并发无界单向非阻塞队列
        • 概述
        • 方法分析
      • 阻塞队列
        • 为什么要有阻塞队列?
        • 阻塞队列的重要方法
        • 有哪些阻塞队列?
      • Fork-join框架
        • 基本概念
        • 使用方法
    • 原子类与并发工具类
      • 原子类
      • 并发工具类
    • 线程池
      • 为什么要用线程池?
      • 实现原理
      • 如何合理的使用线程池
    • Executor框架
      • 为什么要有这框架?
      • Executor框架中有哪些东西?

JAVA基础复习(二):并发

  • 本文配合《Java并发编程的艺术》食用风味更佳。

背景知识

进程与线程

  • 进程
    • 资源所有权。进程是资源分配的最小单位,由操作系统来安排。
    • 调度/执行。操作系统为进程分配时间片,通过调度算法来安排处理器调度进程。
  • 线程
    • 进程的主要任务是1.资源分配2.系统调度和执行的单位。将资源分配依然交给进程处理,将系统调度和执行的最小单位细化成线程。
    • 进程具有至少一个线程,线程间可以共享资源,线程间通信的问题也自然解决了。
    • 线程比进程的创建、切换、销毁的成本小。
  • 多线程(并行编程)的好处
    • 充分利用多个CPU。
    • 防止任务的阻塞,比如socket在accept方法中阻塞等待连接的时候,就可以干别的事情。阻塞的线程进行阻塞队列,正常运行的在另一个队列,时间片不会分配到阻塞队列。

Java中的线程

  • 新建现成的三种方式
    • 实现Runnable方法,重写run方法,利用代理模式交给Thread类.start。
    • 继承Thread类,重写run方法,start调用
    • 实现Callable接口,重写call方法,可以有返回值和异常,通过FutureTask类接收返回值。可以在主线程运行的过程中异步运行线程A,然后在主线程中通过判断是否获得线程A的返回值来决定下一步操作。
  • 守护线程,即后台线程,GC的时候用的就是这个,当所有普通线程去世后,守护线程就退出。从普通线程生成的线程默认是普通线程,从守护线程中生成的默认是守护线程。
  • sleep方法可以让线程休眠一段时间,但是不释放锁,因为sleep是线程方法,和锁没关系。
  • 让出本次处理器时间片,即yield方法,表示建立处理器不要在此时间片时间内继续执行本程序,但是并不代表一定不执行。
  • join方法,A线程中调用B.join的意思是,A等待B运行完。
  • java的线程调度策略是基于优先级的抢占式调度
  • 减少上下文切换的方法
    • 无锁并发编程。多线程竞争锁的时候会引起上下文切换 TODO:为什么,可以通过Hash算法将ID取模分段,不同线程处理不同段。
    • CAS算法, 原子操作,不用上锁。
    • 采用尽量少的线程,只创建必要数量的线程,不要让大量线程处于等待状态。
    • 使用协程而不是线程,协同式多线程是把一个任务分配到不同的线程上,但是一次性只有一个线程能够运行,并且需要手动的释放线程,程序员完全掌握线程的释放时机,占用时机,本质上是串行化运行,但是可以简化编程,LUA语言中用的就是协程。

Java并发机制的底层实现原理

原子性、可见性与有序性

原子性

  • 某一个或一组操作视为不可被其他线程打断的操作。线程1对A上锁后,其他申请A锁的线程都会进入阻塞队列,直到线程1释放锁,如果线程2不要求A锁,那线程2和线程1是可以交替时间片运行的。举例,1想在A坑大便,3也想去A坑大便,2想去B坑大便。于是1和3争夺A坑,1成功了,就上锁以防止自己一泻千里的时候3突然进入。3此时只能在外面等着,而2可以去B坑。synchronized具有原子性,释放锁之前必须先将变量同步回主内存,因此也具有可见性。
  • 如何保证原子性。
    • 采用局部变量或ThreadLocal类。即将线程共有变量变为线程私有的,这样就不怕其他线程改变你的变量了。
    • 采用锁。争抢同一份资源的线程,当其中一个争抢到后就上锁,其他的进阻塞队列等待释放锁。
      • synchronized可以锁一个对象,对于成员方法可以用this作为锁,对于静态方法可以用class作为锁
      • 可重入锁是指当一个线程请求一个它已经获得的锁的时候,这个请求仍然会成功。
    • 声明为final。

可见性

  • 可见性指的是当一个线程修改一个共享变量时,另外一个线程能够读到这个修改的值。volatie确保可见性,synchronized保证可见性和原子性,final保证可见性因为无法被修改,但是也有例外。
  • 可见性不保证原子性,就算i被设计成volatie,多线程下的i++依然会出现问题。

有序性

  • 有序性是指代码在单线程情况下,执行的最终结果和重排序之前是一样的,但是放在多线程中却可能导致并发问题。

volatile和synchronized的应用与实现

应用与实现原理

  • volatile的原理:1.将变量的值写入当前CPU缓存中。2.通知其他CPU缓存的数据无效,因此其他CPU必须重写读取。
  • synchronized的应用
    • http://www.importnew.com/21866.html

    • 对于普通同步方法,锁是当前实例对象,即防止多个线程同时访问这个线程的synchronized方法,但是非synchronized方法可以被其他线程访问。但是锁的是同一个实例,因此如果A a1=new A(),A a2=new A(),那么a1不会阻塞a2
      • 在方法前加synchronized关键字相当于对整个方法加锁,锁是this,即当前对象实例
      • 构造方法不能使用synchronized关键字,但是可以用synchronized代码块同步
    • 对于静态同步方法,锁是当前类的class对象。值得注意的是子类重写父类方法后不会自动具有synchronized关键字,需要显式给予,如果子类没有重写相当于调用父类方法,自动获得同步效果。
      • 锁是当前类的class对象,即这个类的所有实例化对象都被锁。
    • 对于同步方法块,锁是synchronized括号里配置的对象
      • 对指定对象加锁:除了拿到锁的那个线程,其他所有想操作lock对象的线程都被阻塞在使用lock的前一句代码,直到lock被释放。

          class Test implements Runnable
              {
              private byte[] lock = new byte[0];  // 特殊的instance变量
              public void method()
              {
                  synchronized(lock) {
                      // todo 同步代码块
                  }
              }
              
              public void run() {
              
              }
          }
        
      • 如果synchronized(this){}即是对当前对象实例加锁。

    • 不允许有synchronized和非同步的同名方法
    • 如果写操作为synchronized,但是读操作不是,那么读操作可能产生误读。因为非synchronized方法可以读取数据,但是这个数据可能同时在synchronized方法中存在,同步方法中虽然修改但是还未写进主存。容易产生脏读问题。
    • 总结:
      • 普通同步方法,锁是当前实例对象,即方法的this,即调用方法的实例对象.
      • 静态同步方法,锁是当前类的class对象,即所有能调用该静态方法的类、实例对象
      • 同步方法块,锁是括号里面的对象。
      • 能不能锁住要看争夺的是不是同一个对象,比如线程A中的lock和线程B中的lock(如上代码中的lock),A和B线程如果代理的是不同的Runnable,那么就不会同步,因为新建了两个Runnable对象,也就有了两个lock。
  • synchronized的原理:通过monitorenter和monitorexit锁定两个指令之间的所有指令,当遇到enter的时候就会检查锁对象的对象头,来决定当前线程是CAS尝试获取偏向锁还是自旋还是阻塞。当遇到exit时表示应该释放锁。

锁的底层实现原理

java对象头中有锁

  • 为什么synchronized锁的是对象?因为每个对象的对象头中都保存着一个锁的信息。

锁的升级与对比

  • 轻量锁。线程中会创建一个存储锁记录的空间,叫做lock record,里面存有displace mark word:初始化为锁的那个对象的对象头中的mark word。
    • 加锁的过程是线程通过CAS将对象的mark word替换成指向锁记录的指针,而锁记录中的owner指针又指向对象头。(线程的lock record的owner指针指向对象的mark word,而对象的markword已经变为了指向lock record的指针,这是一个双向指向的关系。)如果成功,说明当前线程获得锁,如果失败则尝试自旋获得。所谓自旋是指线程不放弃CPU时间片,在此期间频繁查询某一条件是否为真(即锁是否释放),直到锁的持有者释放锁,因此不会导致锁释放时CAS的失败。自旋的问题是1.占用CPU时间2.视图递归的获得自旋锁必然引起死锁,因为本实例已经获得了一个锁,但是递归中的第二个实例也像获得自旋锁,就变成了我等我自己。
    • 解锁的过程是线程将lock record中的displaced mark word通过CAS放回到对象头中。如果成功则替换完毕,如果失败,说明有锁竞争,此时膨胀为重量锁。注意,轻量锁的轻量体现在不阻塞,只是自旋
  • 重量锁。就是互斥锁,其他所有想要锁的线程,在同步代码块处阻塞。当线程释放锁后,会唤醒所有阻塞中的线程。
  • 偏向锁。本质上是最轻量的轻量锁,用于并发可能性较低的情况。比如你的方法是同步方法,但是其实只是偶尔会发生并发,大多数情况下都是不涉及并发的,就可以用偏向锁。
    • 加锁过程,将对象头的mark word中偏向锁标志位设置为1,存储线程ID,表明该锁偏向于这个ID的线程。如果mark word中是偏向锁,且存储着ID,就表示这个ID的线程自动获得了锁。你看,要满足两个条件,1.有偏向锁。2.存储着ID。假如是偏向锁但是没存储ID,就尝试用CAS设置当前进程ID,如果不是偏向锁,就采用CAS竞争偏向锁,如果竞争失败则说明偏向锁不适合当前场景,应该变为轻量锁。
    • 解锁过程,一旦发生竞争,持有偏向锁的线程就释放锁。释放之后根据锁对象状态,如果锁对象没有正在被加锁,即线程没有执行同步块,就撤销偏向锁,重新偏向线程。如果锁对象已经加锁,就考虑变为轻量锁来争夺锁。这里考虑有两层意思,一是如果释放的时候没有释放成功就会膨胀成重量锁,而是如果自旋过久也会膨胀成重量锁。
  • 锁转换关系
    • JAVA基础复习(二):并发_第1张图片

原子操作的实现原理

  • CAS,Compare and swap,指的是比较并交换,有两个操作数,一个是期望的旧值,一个是新值,就有期望的旧值和被替换的值相同,才会替换为新值。否则就进行循环CAS,这里的循环指的是反复尝试CAS,如果CAS失败,则更新期望的旧值,并进行下一次CAS。出现的问题是1.ABA,2.循环开销大,3.只能保证一个变量的原子操作。解决方案1.时间戳3.将多个变量合成一个对象进行原子更新。
  • 锁。JAVA中除了偏向锁都采用了循环CAS的方式。可以理解,因为偏向锁只需要一次CAS(在创建偏向锁的时候),之后的操作都只是比较偏向锁中的ID是否指向线程而已,一旦出现CAS失败的情况就根本不会循环CAS,而是直接变为轻量锁。轻量锁情况下通过循环CAS竞争锁,竞争到的持有锁,没有的自旋,直到持有锁的线程释放后,自己再CAS拿锁。如果自旋时间过长,或者持有锁线程释放失败,此时膨胀为重量锁。

Java内存模型

java内存模型的基础

两个问题与两种模型

  • 关键问题是:线程间的通信与同步。模型为:消息传递与共享内存。
  • java采用共享内存的方式。

java内存模型的抽象结构

  • 只有堆、方法区中的变量(即实例域、静态域、数组元素)才会有并发问题。
  • 主内存中是共享变量,每个线程中都有共享变量的一个副本,你在自己的线程里修改这个副本并不会导致主内存中的变量发生变化,除非你将自己工作线程中的变量刷新到了主内存中。线程B可以通过在主内存中读取A写入的变量来实现A与B线程的通信。

java中重排序

  • 3种重排序:编译器重排序,CPU重排序,内存重排序(缓存与主内存数据不一致,各个CPU或核心间缓存数据也不同步)。JMM通过制定重排序规则,在编译器层面上禁止部分重排序。在生成的指令中插入内存屏障来禁止部分CPU重排序。
  • 4种内存屏障。loadload,storestore,loadstore,storeload。以storeload为例,store1 storeload load2,即store1刷新到主存之后才允许读load2,并且先于一切读指令。
  • 数据依赖性。注意,数据依赖性只在单线程或单处理器中被考虑,多线程情况下不考虑数据依赖性。
  • as-if-serial。同样也是单线程情况下,不论怎么重排序,不能改变结果。
  • 程序顺序规则。即happens before,语句a happens before 语句b,但是语句b可能重排序在语句a之前。
  • 重排序会破坏多线程程序语义。

happens-before

  • JMM通过规定一些规则来确保happens-before。happens-before是一种定义,是告诉程序员,只要两个操作之间满足了happens before关系,则JMM保证了以下的内存可见性:
    • 一个线程中的每一个写操作都对下一个操作可见,这叫一个线程的每个操作都happens before后序操作。
    • 一个线程解锁后,该线程解锁前做的所有写操作均对另一个获得了同一把锁的线程可见,这叫对一个锁的解锁,happens-before随后对这个锁的加锁。
    • 对volatile变量的写操作对之后所有对这个volatile变量的读操作都可见。
    • 传递性。如果A happens before B,B happen before C,则A happens before C
    • 以上所有“以后”都指的是代码顺序,值得注意的是,代码顺序并不意味着CPU执行顺序,因为有重排序。

顺序一致性

  • 数据竞争是指在一个线程中写一个变量,在另一个线程中读一个变量,但是写和读之间没有通过同步来排序。JMM保证如果正确同步,就保证顺序一致性。
  • 所谓顺序一致性指的是A线程内部有a1,a2,a3的代码执行顺序,B线程内部有b1,b2,b3的代码执行顺序。如果正确同步,执行顺序应该是a1,a2,a3,b1,b2,b3,整体有序且线程A,B内部都有序。如果同步不正确,b1,a1,a2,b2,a3,b3,线程A,B有序,但整体无序。
  • JMM在未同步时并不保证任何的顺序一致性,只保证最小安全性,即数据不会无中生有,要么是默认初始值,要么是脏数据。正确同步时同步块内(临界区)可以重排序,但是准守顺序一致性。
  • long/double都是64位的,因此写操作分高32位和低32位,非原子操作。但是读操作必须具有原子性。 不过volatile long/double必然是原子的

内存语义

volatile的意义

  • 简单理解volatile:具有可见性,对于一个变量的写操作总能被之后的读操作看到,即永远读到最新值。但是不具有原子性,即i++哪怕变量是volatile的,也不是原子性,因为有可能第一个线程中的i自加的过程中,第二个线程的i读到了值,此时第一个线程还没有涉及到写的动作,因此volatile的作用也无从谈起。
  • volatile的重要意义并不仅仅在修饰的那个变量之前,当其与happens before结合时将有无穷的力量。因为所有定义在valoatile 变量写操作之前的写操作都必然happens before于volatile。而下一次读volatile有必然读到最新的值,因此,可以说在读volatile的时候,必然获得了最新一次写volatile之前的所有可见性!

内存语义

  • volatile读的内存语义为:必须从主存中读取。写的内存语义:立即写入主存,并且volatile之前的变量写操作也会立即刷新至主存中。
  • 具体实现:利用内存屏障实现。不允许重排序,以此保证可见性。

锁的内存语义

  • 释放锁后,所有操作都将对获得锁的线程可见。
  • 锁释放的内存语音:立即写入主存。获得锁:必须从主存中读取。
  • JUC包中利用volatile和CAS实现锁的内存语义。在释放锁的时候写volatile类型变量state,在获得锁的时候读volatile变量state,就实现了获得锁的线程获得了释放锁的线程的一切操作的可见性。CAS保证原子性,并且具有volatile的读写内存语义。由此Lock类即保证可见性又保证原子性。
  • 至于synchronized关键字的底层实现,通过monitorenter和monitorextit表示获取锁和释放锁,每当遇到这个指令就要去根据对象头锁的类型来进行锁争夺操作。

final的内存语义

  • 要解决什么问题?构造函数中返回引用和构造方法中赋初值并非顺序性的,即有可能先返回了一个引用,但是这个引用没有赋初值。
  • 构造函数必然先初始化final域,后返回引用。
  • 读一个对象的final域之前,必然先读这个对象。
  • 如果final修饰的是引用类型。几乎和之前一样,想要读取这个对象之前,先要初始化这个对象的final域。
  • 总结,只要被构造对象的引用没有在构造函数中逸出,那即便不使用同步也可以保证在任意线程中都能看到这个final域在构造函数中被初始化之后的值。
  • **什么叫逸出?**一个类不是通过构造方法的返回值发布引用,而是在内部发布了引用。

双重检查锁定与延迟初始化

  • 经典双重锁定,错误版(懒汉式:程序需要这个实例时才去创建对象,创建时机晚于类加载过程,因此被称作懒)
    public class Instance{
        private Instance instance;
        private Instance(){}
        public Instance getInstance(){
            if(instance==null){
                synchronized(Instance.class){
                    if(instance==null)
                    instance = new Instance();
                }
            }
            return instance;
        }
    }
  • 问题:因为构造函数中可能存在重排序,即先返回对象引用,后初始化。这在单线程中没什么问题,只要在使用对象之前确认初始化即可,但是在多线程中,别的线程可能使用了其他线程中还未初始化完毕但已经返回引用的实例。
  • 解决方法:
  • 1.利用volatile修饰instance,以禁止初始化和返回instance实例的重排序。
    • https://www.zhihu.com/question/56606703/answer/674709525

  • 2.采用饿汉式单例(类加载的时候就创建好实例)
    • 当调用getInstance方法时,因为使用了invokeStatic指令,会进行类加载,因此会初始化类变量Instance。因为类加载时有锁以同步类加载的初始化过程,以此来保证单例。因为类加载的时候就创建好实例,因此外界无法获取到还未初始化的实例。
        public class Instance{
            private Instance(){}
            private static Instance = new Instance();
            public static Instance getInstance(){
                return instance
            }
        }

JSR-133的语义增强

  • volatile,严格限制volatile变量与普通变量的重排序,并且volatile的写-读与锁的释放-获取具有相同语义。
  • final通过读、写重排序规则,保证final变量具有初始化安全性。

并发编程基础

理解线程

线程状态

  • 线程的状态分为新建,运行,阻塞,等待(被动唤醒),超时等待(超时唤醒),终止六种状态。

Daemon线程

  • 守护线程,当一个JVM中不存在非守护线程后,即退出。守护线程退出时的finally块不一定会执行,因此不得在Daemon线程的finally块中做资源释放之类的工作。可以设置一个线程为Daemon。

Thread部分源码分析

  • 构造方法。构造方法中调用init方法,新建线程都是从当前线程中派生出来的,继承了当前线程的组,优先级,是否为守护线程等信息。
  • start。调用start0方法,其实是运行Runnable的run方法。
  • interrupt相关。把中断理解为一个标志位,当调用thread.interrupt()的时候,表示想要中断thread线程,将标志位置真。thread内部需要自己写代码判断是否有中断,如果有中断需要自己写代码处理中断。在有阻塞的情况下,比如sleep或者wait,此时线程会阻塞,但是会不停的轮询标志位,一旦发现标志位为真,就会抛出异常,并清除标志位。可以通过isInterrupted方法来判断标志位,通过interrupted方法手动清除中断标记。
    • 还有两种情况当thread.IsInterrupted会返回false。1.抛出异常前清除中断标志位。2.线程结束后,返回false。
  • 过期的suspend,resume和stop方法。意思分别为暂停,恢复和停止。看上去很美好,但是会带来副作用,stop过于强势,会直接终结线程而不保证资源的正确释放。suspend/resume不会释放锁,因为容易造成死锁。
    • 假如有A,B两个线程,A线程在获得某个锁之后被suspend阻塞,这时A不能继续执行,线程B在获得相同的锁之后才能调用resume方法将A唤醒,但是此时的锁被A占有,B不能继续执行,也就不能及时的唤醒A,此时A,B两个线程都不能继续向下执行而形成了死锁。这就是suspend被弃用的原因。

    • 代替方式:通过wait和notify的等待通知机制代替提暂停和恢复,新机制的特点是不抱锁睡觉。通过interrupt和下面的安全终止进程来实现stop,新的关闭的特点就是手动释放资源。
  • 安全的终止进程。设置一个标志位,当标志位改变的时候,线程中断。这个逻辑的代码都需要自己手写,相当于自己写一个线程的析构函数。
  • join。**在A线程中调用B线程.join();代表直到B线程结束,A才继续运行。这是怎么做到的呢?
    • 1.在A线程中锁B线程实例,然后while(B线程没死){B线程.wait()}。再说一遍,这不代表B线程wait,而是A线程从同步队列中加入了等待队列。
    • 2.线程B结束后,在JVM中调用notifyAll(),将所有想要持有B线程锁的线程加入同步队列中。那么自然A也在同步队列中,因此A线程得以继续运行。
  • ThreadLocal。指的是线程变量,即线程独有的,主要是其数据结构比较有意思。Thread中有成员变量,是ThreadLocal类型的,这个类本身可以理解为是一个类似于Math的工具类,也不需要实例化。关键是ThreadLocal里面有个内部类叫ThreadLocalMap,这是一个键值对的Map类,键默认就是ThreadLocal,值是任意类型的,通过ThreadLocal中的泛型决定。这个Map类和HashMap完全不同,没有采用拉链法,而是采用开放地址法,如果发现hash冲突了则向后移动一位,直到有空位。因此可以简略的说**每个线程中都有一个ThreadLocal实例,而ThreadLocalMap(map这是ThreadLocal的类变量,因此所有的ThreadLocal都公用这个一个map)中保存着所有线程中的ThreadLocal和对应的值。**所以A线程的threadlocalA.get()的实际过程是先获得A线程中的ThreadLocalMap实例,然后通过这个map和threadlocalA(作为key)获得值。那如何在线程中使用ThreadLocal呢?只要线程中有threadlocal变量,然后调用set方法,就会自动将这个变量添加到thread和threadlocalmap里。

wait/notify,等待/通知机制

  • 为什么叫通知机制?相当于一个线程可以通知其他线程开始干活,整个过程开始于一个线程,最终执行于另一个线程。

  • wait/notify使得虽然同步块内的内容保证原子性,但是仍然可以交错运行,意思是A的同步块内有a1,a2两件事,B的同步块内有b1,b2两件事。如果没有wait、notify,那么a1,a2必须要全部运行完毕后才释放锁,然后b1,b2。但是通过wait可以a1后提前释放锁,当场阻塞,然后运行完b1后,notify让A继续a2,使得线程间可以有协作。

  • 永远在循环里采用wait和notify,因为这样可以在线程睡眠前后都检查wait的条件。为什么要这样呢?如果用if,用notifyAll的时候会产生虚假唤醒,如果唤醒了多个线程A,B,都放入同步队列中,A被唤醒直接开始生产,生产达到上限后A线程wait。此时可能是消费者线程或者生产者线程获得锁,如果生产者线程B获得锁,将直接开始生产,这是不对的,在生产以前还要判断一下是否要wait。

      while(条件不满足){
          对象.wait();
      }
      对应的处理逻辑
    
  • 如果想用obj的wait和notify,那么一定要先获取obj的锁,即同步块一定要锁obj。只有获得锁的线程才配wait和notify,wait释放锁,但是notify只唤醒,不释放锁,同步块结束才释放锁。

  • wait的本质是从同步队列放到等待队列,notify是从等待队列放置到同步队列。同步队列表示有争抢锁的机会,等待队列没有机会。同步队列中的状态为阻塞。

管道输入、输出流

  • PipedOutputStream,PipedInputStream,PipedWriter,PipeReader,用于线程之间的数据传输,可以在一个线程中接收输入,但是在另一个线程中输出。用之前要用connect函数将输入输出流连接起来。

显式锁

显式锁的接口:Lock接口

  • 其实比起synchronized,我更喜欢lock一点,因为可以手动控制上锁和解锁的时机。

      Lock lock = new ReentrantLock();
      lock.lock();
      try{
      }finally{
          lock.unlock();
      }
    
  • 区别与synchronized的特性

    • 尝试非阻塞地获取锁,tryLock
    • 能被中断的获取锁,lockInterruptly
    • 超时获取锁,tryLock(time)
  • 轮询锁,即tryLock方法,特性是如果获得锁立即返回true,否则返回false。可以根据这个特性来多次尝试获取,通过自定义的重试策略进行轮询。好处是避免了死锁,因为trylock后发现没有获得锁B,就尝试释放已经获取的锁A,然后再重新尝试获得锁A和B,这样就避免了死锁。

  • 可中断锁,第一个try块用来捕捉可中断锁中可能抛出的异常,第二个try块用于释放锁。和synchronized处理中断的方式(标志位置1,是否处理看被中断方的心情)不同,一旦被中断就立刻抛出异常,然后必须处理。比如t2线程中lock.lockinterruptly,那么只要调用t2.interrupt()即可中断t2线程。

    Lock lock = new ReentrantLock();
    try {   //第1个try块

        lock.lockInterruptibly();   //可中断的锁

        try {   //第2个try块
            // ... 具体代码
        } finally {
            lock.unlock();  //释放锁
        }

    } catch (InterruptedException e) {//第一个try所配对的catch,用来处理可能存在的中断
        // ... 在被中断的时候的处理代码
    }
  • 定时锁。可中断的基础上还加入了超时的概念,即如果在一定时间内无法获得锁,就GTMDTTKP。
    Lock lock = new ReentrantLock();
    try {
        boolean result = lock.tryLock(1000L, TimeUnit.MILLISECONDS);
        if (result) {   //在指定时间内获取锁成功
            try {
                // ... 获取到锁的代码
            } finally {
                lock.unlock();  //释放锁
            }

        } else {   //在指定时间内获取锁失败
            // ... 获取锁失败的代码

        }
    } catch (InterruptedException e) {
        // ... 被中断时的异常处理代码
    }

显式锁的基础:队列同步器

AQS干了一件是什么事儿?

  • AQS几乎是JUC包中锁和其他组件的基础。比如ReentrantLock中就有一个Sync内部类继承自AQS,ReentrantLock实现了Lock的方法,实现的手段是底层调用AQS的模板方法,外面包装成Lock接口方法。换而言之,AQS是底层,Lock是表现。
  • AQS有两个核心,state同步状态和获取/修改状态。通过不同的获取和修改同步状态的方式,使得AQS有不同的表现形式,以应对JUC中不同的组件。

AQS中的同步队列

  • 同步队列中每个节点为Node,Node代表一个线程,还包括线程的等待状态、前后节点。
  • 同步队列有head和tail两个节点用于操作这个FIFO的队列。在独占模式下队列头代表获取同步状态成功的节点,这是唯一的,因此更新头结点的过程是无并发的。当一个线程获得同步状态(或者锁)的时候,其他线程都要添加到队列尾,这是存在竞争的,因此通过循环CAS实现添加到尾节点。在共享模式下,释放头结点和增加尾节点都是需要循环CAS的。
    • 获得锁是获得同步状态的一个子集。获得同步状态可以干很多事,比如你获得同步状态后通过CAS把它修改掉以表明你获得了锁,这是一种方式。但是你也可以做别的事,随着进一步的学习,我们后面再看。

AQS部分源码分析

  • acquire。注意这个方法我们是不能重写的,我们只能重写tryaAcquire方法,即以下的分析,只有第一步我们可以定制。比如我们写一个MyLock extends AQS,那么只需要重写tryAcquire,但是调用acquire方法,即可实现下列描述的全部功能。
    • tryAcquire:在AQS中仅仅是输出异常,告知你应该自己重写这个方法,用来尝试获取同步状态。失败后进入addWaiter方法,即将同步失败的线程加入同步队列中
    • addWaiter:此方法中尝试通过CAS设置尾节点,失败后通过enq方法利用死循环CAS确保尾节点设置成功
    • acquireQueued:此方法作用是维护这个队列的每个节点。
      • 如果这个节点是当前队列的第二个节点就尝试获取同步状态,如果不是就采取以下措施:维护队列中的waitStatus
      • shouldParkAfterFailedAcquire:更新队列中节点的waitstatus,如果前驱节点是-1SIGNAL表示前驱节点在同步队列中等待中,即当前节点状态为0即INITIAL初始化。如果前驱节点是1CANCELED说明前驱节点中的线程已经被终止,因此当前节点需要向前寻找到还没有被终止的线程然后连接,如果出现[-1,-1,1,1],那再加入节点应该变成[-1,-1,0]。如果前驱节点是别的,比如说是0,那么就把0变成-1,把自己设置为0.因此一个正常的队列应该是[-1,-1,-1,-1,-1,0].
      • parkAndCheckInterrupt:只是加入阻塞队列还没用,必须要调用系统底层函数将线程阻塞。这样就做到物理意义上的线程阻塞和逻辑意义上的线程阻塞相统一,物理意义上指的是这个线程确实被阻塞了,逻辑意义上指的是这个线程在同步队列的非头节点。
      • 总结:头节点的后节点允许死循环式的检查自身是否得到唤醒,而其他阶段在处理完waitstatus之后就进入了阻塞状态。
  • release。
    • tryRelease:在AQS中仅仅是输出一个异常,告诉你怎么释放你应该自己去重写这个方法。
    • unparkSuccessor。如果释放成功,则唤醒下一个节点,如果下一个节点的状态为1,则从尾节点向前遍历到第一个状态为负的。
    • LockSupport.unpark(s.thread).唤醒next节点的线程并替代头结点。
  • 独占锁实例,互斥锁
    public class PlainLock {
        private static class Sync extends AbstractQueuedSynchronizer {

            @Override
            protected boolean tryAcquire(int arg) {
                return compareAndSetState(0, 1);
            }

            @Override
            protected boolean tryRelease(int arg) {
                setState(0);
                return true;
            }

            @Override
            protected boolean isHeldExclusively() {
                return getState() == 1;
            }
        }

        private Sync sync = new Sync();


        public void lock() {
            sync.acquire(1);
        }

        public void unlock() {
            sync.release(1);
        }
    }
  • acquiredShared:

    • tryAcquireShared:返回值不再是boolean,而是int,表示一个区间,即剩余共享的数量,当<0时代表不可共享,应当加入同步队列中。
    • addWaiter。若tryAS<0,则addWaiter。和独占锁的区别是,1.增加的节点均表示为共享模式2.通过setHeadAndPropagate来设置头结点。
      • setHeadAndPropagate(node,int propagate)。后面的参数表示还允许传播多少个头节点。怎么理解呢?比如允许2个线程持有共享锁,那么当A,B线程共享后,C只能执行addWaiter,但是很巧,C在加入队列的过程中,A,B已经释放了锁,那C在tryAS的结果是1>0,说明自己可以当做头结点,同时propagate=1,说明还能传播一个,让在队列中等待的D也可以获得锁。怎么获得呢?如果propagate>0,且当前节点node.next == SHARED,那么就立即释放当前节点。你想啊,立即释放当前节点的意思不就是说让下一个节点变成头结点么?
  • releaseShared:

    • tryReleaseShared,调用dotRS,其中将当前节点waitStatus置0然后唤醒后序节点。如果当前状态已经为0了,那就将头结点设置为-3PROPAGATE,表示下一次同步状态获取将无条件传播,什么时候会导致这种情况呢?就是队列中有且仅有头结点。
  • 共享锁实例,允许两个线程共享

    public class DoubleLock {
        private static class Sync extends AbstractQueuedSynchronizer {

            public Sync() {
                super();
                setState(2);    //设置同步状态的值
            }

            @Override
            protected int tryAcquireShared(int arg) {
                while (true) {
                    int cur = getState();
                    int next = getState() - arg;
                    if (compareAndSetState(cur, next)) {
                        return next;
                    }
                }
            }

            @Override
            protected boolean tryReleaseShared(int arg) {
                while (true) {
                    int cur = getState();
                    int next = cur + arg;
                    if (compareAndSetState(cur, next)) {
                        return true;
                    }
                }
            }
        }

        private Sync sync = new Sync();

        public void lock() {
            sync.acquireShared(1);     
        }

        public void unlock() {
            sync.releaseShared(1);
        } 
    }
  • 超时独占锁
    • tryAcquireNanos().和《java并发编程的艺术》描写的不同,java1.8中对这边的逻辑代码进行了修改。通过deadline来表示停止自旋的时间,在死循环中判断,如果自己超过了deadline的时间后还在死循环中,就跳出循环并返回false。如果自己没有获得同步状态并且时间还没到,就用parkNanos方法让自己堵塞一段时间,这个时间最长不超过nanosTimeout。
      • LockSupport.parkNanos(this, nanosTimeout);这句话是调用系统底层函数,要求该线程阻塞不超过nanosTimeout时间。
      • 如果设置的超时时间非常短,就不会使用该超时时间,而是依然采用自旋,因为非常短的超时等待无法做到精确,如果此时仍然进行超时等待,可能导致超时等待时间远远大于设定值。

LockSupport工具类

  • 通过park阻塞线程,unpark唤醒线程,park(nanos)阻塞最长不超过nanos的时间,在tryAcquire(nanos)中会用到,而一般的tryA中则是直接阻塞直到唤醒。

Condition接口

  • 用法
    • 作用于Object类的wait和notify类似,但是因为这个显式锁是我们手动new出来的,因此这个Condition也是来自于锁的。即一个锁可以有一个condition,这和synchronized是类似的,即一个synchronized锁一个对象,一个对象可以有wait和notify一组关系,一个Lock具有多个condition,这个condition有await和signal。
    • 上文说到的多个就是显著优点,一个锁可有多个状态。Lock在conditionA中await的意思是,lock只能在conditiona的signal或signalAll及中断中被唤醒,无法被本lock的conditionB唤醒。
  • 实现原理即方法分析
    • AQS中包括一个ConditionObject impelements Condition。比如ReentrantLock中方法newCondition()本质上是返回ReentrantLock中Sync类实例sync的new ConditionObject()。这个不是类方法,因此一个Lock可以return很多condition出来。
    • 等待队列是一个FIFO队列,每个节点复用了同步队列的Node,但是是单向队列,只指向后项节点,其辅助头尾引用为firstWriter与lastWriter。condition的await方法本质是将当前持有锁的这个线程放置到等待队列的末尾,这个过程必然是不存在锁竞争的,因为调用await之前该线程还没有释放锁。signal的名字叫唤醒,听上去好像调用之后原本await的线程就会突然起床一样,但是实际上只是把原本await的线程加入同步队列,让他有机会被调用而已。
    • ConditionObject实现了包括await,await(nanos),signal,signalAll等方法。
      • await方法,能调用await的线程必然持有锁,因此必然是同步队列的首节点,因此此方法简单来说就是将同步队列的头节点放到等待队列的尾节点
      • signal方法,能调用signal的线程必然持有锁,因此必然是同步队列的首节点,但是和这个首节点没啥关系,重点是会将等待队列的首节点放置到同步队列的尾节点,让原本等待队列中必然阻塞的线程到同步队列中参与竞争

显式锁的一种常见实现:重入锁,ReentrantLock

  • synchronized是隐式可重入的,因为一个线程不论获得多少次obj的锁,这个对象头都是指向这个线程的,所以必然可重入。但是设计语言层面的锁的时候,可重入锁的实现有两点要注意——1.线程再次获得锁的时候,tryAcquire是成功的。2.线程获得了1000次锁,释放的时候也要释放1000次,就像代码块一样,你lock.lock了一千次,那必须lock.unlock一千次,直到最后一个unlock该线程才彻底释放锁。

  • ReentrantLock,内部类static final Sync extends AQS,static final FairSync和NonfairSync extend Sync。


  • 构造函数:默认为非公平锁,sync变量 = new NonfairSync(),因此lock.lock()实际上调用的是非公平锁的lock()。

  • lock。lock方法逻辑是这样的

    • **非公平情况下:**调用nonfairsync的lock,先尝试一次CAS,这次cas是插队的,如果失败了则调用acquire方法(说明要排队了),在acquire方法中先调用tryAcquire方法(这个方法在nonfairsync中已经重写了),然后调用addWaiter和acquireQueued。
      • nonfairsync的TryAcquire。上文说到在nonfairSync中已经重写了tryAcquire,重写的逻辑都在nonfairTryAcquire中。state用于计数,通过当前线程是否等于持锁线程来决定计数器是否自增,以表明可重入的概念。如果持有锁的线程和当前想要持有锁的线程是同一个,则state自增,如果不存在持有锁的线程,则当前线程持有锁,如果当前想要持有的锁不是已经持有锁的,返回false。通过tryAcquire中对想要获得锁的线程和持有锁的线程的判断来实现可重入
    • 公平情况下,调用fair的lock,lock中直接acquire,说明不管哪一个线程都要排队。在acquire方法中先调用tryAcquire方法(这个方法在nfairsync中已经重写了),然后调用addWaiter和acquireQueued。
      • fairsync的tryAcquire。与非公平的不同,公平的要额外判断一下本线程是否是同步队列中头结点的下一个。通过AQS的hasQueuePredecessors实现,但是注意这个方法返回的意思是:还有前驱节点,取反后表示当前节点就是头结点的下一个。**为什么要判断?**因为发出lock请求的线程不一定在队列中,比如刚刚释放的哪一个线程,如果它也来抢,就会因为在此处判断为不是头节点的后一个而被放置到队列的尾端,从而实现了FIFO,即公平。
    • **为什么会出现不公平?不是每次都从队列头结点的下一个取么?不是必然按着顺序么?**锁释放后,唤醒下一个节点,此节点内的线程通过CAS尝试获取锁,但是此刻可能有另一个线程尝试获取,这个线程并不在队列中。比如刚刚释放锁的那个线程,它就不在队列中,但是它还想获取锁,此时它也CAS,因此有可能插队。但是公平锁的情况下,会直接把插队的放置到队尾。

    https://zhuanlan.zhihu.com/p/33793637

  • unlock。逻辑是调用AQS的实现类nonfairsync的release方法,因为在sync类中重写了tryRelease,因此调用sync的tR方法。

    • tryRelease。这个方法不是写在nonfairsync里面的,而是写在其父类Sync内的。只要释放一次锁就减少一次计数,当计数器为0的时候,该线程彻底释放锁。
    • 通过setExclusiveOwnerThread(null),标记为独占锁的持有线程为null。
  • tryLock:就是不执行acquire,直接执行tryacquire,没有后续加入队列的操作。

  • tryLock(time):先立刻tryAcquire一下,然后再doAcquireNanos,在tryA中是不存在加入队列的操作的,在doAN中是存在的,因为doAN中要加入阻塞队列先休眠一段时间再重新检测自己是否能获得同步状态。

显式锁的另一种常见实现:读写锁,ReentrantReadWriteLock

  • 读是共享的,写是排他的,不同线程间的读和写是互斥的,但是一个线程可以同时获得读锁和写锁。读写锁依然是可重入且支持公平/非公平设定的。
  • 读写状态的设计,将state这个int类型的32位分为高16位和1第16位。高16位表示读锁状态,低16位表示写锁状态。
  • ReentrantReadWriteLock中有Sync extends AQS,fairSync和nonfairSync extends Sync,WriteLock impelements Lock,ReadLock impelements Lock。
  • 写锁的获取和释放
    • lock。直接调用sync.acquire,和可重入锁不同,这里没有直接CAS,读写锁的公平性问题容后再说。这里是Sync重写的tryacquire,其中逻辑有四条:1.只要存在读锁,不论是不是当前线程,都不允许获得写锁,即不能锁升级。试想一个场景,1234号线程都有读锁正在读取数据,4号线程获得了写锁,然后写完后释放写锁,1234还同时持有读锁就一起读数据,结果只有4号读到的是最新的,123读的都不是最新的,因此只要有读锁,就不允许获得写锁。。。2.从可重入角度,如果想持有锁的线程和正在持有锁的线程不一样,则false。3.因为只能容纳16位的写锁,如果可重入次数过多,失败。4.因为公平性考虑,写锁的lock操作在公平锁情况下不允许插队,在非公平锁情况下可以插队。第4点在代码中的体现就是,在fairsync中需要判断是否为首节点的后项节点,在nonfairsync中不需要判断。
    • unlock。直接调用sync.release,tryRelease方法在Sync类中被重写,没什么好说的,减少低16位的计数器即可。
  • 读锁的获取和释放
    • lock,调用sync.acquireShared.这个是共享锁,逻辑是:1.如果写锁存在,则不得加读锁,其他情况加读锁,但是允许锁降级,即获得写锁的线程可以获得读锁

    https://my.oschina.net/meandme/blog/1839265

    • 这篇帖子说到一种情况,在非公平锁情况下,因为读的频率远远大于写,而且有读锁的时候写线程不得持有写锁,因此可能写线程一直抢不到锁,从而产生写线程饥饿,为解决这个问题,如果队列中读线程后面紧接着是写线程就会优先给写线程。
  • 锁降级,写锁降级成读锁。
    • 操作流程:线程先获取写锁,然后获取读锁,然后释放写锁。一定是在持有写锁的时候再持有读锁,这个叫做锁降级。
    • 使用场景:前半段只希望单一线程读,后半段希望读可以并行。
    • 目的是:如果线程1不获取读锁而是直接释放写锁,线程2获取了写锁并且写入了数据,此时线程1无法感知到线程2的数据。如果线程1先获得读锁,线程2因为这个世界存在读锁因此他的写锁请求会被阻塞(还记得么,tryacquire中只要有读锁就不允许获得写锁),因此线程1可以继续使用数据。

Java并发容器和框架

ConcurrentHashMap

概述

  • 为什么要用ConcurrentHashMap?
    • hashmap在多线程情况下扩容可能造成循环链表(1.8之前,1.8之后采用尾插法,不会造成循环链表),next指针永远不为null,插入的时候也可能出现数据丢失。

    https://juejin.im/post/5a66a08d5188253dc3321da0

    • hashtable效率低下且过时,之所以效率低下是因为hashtable的锁是对整个hashtable上锁,所有访问hashtable的线程都是竞争关系,对于这一点ConcurrentHashMap通过分段锁进行了改进。
  • 1.8之前是通过segment进行分段锁
  • 1.8对table的每一个桶加锁,每一个桶里放的是链表或红黑树

方法分析

https://blog.csdn.net/u010723709/article/details/48007881

  • 5种构造函数
    • 多了一种可以控制并发等级concurrencyLevel的构造函数。有一个新的成员变量叫volatile sizeCtl,-1表示正在初始化或者有1个线程正在扩容,-N表示有n-1个线程正在扩容,正数表示下一次扩容大小。相较于hashmap,为了应对并发,如果初始值是可设定最大容量的1/2即采用最大容量,在hashmap中大于最大容量才限幅成最大容量。
  • initTable().
    • table[]的初始化会延迟到第一次putVal。通过initTable初始化并保证并发安全。
      • initTable中通过CAS将sizeCtl设置为-1表示当前线程创建了一个table,其他线程检测到该变量变为-1后就yield让出时间片。初始化table后将sizecnt设为0.75*容量。
    • 三个原子操作
      • tabAt,找到table位于i位的node。
      • casTabAt,通过cas设置位于i位的node,使用cas时要求你已经知道了原来这个节点的值是多少
      • setTabAt,利用volatile方法设置i为的node
  • transfer(),扩容。
    • ForwardingNode,一个特殊的节点,hash值为-1,用来表示当前节点为空,或者已经被别的线程扩容完毕。
    • 单线程
      • 利用cas确保只有一个线程可以调用transfer(oldtable,null)方法(程序中通过判断第二个参数是否为null来决定是否要新建nextTable),其他线程只能调用transfer(oldtable,nt)以确保可以单线程的构建一个nextTable,容量是原来的两倍。将sizeCnt设置为(rs << RESIZE_STAMP_SHIFT) + 2))反正是个负数,盲猜一个-2。当sizeCnt为负数时候,每有一个线程想要参与扩容就+1,但是这咋还能越加越少呢,太真实了吧,反正这边就是用一系列的位运算达成了以下结果:sizeCnt的负数表示单线程结束,-N表示有N-1个线程参与扩容。
    • 多线程
      • 多线程并不是比如5个线程都从头到尾遍历oldTable进行添加,而是每个线程分配16个桶。线程1是31到16,线程2就是15到0这样分配的,从后向前遍历。
      • 只要遍历到forwarddingNode就说明这里已经被处理过了,即跳过当前位置。如果当前位置还未被处理就对当前位置上锁,然后对这个链表进行如下处理。还记得我们的hashmap中的loHead和hiHead么,就是我说的两开花操作,扩容后只可能在i+n和i两个下标。因此ConcurrentHashMap通过算法将原链表拆分成两部分,一部分以CAS放在了i,一部分放在了i+n。

    https://www.jianshu.com/p/f6730d5784ad

  • put()。
    • ConcurrentHashMap的key和value不允为null,因为map.get(key)==null时无法判断key不存在还是value为null。那我可以用map.containsKey(key)来判断啊,不行,因为你get和contains之间由于并发的关系可能key已经从存在变为不存在了。
    • putVal()内部有个超级大死循环,put成功才返回,因此:
      • 如果当前位置为空,那就直接put,不用加锁,因为如果后面有人并发put的话,自然会加锁
      • 如果当前位置的hash为-1,说明遇到了forwardingNode,则在putVal中通过helpTransfer方法使得该线程协助扩容。
      • 如果非空且hash不是-1,那就要加锁put。
      • 大循环中总有一个时刻能put成功。
  • get().
    • 根据hash值确定位置,如果能查就正常查,如果发现要查的节点的hash=-1,说明这个节点已经不在oldTable,要去nextTable查询,因此调用Node的find方法,在nextTable中查询。这也是为什么ForwardingNode的next指向了nextTable。
    • ForwardingNode重写了Node的find方法。调用find方法的如果是forwardingNode,先获取next指向的nextTable,然后根据hash在nextTable中找。
  • size()与mappingCount()只返回一个大概值,在老版本中是不加锁的测试两次大小如果相同就返回值,不然就加锁所有的segement。可惜1.8已经无法用这种方式实现统计个数了。

ConcurrentLinkedQueue,并发无界单向非阻塞队列

概述

  • JAVA基础复习(二):并发_第2张图片
  • 基于单向链表的无界线程安全队列,FIFO。添加元素添加到尾部,从头部删除元素,通过CAS实现,变量基本都是volatile。

方法分析

  • 构造函数:head和tail引用都指向空的头节点,便于之后用头插法等。
  • 核心思想:head不一定是真的head,tail也不一定是真的tail,但是可以通过head找到真的head,通过tail找到真的tail。入队元素不能为空,删除元素师需要先设置元素节点Node的值为null。理由和之前一样,如果出队操作返回null,不知道是队内有元素为null还是队内无元素。那你就说了,可以判断队列的size是否为空啊,但是和concurrenthashmap一样,在并发情况下两次操作之间谁有知道发生了什?
  • offer:通过cas将元素添加到队尾,但是不一定移动tail,只有当tail距离最后一个元素大于等于HOPS(默认为1)时才会设置为tail。offer中做两件事,首先判断真正的尾节点在哪,然后将入队节点插入到真正的尾节点后面,如果tail距离真正的尾节点超过HOPS则更新尾节点。
    • 为什么要设计tail不是真正的尾节点,而要间隔HOPS个呢?因为写volatile的开销比较大,而读额开销小,因此减少写的次数(每次都要设置尾节点,自然写的次数就增加了)。
  • poll。不是每次出队都移动head,只有当head与真正的头节点相距超过HOPS时才更新头结点。怎么判断真正的头节点在哪里呢?出队的节点并不会断开node,而是将node的值域item变为null,因此真正的头节点向后遍历到第一个不为null的节点就是真正的头节点。

阻塞队列

为什么要有阻塞队列?

  • 多个线程都想对队列中值进行读写,即出队入队操作。若线程不安全,则会造成出队入队失败,因此原有的offer和poll方法必须设计为线程安全的。
  • 阻塞是为了保证队列在空和满的情况下仍然保证并发安全,即队列满了就不向里面加入元素了,队列空了就不向里面取元素了。
  • 相对于concurrentlinkedqueue,阻塞队列的效率更低,因为用了锁,而concurrentlinkedqueue用的是cas和volatile。
  • 小结:所以同步是为了多个线程对队列进行出队入队操作的有序,阻塞是为了防止满和空后的错误操作。

阻塞队列的重要方法

方法\处理方式 抛出异常 返回特殊值 一直阻塞 超时退出
插入方法 add(e) offer(e) put(e) offer(e,time,unit)
移除方法 remove() poll() take() poll(time,unit)
检查方法 element() peek() 不可用 不可用
  • put和take是其他队列中所没有的方法,put是生产者将元素放入队列,如果队列已满则阻塞,take是消费者从队列中拿出元素,如果队列已满则阻塞。如果是无界队列,即队列没有容量限制,那么take和put一定不会阻塞。
  • add和remove会抛出异常,即如果队列满则抛出满异常,空则空异常。这段代码是AbstractQueue保证的,对offer进行了封装。
  • offer和poll队列满或空则false,否则true,无界队列的offer一定是true。还提供超时方法。

有哪些阻塞队列?

  • ArrayBlockingQueue 数组有界队列FIFO
    • 看了一下源码,没什么大不了的,就是通过ReentrantLock和Condition内部实现了一个消费者生产者模式。具体来说是:offer和poll方法在进队和出队的时候都加显式锁从而实现并发队列,put和take通过notFull和notEmpty两个lock的Condition来await或者signal,从而实现消费者生产者模式。有界是通过构造函数指定的,没有无参构造函数,规定了必须有界,这个界就是内部数组的大小,且不扩容。
    • 默认非公平锁,先阻塞的线程不一定先操作队列。
  • LinkedBlockingQueue 链表单向有界队列FIFO
    • 数据结构Node为单向链表节点,有界的界默认是int的最大值,这是和concurrentLinkedQueue的第一个区别
    • 计算size时,不需要遍历整个队列,直接返回atomInteger类型变量count值即可,而concurrentLinkedQueue则需要遍历
    • count在每次入队和出队是都会利用AtomXXX类的CAS进行原子自增或自减少,如下代码:
      • 那你就奇怪了,为什么明明已经加锁了还要通过cas来改变数量呢?因为LinkedBlockingQueue有putLock和takeLock两把锁,支持两个线程一个增加一个减少,这我不仅就要发问,为什么呢?因为一个是尾插法,一个是删除头节点,可以同时进行,为什么可以同时进行了,因为在LinkedBlockingQueue中有head和last引用,在插入时不会改变head引用,在删除时不会改变last引用。
  • PriorityBlockingQueue 支持优先级排序的无界队列
    • 类似PriorityQueue,最小堆,因此元素是升序排列的。因为是无界,因此put的时候永远不会阻塞, 即不需要await。
    • 因为优先级队列会扩容,因此是无界的
  • DelayQueue 使用优先级队列实现的无界队列
    • 内部也是priorityqueue,因此无界。但是队列中的元素必须实现delayed接口,此接口要实现两个方法,一个是比较大小,这个建议设置为让延时时间最长的放在队列末尾,不然队列头因为延时时间没到而阻塞,队列后面的元素延时时间到了却因为在后面而出不来,另一个是getDelay返回当前元素还需要延迟多少时间才能获取。
    • 有什么用?1.缓存系统的设计,用delayqueue存储缓存元素的有效期,一旦能从队列中获取元素则说明缓存过期。2.定时任务调度,一旦能从队列中获取到任务就立即执行。
    • 阻塞的策略,只要没有达到延时时间就阻塞。
  • SynchronousQueue 不存储元素的队列
    • put操作在没有take的时候会阻塞,直到有take才会完成一次配对
    • 可以设置公平和非公平,公平情况下用队列实现(队列存储线程),非公平情况下用栈实现。公平指的是线程1put,线程2put,线程3take此时线程1和线程3配对,就像队列一样,[1put,2put]队列FIFO,遇到take后1put出队。非公平是线程2和线程3配对,栈是[2put,1put],因为栈是先进后出,所以take匹配2put。
    • 如果没有消费者线程,offer直接返回false。
  • LinkedTransferQueue 链表组成的无界队列
    • 不是通过锁实现的
    • 多了transfer和tryTransfer方法。transfer的作用是,如果有线程请求take就将当前生产的资源直接给它而不经过容器,如果没有线程请求take就放置到队列末尾直到有线程take才返回。tryTransfer就是尝试一次直接传递给消费者,不论结果都返回。

    transfer算法比较复杂,大致的理解是采用所谓双重数据结构(dual data structures)。之所以叫双重,其原因是方法都是通过两个步骤完成:保留与完成。比如消费者线程从一个队列中取元素,发现队列为空,他就生成一个空元素放入队列,所谓空元素就是数据项字段为空。然后消费者线程在这个字段上旅转等待。这叫保留。直到一个生产者线程意欲向队例中放入一个元素,这里他发现最前面的元素的数据项字段为NULL,他就直接把自已数据填充到这个元素中,即完成了元素的传送。

    • 如果队列中有元素,tansfer会等到队列中元素消费完毕后才传递,所以如果只用transfer方法就是SynchronousQueue,因为队列中永远不可能出现值。
      -只用put就是无界的LinkedBlockingQueue的功能,只用offer就是concurrentlinkedqueue的功能。
  • LinkedBlockingDeque 链表双向队列,有界
    • 实现方法类似于ArrayBlockingQueue而不是LinedBlockingQueue,是一把锁两个状态。

Fork-join框架

基本概念

  • 将大任务分解成多个小任务,然后将每个小任务的结果汇总的框架。
  • 工作窃取,将某个线程从其他的队列中窃取来执行。因为是大任务分解的子任务们放到了不同的队列,每个队列都创建了一个单独的线程来执行,A线程执行A队列的任务,B线程执行B队列的任务。但是如果A执行的快,B执行的慢,就会造成木桶效应,因此A会窃取B队列中的任务完成。如下图,展示的是双端队列,当线程1的任务做完后会窃取线程2的尾端事务做以防止线程间的竞争。

使用方法

  • 继承RecursiveTask类用于返回结果的任务,继承RescursiveAction表示不用返回结果的任务。ForkJoinPool类对象用submit方法调用前两种任务,通过Future f获得返回值,通过f的get方法获得值。Task和Action都继承与ForkJoinTask
  • 继承后的类需要实现compute方法,该方法中需要递归的创建类,所以可以说ForkJoin的任务类都是递归类,在类的方法里要新建任务。逻辑就是一个终止条件,如果满足就返回,不然就将任务切分成小块,交给不同的任务类对象.fork处理,然后将返回值join起来。

原子类与并发工具类

原子类

  • 基本类型 AtomicInteger、AtommicLong、AtomicBoolean。
    • 为什么没String?因为String保存的是常量,无法对String本身进行修改,因此也无所谓原子操作。
    • 其他的基本类型呢?先强转成integer,再强转回来,反正底层都是4字节存储。
  • 数组类型 AtomicIntegerArray、AtommicLongArray、AtomicReferenceArray,传入下标,期望当前值,设定值。
  • 引用类型 AtomicReference(原子的使得引用从一个对象改为指向另一个对象),AtomicReferenceFieldUpdater(原子的更新引用里的字段),AtomicMarkableReference(原子的更新类型)
  • 更新字段 AtomicIntegerFieldUpdater,AtomicLongFieldUpdater,AtomicStampedReference.最后一个可以加时间戳以解决ABA问题,前面两个构建的时候要指定类和字段

并发工具类

  • CountDownLatch,倒计时门栓,说明倒计时结束就开门:定一个倒计时,调用countDown方法时即倒数一次,countdownlatch的await方法阻塞当前线程直到倒数为0。也就是说,倒计时结束即结束阻塞,有点类似join。
    • join:在A线程调用B.join,则A阻塞直到B完成。
    • countdownLatch:设置一个倒计时为2的countdownlatch,在A线程中开启B,C线程,在B线程结束处调用c.countdown,在C线程结束出调用c.countdown,在a线程开启b,c后调用c.await,即当B,C线程运行完毕后,a线程才会运行c.await后语句。
  • CyclicBarrier,循环栅栏:物理意义是,让多个线程到达规定的设定点后才停止阻塞。比如CyclicBarrier cb,A,B,C线程,你希望有在A执行到a语句,B执行到b语句,C执行到c语句时,开启D线程,那就在a,b,c语句后加cb.await。只有当执行到a.b.c语句时,才会解除篱笆。
    • 不是我说,这和上面的countdownlatch没区别啊?有!区别在后者是可循环利用的,可以用reset方法重置,但是countdownlatch只能用一次。
  • Semaphore,信号量,用于流量控制。本质上就是共享锁的应用,指定最多多少个线程可以共享,但是不是用lock和unlock,直接用的acquire和release。那么直接的么,直接用AQS的方法,人家锁都还包装了一层呢。
  • Exchanger,交换着,用于线程间写作的工具类。比如A线程录入银行流水,录入完后调用e.exchange(T t1),此时A线程阻塞,B线程同时也在录入同样的银行流水,流入完后调用e.exchange(T t2),此时A线程停止阻塞,两个线程交换t1,t2.

线程池

为什么要用线程池?

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。任务到达时不需要再创建线程,而是之间利用已有的线程。
  • 提高线程的可管理性。使用线程池可以对线程进行统一的分配,调优和监控。

实现原理

https://juejin.im/entry/58fada5d570c350058d3aaad

  • 当新的任务出现时,线程池会如何处理呢?1.尝试从核心池中分配工作线程。2.核心池没有线程了,就加入等待队列。3.等待队列满了,就尝试从最大线程池中创建线程。4.最大线程池都满了,就交给饱和策略(rejectedExecutionHandler)。
  • https://juejin.im/post/5c33400c6fb9a049fe35503b#heading-1

  • AtomicInteger ctl,高3位保存线程池状态,低29位保存当前线程数量,线程池状态有如下状态
    • running,接收新任务,处理队列任务
    • shutdown,不接受行任务,但处理队列任务
    • stop,不接受行任务,也不处理队列任务,中断所有处理中的任务
    • tidying,所有任务都被终结,有效线程为0,调用terminated方法
    • terminated,当terminated方法结束后
  • worker exttends AQS impelements Runnable。worker线程封装了我们交给线程池处理的任务,因此worker可以复用的处理多个任务。
    • 内部实现了一个不可重入的互斥锁,这个锁是用来控制是否可以中断的。lock方法获取独占锁表示当前线程正在执行,如果正在执行则不应该中断,如果空闲则可以中断。这样的目的是为了线程池使用shutdown方法或tryTerminate方式时可以判断线程池中的线程是否空闲从而进行关闭。
    • 构造方法是通过ThreadFactory(线程池的构造函数中传入)构建一个线程(这个线程是执行任务的线程,即复用线程),将成员变量thread设置为this,即worker线程启动后率先调用自己的run方法,然后在自己的run方法中调用ThreadPoolExecutor.runworker方法从而实现任务。要实现的任务通过firstTask保存。
    • runworker(this),执行w.firstTask,方法中留给子类两个方式可以实现,beforeExecute和afterExecute
  • 构造函数参数须知 ThreadPoolExecutor(…)
    • corePoolSize,核心线程池数量。
    • maximumPoolSize,最大线程池数量
    • keepAliveTime,线程空闲时的存活时间,即当线程没有任务执行时继续存活的时间
    • unit,上面这个参数的单位,是一个TimeUnit枚举类的常量
    • workQueue,保存等待执行的任务的阻塞队列
      • SynchronousQueue,直接切换,不存在任务队列
      • 有界队列:LinkedBQ,ArrayBQ,LinkedBD
      • 无界队列:PriorityBQ,DelayBQ,LinkedTransferQ
    • threadFactory,用来创建新的线程,默认使用Executors.defaultThreadFactory()创建,规定线程优先级,是否为守护线程,线程名称。
    • RejectedExecutionHandler,表示线程的饱和策略。
      • AbortPolicy:直接抛出异常、默认的
      • CallerRunsPolicy:用调用者所在的线程执行任务
      • DiscardOldestPoliscy:丢弃阻塞队列中最靠前的任务并执行当前任务
      • DiscardPolicy:直接丢弃任务
      • 以上四个都是内部类,定义了四种不同的策略。
  • execute方法:
    • addworker(command,boolean),通过第二个参数决定是核心还是非核心。
      • 新建一个worker线程,传入当前任务。 每次新建任务都要使用mainLock。
        • mainlock,即全局锁,这是一个可重入锁,作用是保证线程创建的稳定性,所谓稳定性是指线程池的中断操作会导致创建线程的不稳定。
        • mainlock有一个condition叫termination,用于支持终止操作。
      • 真是因为mainLock的存在,所以线程池的设计思路是,尽量减少mianlock的加锁过程,因此才有了核心池和最大池的概念。当核心池打满后,一个合理的阻塞队列+核心池基本可以满足功能,如果需要频繁addworker,说明线程池需要调整。
    • worker线程在工作的时候,如果当前任务做完了,就会从阻塞队列中take任务做。
  • processWorkerExit方法
    • runworker运行结束后,即当前任务运行完或者getTask()null时,调用processWorkerExit。什么时候null呢?1.线程池状态为STOP,且阻塞队列为空。2.线程池allowCoreThreadTimeOut设置为true且当前只有一个线程,并且该线程停止运行。并不是说当前只有一个线程且该线程停止运行后,getTask就是null。线程池中有一个timed成员变量默认为false,即设置keepalive变量仅仅决定了从队列中拿线程的最大时间,超出前用poll拿,超出后用take拿。
      • boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;所以当core为0的时候,timed也几乎是true的,即用完就关闭
    • 移除的时候也要用mainlock,因为只要涉及到线程的增加和删除,都要用mainlock,所以用keepalivetime可以有效降低mainlock次数,但是也不易设计太大,白白占用系统资源。
    • 用一个HashSet workers,保存了全部的工作线程,从这里面移除。
    • 调用tryTerminate(),此方法会根据线程池状态判断是否结束线程池。
  • 一个工作线程的生命周期
    • execute方法判断是否addworker,创建worker实例,调用worker线程的run方法,run方法中调用runworker运行firstTask或者从队列中take来的。如果超过一段时间都不来任务就结束线程。结束线程会触发一次检查是否需要关闭线程池的tryTerminate方法。
  • 关闭线程池
    • shutdown方法,将线程池状态切换到shutdown状态(准备关闭状态,不接客了,但是当前的客人得处理完),调用interruputIdeWorkers尝试中断所有空闲worker,最后调用tryTerminate尝试关闭线程池。
      • interruputIdeWorkers方法会遍历workers中的每一个线程,如果是空闲的就调用interrupt方法。遍历关闭之前要获取mainlock锁,因为workers是hashset的,这是非线程安全的。
    • shutdownNow,设置黄台为STOP(不仅不接客,还要把现在的客人赶走),中断所有工作线程无论是否空闲,取出阻塞队列中还未执行的任务。反正就是尽一切手段让线程池队列没东西,线程全中断以进入tidying状态,最后进入tryTerminate方法。
    • 实测未关闭线程池会导致main函数退出后程序不完全退出,因为默认的timed都为false
  • 线程池的监控
    • getTaskCount 获得线程池中已经执行的和未执行的任务总数
    • getCompeletedTaskCount 获取已完成的
    • getLargestPoolSize,获取曾经创建过的最大线程数量
    • getPoolSize 线程池当前线程数量
    • getActiveCount,线程中正在执行任务的线程数量

如何合理的使用线程池

  • CPU密集型还是IO密集型还是混合
    • CPU密集型说明每个线程计算量大,尽量安排线程少,线程数=CPU数量+1
    • IO密集型说明CPU处理负担小且线程常常阻塞等待,进行线程多,线程数=CPU*2
  • 任务是否具有优先级
    • 有优先级则考虑阻塞队列采用priorityBQ
  • 任务执行时间
    • 根据不同时间可以交给不同的线程池处理,灵活配置keepalive时间
  • 任务的依赖性,比如数据库
    • 因为线程提交sql要经过网络再到数据库服务器,等数据库处理完再返回CPU,因此线程常常阻塞等待,可以考虑采用多个线程
  • 总结:计算量大线程池小,计算量小且线程容易阻塞线程池大
  • 尽量使用有界队列,以防止内存被占满。

Executor框架

为什么要有这框架?

  • 核心点是,我们启动线程的方法是new Thread(new Runnable).start,那么Thread即是线程的执行者,又是线程本体。因此尝试将线程本体和线程的执行者分开,逻辑上分成任务和执行器两个概念。任务就是一个Task,可以是实现Runnable或者实现callbale的,执行器就是Executor。
  • 分离的好处是Task重视任务的逻辑实现,Executor重视控制线程的启动执行和关闭,让线程更易管理,采用线程池让效率更高,开销更小。

Executor框架中有哪些东西?

  • Executor是一切执行器的本源,实际上用的是ThreadPoolExecutor类和ScheduledThreadExecutor类,此接口只规定了excute方法,ExecutorServicre接口定义了submit方法,shutdown等关闭方法
    • ThreadPoolExecutor就是上一章节线程池的实现类,我们可以定制也可以采用其子类,将在下一小节介绍。
    • ScheduleThreadPoolExecutor可以再给定的延迟后执行命令或者定期执行命令。类似于定时器任务,但是更加灵活。
  • Runnable和Callable是所有任务的本源,callable的任务可以返回结果和抛出异常。
  • Future接口的FutureTask实现类用于接收callable类任务的返回值,get方法接收返回值,接收到之前会阻塞。
    • submit(callable c)中会将c包装成FutureTask impelements Runnable,Future,因为实现了Runnable方法,所以submit方法会调用execute方法运行FutureTask。
    • 当调用Future.get方法时会判断此时futureTask类的状态,若还没有运行完毕则阻塞。
    • 1.8中已经不再使用基于AQS的sync了,而是内部自己维护一个static final waitnode单链表,每个节持有一个线程,和AQS类似,头结点是运行线程,运行后会唤醒下一个。因此不同的FutureTask实例实际上是通过类变量这个链表实现的同步。
  • Executors工厂类,可以创建SingleThreadExector,Cache线程池等。
    • newSingleThreadExecutor,采用LinkedBQ,max和core都为1
    • newCachedThreadPool,采用SynchronousQ,核心为0,最大为Int最大值。因为采用同步队列,本质上队列就是传送作用。因为core为0,keepalive时间为60,即线程超过60秒没有任务就自动取消,getTask为null。
    • newFixedThreadPool,采用LinkedBQ,max和core数量相同
    • newWorkStealingPool,采用ForkJoinPool
    • newScheduledThreadPool,采用DelayWorkQueue
    • newSingleThreadScheduledExecutor
      • 优先级队列保存内部任务的开始时间,执行后更新开始时间再重新入delayqueue
  • 如何使用

你可能感兴趣的:(JAVA面试复习笔记,java并发)