Java并发编程

295 阅读15分钟

基本摘抄自juejin.cn/post/684490… 自己重新写一下加深印象

基础知识

为什么使用并发编程?

  • 提高多核CPU的利用率:一般来说一台主机上是有多个CPU的,理论上讲,操作系统会将多线程交给多个CPU并行执行,这样就提高了CPU的使用率
  • 提升程序的响应速度,对于可拆分并行执行的程序,可以通过启动多个线程的形式来实现
  • 简单来讲就是:
    • 充分利用多核CPU的计算能力
    • 方便进行业务拆分,提升应用能力

并发编程的三个必要因素

  • 原子性:指一个操作要么全部执行成功,要么全部执行失败
  • 可见性:一个线程对于共享资源的修改,另外一个资源是可以看到的
  • 有序性:程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序)

并行和并发有什么区别?

  • 并发:多个任务通知执行在一个CPU上,按细分的时间片轮流(交替)执行,从逻辑上看任务是同时执行的
  • 并行:单位时间内,多核处理器处理多个任务,真正意义上的“同时进行”
  • 串行:有n个任务,一个线程顺序执行。由于任务、方法都在一个线程执行,所以不存在线程安全的问题

线程和进程的区别

  • 什么是线程和进程
    • 进程:一个在内存中运行的应用程序,每个正在系统上运行的程序都是一个进程。进程是系统分配资源的最小单位
    • 线程:进程中的一个执行任务(控制单元),它负责在程序中独立执行。是系统调度的最小单位
  • 进程与线程的区别
    • 根本区别:进程是操作系统分配资源的最小单位,线程是操作系统调度的最小单位
    • 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间切换的开销较大。线程可以看做轻量级的进程,它的代码和数据空间都是共享的,每个线程有自己独立的运行栈和程序计数器,线程之间的切换开销较小。
    • 内存分配:同一进程的线程是共享数据资源和内存空间的。而进程和进程之间的地址空间和资源是相互独立的。
    • 影响关系:一个进程崩溃了,并不会对其他进程产生影响。但是一个线程崩溃了有可能导致整个进程死掉了。

什么是上下文切换

  • 多线程编程一般情况下线程的数量都是大于CPU数量的,而一个CPU在任意的一个时刻只能被一个线程使用。为了让这些线程有效的使用,CPU采取的策略是为这些线程分配时间片轮流执行,当一个线程的时间片执行完毕之后,这个线程就会变成就绪状态让下一个线程执行。而这个过程就属于线程的上线文切换。
  • 当前任务在执行完CPU时间片切换到另一个线程之前,会保存当前的状态到程序计数器中。以便下次再执行的时候,可以从这个状态继续执行。任务从保存到再加在的过程就是一次上下文切换。
  • 上线文切换通常计算密集型的,需要相当可观的处理时间,可能是操作系统中最耗时的操作了。
  • Linux系统相比其他系统有很多优点,其中一点就是,其上线文切换和模式切换的时间消耗非常少。

守护线程和用户线程的区别?

  • 用户线程:运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程
  • 守护线程:运行在后台,为其他前台线程服务。可以说守护线程是JVM中非守护线程的“佣人”。一旦所有的用户线程执行完毕之后,守护线程才会和JVM一起结束工作。典型的守护线程如 垃圾回收线程

什么是线程死锁

  • 死锁是指两个或者两个以上的线程在执行过程中,由于资源竞争或者由于彼此通信造成了一种死锁现象,若无外力作用,他们讲无法推进下去。
  • 如下图所示,线程A持有资源B,线程B持有资源A,他们想同时申请对方的资源,所以这两个线程就会互相等待而进入死锁状态

形成死锁的四个必要条件?

  • 互斥条件:在一段时间内,一个资源只能由一个线程占用,如果其他线程想要使用这个资源,就需要阻塞等待。
  • 占有且等待条件:指一个线程已经拥有一个资源了,在拥有自己这个资源的同时,还要去获取别的资源。
  • 不可抢占条件:别的线程已经拥有了某个资源,不能因为自己需要,就将别的线程的资源抢占。
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

创建线程的四种方式

  • 继承Tread类
public class MyThread extends Thread {
@Override
public void run() {
    System.out.println(Thread.currentThread().getName() + " run()方法正在执行...");
}
  • 实现Runnable接口
public class MyRunnable implements Runnable {
@Override
public void run() {
    System.out.println(Thread.currentThread().getName() + " run()方法执行中...");
}
  • 实现Callable接口
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
    System.out.println(Thread.currentThread().getName() + " call()方法执行中...");
    return 1;
}
  • 使用匿名内部类方式
public class CreateRunnable {
    public static void main(String[] args) {
        //创建多线程创建开始
        Thread thread = new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    System.out.println("i:" + i);
                }
            }
        });
        thread.start();
    }
}

Runnable和callable有什么区别

相同点:

  • 都是接口
  • 都可以编写多线程程序
  • 都是采用Thread.stat()启动线程

主要区别:

  • 接口返回值:
    • Runnable接口的run方法无返回值;
    • Callable接口call方法有返回值,是个泛型。和Future、FutureTask配合可以来获取异步执行的结果
  • 异常捕获情况:
  • Runnable接口run方法只能抛出运行时异常,且无法捕获处理;
  • Callable接口call方法允许抛出异常,可以获取异常信息; 注:Callable接口支持返回执行结果,需要调用FutureTask.get()获得,此方法会阻塞进程继续往下执行,直到Callable的线程结束

什么是FutureTask

  • FutureTask表示一个异步运算的任务
  • FutureTask里面可以传入一个Callable的具体实现类,可以对这个异步运算的结果记性等待获取、判断是否已经完成、取消任务等操作。
  • 只有当运算结果完成的时候才能够取回,如果运算结果未完成,调用get方法会阻塞。
  • 一个FutureTask对象可以调用Callable和Runnable的对象进行包装,由于FutureTask也是Runnable接口的实现类,所以FutureTask也可以放入线程池中

线程的状态

  • 新建(new):新创建一个线程对象
  • 就绪(runnable):可运行状态;线程创建之后,当调用线程对象的start()方法,改线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。
  • 运行(running):运行状态就代表已经获取了CPU时间片(timeslice),执行程序代码。注:就绪状态是进入运行状态的唯一入口,线程想要运行必须要处于就绪状态中。
  • 阻塞(block):处于运行状态中的线程由于某些原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态。直到其进入到就绪状态,才有机会再次被CPU选中执行。
    • 阻塞分为三种状态:
      • 1、等待阻塞:运行中的程序执行wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本县城进入到等待队列中。
      • 2、同步阻塞:线程在获取synchronized同步锁失败(因为锁被其他的线程占用),则JVM会将该线程放入锁池中(lock pool),线程会进入同步阻塞状态。
      • 3、其他阻塞:通过调用线程的sleep()或join()方法,或者发出了I/O请求时,线程会进入阻塞状态。当sleep()状态超时,或者I/O请求完成之后,线程会重新转入就绪状态
  • 死亡(dead):线程的run()、main()方法结束,或者因为异常退出run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

Java线程调度方式有哪些?

  • JVM虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配CPU的使用权。Java是由JVM中的线程计数器来实现线程调度的
  • 有两种调度模型:抢占式调度模型和协同式调度模型
    • 抢占式调度模型:指每条线程的执行的时间、线程的切换都是由系统控制。由系统分配相应的时间片运行。可以设置线程的优先级,这样系统可以会选择就绪状态中优先级较高的线程执行。
    • 协同式调度模型:指某一个线程执行完毕之后,主动通知系统切换到其指定的另一个线程执行。注:这个调度方式有个问题,就是当一个线程崩溃,可能会导致整个进程崩溃

线程的调度策略:

线程调度器会选择优先级比较高的线程,但是如果发生以下情况,就会终止线程的运行

  • 线程中调用了yield方法让出对CPU的占有权利
    • yield()方法就是让当前线程回到就绪状态,让出对CPU的使用权,允许同等级的线程获得运行的机会
    • 大多数情况下,yield()将导致线程从运行状态转到可运行状态,但有可能没有效果
  • 线程中调用了sleep()方法使线程进入睡眠阻塞状态
  • 线程由于IO操作受到了阻塞
  • 另一个更高优先级的线程出现了
  • 在支持时间片的系统中,该线程的时间片用完

sleep()和wait()区别

  • 类的不同:sleep是Tread类的静态方法。wait是Object类的实例方法
  • 是否释放锁:sleep不释放锁,wait释放锁;
  • 用途不同:Wait通常被用于线程交互/通信,sleep常用于暂停运行
  • 用法不同:wait()方法调用之后,线程并不会自动苏醒,而是需要别的线程通过调用notify()或者notifyAll()方法来唤醒。sleep()方法在执行完成之后就可以自动唤醒了。 注:wait()方法可以通过wait(long timeout)设置超时时间,超时时间过了同样可以自动唤醒

如何调用wait()方法?使用if判断还是循环?为什么?

  • 处于等待状态中的线程可能收到错误的警告或者伪唤醒,如果不在循环中判断等待条件,线程可能就会执行结束了。
  • wait还是在循环中处理好,因为线程在CPU执行之前,其他条件可能还没有满足,所以在处理前需要循环检测条件是否满足了。
  • 下面是wait()和synchronized的标准使用方式
synchronized (monitor) {
    //  判断条件谓词是否得到满足
    while(!locked) {
        //  等待唤醒
        monitor.wait();
    }
    //  处理其他的业务逻辑

为什么wait()、notify()、notifyAll()必须在同步块中使用

  • 因为当一个线程需要调用对象的wait()方法的时候,必须持有这个对象的锁。紧接着它就会释放这个锁并进入等待状态,直到其他线程执行这个对象的notify方法
  • 同样当一个线程调用对象的notify方法的时候,它会释放这个锁,让其他线程可以得到这个锁。
  • 由于所有的方法都需要线程持有对象的锁,这样只能通过同步来实现,所以需要在代码块中使用

interrupted方法和isInterrupted方法的区别

  • interrupt:中断线程的方法。调用该方法可以将线程置为“中断”状态 注意:线程中断仅仅是置线程于中断位,但是并不会停止线程。需要用户自己去监视线程的状态并为之做相应的处理。支持中断的方法(也就是线程中断之后会抛出interruptedException的异常)就是在监视线程的中断状态,一旦线程出现“中断状态”,就会抛出异常。
  • interrupted:是静态方法,查看当前中断信号是true还是false并清除标记,如果一个线程被中断了,那么第一次返回是true,第二次以及之后就是返回的false了
  • isInterrupted:是实例方法,可以返回当前中断信号是true还是false。并不会清除标记

Java如何实现线程之间的通讯与协作的

  • 可以通过中断和共享变量的方式来实现线程之间的通信与协作
    • 例如经典的消费者-生产者模型:当队列满时,会让生产者交出对临界资源的使用权,生产者进入挂起状态,并通知消费者消费数据。当队列为空是,消费者会交出对临界资源的使用权,消费者进入挂起状态,并通知生产者生产数据
  • Java中通讯协作最常见的方式
    • synchronized加锁的线程的Object类的wait()/notify()/notifyAll()
    • ReentrantLock类加锁的线程的Condition类的await()/signal()/siganlAll()
  • 线程之间的数据直接交换
    • 通过管道进行线程间通信:字符流、字节流

在Java程序中如何保证多线程的安全运行

  • 1、使用安全类,例如java.util.concurrent包下的类,原子类AtomicInteger
  • 2、使用自动锁 synchronized
  • 3、使用手动锁Lock
  • 手动锁代码示例如下
Lock lock = new ReentrantLock();
lock. lock();
try {
    System. out. println("获得锁");
} catch (Exception e) {
    // TODO: handle exception
} finally {
    System. out. println("释放锁");
    lock. unlock();
}

多线程的常用方法

  • sleep():前置当前线程睡眠多久,进入阻塞状态。
  • isAlive():判断一个线程是否存活。
  • join():等待线程终止,会阻塞当前线程。
  • activeCount():程序中活跃的线程数量
  • enumerate():枚举程序中的线程
  • currentThread():获取当前线程
  • isDaemon():判断当前线程是否是守护线程
  • setDaemon():设置守护线程
  • setName():设置线程名称
  • wait():强迫线程等待,Object的方法
  • notify():通知唤醒等待的线程,Object的方法
  • setPriority():设置线程的优先级

并发理论

Java垃圾回收的目的

  • 垃圾回收是回收内存中没有引用的对象或者超出作用域的对象
  • 垃圾回收的目的是识别并且丢弃应用不再使用的对象来释放和重用资源

Java内存模型

  • 共享模型指的是Java内存模型(JMM),Java内存模型是抽象出来的概念。

  • Java内存模型规定,将所有的变量都存放到主内存中,当线程使用变量的时候,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量操作的是自己的工作内存。

  • Java内存模型是一个抽象的概念,在实际工作中线程的工作内存是如下图所示

  • 图中所示是一个双核CPU系统架构,每个核都有自己的控制器和运算器

  • 其中控制器包含一组寄存器和控制操作器

  • 运算器执行算数逻辑运算

  • 没个核都有自己的一级缓存,在有些架构中还有一个所有CPU都共享的二级缓存。

  • Java内存模型的工作内存,就对应这里的L1或者L2缓存或者CPU的寄存器 注:由于寄存器的存在,线程会优先从缓存中获取数据,当得不到数据的时候,才会去内存中获取。这样就会存在一个内存不可见的问题。

finalize()方法什么时候被调用?析构函数(finalization)的目的是什么?

  • 垃圾回收器(garbage collector)决定回收对象的时候,就会调用对象的finalize()方法。
  • finalize方法是Object类的一个方法,可以覆盖此方法来实现对资源的回收;例如文件的IO,网络的连接等 注意:一旦垃圾回收期准备释放对象占用的资源,将首先调用对象的finalize()方法,并且在下一次垃圾回收动作发生时,才是真正回收对象的占用空间。
  • 析构函数(finalization)大部分时候,什么都不用做(也不需要重载)。只有在某些特殊的情况下,比如说调用了native方法(一般是用C写的),可以要在finalization里去调用C函数(native方法)
    • Finalization主要是用来释放被对象占用的资源(不是内存,而是其他的资源,比如文件(file Handler)、端口(ports)、数据库连接(DB connection)等)。然而,它并不能真正有效的工作

重排序实际执行的指令步骤

  • 编译优化的重排序:编译器在不改变单线程程序的语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序:现代处理器采用指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应指令的执行顺序
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

这些重排序对于单线程没问题,但是多线程都可能会导致多线程程序出现内存可见性问题

并发关键字synchronized

  • 在Java中,synchronized关键字是用来控制线程同步的,保证synchronized修饰的代码在多线程环境下只能有一个线程执行。synchronized可以修饰类、方法、变量。
  • Java中的线程可操作系统的原生线程是一一对应的,所以当阻塞一个线程,就需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作。而synchronized的操作就会导致上下文切换。
  • 在Java早期,synchronized是一个重量级的锁,因为监视器锁(monitor)是依赖于底层操作的Mutex Lock实现的,挂起或者唤醒一个线程都需要操作系统来实现,这样每次来回的在用户态和内核态切换,导致会有大量的时间开销。Jdk1.6之后,对synchronized进行了锁优化,如自旋锁、适应性自旋锁、锁消除、锁优化、偏向锁、轻量级锁等级来减少锁操作的开销。

synchronized的主要使用方式

  • 修饰实例方法:作为当前对象实例的锁,进入同步代码块的时候需要获取当前对象的锁。
  • 修饰静态办法:给当前类加锁,会作用于这个类的所有实例对象。所以当线程A调用一个实例对象的非静态synchronized方法,然后线程B调用这个实例对象所属类的静态synchronized方法,线程B是可以获取锁的,以为线程A获取的是实例对象的锁,而线程B获取的是类的锁
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块时需要获取对象的锁。

双重校验锁实现线程单例(线程安全)

public class Singleton {
   private volatile static Singleton uniqueInstance;
   private Singleton() {}

  public static Singleton getUniqueInstance() {
   		//先判断对象是否已经实例过,没有实例化过才进入加锁代码
      	if (uniqueInstance == null) {
          	//类对象加锁
          	synchronized (Singleton.class) {
              	if (uniqueInstance == null) {
                  	uniqueInstance = new Singleton();
              	}
          	}
      	}
      return uniqueInstance;
  }

另外,uniqueInstance使用volatile修饰也是很有必要的,防止指令重排 uniqueInstance = new Singleton();这段代码实际的执行步骤分为三部

  • 1、为uniqueInstance分配内存空间
  • 2、初始换uniqueInstance
  • 3、将uniqueInstance指向分配的内存空 由于JVM有指令重排的特性,有可能执行顺序就会变成1→3→2,单线程时没有问题的,多线程下会导致一个线程获得还没有初始化的实例。

synchronized的底层实现原理

  • synchronized底层通过以恶搞monitor监视器来实现的
  • 每个对象都有监视器锁(monitor),每个被synchronized修饰过的代码当它的monitor被被占用的时候,就是被锁定住然后尝试获取monitor的所有权,过程:
    • 如果monitor的进入数为0,则线程进入代码块,monitor的进入数加1,该线程为monitor的持有者
    • 如果线程已经持有这个monitor了,当它再次进入的时候,monitor的进入数再次加1
    • 如果其他线程已经占有monitor的使用股了,那当前线程阻塞,直到monitor的进入数变为0,再尝试获取monitor的所有权

synchronized的可重入原理

  • 重入锁是指一个线程获取该锁之后,可以再次获取该锁。底层维护一个计数器,每当获取一次锁,计数器就加1,再次获取该锁再次加1。释放锁时,计数器减1。当计数器数量变为0时,表示该锁未被任何线程拥有,其他线程可以竞争锁。

什么时自选锁

  • 很多synchronized修饰的时非常简单的代码,很快就能执行完毕,此时等待的线程加锁是一种不太值得的操作。因为线程阻塞会导致用户态和内核态的切换,太耗费时间。
  • 可以让代码再synchronized的边缘忙循环,不放弃对CPU的使用权,这就是自旋。如果多次循环之后还没有获取到锁,再进行阻塞操作

synchronized的锁升级原理

  • 在锁对象头里面有一个threadid字段
    • 在第一次访问的时候,如果threadid为null,jvm让线程持有偏向锁,并将threadid设置为线程的id。
    • 再次进入的时候会判断threadid的id是否与线程id相同,如果一直则直接使用这个对象,如果不一致则升级为偏向锁为轻量级锁。通过自选次数来获取锁,如果超过自选次数依然没有获取锁,则会把锁审计为重量级锁。

synchronized和Lock的区别

  • 首先synchronized是关键字,在JVM层面,而Lock是Java类
  • synchronized可以给类,方法,代码块加锁,而Lock只能给代码块加锁
  • synchronized不需要手动获取锁和释放锁,当遇到异常时会自动释放锁。Lock需要自己加锁和释放锁,如果使用不当没有使用unlock就是导致死锁。
  • 通过Lock可以得知有没有成功获取到锁,而synchronized时没有办法得知的。

synchronized和ReentrantLock的区别

  • 相同点
    • 两个都是可重入锁
  • 不同点:
    • ReentrantLock使用起来更加灵活,但是需要配置锁的释放。
    • ReentrantLock必须手动加锁和释放锁,synchronized不需要手动加锁和释放锁
    • ReentrantLock只适用于代码块加锁,而synchronized可以修饰类、方法、变量等
    • 两者的锁机制也是不相同的,ReentrantLock底层调用的Unsafe的park方法加锁。synchronized操作是的对象头中的Mark word

volatile关键字

  • 对于可见性:Java提供了volatile关键字用来保证可见性和禁止指令重排。volatile提供的happens-before的保证,确保一个线程的修改能对其他线程是可见的。
  • 当一个共享变量被volatile修饰的时候,它会保证被修改的值会立刻刷新到内存中去。当其他线程需要读取的时候,它会去内存中读取新值。
  • 从实践的角度,volatile的一个重要作用就是和CAS结合使用,保证了原则性。
  • volatile常用于多线程环境下的单次读写。

什么是CAS

  • CAS就是compare and swap 的缩写,比较交换
  • CAS是一种基于锁的操作,是一种乐观锁。乐观锁是通过某种不加锁的方式获取资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提升。
  • CAS包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。如果内存中的值和A一样,那就将A更新为B。CAS是通过循环来获取内存中的值的,如果第一次循环内存中的值已经被更新为其他的值,就需要把这个值获取,然后再次循环判断。

CAS会产生什么问题?

  • ABA问题:
    • 比如说线程1从内存位置V中获取数据A,线程2也从内存位置获取数据A。然后线程1将数据更新为B,之后再更新为A。而线程2去更新数据的时候,发现内存V中的数据依然是A,就直接更新数据了。尽管CAS操作成功了,但是可能存在潜藏的问题。
  • 循环时间长,开销大:
    • 对于竞争资源比较严重、资源处理时间比较长的情况,CAS自旋的概率会比较大,会浪费更多的CPU资源。
  • 只能保证一个共享变量的原子操作

什么是原子类

  • java.util.concurrent.atomic包:是原子类的小工具包
  • 例如:AtomicInteger 标识int类型的值,提供了原子的 compareAndSet方法,以及原子的添加、递增、递减等方法。

原子类的常用类

  • AtomicInteger
  • AtomicBoolean
  • AtomicLong
  • AtomicReference

死锁与活锁的区别?死锁与饥饿的区别?

  • 死锁:是两个或者两个以上的进程(或线程)的执行过程中,因为竞争资源造成了相互等待的现象,若无外力作用,他们将无法推进下去。
  • 活锁:任务或者执行者没有被阻塞,由于一些条件没有满足,导致一直重复尝试,失败,再尝试
  • 活锁和死锁的区别在于:活锁是一直在改变状态,而死锁是一直处于等待状态。活锁是能够自行解开的,而死锁不能。
  • 饥饿:一个或者多个线程由于种种原因无法获取资源,导致一直无法运行的状态。
  • Java中导致饥饿现象的原因:
    • 高级别的线程吞噬了所有低级别线程的CPU时间
    • 线程永久的被阻塞在等待进入同步块的状态,因为其他线程总是能在它之前持续的对该同步块进行访问。
    • 线程等待一个本身也处于永久等待完成的对象(比如调用这个对象的wait方法),因为其他线程总是被持续的获得唤醒。

线程池

什么是线程池?

  • Java的线程池是运用场景最多的并发框架,几乎所有需要异步或者并发执行任务的程序都可以使用线程池。在开发过程中,合理的使用线程池能够带来许多好处。
    • 降低资源消耗。通过重复利用已经创建的线程降低线程的创建和销毁所带来的消耗。
    • 提高响应速度。任务到达时,不需要等待线程创建就可以运行。
    • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

ThreadPoolExecutor

  • ThreadPoolExecutor就是Java的线程池
  • ThreadPoolExecutor的核心参数
    • corePoolSize:核心线程的数量
    • maximumPoolSize:最大线程数量
    • keepAliveTime:线程保持时间,N个时间单位
    • unit:时间单位(比如秒,毫秒)
    • workQueue:阻塞队列
    • threadFactory:线程工厂
    • handler:线程池拒绝策略

Executors

  • Executors框架实现的就是线程池的功能
  • Executors提供的四种创建线程池的方式
    • newCacheThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要可灵活回收线程,若无可回收,则新建线程。
     public static ExecutorService newCachedThreadPool() {
          return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                        60L, TimeUnit.SECONDS,
                                        new SynchronousQueue<Runnable>());
      }
    
    • newFixedThreadPool:创建一个定长的线程池,可控制线程最大并发数,超出的线程会在队列中等待。
    public static ExecutorService newFixedThreadPool(int nThreads) {
          return new ThreadPoolExecutor(nThreads, nThreads,
                                        0L, TimeUnit.MILLISECONDS,
                                        new LinkedBlockingQueue<Runnable>());
      }
    
    • newScheduledThreadPool:创建一个定长的线程池,支持定时及周期性任务执行。
    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
          return new ScheduledThreadPoolExecutor(corePoolSize);
      }
      
      public ScheduledThreadPoolExecutor(int corePoolSize) {
          super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
                new DelayedWorkQueue());
      }
    
    • newSingleThreadExecutor:创建一个线程化的线程池,它只会唯一的工作线程来执行任务,保证所有的任务按照指定顺序(FIFO,LIFO,优先级)执行
    public static ExecutorService newSingleThreadExecutor() {
          return new FinalizableDelegatedExecutorService
              (new ThreadPoolExecutor(1, 1,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>()));
      }
    

Executor和Executors的区别

  • Executors工具类的不同方法按照我们的需求创建不同的线程池,来满足业务的需求。
  • Executor接口对象能够执行我们的任务
  • ExecutorService 接口继承了Executor并且展开了扩展,提供了更多的方法我们能获得任务执行的状态以及任务的返回值。

四种构建线程池的区别和特点

1、newCachedThreadPool

  • 特点:newCachedThreadPool创建一个可缓存线程池,如果当前线程池的长度超过了处理的需要时,它可以灵活的回收空闲的线程,当需要增加时, 它可以灵活的添加新的线程,而不会对池的长度作任何限制
  • 缺点:设置的线程池最大值太大了,会导致堆内存溢出
  • 代码示例:
package com.lijie;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TestNewCachedThreadPool {
    public static void main(String[] args) {
        // 创建无限大小线程池,由jvm自动回收
        ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
        for (int i = 0; i < 10; i++) {
            final int temp = i;
            newCachedThreadPool.execute(new Runnable() {
                public void run() {
                    try {
                        Thread.sleep(100);
                    } catch (Exception e) {
                    }
                    System.out.println(Thread.currentThread().getName() + ",i==" + temp);
                }
            });
        }
    }
}

2、newFixedThreadPool

  • 特点:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。定长线程池的大小最好根据系统资源进行设置。
  • 缺点:线程数量是固定的,但是阻塞队列是无界队列。如果有很多请求积压,阻塞队列越来越长,容易导致OOM(超出内存空间)
  • 总结:请求的挤压一定要和分配的线程池大小匹配,定线程池的大小最好根据系统资源进行设置。如Runtime.getRuntime().availableProcessors() Runtime.getRuntime().availableProcessors()是查看CPU的核心数量
package com.lijie;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TestNewFixedThreadPool {
    public static void main(String[] args) {
        ExecutorService newFixedThreadPool = Executors.newFixedThreadPool(3);
        for (int i = 0; i < 10; i++) {
            final int temp = i;
            newFixedThreadPool.execute(new Runnable() {
                public void run() {
                    System.out.println(Thread.currentThread().getName() + ",i==" + temp);
                }
            });
        }
    }
}

3、newScheduledThreadPool

  • 特点:创建一个固定长度的线程池,而且支持定时的以及周期性的任务执行,类似于Timer(Timer是Java的一个定时器类)
  • 缺点:由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务(比如:一个任务出错,以后的任务都无法继续)。
  • 代码示例
package com.lijie;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class TestNewScheduledThreadPool {
    public static void main(String[] args) {
        //定义线程池大小为3
        ScheduledExecutorService newScheduledThreadPool = Executors.newScheduledThreadPool(3);
        for (int i = 0; i < 10; i++) {
            final int temp = i;
            newScheduledThreadPool.schedule(new Runnable() {
                public void run() {
                    System.out.println("i:" + temp);
                }
            }, 3, TimeUnit.SECONDS);//这里表示延迟3秒执行。
        }
    }
}

4、newSingleThreadExecutor

  • 特点:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它,他必须保证前一项任务执行完毕才能执行后一项。保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
  • 缺点:缺点的话,很明显,他是单线程的,高并发业务下有点无力
  • 代码示例
package com.lijie;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TestNewSingleThreadExecutor {
    public static void main(String[] args) {
        ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();
        for (int i = 0; i < 10; i++) {
            final int index = i;
            newSingleThreadExecutor.execute(new Runnable() {
                public void run() {
                    System.out.println(Thread.currentThread().getName() + " index:" + index);
                    try {
                        Thread.sleep(200);
                    } catch (Exception e) {
                    }
                }
            });
        }
    }
}

线程池都有哪些状态

  • RUNNING:这是最正常的状态,接收新的任务,处理等待队列中的任务
  • SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。
  • STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。
  • TIDYING:所有的任务都销毁了,workCount为0,线程池的状态在转换为TIDYING状态时,会执行所有钩子方法terminated()
  • TERMINATED:terminated()方法结束以后,线程池的状态就会变成这个。

线程池中的submit()和execute()方法有什么区别?

  • 相同点:
    • 都可以开启线程执行池中的任务
  • 不同点:
    • 接收参数不同:execute()方法只能执行Runnable类型的任务。submit()可以执行Runnable和Callable类型的任务。
    • 返回值不同:submit可以返回持有计算结果的Future对象,而execute没有
    • 异常处理:submit()方便Exception处理

ThreadPoolExecutor的饱和策略有哪些?

如果当前同事运行的数量达到了最大数量,并且等待队列也已经被放满了时,ThreadPoolExecutor会定定制一些拒绝策略:

  • ThreadPoolExecutor.AbortPolicy:抛出RejectedExecutionException来拒绝新任务的请求处理
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务。您不会任务请求。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

线程池的执行原理

提交一个线程到线程池中,线程池的处理

  • 1、判断线程池的核心线程是否都在执行任务,如果不是(核心线程空闲)则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则进入下一个流程
  • 2、线程池判断等待队列是否已满,如果没有,则进入线程等待队列中。如果等待队列已满则进入下一个流程。
  • 3、判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则采取饱和策略来处理任务。

CPU密集型

  • CPU密集型就是任务需要大量的计算,而没有阻塞,CPU一直全速运行
  • CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程),而在单核的CPU上,无论开几个模拟的多线程,该任务都不可能得到加速,因为CPU总的运算能力就那样。

IO密集型

  • IO密集型,即该任务需要大量的IO,即大量的阻塞。在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力浪费在等待。所以在IO密集型任务中使用多线程可以大大的加速程序运行,即时在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。

并发容器

什么是Vector

  • Vector和ArrayList一样,底层都是由数组构成的,不同的是它支持线程同步,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起数据不一致的问题,但是实现同步需要很高的花费,访问它比访问ArrayLis慢多了。

HashTable为什么是线程安全的?

  • 因为HashTable的内部方法都被synchronized修饰了,所以是线程安全的。其他的都和HashMap一样。

说说对ConcurrentHashMap的理解

JDK7中的ConcurrentHashMap

  • ConcurrentHashMap采用了锁分段技术,首先将数据分成一段一段存储,然后每一段数据配一把锁,当一个线程占用锁访问访问其中一段数据时,当前段的数据不能被其他线程访问,但是其他段的数据可以被访问的
  • Segment的代码
//Segment的结构
static final class Segment<K,V> extends ReentrantLock implements Serializable {
	 //加载因子默认0.75
	 final float loadFactor;

	 //阙值:达到多少个元素的时候需要扩容
	 transient int threshold;

	 //内部的哈希表,节点就是HashEntry
	 transient volatile HashEntry<K,V>[] table;

	 //添加键值对到内部数组+链表中
	 final V put(K key, int hash, V value, boolean onlyIfAbsent){..};
}
  • 由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap中扮演锁的角色;
static final class HashEntry<K,V> {
	//此节点的hash值
	final int hash;
	//此节点的键
	final K key;
	//此节点的值
	volatile V value;
	volatile HashEntry<K,V> next;

	HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
		this.hash = hash;
		this.key = key;
		this.value = value;
		this.next = next;
	}

	//设置下一个节点的引用
	final void setNext(HashEntry<K,V> n) {
		UNSAFE.putOrderedObject(this, nextOffset, n);
	}
}	
  • HashEntry用于存储键值对数据,一个ConcurrentHashMap里包含一个Segment数组。
  • Segment数组和HashMap类似,都是由数组加链表组成的。
  • 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素。
  • 每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,需要获取Segment的锁。

JDK8中的ConcurrentHashMap

  • ConcurrentHashmapJDK8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
static class Node<K,V> implements Map.Entry<K,V> {
	final int hash;
	final K key;
	volatile V val;
	volatile Node<K, V> next;

	Node(int hash, K key, V val, Node<K, V> next) {
		this.hash = hash;
		this.key = key;
		this.val = val;
		this.next = next;
	}
	......
}

SynchronizedMap 和 ConcurrentHashMap 有什么区别?

  • SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map。
  • ConcurrentHashMap 使用分段锁来保证在多线程下的性能。
  • ConcurrentHashMap 中则是一次锁住一个桶。ConcurrentHashMap 默认将hash 表分为 16 个桶,诸如 get,put,remove 等常用操作只锁当前需要用到的桶。
  • 这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的。
  • 另外 ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当iterator 被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时 new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变。

CopyOnWriteArrayList是什么?

  • CopyOnWriteArrayList是一个并发容器。有很多人称它是线程安全的,但是它有个前提条件,就是非复合的场景下操作它是线程安全的
  • CopyOnWriteArrayList(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出 ConcurrentModificationException。在CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。

CopyOnWriteArrayList 的缺点?

  • 在写操作的时候需要复制数组,如果数组很大可能会引发young GC或者Full GC
  • 不能满足试试读的场景。拷贝数组和新增元素都需要时间,所以调用一个set函数之后,读取的数据可能还是旧的。虽然CopyOnWriteArrayList能做到最终一致性,但是还是没法满足实时性要求。
  • 由于实际使用中可能没法保证 CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次 add/set 都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。

并发队列

什么是并发队列

  • 消息队列很多人知道:消息队列是分布式系统中重要的组件,是系统与系统直接的通信
  • 并发队列是多线程有次序的共享数据的重要组件

并发队列和并发集合的区别

  • 队列遵循先进先出原则,可以想象成排队检票,队列一般用来解决大数据量采集处理和显示。
  • 并发集合就是在多线程环境中共享数据的

怎么判断是阻塞队列还是非阻塞队列

  • 在JDK提供了Queue接口,一个是QUeue接口下的BlockingQueue接口为代表的阻塞队列,另一种就是高性能(非阻塞)队列

常用并发队列

非阻塞队列

  • ArrayDeque(数组双端队列)
    • 是JDK容器中的一个双端队列实现,内部使用数组进行元素存储,不允许存储null值。
    • 可以高效的进行元素查找和尾部插入取出,是用作队列、双端队列、栈的最佳选择,性能比LinkedList还要好
  • PriorityQueue(优先级队列)
    • 基于优先级的无界优先级队列
    • 优先级队列元素按照自然顺序进行排序,或者根据构造队列时提供的Comparator进行排序
    • 该队列不允许使用null元素,也不允许插入不可比较的对象
  • ConcurrentLinkedQueue(基于链表的并发队列)
    • 适用于高并发场景下的队列,通过无锁的方式,实现了高并发状态下的高性能
    • 性能要好于BlockingQueue接口
    • 是一个基于链接节点的无界线程安全队列,该队列遵循先进先出原则。不允许null元素。

阻塞队列

  • DelayQueue(基于时间优先级的队列,延期阻塞队列)
    • DelayQueue是一个没有边界的BlockingQueue实现,假如的元素必须实现Delayed接口。
    • 当调用put方法时,会触发Delayed接口中的compareTo方法进行排序,也就是说队列是按照到期时间排序的。排在队列头的最早到期
  • ArrayBlockingQueue(基于数组的并发阻塞队列)
    • 有界的阻塞队列,内部的实现是数组
    • 先进先出的方式存储数据
  • LinkedBlockingQueue(基于链表的FIFO双端阻塞队列)
    • 由链表组成的双向阻塞队列,即可以从队列的两端插入和移除元素。
    • 相比于其他阻塞队列,LinkedBlockingDeque多了addFirst、addLast、peekFirst、peekLast等方法,
    • LinkedBlockingDeque是可选容量的,在初始化时可以设置容量防止其过度膨胀,如果不设置,默认容量大小为Integer.MAX_VALUE。
  • PriorityBlockingQueue(带优先级的无界阻塞队列)
    • 无界队列,没有限制
    • 传入的对象必须实现Comparable接口
  • SynchronizedQueue(并发同步阻塞队列)
    • 内部只能包含一个元素的队列。
    • 当队列中有元素的时候,插入元素到队列的线程被阻塞,直到另一个线程获取队列中的元素
    • 当队列中无元素的时候,线程获取元素会被阻塞,直到其他线程插入元素。

阻塞队列常用的方法

方法类型抛出异常特殊值阻塞超时
插入add()offer(e)put(e)offer(e,time,unit)
移除remove()poll()take()poll(e,time,unit)
检查element()peek()不可用不可用
方法结果说明
抛出异常当阻塞队列满的时候,往队列中插入数据会抛出java.lang.IllegalStateException的异常,当阻塞队列满的时候,再往队列add插入元素会抛出 java.lang.IllegalStateException: Queue full当队列为空的时候,再往队列里remove元素时就会抛出 java.util.NoSuchElementException如果队列问空时,使用element()方法检查时也会抛出 java.util.NoSuchElementException
特殊值当队列满的时候,再往队列offer插入元素时,会返回false当队列为空的时候,再往队列中poll()获取元素时,就会返回null当队列为空的时候,再往队列中peek()检查元素时,就会返回null
一直阻塞当阻塞队列满的时候,生产线程继续往队列里put元素,队列会一直阻塞生产线程直到put数据或者相应中断退出当阻塞队列为空时,消费线程试图从队列里take元素,队列会一直阻塞消费线程直到队列可用。
超时当阻塞队列为满时,队列会阻塞生产线程一段时间,超过时限之后生产线程会自动退出。

并发常用的工具类

  • CountDownLatch
    • CountDownLatch位于java.util.concurrent包下面,利用它可以实现计数器的功能。例如当A线程需要再其他几个线程执行完毕之后再执行,就可以使用这个线程。
  • CyclicBarrier(回环栅栏)
    • 它的作用就是会让所有线程都等待完成后才会继续下一步行动。
    • CyclicBarrier初始化时规定一个数目,然后计算调用了CyclicBarrier.await()进入等待的线程数。当线程数达到了这个数目时,所有进入等待状态的线程被唤醒并继续。
    • CyclicBarrier初始时还可带一个Runnable的参数, 此Runnable任务在CyclicBarrier的数目达到后,所有其它线程被唤醒前被执行。
  • Semaphore (信号量)
    • Semaphore 是 synchronized 的加强版,作用是控制线程的并发数量(允许自定义多少线程同时访问)。就这一点而言,单纯的synchronized 关键字是实现不了的。
    • Semaphore是一种基于计数的信号量。它可以设定一个阈值,基于此,多个线程竞争获取许可信号,做自己的申请后归还,超过阈值后,线程申请许可信号将会被阻塞。