并发基础

进程与线程

  • 进程(资源管理最小单位):

    程序的一次执行过程,运行程序的基本单位,进程是动态的。系统执行一个程序即进程从创建,运行到死亡的过程。

    在Java中,启动main函数即启动了一个JVM的进程,main所在的线程是进程中的一个线程。

  • 线程(任务调度最小单位):

    线程是比进程更小的执行单位,一个进程在执行过程中可以产生多个线程,同类线程之间共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈、本地方法栈

    系统在产生一个线程,或在多个线程之间切换工作时,其负担比操作进程小得多,因此线程也称为轻量级进程。

线程状态

  • 新建(NEW):线程创建未启动,此时还未调用start。
  • 可运行(RUNABLE):线程处于运行状态或等待资源调度进入运行状态。
  • 阻塞(BLOCKED):线程等待获取锁。
  • 无限期等待(WAITING):执行Object.wait()或Thread.join()进入,没有主动唤醒则一直等待。
  • 限期等待(TIMED_WAITING):自动唤醒,即等待时设置了时间,sleep() 或 wait() 或 join()设置时间。
  • 死亡(TERMINATED):线程结束或异常结束。

并发与并行

  • 并发:同一时间段,可以多个任务执行,但单位时间不一定同时执行。
  • 并行:单位时间内,多个任务同时执行。

线程的生命周期和状态

  • NEW:新建,线程被构建,但还没有调用start()方法
  • RUNNABLE:可运行,一般操作系统细分为READY(就绪)和RUNNING(运行),Java中3统称位运行中
  • BLOCKED:阻塞,线程因为锁被阻塞
  • WAITING:等待,线程进入等待状态,进入该状态的线程需要依靠其他线程的通知才可返回运行状态
  • TIME_WAITING:超时等待,该状态在等待基础上增加了超时限制,超时后线程自行返回运行状态
  • TERMINATED:终止,表示线程已执行完毕

守护线程Daemon

守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。

当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。

main() 属于非守护线程。

在线程启动之前使用 setDaemon() 方法可以将一个线程设置为守护线程。

上下文切换

线程执行过程中会有自己的运行条件和状态(上下文),一般出现以下情况,线程会从占用CPU状态中退出:

  • 主动让出CPU,例如调用sleep()、wait()进入睡眠、等待状态
  • 时间片用完,操作系统防止一个进程或线程长时间占用CPU,使其他进程后线程无用。
  • 调用阻塞类型的系统中断,例如请求IO,线程被阻塞
  • 线程终止/结束运行

前三种都会发生线程切换,线程切换时要保存当前线程的上下文。当下次线程占用CPU时恢复现场,并加载下一个将占用CPU的线程上下文。这就是上下文切换

线程调度的方法

  • sleep():线程定时休眠,进入阻塞状态,不会释放锁。
  • wait():线程进入阻塞状态,释放持有的对象的锁。
  • join():在A线程中调用B线程的join(),A线程进入阻塞状态,直到B线程结束。
  • yield():线程让步,当前线程由运行状态进入就绪状态,让出资源,所有线程重新争取资源。

sleep与wait区别

  • wait()是Object方法,sleep()是Thread方法
  • sleep不释放锁,wait释放锁
  • wait没有设置时间,只能被notify()、notifyAll()主动唤醒。

死锁

简述

多个线程循环阻塞,循环等待其他线程释放资源,而产生死锁必须具备四个条件:

  • 互斥条件:资源任何时刻只有一个线程占用,不能多个线程共用
  • 请求与保持条件:一个进程因请求资源阻塞时,对已获取资源保持不放
  • 不可剥夺条件:进程已获取的资源在未使用完之前,是不能被其他进程强行剥夺的,只可使用完后自行释放
  • 循环等待条件:若干进程之间的循环等待资源关系形成环

预防与避免线程死锁

只需破坏任意一个死锁的必要条件即可预防死锁。

  • 破坏互斥:硬件资源实现同时访问可破坏,软件资源一般无法打破
  • 破坏请求与保持:一次性申请全部的资源
  • 破坏不可剥夺:占用部分资源的进程进一步申请其他资源时,若申请不到,可主动释放自己占用的资源
  • 破坏循环等待:通过按序申请资源来预防。按规定顺序申请资源,释放时则反序执行。

避免死锁:一般可在资源分配时,通过算法(银行家算法等)对资源进行合理分配来避免死锁。

内存模型JMM

Java内存模型规定所有变量都存储在主内存(共享变量),每条线程还有自己的工作内存,线程在工作内存中保存了被该线程使用的变量的主内存副本

并发特性

  • 原子性(synchronized、原子类)

    一次或多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。

  • 可见性(volatile、synchronized、final)

    当一个线程修改了共享变量的值时,其他线程能立即得知这个修改。

  • 有序性(volatile、synchronized)

    本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

    也就是说代码编写顺序和其执行顺序可能不同。可能会通过指令重排进行优化导致乱序执行。

synchronized

synchroized解决多线程间访问资源的同步性,它可以保证被修饰的方法或代码块在任意时刻都只有一个线程执行。

Java早期版本,syn属于重量级锁,效率低。JDK6后对syn进行了大量优化。

  • syn修饰,作用对象是这个类的所有对象

  • syn修饰实例方法,作用于当前对象实例加锁,进入同步方法前需要获取当前对象实例的锁

  • syn修饰静态方法,给当前类加锁,作用于当前类的私所有对象实例,进入同步方法前需要获取当前class的锁

  • syn修饰代码块,指定加锁的对象,对指定的 对象/类 加锁,进入同步方法前获取对应指定对象/class的锁

构造方法不能被syn修饰,构造方法本身就是线程安全的。

锁升级流程

锁主要存在无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态这四个状态。它们随着竞争逐渐升级。锁可以升级但不可以降级,这种策略是为了提高获得锁和释放锁的效率。

锁升级其实就是优化,除此之外还有锁消除和锁粗化。

  • 偏向锁(第一个获取锁的线程)

    无竞争情况下把整个同步消除掉,CAS操作都不执行。一旦出现另外的一个线程去尝试获取这个锁的情况,偏向锁马上失效。

    偏向锁是只针对一个线程的,线程获取锁后不会有解锁的操作,因为是单一线程。这样可以减少开销,但如果有两个线程来竞争锁,偏向锁就会失效,并升级为轻量级锁。

  • 轻量级锁(CAS)

    无竞争情况下使用CAS操作去消除同步使用的互斥量。

    轻量级锁使用CAS进行操作,实现锁的获取。当锁竞争十分严重的情况下,将轻量级锁升级为重量级锁。

  • 自旋锁

    由于轻量级锁是CAS实现,CAS也就涉及到了自旋锁,自旋就是循环等待锁释放。

    轻量级锁竞争失败时,会进行自旋,自旋成功获取锁,依旧是轻量级锁,自旋获取失败,则膨胀为重量级锁。

  • 重量级锁(互斥量 mutex)

    重量级锁是直接让线程进入堵塞状态,当持有锁的线程释放锁后,唤醒堵塞的线程,然后线程再次竞争锁。

volatile与synchronized区别

  • volatile效率比synchronized效率高,volatile只能修饰变量,synchronized可修饰方法和代码块。
  • volatile保证有序性、可见性,但不保证原子性。synchronized有序性、可见性、原子性都可以保证。
  • volatile用于解决变量在多线程之间的可见性,synchronized可解决多个线程之间访问资源的同步性。
  • volatile防止指令重排。

ReentrantLock可重入锁

手动调用lock()、unlock()方法,来获取释放锁,一般配合try-finally,操作内容写在try中,在finally中保证可以释放锁。基于AQS。

syn和Reen对比

  • syn是关键字,属于JVM层面。Reen是属于JDK层面,实现了Lock接口。
  • syn无需手动释放锁。Reen需要调用unlock()手动释放锁,一般配合try-finally保证锁被释放。
  • syn发生异常时,会自动释放锁,不会导致死锁。Reen如果没有主动配置try-finally去释放锁,可能会导致死锁。
  • syn不可中断,除非抛出异常或执行完毕。Reen可中断,使用try Lock()。
  • syn是非公平锁,Reen默认是非公平锁,但可以设置成公平锁。Reen构造方法传入boolean参数可指定是否创建公平锁。
  • syn只能唤醒一个线程或全部唤醒。Reen可以分组唤醒或精确唤醒,Reen可以绑定多个Condition对象。
  • 都是可重入锁,自己可以重复获取自己的内部锁,可避免死锁发生。

ReentrantReadWriteLock(锁降级)

基于AQS实现。读写锁维护一对锁,一个读锁(共享锁)和一个写锁(独占锁)。实现了ReadWriteLock接口。

  • 允许多个读线程同时访问
  • 在写线程访问时,所有读线程和写线程都会被阻塞
  • 和ReentrantLock一样,支持非公平锁和公平锁。

锁降级

锁降级是一个为了保证数据可见性的操作。如果线程连续两次对同一个变量进行写操作,然后执行其他逻辑最后输出变量。会发现两次变量输出都是第二次修改的变量值,第一次修改被覆盖了,如果我们使用锁降级,在写锁中加入读锁然后释放写锁,当第二个线程进来时由于第一个线程的读锁未释放会等待,直到第一次修改的逻辑全部执行完后释放读锁。此时变量值展示是第一次修改,然后第二次修改才会进行,最后展示第二次修改的变量值。

锁降级保证数据的可见性,防止其他线程篡改数据。

乐观锁

CAS(Compare And Swap)

  • 需要读写的内存位置 V
  • 变量进行比较的旧值 A
  • 变量拟写入的新值 B

当内存位置V的值等于旧值A,说明未修改过,位置V的值更新为B,否则不进行操作。CAS一般是自旋的,不断的重试。CAS由CPU支持的原子操作,原子性在硬件层面得到保证。

若是竞争大的场景,不建议使用CAS,会导致频繁自旋。

版本号控制

全局定义一个版本号,每次更新前先读取数据,并查看版本号,进行写入操作时会再次查看版本号,两次版本号一致,则说明操作执行过程中没有其他线程干扰,完成更新操作,并使版本号+1。不一致则操作失败。

版本号一般加在数据库表中,让读写操作无干扰。

ABA问题

内存位置V的值,前后两次读取值都是A,不能它没有被修改过,因为可以进行二次修改即ABA,最后读取的还是A值。

解决:

  • 版本号
  • 使用boolean变量,表示是否修改过
  • 原子类

ThreadLocal

ThreadLoacal让每一个线程绑定自己的值,在里面存储每个线程的私有数据。

每一个访问ThreadLocal变量的线程都会有这个变量的本地副本,通过get、set方法对变量进行操作,从而避免线程安全问题。

在我的商城项目中,通过ThreadLocal解决了Service层获取session内信息的问题。首先MVC拦截器会检查请求的session是否存在,若存在请求通过并将信息set到ThreadLocal中,然后我们在Service层处理业务逻辑时就可以通过get得到相应的信息。

ThreadLocal相当于维护了一个Map,KV存储。K是弱引用,V是强引用,可能产生K = null的情况,在ThreadLocal调用完后最好执行 remove() 防止内存泄露。

使用get、set不会导致V内存泄露,只有线程长时间不被使用才会导致GC将K清理。

使用线程

Runnable接口(推荐)

实现Runnable接口,重写run方法,在执行类中声明一个Thread对象,并传入自定义的Runnable对象,然后Thread对象执行start方法启动线程。

1
2
3
4
5
6
7
8
9
10
11
public class MyRunnable implements Runnable {
@Override
public void run() {
// ...
}
}

public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}

Callable接口(有返回值,使用FutureTask封装)

实现Callable接口,重写call方法,和Runnable不同,可以有返回值,执行线程需要先通过FutureTask封装,然后声明Thread对象传入FutureTask对象,执行start方法启动线程。

注意Thread对象参数是Runnable型,而FutureTask是继承Runnable的,所以通过Callable实现线程,需要FutureTask封装。

FutureTask的get方法可以取出Callable的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyCallable implements Callable<Integer> {
public Integer call() {
return 250;
}
}

public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}

Thread类(实现Runnable接口)

Thread类实现了Runnable接口,我们也是通过重写run方法。然后直接声明自定义的Thread对象,执行它的start方法就可以启动线程。

1
2
3
4
5
6
7
8
9
10
11
public class MyThread extends Thread {
@Override
public void run() {
// ...
}
}

public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}

实现与继承区别

Java特性多实现,单继承,肯定是实现接口更好,且继承整个Thread类开销过大。

run和start区别

run是线程默认执行的方法,start是启动线程,线程进行异步执行,如果没有start,直接执行run就是在当前线程调用了一个普通方法而已。

线程池(ThreadPoolExecutor)

参数分析

1
2
3
4
5
6
7
8
ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
  • corePoolSize

    核心线程数,线程池维护的一个最小线程数量,只要线程池不销毁就一直存在,除非设置allowCoreThreadTimeOut

  • maximumPoolSize

    最大线程数,不会无限创建线程,如果阻塞队列无限大,这个参数就没意义了。

  • keepAliveTime

    空闲线程存活时间,当前线程数大于核心线程数时,在keepAliveTime时间后,超过核心线程数的空闲线程被销毁

  • unit

    keepAliveTime的时间单位

  • workQueue

    阻塞队列用来储存等待执行任务的队列,新任务到来时会先判断线程数是否达到核心线程数,若达到则先在队列中进行等待,队列满了则判断最大线程数是否饱和,未饱和则FIFO执行任务,饱和执行拒绝策略。

  • threadFactory

    线程创建工厂,可自定义

  • handler

    饱和策略,任务达到上限,可拒绝执行任务,有多种拒绝策略

    • AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理
    • CallerRunsPolicy:由调用线程池execute()方法的线程 执行被拒绝的任务,强行保证信息不丢失,但效率低。
    • DiscardPolicy:直接丢弃新任务
    • DiscardOldestPolicy:丢弃最早未被处理的任务

线程池的种类

  • FixedThreadPool

    固定大小的线程池,core和max都是我们传入的数值,也就是最大线程数就是核心线程数,没有非核心线程,线程不可回收。

  • SingleThreadExecutor

    core=max=1,只有一个线程的线程池,单线程线程池逐个执行任务,可保证任务的执行顺序,先进先出。

  • CaheedThreadPool

    core = 0,max = int的最大值,没有核心线程,非核心线程是 int_max值。所有都是空闲线程都可回收。

  • ScheduledThreadPool

    执行周期性或定时任务的线程池。

线程池执行流程

45.png

  • poolSize < core,直接创建核心线程执行任务。
  • poolSize > core,将任务放到阻塞队列FIFO。
  • 阻塞队列满了 && poolSize < max,创建非核心线程执行任务。
  • poolSize > max,执行拒绝策略。

为什么推荐自定义线程池

  • FixedThreadPoolSingleThreadExecutor,官方设置其阻塞队列时,直接使用 LinkedBlockingQueue(),会走无参构造器,无参构造将容量设置为 Integer.MAX_VALUE,会导致大量请求堆积,最终导致OOM内存溢出。
  • CaheedThreadPoolScheduledThreadPool,官方设置它们的最大线程数都是 Integer.MAX_VALUE,也就是说可以大量创建线程,可能导致OOM内存溢出。

AQS

AQS原理

AbstractQueuedSynchronizer 是一个构建锁和同步器的框架,例如ReentrantLock、Semaphore都是基于AQS实现的。

  • 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。
  • 如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即 将暂时获取不到锁的线程加入到队列中。

其中CLH是一个FIFO双向队列,AQS将请求共享资源的线程封装成CLH的一个Node来实现锁的分配。

1
private volatile int state;//共享变量,使用volatile修饰保证线程可见性

AQS定义了一个成员变量state表示同步状态,AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。我们后续一些基于AQS实现的类,其操作的限制数就是这里的state。

AQS对资源共享的方式

  • 独占:只有一个线程执行,ReentrantLock
  • 共享:多线程同时执行,CountDownLatch、CyclicBarrier、Semaphore、

CountDownLatch(倒计时器)

基于AQS实现,CountDownLatch 允许数个线程阻塞在一个地方,直到所有线程的任务执行完毕。

CountDownLatch 会维护一个计数器 cnt,每当一个等待线程完成任务就是执行 countDown() 方法,使计数器值-1,只有当 cnt 减至0时,那些调用 await() 进入等待的线程会被唤醒。

一般 await() 配合任务调用使用,每个线程完成任务后就 countDown(),然后执行 await() 等待其他线程完成任务。

CyclicBarrier(循环栅栏)

基于ReentrantLock实现,而ReentrantLock是基于AQS实现的。用来控制多个线程互相等待。

CyclicBarrier 让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障,屏障才会被打开,这一组线程才可以继续执行。

CyclicBarrier 内部也是使用计数器实现,线程执行 await() 方法之后计数器会-1,并进行等待,直到计数器为 0,所有调用 await() 方法而在等待的线程才能继续执行。

CyclicBarrier 和 CountDownLatch 类似,但 CyclicBarrier 可以调用 reset() 方法进行循环使用。而 CountDownLatch 只能被调用一次。

Semaphore(信号量)

基于AQS实现,Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。

acquire() 阻塞线程,从信号量获取许可

release() 增加许可,可释放一个被阻塞的 acquire()。

Semaphore用来维护许可的数量。