基本概念
进程&线程
程序是由指令和数据构成的,指令被用来运行,数据被用来独写
当一个程序被运行,就会从磁盘将这个程序的代码加载到内存,这样开启了一个进程
进程
是系统程序运行的基本单位,拥有独立的内存空间,随着程序的运行而创建,随着程序的关闭而消亡
线程
是CPU进行资源调度的基本单位,也是组成进程的基本单位;同一个进程中的线程共享内存空间(堆和方法区);每个线程拥有自己的程序计数器、虚拟机栈和本地方法栈;线程的切换要比进程的切换开销小得多,但是不利于资源的管理和保护
main() 函数可以启动一个JVM进程,main() 函数所在的线程称为主线程
并行&并发
并行
并发
单位时间内,多个任务同时执行(多线程的CPU)
线程的并发可能带来内存泄漏、死锁、线程不安全等情况
上线文切换
进程和线程在执行中都会有自己的运行条件和状态(上下文),如内存信息、程序计数器和栈信息等。
当线程失去CPU资源时(sleep、wait、CPU时间片用完、请求IO被阻塞)等情况,就会发生线程切换,需要保存当前线程的上下文,并加载下一个待运行线程的上下文;频繁的上下文切换会造成整体效率低下
线程
线程的生命周期
状态名称 | 说明 |
---|---|
NEW | 初始状态、线程对象被创建,但还没有调用 start() |
RUNNABLE | 运行状态,Java将就绪和运行笼统称为运行 |
BLOCKED | 阻塞状态,表示线程阻塞于锁 |
WAITING | 等待状态,需要其他的线程做出一些特定动作(通知、中断) |
TIME_WAITING | 超时等待状态,等待指定的时长后继续尝试执行 |
TERMINATED | 终止状态,表示线程执行完毕 |
线程的创建
继承 Thread 类
实现 Runnable 接口
实现 Callable 接口
继承 Callable 接口配合 FutureTask 使用,可以有返回值,可以抛出异常
public class MyCallable implements Callable<String> {
@Override
public String call() {
return "Callable";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable myCallable = new MyCallable();
FutureTask<String> futureTask = new FutureTask<>(myCallable);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}
守护线程
当JAVA进程中有多个线程在执行时,只有当所有非守护线程都执行完毕后,JAVA进程才会结束。但当非守护线程全部执行完毕后,守护线程无论是否执行完毕,也会一同结束。
如垃圾回收线程
//将线程设置为守护线程, 默认为false
Thread.setDaemon(true);
线程调度
start()
start() 用于启动一个线程,从创建状态进入就绪状态,等待CPU调度运行;start() 会先执行相应的准备工作,然后自动执行run()方法运行线程中的内容
run()
在main()函数中直接执行run()方法会被当做一个普通的方法去串行执行,而非启动一条线程并发执行
sleep()
sleep(long timeout)会使线程暂停运行,但不释放锁,直到超时后线程苏醒并继续运行
wait()
wait() 通常用于线程间交互,被调用后,线程会释放锁并进入暂停状态,需要别的对象去调用notify()或者notifyAll()方法
wait(long timeout)超时后线程会自动苏醒,并尝试去拿到锁继续运行
yield()
notify() & notifyAll()
join()
线程合并,如果线程t1在运行中调用了线程t2的join()方法,t1线程会进入阻塞状态,直到t2运行完毕,t1才会继续运行
join(long timeout)表示t1线程阻塞的时间
join()方法其实是依赖于wait()方法实现的,会释放当前对象的锁
interrupt()&isInterrupted()
interrupt()用于打断线程当前的状态,可以通过isInterrupted()方法查看线程打断标记,查看完后会重置成false
如果当前线程处于阻塞状态(调用了sleep、wait、join 方法),则会唤醒线程继续运行,打断标记为false
如果当前线程处于正常运行状态,打断标记为true;正常运行的线程可以获取打断标记来控制线程接下来的行为
线程同步
锁
锁的四种状态
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
他们会随着竞争的激烈而逐渐升级;锁可以升级不可以降级,这种策略是为了提高获得锁和释放锁的效率
死锁
死锁的四个必要条件
- 互斥条件:该资源在任意一个时刻只能有一个线程占用
- 请求与保持条件:一个线程因请求资源而被阻塞,同时对已获得的资源保持占用
- 不剥夺条件:线程已获得的资源在未使用完不能被其它资源强行剥夺,只有自己使用完后才会释放
- 循环等待条件:若干个线程之间形成一种头尾相接的循环等待资源的关系
其中,请求保持条件、不剥夺条件和循环等待条件可以被手动编码避免,防止造成死锁
synchronized
俗称对象锁,采用互斥的方式,让同一时刻最多只能有一个线程持有对象锁,其它想要获取这个对象锁的线程就会被阻塞住(blocked),从而保证资源数据的正确性
synchorized可以用来修饰:
- 实例方法:给当前对象加锁
- 静态方法:给当前类加锁
- 代码块:synchorized(this|object)表示给对象加锁,synchorized(类.class)表示给当前class加锁
早期 synchronized 依赖于 monitor(基于C++由ObjectMonitor实现,monitor又依赖于操作系统层面的 Mutex Lock 实现);被修饰的代码块通过 minitorenter
和monitorexit
指令来实现获取和释放锁,被修饰的方法通过ACC_SYNCHRONIZED
指令来标识同步方法;线程切换时需要从用户态转换到内核态,开销比较巨大
JDK1.6 后 synchronized 引入了自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销
monitor
volatile关键字
由于JVM具有指令重排的特性,使用volatile关键字可以禁止JVM的指令重排,还能保证变量的可见性,保证多线程环境下运行正常
volatile 只能用来修饰变量,synchronized 用于修饰代码块和方法,两者是互补的存在
synchronized 所做的优化
轻量级锁
当一个对象被多个线程所访问时,但是访问的时间是错开的(不存在竞争),此时可以使用轻量级锁来优化,而不是直接使用 monitor
- 每个调用了同步代码的栈帧都会拥有一个锁记录对象(Lock Record),用于存储锁定对象的 mark word
- Lock Record 中的Object Reference会指向当前栈帧关联的锁对象,并尝试使用CAS去替换锁对象头中的mark word
若替换成功,则将锁对象头中的信息替换成锁记录对象的地址,并设置状态为00
锁膨胀
当一个线程尝试给一个对象加轻量级锁时,如果CAS操作失败(已经被其它线程占用),则当前线程则会进入锁膨胀过程:
将锁对象的对象头中的mark word改为关联的monitor对象的地址,并且状态改为01(重量级锁)
将该线程放入 monitor 对象中的 EntryList 中,并进入阻塞状态,等待锁的释放
自旋锁
当一个线程进入阻塞状态之前,会根据情况不断地去尝试获取monitor的owner,这一动作成为自旋;
当重试一定次数后依然不能获取锁,则被放入EntryList中进入阻塞状态;自旋锁只能在多核CPU中有效偏向锁
偏向锁主要用于优化轻量级锁重入的情况,当轻量级锁没有其它线程竞争,每次进行重入操作都会执行CAS操作,导致性能降低;引入偏向锁做了以下优化:
第一次CAS时会将当前线程的ID写入锁对象的Mark Word中,此后线程进行重入锁操作时发现锁对象的mark word为自己的线程ID,就不会进行CAS操作
- 若有其它的线程尝试获取当前对象的锁时,则会撤销偏向锁,发生锁膨胀
线程通信
JUC
AQS
Abstract Queued Synchronizer,是一个用来构建锁和同步器工具的框架
AQS的核心思想是,当被请求的共享资源为空闲状态时,则将当前请求的资源设置为有效工作线程,并将共享资源设置为锁定状态;当如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁的分配机制,这个机制AQS是用CLH队列实现的,将暂时获取不到锁的线程加入这个队列中CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。
我们可以通过实现Lock接口来实现一个自定义的锁,需要实现以下方法
- lock() 加锁
- lockInterruptibly() 加可打断锁
- tryLock() 尝试加锁一次
- unlock() 解锁
- newCondition() 条件变量
如果手动实现以上方法会比较困难,此时就需要借助 AQS 框架来构建一个自己的Sync来实现以上功能
资源共享方式
Exclusive
独占锁,一次只能有一个线程执行,如 ReentrantLock;又可以分为公平锁和非公平锁
- 公平锁:按照阻塞队列中的顺序去获取锁
- 非公平锁:先通过两次CAS操作去获取锁;如果获取失败,则将当前线程加入队列中等待唤醒
Share
共享锁,多个线程可以同时访问资源,常见的实现有:CountDownLatch、Semaphore、CyclicBarrier、ReadWriteLock等,其中 ReentrantReadWriteLock 可以看成组合式的,在读取操作时使用共享锁,写入操作时使用独占锁原理解析
四个重要属性
AQS 底层使用了模板方法,自定义同步器时需要重新AQS提供的钩子方法//头结点,可以浅显理解成当前持有锁的线程
private transient volatile Node head;
//阻塞队列的尾节点
private transient volatile Node tail;
//表示当前资源是否被占用,0表示没有被占用,1表示被占用,大于1表示锁可以重入
private volatile int state;
//表示当前持有独占锁的线程
private transient Thread exclusiveOwnerThread;
钩子方法:是一种声明在抽象类中的抽象方法,可以是空的方法(可以由子类实现),也可以是默认的实现方法;模板设计模式通过钩子方法来控制固定的实现步骤
//独占方式,用于尝试获取资源的锁
protected boolean tryAcquire(int arg)
//独占方式,用于尝试释放资源的锁
protected boolean tryRelease(int arg)
//共享方式,用于尝试获取资源的锁,表示获取成功并且返回资源数量,负数表示获取失败
protected int tryAcquireShared(int arg)
//共享方式,用于尝试释放资源的锁
protected boolean tryReleaseShared(int arg)
//查询当前线程是否独占资源,只有用到 condition 时才需要去实现
protected boolean isHeldExclusively()
ReentrantLock
非公平锁会先执行 CAS 操作尝试拿锁,如果失败后在后续的步骤中会无视阻塞队列的存在,直到获取锁彻底失败,才会被存入阻塞队列中
可重入
可打断
锁超时
公平锁
条件变量
ThreadLocal
ThreadLocal 用于提供线程类的局部变量,不同的线程间不会互相干扰,只作用于线程的生命周期内
这样做可以减少一个线程内多个函数或组件之间的一些功能变量传递的复杂性
synchronized 和 ThreadLocal 都能够解决线程的并发问题,但 synchronized 解决的是同一个资源间的同步访问;ThreadLocal 解决的是多线程间数据的隔离。
优点:
- 传递数据:ThreadLocal会保存每个线程中的数据,在需要的地方可以直接取出使用
线程隔离:不同的线程之间的数据是隔离的,因此具有并发性,性能更高
底层结构
早期的 ThreadLocal 中定义了一个 Map(ThreadLocalMap),以线程为key,局部变量作为线程的 value;不同的线程执行时都会去到map里面获取自己的对应的变量,如果不存在则新建(线程的数量影响map的大小,变量的数量决定map的个数);
后期ThreadLocal做了优化,这个Map的引用被定义到了Thread类中,ThreadLocal作为这个map的key,局部变量作为value;线程在执行时可以直接得到自己的map,然后根据 ThreadLocal 对象去获取所需的局部变量(线程的数量影响map的个数,变量的数量影响map的大小);
这样设计的原因:可以减少每个map存储的entry的数量,实际业务中,线程的数量一般大于局部变量数量
- map可以随着线程生命周期的结束被销毁
-
ThreadLocalMap
ThreadLocalMap 是 ThreadLocal 的一个静态内部类,
内存泄漏
内存泄漏:memory leak,指的是程序中已分配的堆内存由于某种原因未释放或者无法释放,造成系统内存的浪费,导致系统运行速度减慢甚至出现崩溃;内存泄漏的堆积最终会导致内存溢出
- 内存溢出:memory overflow,系统没有足够的内存供申请者使用
ThreadLocalMap 的key为ThreadLocal的弱引用,而value为变量的强引用;当ThreadLocal没有被强引用使用的情况下,在垃圾回收的时候就会被清理掉,即ThreadLocalMap的key会变成null,但是value由于是强引用的关系,并不会被清理;ThreadLocalMap底层会在调用 set(),get(),remove()的时候清理掉相关key为null的记录。
但是如果不手动remove掉已经使用完的ThreadLocal的话,在线程长时间驻留的情况下还是会有发生内存泄漏的风险。
线程池
通过创建 ThreadPoolExecutor,可以提高对资源的利用率
- 降低对资源的消耗:重复利用已经创建的线程
- 提高响应速度:无需耗费资源重新创建线程
-
execute() 和 submit()的区别
execute():用于提交不需要返回值的任务,所以无法判断任务是否被执行成功
submit():用于提交需要返回值的任务,通过返回的 Future 对象的 get() 方法来判断任务是否执行成功
不被推荐的创建方式
FixedThreadPool
:创建一个固定数量线程的线程池,允许请求的队列为 Integer.MAX_VALUE,可能堆积大量请求导致OOMSingleThreadExecutor
:创建一个只有一个线程的线程池,允许请求的队列为 Integer.MAX_VALUE,可能堆积大量请求导致OOMCachedThreadPool
:可以根据实际情况创建线程池,允许创建的线程数量为 Integer.MAX_VALUE,可能创建大量线程导致OOMScheduledThreadPool
:允许创建的线程数量为 Integer.MAX_VALUE,可能创建大量线程导致OOM推荐的创建方式
推荐通过
ThreadPoolExecutor
创建,更加精细的控制线程池状态7大核心参数
corePoolSize:核心池,最小可以同时运行线程的数量
- maximumPoolSize:最大可以同时运行线程的数量
- workQueue:阻塞队列大小
- keepAliveTime:存活时间,线程工厂中产生的空闲的线程存活的时间
- unit:时间单位
- threadFactory:线程工厂,executor 创建新线程时用到
- handler:饱和策略(拒绝策略)
执行过程:
- 一开始线程池会创建 corePoolSize 数量的线程以供使用
- 当 corePoolSize 个线程都被占用时,新来的线程就会被存入阻塞队列中
- 当阻塞队列满时,就会调用线程工厂,产生新的线程
- 当线程池中所有的线程达到 maximumPoolSize 个数时,新来的任务就会触发拒绝策略
- 当线程工厂产生的线程处于空闲状态时,则会在存活指定的时间后被销毁
常见的拒绝策略
ThreadPoolExecutor.AbortPolicy
:抛出 RejectedExecutionException 异常来拒绝新的任务ThreadPoolExecutor.CallerRunsPolicy
:调用执行自己的线程运行任务;若线程生命周期结束,则直接丢弃该任务ThreadPoolExecutor.DiscardPolicy
:直接丢弃新的任务请求ThreadPoolExecutor.DiscardOldestPolicy
:丢弃最早未处理的任务请求