线程池的原理,为什么要创建线程池?创建线程池的方式?
线程池的原理
线程池是一种基于池化思想管理线程的工具,线程过多不仅会带来线程不断创建、销毁等额外开销,同时还会降低计算机的整体性能。线程池维护多个线程,等待监督管理可并发执行的任务。这种做法,一方面避免了处理任务时线程创建销毁的开销,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用
核心参数:
核心线程数、最大线程数、任务队列、自定义线程工厂、线程空闲时间、线程空闲时间单位、拒绝策略
- 新任务进来,先判断当前线程池中的线程数是否达到核心线程数
- 是,新任务放到任务队列中,任务队列放满之后,判断线程池中当前线程数量是否到达最大线程数
- 否,创建新线程执行该任务
- 是,根据线程池的设置的拒绝策略进行处理
- 直接抛出异常
- 直接丢弃
- 丢弃最久的任务
- 返回给调用线程执行该任务
- 否,创建新线程执行该任务
- 是,新任务放到任务队列中,任务队列放满之后,判断线程池中当前线程数量是否到达最大线程数
- 当线程池中任务队列空了,然后线程池的线程存在空闲了,超出核心线程数的线程在指定空闲时间后释放
为什么要创建线程池
- 减少线程创建、销毁的开销
- 统一管理线程创建、分配
- 防止不断创建线程导致宕机
创建线程池的方式
Executors工具类 | 方法 | 核心线程数 | 最大线程数 | 空闲时间 | 任务队列 | | —- | —- | —- | —- | —- | | newCachedThreadPool() | 0 | Integer.MAX_VALUE | 60L | SynchronousQueue | | newFixedThreadPool(int nThreads) | nThreads | nThreads | 0 | LinkedBlockingQueue | | newSingleThreadExecutor() | 1 | 1 | 0 | LinkedBlockingQueue | | newScheduledThreadPool(corePoolSize) | corePoolSize | Integer.MAX_VALUE | 0 | DelayedWorkQueue | | newSingleThreadScheduledExecutor() | 1 | Integer.MAX_VALUE | 0 | DelayedWorkQueue |
- 最大线程数为Integer.MAX_VALUE的,在执行大量任务时,可能会造成堆外内存溢出
- LinkedBlockingQueue是无界队列,任务量过大,会导致堆内存溢出
new ThreadPoolExecutor(…)
线程的生命周期,什么时候会出现僵死进程
- 创建线程对象,线程处于新建状态
- 线程对象调用start方法,启动线程,线程处于就绪状态
- CPU调度到处于就绪状态的线程,线程进入运行状态
- 阻塞状态
- 等待阻塞:调用wait方法
- 同步阻塞:获取锁失败
- 其他阻塞:join()、sleep()、IO请求
- 线程执行完毕或者异常退出,进入死亡状态
僵尸进程
产生:
当一个进程创建了一个子进程时,他们的运行时异步的。即父进程无法预知子进程会在什么时候结束,那么如果父进程很繁忙来不及wait 子进程时,那么当子进程结束时,会不会丢失子进程的结束时的状态信息呢?处于这种考虑unix提供了一种机制可以保证只要父进程想知道子进程结束时的信息,它就可以得到
这种机制是:
在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存。但是仍然保留了一些信息(如进程号pid 退出状态 运行时间等)。这些保留的信息直到进程通过调用wait/waitpid时才会释放。这样就导致了一个问题,如果没有调用wait/waitpid的话,那么保留的信息就不会释放。比如进程号就会被一直占用了。但系统所能使用的进程号的有限的,如果产生大量的僵尸进程,将导致系统没有可用的进程号而导致系统不能创建进程。所以我们应该避免僵尸进程
避免:
- 父进程通过wait和waitpid等函数等待子进程结束,这会导致父进程挂起
- 执行wait()或waitpid()系统调用,则子进程在终止后会立即把它在进程表中的数据返回给父进程,此时系统会立即删除该进入点。在这种情形下就不会产生defunct进程
- 如果父进程很忙,那么可以用signal函数为SIGCHLD安装handler。在子进程结束后,父进程会收到该信号,可以在handler中调用wait回收
- 如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCLD, SIG_IGN)或signal(SIGCHLD, SIG_IGN)通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收,并不再给父进程发送信号
- fork两次,父进程fork一个子进程,然后继续工作,子进程fork一个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。不过子进程的回收还要自己做
说说线程安全问题,什么是线程安全,如何实现线程安全
线程安全
多线程场景下,程序按照预期正常运行
线程安全问题
- 线程A读不到线程B新写的数据
- 线程A写操作覆盖线程B写操作
- 线程A参与了线程B的运算
- ……
如何实现线程安全
synchronized、Lock、CAS、Collections.synchronizedXXX()
保证线程安全需要保证三点:可见性、原子性、有序性(指令重排序)
- 互斥同步:synchronized、Lock、Collections.synchronizedXXX()、JUC包
- 一个线程进入监视器,其他线程必须等待,直到这个线程退出监视器
- 非阻塞同步:CAS
- 无同步方案:ThreadLocal将共享数据的可见范围保证在一个线程内,保证线程间不存在数据竞争问题
线程池运行状态
如何合理配置线程池大小
估算:
Little’s Law(利特尔法则)
在一个稳定的系统(L)中,长期的平均顾客人数,等于长期的有效抵达率(λ),乘以顾客在这个系统中平均的等待时间(W);或者,我们可以用一个代数式来表达:L = ``λ``W
一个系统请求数等于请求的到达率与平均每个单独请求花费的时间之乘积
假设服务器单核的,对应业务需要保证请求量(QPS):10 ,真正处理一个请求需要 1 秒,那么服务器每个时刻都有 10 个请求在处理,即需要 10 个线程。同样,我们可以使用利特尔法则(Little’s law)来判定线程池大小。我们只需计算请求到达率和请求处理的平均时间。然后,将上述值放到利特尔法则(Little’s law)就可以算出系统平均请求数。估算公式如下
线程池大小 = ((线程 IO time + 线程 CPU time ) / 线程 CPU time ) * CPU数目
- 一个请求所消耗的时间 (线程 IO time + 线程 CPU time)
- 该请求计算时间 (线程 CPU time)
- CPU 数目
压测:
不过最后的最后,我们还是需要通过压力测试来进行微调,只有经过压测测试的检验,我们才能最终保证的配置大小是准确的
volatile、ThreadLocal的使用场景和原理
volatile使用场景和原理 volatile实现原理 ThreadLocal使用场景和实现原理 ThreadLocal实现原理
volatile应用场景
- 轻量级的读写锁
- 状态标记
- DCL单例模式
volatile原理
- 计算机内部数据通信:CPU——>寄存器——>高速缓存——>内存
- volatile变量的写操作,会在正常汇编指令前加上lock前缀的指令
- lock前缀指令将引起当前处理器缓存的数据写到系统内存
- 处理器的缓存写回到内存中将会导致其他处理器的缓存失效
- CPU缓存一致性协议(MESI协议和RFO请求)
特性:
- 可见性
- volatile的写内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。和锁synchronized的释放内存语义一致
- volatile的读内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效。线程接下来将从主内存中读取共享变量。和锁synchronized的获取内存语义一致
- 有序性:内存屏障
- 编译器禁止volatile读与volatile读后面的任意内存操作重排序
- 编译器禁止volatile写与volatile前面的任意内存操作重排序
ThreadLocal的适用于两种场景
- 每个线程需要有一个自己单独的实例
- 实例需要在多个方法中共享,但不希望被多线程共享
所以可以存储用户的session、解决线程安全问题
ThreadLocal的原理
ThreadLocal能让线程拥有自己内部独享的变量
- 每个线程都有一个对应的Thread对象,而Thread类有一个成员变量ThreadLocalMap
- ThreadLocalMap的key是ThreadLocal的引用,value是ThreadLocal存储的共享变量副本
- 当某个线程需要获取ThreadLocal中的值时,底层会获取当前线程的Thread对象中的Map集合
- 然后以ThreadLocal作为key,从Map集合查找value值
所以ThreadLocal能够做到线程独立的原因就是,值不是存在ThreadLocal中,而是存储在线程对象中的
ThreadLocal什么时候会出现OOM的情况?为什么?
弱引用
ThreadLocalMap中的Entry继承了WeakReference类,是一个弱引用,当没有其他强引用指向key时,这个key将在下一次GC被JVM回收,这样做的目的就是有助于GC
我们在使用完ThreadLocal之后,解除对它的强引用,希望它被JVM回收。但是JVM无法回收它,因为我们虽然在此处释放了对它的强引用,但是它还有其它强引用,那就是Thread对象的ThreadLocalMap的key。ThreadLocalMap的key是ThreadLocal对象的引用,如果这个引用是一个强引用,那么在当前线程执行完毕,被回收前ThreadLocalMap不会被回收,它的key引用的ThreadLocal也就不会被回收,这就是问题所在。使用弱引用可以保证在其他对ThreadLocal的强引用解除后,ThreadLocalMap对它的引用不会影响JVM对它的垃圾回收
ThreadLocal导致的内存溢出
ThreadLocalMap对key使用弱引用,避免JVm无法回收ThreadLocal,但是还有另一个问题:key虽然是弱引用,但是value是强引用,这样会导致什么问题呢?这会导致key被JVM回收,但是value却无法被回收,key对应的ThreadLocal被回收后,key变成了null,但是value还是原来的value,因为被ThreadLocalMap所引用,将无法被JVM回收。如果value占内存比较大,线程较多的情况将持续占用大量内存,甚至导致内存溢出
解决办法:使用完ThreadLocal对象准备释放threadLocal前先调用threadLocal.remove()方法
remove方法会从当前线程的ThreadLocalMap中删除key为当前ThreadLocal的那一个记录,key和value都会置为null,这样就解除了ThreadLocalMap对value的强引用,使得value可以被JVM正常回收了,所以**确认不再使用的ThreadLocal对象一定记得要调用remove方法
synchronized、volatile区别
- volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的, 需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住
- synchronized既保证可见性、又保证原子性;volatile能保证可见性、不能保证原子性
- synchronized加锁,会阻塞线程;volatile不加锁,不会阻塞线程
- volatile只能使用在变量上;synchronized可以作用在对象、变量、类以及方法上
- synchronized(变量/对象/类对象)或者synchronized方法
- volatile int a= 0;
synchronized锁粒度
- 代码块——细粒度 synchronized(lock){}
- 锁实例对象——其他试图访问该实例对象的线程被阻塞
- 锁类对象——其他试图访问该类对象的线程被阻塞
- 锁成员变量——其他试图访问该成员变量的线程被阻塞
- 锁类对象和锁实例对象实际上是两把不同的锁,互不影响
- 方法——粗粒度 synchronized void method(){}
- 锁实例方法——其他试图访问该实例对象的线程被阻塞
- 锁类方法——其他试图访问该类对象的线程被阻塞
- synchronized(null)会抛出NullPointerException,所以DCL使用的是类对象
模拟死锁场景
死锁
- 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
- 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路
public class DeadLock2 {
public static void main(String[] args) {
Object o1 = new Object();
Object o2 = new Object();
Thread t1 = new Thread1(o1,o2);
Thread t2 = new Thread2(o1,o2);
t1.start();t2.start();
}
static class Thread1 extends Thread {
private Object o1;
private Object o2;
public Thread1(Object o1, Object o2) {
this.o1 = o1;
this.o2 = o2;
}
@Override
public void run() {
synchronized (o1) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
}
}
}
}
static class Thread2 extends Thread {
private Object o1;
private Object o2;
public Thread2(Object o1, Object o2) {
this.o1 = o1;
this.o2 = o2;
}
@Override
public void run() {
synchronized (o2) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1) {
}
}
}
}
}
解决办法
调节申请锁的范围,申请下一把锁时自身锁已经释放了
- 调节申请锁的顺序,相同顺序申请
- 使用Semaphore 信号量,tryAcquire方法可以设置超时时间
- 可以控制资源能被多少线程访问,这里我们指定只能被一个线程访问,就做到了类似锁住
- 而信号量可以指定去获取的超时时间,我们可以根据这个超时时间,去做一个额外处理
原子性与可见性
- 原子性:一个操作不能被打断,要么全部执行完毕,要么不执行
- lock、synchronized
- 可见性:一个线程对共享变量的修改,其他线程能立刻感知到
- volatile、synchronized、final、lock