Java多线程笔记

173 阅读20分钟

目录

  1. 思维导图
  2. 主要内容
    • 基础概念
      • 并行/并发
      • 进程/线程
      • 守护线程
    • 线程创建
      • 继承Thread类
      • 实现Runnable/Callable接口
    • 线程池
      • 核心参数
      • 拒绝策略
      • 容器队列
      • 异常处理
      • 4种预置线程池
      • 应用
    • 线程调度
      • 等待
      • 通知
      • 让出优先权
      • 中断
      • 休眠
    • 线程状态转换
      • 线程的状态
      • 上下文切换
    • 线程通信机制
      • volatile
      • synchronized
      • 等待/通知
      • 管道输入/输出流
      • Thread.join
      • ThreadLocal
    • Java内存模型(JMM)
      • 原子性/有序性/可见性
      • 指令重排
      • happens-before
      • as-if-serial
      • CAS
    • 死锁
      • 产生的原因
      • 排查
    • Java并发包(JUC)
      • AQS
      • ReentrantLock
      • ThreadLocal
      • CountdownLatch
      • CopyOnWriteList
      • ConcurrentLinkedQueue
      • Semaphore

1. 思维导图

多线程.png

2. 主要内容

2.1 基础概念

  • 并行/并发
    • 并行: 同一时刻,两个线程都在执行。这就要求有两个CPU核心去分别执行两个线程。
    • 并发: 同一时刻,只有一个执行,但是一个时间段内,两个线程都执行了。并发的实现依赖于 CPU切换线程,因为切换的时间特别短,所以基本对于用户是无感知的。
  • 进程/线程
    • 进程:进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位。
    • 线程:线程是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。
  • 守护线程
    • Java中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。在JVM 启动时会调用 main 函数,main函数所在的线程就是一个用户线程。其实在 JVM 内部同时还 启动了很多守护线程, 比如垃圾回收线程。
    • 守护线程和用户线程的区别呢?区别之一是当最后一个用户线程结束时, JVM会正常退出, 而不管当前是否存在守护线程,也就是说守护线程是否结束并不影响 JVM退出。

2.2 线程创建

  • 继承Thread类,重写run()方法,调用start()方法启动线程
public class ThreadTest {
  /**
  * 继承Thread类 */
  public static class MyThread extends Thread { 
    @Override
    public void run() {
    System.out.println("This is child thread");
    } }
  public static void main(String[] args) { 
  	MyThread thread = new MyThread(); 
  	thread.start();
  } 
}
  • 实现Runnable接口,重写run()方法
public class RunnableTask implements Runnable { 
  public void run() {
    System.out.println("Runnable!"); 
  }
  public static void main(String[] args) { 
  	RunnableTask task = new RunnableTask();
    new Thread(task).start();
  } 
}

threadsPool.execute(new Runnable() {
	@Override public void run() {
	}
});
  • 实现Callable接口,重写call()方法,通过FutureTask获取任务执行的返回值
public class CallerTask implements Callable<String> { 
  public String call() throws Exception {
  	return "Hello,i am running!"; 
  }
  public static void main(String[] args) {
    //创建异步任务
    FutureTask<String> task=new FutureTask<String>(new CallerTask()); 
  	new Thread(task).start();//启动线程
  	try {
  		//等待执行完成,并获取返回结果 
  		String result=task.get();
  		System.out.println(result);
  	} catch (InterruptedException e) { 
  		e.printStackTrace();
    } catch (ExecutionException e) { 
    	e.printStackTrace();
    } }
}

 
Future<Object> future = executor.submit(harReturnValuetask); 
try { 
	Object s = future.get(); 
} catch (InterruptedException e) {
// 处理中断异常
} catch (ExecutionException e) {
// 处理无法执行任务异常 
} finally {
// 关闭线程池 executor.shutdown(); 
}

2.3 线程池

2.3.1 核心参数

  • corePoolSize: 核心线程数,一般cpu核心数*2
  • maximumPoolSize: 队列满了,还有新任务加入,根据maximumPoolSize创建新线程
  • keepAliveTime: 非核心闲置线程存活时间
  • unit: 非核心线程保持存活的时间单位
  • workQueue: 线程池任务等待队列
  • threadFactory:线程工厂,线程池创建线程时调用的工厂方法,通过此方法可以设置线程的优先级、线程命名规则以及线程类型(用户线程还是守护线程)等
  • RejectedExecutionHandler:定义任务拒绝策略

2.3.2 拒绝策略

  • AbortPolicy: 直接抛出异常,默认使用此策略
  • CallerRunsPolicy: 用调用者所在的线程来执行任务
  • DiscardOldestPolicy: 丢弃阻塞队列里最老的任务,也就是队列里靠前的任务
  • DiscardPolicy: 当前任务直接丢弃

2.3.3 容器队列

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列,FIFO
  • LinkedBlockingQueue:常用,一个由链表结构组成的有界阻塞队列,FIFO
  • SynchronousQueue:一个不存储元素的阻塞队列,每个插入 操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,newCachedThreadPool线程池使用了这个队列
  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素,newScheduledThreadPool线程池使用了这个队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。与SynchronousQueue类似,还含有非阻塞方法。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

2.3.4 异常处理

  • try catch 捕获
  • submit执行,future.get得到异常结果
  • ThreadFactory定义Thread.UncaughtExceptonHandler处理异常
  • 重写ThreadPoolExecutor.afterExecute,传递异常引用

2.3.5 4种预置线程池

  • newFixedThreadPool (固定数目线程的线程池)
    • 核心线程数=最大线程数,阻塞队列是无界队列LinkedBlockingQueue,可能会导致OOM
  • newCachedThreadPool (可缓存线程的线程池)
    • 核心线程数为0
    • 最大线程数为Integer.MAX_VALUE,即无限大,可能会因为无限创建线程,导致OOM
    • 阻塞队列是SynchronousQueue
    • 非核心线程空闲存活时间为60秒
  • newSingleThreadExecutor (单线程的线程池)
    • 核心线程数=最大线程数=1,阻塞队列是无界队列LinkedBlockingQueue,可能会导致OOM
  • newScheduledThreadPool (定时及周期执行的线程池)
    • 最大线程数为Integer.MAX_VALUE,也有OOM的风险
    • 阻塞队列是DelayedWorkQueue
    • keepAliveTime为0
    • scheduleAtFixedRate() :按某种速率周期执行
    • scheduleWithFixedDelay():在某个延迟后执行

2.4 线程调度

2.4.1 等待

  • wait()/join(): 调用该方法的线程获取对象的锁,然后释放锁等待被唤醒(join内部调用的是wait())

2.4.2 通知

  • notify() : 一个线程A调用共享对象的 notify() 方法后,会唤醒一个在这个共享变量上调用 wait 系列方法后被挂起的线程。 一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程 是随机的。
  • notifyAll() : notifyAll()方法则会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程。

2.4.3 让出优先权

  • yield() :Thread类中的静态方法,当一个线程调用 yield 方法时,实际就是在暗示线程调度器当 前线程请求让出自己的CPU ,但是线程调度器可以无条件忽略这个暗示。

2.4.4 中断

  • Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执 行,而是被中断的线程根据中断状态自行处理。
  • void interrupt() :中断线程,例如,当线程A运行时,线程B可以调用线程interrupt() 方法来设 置线程的中断标志为true 并立即返回。设置标志仅仅是设置标志, 线程A实际并没有被中断, 会 继续往下执行

2.4.5 休眠

  • sleep(long millis) :Thread类中的静态方法,当一个执行中的线程A调用了Thread 的sleep方法 后,线程A会暂时让出指定时间的执行权,但是线程A所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,接着参与 CPU 的调度,获取到 CPU 资源后就 可以继续运行。

2.5 线程状态转换

2.5.1 线程的状态

线程状态转换.png

  • NEW 初始状态:线程被创建,但还没有调用start()方法
  • RUNNABLE 运行状态:Java线程将操作系统中的就绪和运行两种状态笼统的称作“运行”
  • BLOCKED 阻塞状态:表示线程阻塞于锁
  • WAITING 等待状态:表示线程进入等待状态,进入该状态表示当前线程需要等待其他 线程做出一些特定动作(通知或中断)
  • TIME_WAITING 超时等待状态:该状态不同于 WAITIND,它是可以在指定的时间自行返回 的
  • TERMINATED 终止状态:表示当前线程已经执行完毕

2.5.2 上下文切换

  • 为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转也就是给每个线程分配 一个时间片,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换。

2.6 线程通信机制

2.6.1 volatile

  • 可见性:
    • 作用: 用来修饰字段(成员变量),以确保对某个变量的更新对其他线程马上可见
    • 实现: 线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。 当其它线程读取该共享变量 , 会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
  • 有序性:
    • 作用:
      1. 普通读写 - 普通读写可以重排序
      2. 普通读写 - volatile读可以重排序
      3. volatile写 - 普通读写可以重排序
      4. 其他情况都不能重排序
    • 实现: 编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序
      1. 在每个volatile写操作的前面插入一个 StoreStore 屏障(禁止前面的普通写和后面的volatile写重排序,保证普通写的数据可以被volatile读到)
      2. 在每个volatile写操作的后面插入一个 StoreLoad 屏障(禁止前面的volatile写和后面的volatile读/写重排序,开销最大,有其他三个屏障的效果)
      3. 在每个volatile读操作的后面插入一个 LoadLoad 屏障(禁止后面的普通读和前面的volatile读重排序,保证volatile读要的数据被读到)
      4. 在每个volatile读操作的后面插入一个 LoadStore 屏障(禁止后面的普通写和前面的volatile读重排序,保证volatile读的数据一定能读到)

2.6.2 synchronized

  • 修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。
  • 可见性
    • 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
    • 线程加锁后,其它线程无法获取主内存中的共享变量。 线程解锁前,必须把共享变量的最新值刷新到主内存中。
  • 有序性
    • synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized保证同一时刻,代码是单线程执行的。
    • 因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。 所以synchronized保证的有序是执行结果的有序性,而不是防止指令重排的有序性。
  • 可重入
    • 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之 后,计数器就会-1,直到计数器清零,就释放锁了。之所以,是可重入的。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线 程执行完毕后 -1,直到清零释放锁。

2.6.3 等待/通知

  • wait() 持有对象锁的线程等待被唤醒,直到另一个持有对象锁的线程调用了notify/notifyAll,或者是过了指定的时间,唤醒后若未获得锁即为BLOCKED,获得锁没有CPU时间片即为READY,有了锁和时间片变RUNNING
  • 可以通过Java内置的等待/通知机制(wait()/notify())实现一个线程修改一个对象的值,而另一个线 程感知到了变化,然后进行相应的操作。

2.6.4 管道输入/输出流

  • 管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。 管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。

2.6.5 Thread.join

  • 如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从 thread.join()返回。join函数的设计目的是可以让一个线程等待另一个线程终止(让出锁)后再进行后续的业务。

2.6.6 ThreadLocal

  • 线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

2.7 Java内存模型(JMM)

2.7.1 抽象模型:

  • 线程间共享变量在主内存中
  • 每个线程有个私有的本地内存,存储了该线程读写共享变量的副本

2.7.2 原子性/有序性/可见性

  • 原子性: 一个操作是不可分割,不可中断的,要么全部执行,要么全部不执行。基本类型的读取/复制,分别由JMM保证原子性;代码块的原子性用synchronized保证。
  • 可见性: 一个线程修改了某个共享变量的操作时,另一个线程可以立刻看到这个修改。volatile(见2.6.1)、 synchronized(见2.6.2)、final可以在不同场景下保证可见性。
    • final的可见性原理, 对于final域,编译器和处理器要遵守两个重排序规则: 1.在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。目的: 保证在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了。原理:编译器会在final域的写之后,插入一个StoreStore屏障。 2.初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。目的: 先读final域对象的引用,后读final域的值。原理: 编译器会在读final域操作的前面插入一个LoadLoad屏障。
  • 有序性: 一个线程的执行代码,从前往后依次执行,单线程下可以认为程序是有序的

2.7.3 指令重排

  • 在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序:
    1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
  • 指令重排也是有一些限制的,有两个规则 happens-before 和 as-if-serial 来约束。

2.7.3.1 happens-before

  • 定义:
    1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而 且第一个操作的执行顺序排在第二个操作之前。
    2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照 happens- before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法
  • 六大规则:
    1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
    2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
    3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
    4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
    5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的 ThreadB.start()操作happens-before于线程B中的任意操作。
    6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens- before于线程A从ThreadB.join()操作成功返回。

2.7.3.2 as-if-serial

  • 定义:
    • 不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
    • 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。如果操作之间不存在数据依赖关系,就可能被编译器和处理器重排序。

2.7.4 CAS

  • 定义: 包含3个参数,内存地址a,预期值b,目标值c,只有地址a的值=b时,a才会被更新成c。
  • 原理: 作为一条cpu指令,CAS本身能够保证原子性

2.7.4.1 CAS的常见问题

  1. ABA问题: 因为只关心值,如果值中途被修改了又改回来无感知
    • 解决: 通过给值加上版本号,每次修改时同时检查目标值和版本号,都一致才修改并且更新版本号,如AtomicStampedReference,用volatile修饰pair,pair封装数据和版本号,提供的compareAndSet方法同时检查值和版本号
  2. 自旋的性能开销:
    • 解决: JVM对于自旋的性能做了优化, 1. 默认自旋的阈值10次,10次后升级成重量级锁; 2. 适应自旋锁,JVM根据过往的上锁结果判断是否允许更多的自旋次数(之前成功过),还是减少自旋
  3. 只能保证一个变量的操作原子性,不适用于多个变量的场景
    • 解决: 1. 改用锁来保证原子性;2. 类似AtomicStampedReference,封装多个变量,自定义判断相等的逻辑

2.8 死锁

2.8.1 锁升级的流程

  1. 初始状态: 无锁(对象头中锁标志=01,偏向锁状态=0),此时仅更新偏向锁状态=1,线程id=当前线程,即开始执行同步逻辑
  2. 判断对象头中锁标志=01,偏向锁状态=1,线程id=当前线程,则持有偏向锁,否则开始竞争偏向锁。
  3. 进入同步块,cas尝试把线程id写入对象的对象头,如果成功则获取偏向锁
  4. 如果3中cas失败,或者对象头字段表示对象已经上了偏向锁,说明有锁竞争,升级成轻量锁,自旋等待锁释放
  5. 自旋达到阈值,升级成重量级锁,线程阻塞等待被唤醒

2.8.2 死锁产生的条件

  1. 互斥: 线程对获取的资源进行排他性使用,其他线程只能等待
  2. 请求并持有: 线程1持有了资源A,想要申请持有资源B,资源B已经线程2持有,线程1被阻塞时不会释放资源A
  3. 资源不可剥夺: 线程获取的资源只能用完自己释放,在此之前不会被其他线程释放
  4. 环路等待: 线程1持有了资源A,想要申请持有资源B,资源B已经线程2持有, 线程2想申请持有资源A

2.8.3 避免死锁

  1. 请求并持有: 一次性申请所有资源
  2. 资源不可剥夺: 持有部分资源的线程想申请别的资源不成功时,主动释放
  3. 环路等待: 按序申请资源,每个线程都先申请序号小的资源,再申请序号大的

2.8.3 排查

  1. 使用jps查找运行的Java进程:jps -l
  2. 使用jstack查看线程堆栈信息:jstack -l 进程id

2.9 Java并发包(JUC)

2.9.1 AQS

2.9.2 ReentrantLock

2.9.3 ThreadLocal

  • 定义: ThreadLocal,是一个线程(Thread)本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个 线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。
  • 工作中用的场景: http接口的入口,从cookie里获取用户身份信息(uid, 店铺id,渠道标识等),封装在ThreadLocal里,链路逻辑自行取用
  • 实现:
    • 存放数据ThreadLocal.ThreadLocalMap是一个线程(Thread)本地变量,保证了线程的隔离性
    • map的key是ThreadLocal的弱引用,value是业务定义的值
    • key是ThreadLocal的弱引用的原因: 如果是key是ThreadLocal的强引用,则栈上的ThreadLocal Reference被回收,ThreadLocal仍然被key强引用,无法回收,内存泄露
    • ThreadLocal的值用完需要显式remove,因为ThreadLocalMap生命周期和Thread一致,会出现entry的key回收,value不回收的情况,导致内存泄露
  • 数据结构
    • 容器: 数组
    • key的计算: hash取余,每创建一个ThreadLocal对象增加黄金分割数,好处是散列非常均匀
    • 扩容: Entry 的数量已经达到了列表的扩容阈值 (len2/3) 且size >= threshold 3/4 ,扩容2倍
    • hash冲突: 开放地址法,从当前槽位往后一直找到一个空的

2.9.4 CountdownLatch

  • 一个一次性的线程协调器,初始化的计数归零后不能重置
  • 应用场景:
    1. 等待所有子线程结束的时间点: 子线程内部执行countDown(),主线程执行await()
    2. 协调所有子线程统一开始: 主线程执行countDown(),子线程执行await()

2.9.5 CopyOnWriteArrayList

  • 原理: 每次增/删/改操作,内部用ReentrantLock上锁然后解锁;数据容器使用volatile修饰保证可见性
  • 场景: 读操作无锁,适用于读多写少的并发,不需要写入后实时读的场景
  • 代价: 数组的复制耗时较多(复制完成前读到的都是旧的副本),内存消耗较多,大量数组复制操作容易引发gc,可以考虑用addAll这样的批量操作来减少复制
  • 思想:
    1. 读写分离
    2. 最终一致性
    3. 用volatile修饰内部数据容器数组,volatile的可见性保证使得容器数组被指向新的数组后立刻对其他线程可见

2.9.6 ConcurrentLinkedQueue

  • 场景: 并发写的场景
  • 思想:
    1. 尾插法降低锁竞争
    2. 实际插入时使用CAS
    3. volatile修饰内部Node变量保证可见性

2.9.7 Semaphore

  • 场景: 用来控制多线程同时访问指定数量的共享资源
    1. 数据库线程池,控制获取数据库连接的数量
  • 思想:
    1. 令牌思想,每个线程尝试获取有限的资源,没获取到的就等待,获取到执行完后释放资源