1.线程池的由来
本来多进程就是为了解决并发编程的方案,但是进程有点太重量了(创建和销毁,开销比较大)
因此引入了线程,线程比进程要轻量很多。
即便如此,如果某些场景中,需要频繁的创建销毁线程,此时,线程的创建和销毁的开销,也就无法被忽视了。线程池就是为了解决这样的问题而来。
2.线程池的使用
使用线程的时候,不是说用的时候才创建,而是提前创建好,放到一个“池子里”(和字符串常量池是类似的东西)
当我们需要使用线程的时候,直接从池子里取一个线程过来。
当我们不需要这个线程的时候,就把这个线程还回池子中。
如果是真的创建/销毁线程,涉及到用户态和内核态的切换。(切换到内核态,然后创建出对应的PCB来)
如果不是真的创建销毁线程,而只是放到池子里,就相当于全在用户态,就能搞定这个事情。
啥是用户态和内核态?
用户态是应用程序执行的代码。
内核态是操作系统内核执行的代码。
一般认为,用户态和内核态之间的切换,是一个开销较大的操作。
3.线程池 ThreadPoolExecutor
在Java标准库里面,也提供了现成的线程池组件。
ThreadPoolExecutor
ThreadPoolExecutor 里面包含的线程的数量并不是一成不变的。
能够根据任务量来自适应。
如果任务比较多,就会多创建一些线程。
如果任务比较少,就少创建一些线程。
经典面试题:ThreadPoolExecutor的构造参数都是啥意思
corePoolSize:核心线程池
maximumPoolSize:最大线程数
这两个值具体设成多少合适?
通过实验的方法来确定比较合适。
keepAliveTime:描述非核心线程存在的时间。
unit:时间单位,也是keepAliveTime的单位 ms,s,minute
本质是性能和资源之间做权衡。
workQueue:阻塞队列,来组织线程池要执行的任务。
threadFactory:线程的创建方式
RejectedExecutionHandler:拒绝策略。根据具体的业务场景,来选取具体的拒绝策略,用于处理极端情况。
丢弃最新的任务,丢弃最老的任务,阻塞等待,抛出异常。
4.线程池 Executors 类
由于 ThreadPoolExecutor 使用起来比较复杂,标准库又提供了一组其他的类,相当于对 ThreadPoolExecutor 又进行了一层封装。
这个类相当于一个“工厂类”
通过这个类提供的一组工厂方法,就可以创建出不同风格的线程池实例了。
在以上几个工厂方法里面,调用了 ThreadPoolExecutor 的构造方法,同时把对应的参数进行了传递,并返回 ThreadPoolExecutor 实例。
public static void main(String[] args) {
// 使用一下标准库中的线程池
ExecutorService service = Executors.newFixedThreadPool(10);
// 给这个实例里面加入一些任务
for (int i = 0; i < 20; i++) {
service.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
当前线程池里有10个工作线程
往任务队列中加入20个任务
此时这10个工作线程就会从任务队列中,先取出10个任务,然后并发执行这些任务
这些线程谁执行完了,当前的任务,谁就去任务队列中重新取一个新的任务。
直到把线程池任务队列中的任务都取完了,此时线程池的工作线程就阻塞等待(等待新的任务的到来)
5.工厂模式
工厂模式存在的意义就是在给构造方法填坑(定性);例如,平面坐标系和极坐标系的构造函数。
工厂模式主要是为了创建实例
构造方法的限制比较多
为了解决构造方法留下来的坑:
不使用构造方法来构造实例了,而是使用其他的方法来进行构造实例,这样的用来构造实例的方法,就称为“工厂方法”。
工厂方法其实就是普通的方法,这个工厂方法里面会调用对应的构造方法,并进行一些初始化操作,并返回这个对象的实例。
6.实现一个简单版本的线程池
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class ThreadDemo25 {
static class Worker extends Thread{
private BlockingQueue<Runnable> queue = null;
public Worker(BlockingQueue<Runnable> queue){
this.queue = queue;
}
@Override
public void run() {
// 工作线程的具体的逻辑
// 需要从阻塞队列中取任务
while (true){
try {
Runnable command = queue.take();
// 通过 run 来执行这个具体的任务
command.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
static class Threadpool{
// 包含一个阻塞队列,用来组织任务
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
// 这个 list 就用来存放当前的工作线程
private List<Thread> workers = new ArrayList<>();
private static final int MAX_WORKER_COUNT = 10;
// 通过这个方法,把任务加入到线程池中
// submit 不光可以把任务放到阻塞队列中,同时也可以负责创建线程
public void submit(Runnable command) throws InterruptedException {
if(workers.size() < MAX_WORKER_COUNT){
// 如果当前工作线程的数量不足线程数目上限,就创建出新的线程
// 工作线程就专门搞一个类来完成
// worker 内部要能够取到队列的内容,就需要把这个队列实例通过 worker 的构造方法,传过去
Worker worker = new Worker(queue);
worker.start();
workers.add(worker);
}
queue.put(command);
}
}
public static void main(String[] args) throws InterruptedException {
Threadpool pool = new Threadpool();
for (int i = 0; i < 10; i++) {
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
}
加锁,是一个开销比较大的事情。
我们希望在一些特定场景下,针对场景做出一些取舍,好让锁更高效一些。
1.乐观锁和悲观锁
乐观锁:假设锁冲突的概率比较低,甚至与基本没有冲突。
就只是简单处理一下冲突。
悲观锁:假设锁冲突的概率比较高,甚至于每次尝试加锁都会有冲突。
此时就会愿意付出更多的成本来处理冲突。
synchronized其实就是悲观锁为主(也不全是,也有的时候是乐观锁)
乐观锁的典型实现:引入“版本号”对余额进行修改,更轻量。
2.读写锁
多个线程同时尝试修改同一个变量~线程不安全
如果多个线程同时读取同一个变量~线程安全
有些场景中,本来就是 写 比较少,读 比较多的情况
两个读线程,其实不存在线程安全问题,就不必互斥。
两个写线程之间,其实存在线程安全问题,就需要互斥。
一个读线程一个写线程之间,其实也存在线程安全问题,也需要互斥。
因此,就可以根据读写的不同场景,给读和写分别加锁。
synchronized 没有对读写进行区分,只要使用就一定互斥了。
Java标准库里提供了一个类
ReentrantReadWriteLock.ReadLock 能够构造一个读锁实例。
ReentrantReadWriteLock.WriteLock 能够构造一个写锁实例
对于读操作比较多,写比较少的情况,使用读写锁,就能大大的提高效率。(降低锁冲突的概率)
3.重量级锁和轻量级锁
这些锁策略和策略之间,并不是完全互不相关,可能会有部分重叠。
重量级锁:加锁解锁开销很大,往往是通过内核来完成的。
轻量级锁:加锁解锁开销很大,往往是只是在用户态完成的。
看重的是加锁解锁的开销大不大,和应用场景没啥关系。
加锁这里的“互斥”能力,是哪里来的?
归根结底,是CPU的能力~CPU提供了一些特殊的指令,通过这些指令来完成互斥。
操作系统内核在对这些指令进行了封装,并实现了阻塞等待。
CPU提供了一些特殊指令(原子操作的指令)
操作系统对这些指令封装了一层,提供了一个 mutex(互斥量)
Java JVM 相当于对操作系统提供的 mutex 再封装一层,实现了 synchronized 这样的锁。
如果是当前的锁,就是通过内核的 mutex 来完成的,此时这样的锁往往就开销比较大。
如果当前的锁,是在用户态,通过一些其他的手段来完成,这样的锁往往就开销更小。
synchronized 既是一个轻量级锁,也是一个重量级锁,根据场景自动适应。
锁策略面试题常见问法:谈谈对×××锁的理解
4.自旋锁和挂起等待锁
自旋锁:如果线程获取不到锁,不是阻塞等待,而是循环的快速的再试一次,因此就节省了操作系统调度线程的开销,要比挂起等待锁更能及时的获取到锁。问题是更浪费CPU资源。
挂起等待锁:如果线程获取不到锁,就会阻塞等待。啥时候结束阻塞,就取决于操作系统具体的调度。当线程挂起的时候,不占用CPU。
啥时候使用挂起等待锁,啥时候使用自旋锁?
大的原则是:
这个自旋和挂起等待,这样的策略在 synchronized 中内置,自适应。
5.公平锁和非公平锁
对于公平锁来说,追求先来后到的原则,先等待的线程先获得锁。要想实现公平锁,就需要有额外的数据结构(比如有个队列,通过这个队列来记录这个先来后到的过程)
对于非公平锁来说,不遵守先来后到的规则,获取锁的概率是均等的,完全取决于操作系统的调度了。
选择哪一个锁看需求选择:
大部分情况下,使用非公平锁就够了。
有些场景下,我们期望对于线程的调度的时间成本是可控的,这个时候就更需要公平锁了。
synchronized 是非公平锁
6.可重入锁和不可重入锁
如果针对同一把锁,连续加锁两次,
如果是不可重入锁,就会出现“死锁”
如果是可重入锁,就不会死锁。
让当前的锁,记录一下这个锁是谁持有的,如果发现,当前有一个线程再次尝试获取锁,这个时候,就让代码能够运行,而不是阻塞等待。同时在这个锁里也维护一个计算器。
synchronized 就是可重入锁
1.什么是CAS
compare and swar,字面意思是“比较和交换”,原子操作。
2.CAS的应用—实现原子类,实现自旋锁
像 i++ 这样的操作是线程不安全的。如果需要多线程并发的 i++,就需要加锁。
加锁操作是比较低效的,因此使用 CAS 就可以既能够高效的完成自增,同时又能够保证线程安全。(高效+安全)
基于CAS这样的操作,就实现出了一些“原子类”,这些原子类都是不需要加锁就能保证线程安全。
3.CAS 中的 ABA 问题
使用 CAS 的时候无法区分这个数据是始终没变,还是这个数据从A 变成了B,又变回了A。
大部分情况下,ABA问题,其实影响不大。
描述 ABA导致的 bug,导致误判。
如何解决 ABA 问题?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当
前版本号比之前读到的版本号大, 就认为操作失败.
结合之前讲到的锁策略,可以对synchronized做一个直观的认识
synchronized的锁升级
偏向锁,其实也就是一种乐观锁,本质上是一种“延时加锁”。
偏向锁只是在对象头重设置一个“偏向锁标记”,这个只做标记,就比真正的加锁,要高效很多。
如果赌赢了,那么这次就不真加锁了,直到锁的释放,这整个过程中根本没有涉及到加锁解锁,因此就很快
如果赌输了,比如线程1尝试获取这把锁,锁进入偏向锁状态,此时如果有一个线程2,也尝试竞争这个锁,那么此时线程1就会抢先把这个锁拿到,然后线程2去等待。
完全没竞争的时候,是偏向锁,如果出现了竞争,但是这时候竞争还比较小,此时我们就会进入到“轻量级锁”状态。
此处的轻量级锁,是基于CAS实现的自旋锁,是属于完全在用户态完成的操作。
因此这里面不涉及到内核态用户态的切换,也不涉及到线程的阻塞等待和调度,知识多费了一些CPU而已。
但是能保证更高效的获取到锁。
但是如果当前的场景,是锁冲突概率比较大,锁的竞争比较激烈。此时锁还会进一步的膨胀成重量级锁。
如果锁冲突的概率太大了,轻量级自旋锁,就会浪费大量的CPU(在等待的时候是CPU空转的)
使用更重量的挂起等待锁,就可以解决这个问题。
对于挂起等待锁来说,当锁等待的过程中,是释放CPU(不占用CPU)
代价就是引入了线程的阻塞和调度开销
这些过程的转变,完全是自适应。
synchronized 除了这个自适应的锁升级之外,还有一些重要的优化手段。
锁消除,其实就是编译器和JVM自行判定一下,看看当前这个代码是否真的需要加锁。(JVM和编译器,不相信程序猿的水平)
锁的粗化
JVM和编译器,会进行一些智能的判定,把多组synchronized合并为一组。
锁的粒度
synchronized 代码中包含多少代码,如果包含的代码多,认为锁的粒度比较粗。如果包含的代码少,认为锁的粒度比较细。
关于线程创建:
Callable也是一种创建线程的方式
Runnable只是描述了一个过程,不关注结果(不关注返回值)
Callable也是描述了一个过程,同时要关注返回结果
Callable中包含一个call方法,和Runnable.run类似,都是描述一个具体的任务,但是call方法是带返回值的。
如果我们期望创建一个线程,并关注这个线程产生的返回结果,使用Callable就比较合适。
public class ThreadDemo26 {
static class Result{
public int sum;
public Object locker = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread(){
@Override
public void run() {
int sum = 0;
for (int i = 0; i <= 1000 ; i++) {
sum+=i;
}
result.sum = sum;
synchronized (result.locker) {
result.locker.notify();
}
}
};
t.start();
// 此处我们期望,这个线程的计算结果能够被主线程获取到
// 为了解决这个问题,就需要引入一个辅助的类
// 当代码写成这个样子的时候,发现在主线程中,是无法得到 sum 的值的
// 主要是因为当前 t 线程和主线程之间是并发的关系
// 执行的先后顺序不能确定
// 解决方案是,让main这个线程先等待(wait)。t线程计算完毕之后,通知唤醒 main 线程即可
synchronized (result.locker) {
while (result.sum == 0){
result.locker.wait();
}
}
System.out.println(result.sum);
}
}
此时我们需要使用wait notify 以及 sybchronized 这些机制相互配合,才能完成这个工作。
使用Callable接口,会更加方便。
package java100_0926;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo27 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i <= 1000 ; i++) {
sum+=i;
}
return sum;
}
};
// 由于 Thread 不能直接传一个 callable 实例,就需要一个辅助的类来包装一下
// futureTask保存了Callable返回的结果
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
// 尝试在主线程获取结果
Integer result = futureTask.get();
System.out.println(result);
}
}
介绍下Callable是什么?
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.
concurrent 并发
1.ReentrantLock
public void increase(){
lock.lock();
count++;
lock.unlock();
}
ReentrantLock把加锁和解锁操作拆分开了
这种风格的代码,是常见的写法。
import java.util.concurrent.locks.ReentrantLock;
public class ThreadDemo28 {
static class Counter{
public int count = 0;
public ReentrantLock lock = new ReentrantLock();
public void increase(){
lock.lock();
count++;
lock.unlock();
}
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter.count);
}
}
ReentrantLock相当于对 synchronized进行了补充
public ReentrantLock lock = new ReentrantLock(true);
2.原子类
package java100_0926;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadDemo28 {
static class Counter{
//public ReentrantLock lock = new ReentrantLock(true);
public AtomicInteger count = new AtomicInteger(0);
public void increase(){
// lock.lock();
// count++;
// lock.unlock();
count.getAndIncrement();
}
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter.count);
}
}
3.信号量 Semaphore
信号量是一个计算器(int整数)
功能:描述可用资源的个数
也可以使用信号量来控制线程安全。
创建信号量的时候,设置一个初始值(可用资源的个数)
如果把初始值设为1了,此时这个信号量就只有0 1两种取值
称为“二元信号量”
二元信号量就和锁的功能是类似的
P操作:申请资源
V操作:释放资源
import java.util.concurrent.Semaphore;
public class ThreadDemo29 {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
// 先尝试申请资源
System.out.println("准备申请资源");
semaphore.acquire();
System.out.println("申请资源成功");
// 申请到了之后,sleep 1000ms
Thread.sleep(1000);
// 再释放资源
semaphore.release();
System.out.println("释放资源成功");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 创建 20 个线程
// 让这20个线程来分别去尝试申请资源
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
}
}
4.CountDownLatch
import java.util.concurrent.CountDownLatch;
public class ThreadDemo30 {
public static void main(String[] args) throws InterruptedException {
// 有八个线程在跑
CountDownLatch latch = new CountDownLatch(8);
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("起跑");
// random 方法得到的是一个[0,1)之间的浮点数
// sleep 的单位是 ms,此处 *10000 意思是 sleep[0,10)区间范围内的秒数
try {
Thread.sleep((long) (Math.random()*10000));
} catch (InterruptedException e) {
e.printStackTrace();
}
latch.countDown();
System.out.println("撞线完成!");
}
};
for (int i = 0; i < 8; i++) {
Thread t = new Thread(runnable);
t.start();
}
latch.await();
System.out.println("比赛结束");
}
}
线程不安全的集合类
如果想在多线程环境下来使用这些,就需要做一些特殊的处理~典型的办法就是加锁。
Stack String StringBuffer 是线程安全
Strng 内部并没有进行加锁,但是仍然是线程安全?
String是一个不可变对象,指的就是,没有提供public的方法,来修改 String的内容。
多线程环境下使用哈希表
ConcurrentHashMap 其实也是使用 synchronized 加锁,但是加锁方式和 HashTable 区别很大。
HashTable 是直接针对 this 对象来加锁
一个HashTable实例,就只有一把锁
此时如果有10个线程并发修改这个HashTable,此时10个线程也就都在竞争同一把锁了(锁冲突的概率比较高)
ComcurrentHashMap锁对象,是针对数组的每个元素来进行加锁(也就是针对每个哈希桶来加锁)
此时如果再有十个线程,并发修改哈希表,此时如果当前线程之间计算出的 数组位置(hashcode%数组长度)是不相同的,此时就没有锁竞争。
即使有两个线程修改的元素正好在同一个数组位置上,此时才会发生锁竞争。
1.ConcurrentHashMap 针对修改操作的加锁,使用的是粒度更小的锁,针对每个哈希桶来分别设定锁。大大降低了锁冲突的概率(针对java1.8)
在java1.8之前,ConcurrentHashMap使用的是“分段锁”,相当于把这些哈希桶分成若干组,每组分配一个锁。
2.ConcurrentHashMap针对读操作,没加锁,而是直接使用volatile
设计者评估了说,读操作,其实影响不大,读到一个旧的值和新的值,对于实际开发来说没有明显的影响
3.更充分的利用了CAS的特性,比如获取/修改size属性(元素个数)
4.更优化的扩容方式
HashTable的扩容:
如果某次put操作,导致当前的元素个数太多了,就会触发扩容,这个扩容就会需要创建一个更大的内存,并且把数据复制过去一份。
这就会直接导致这次插入操作,非常非常低效。
ConcurrentHashMap 基本思路,就是“化整为零”
如果某个插入操作触发了扩容,不是一口气扩容完,而是只搬运一部分元素。
下次再对这个ConcurrentHashMap操作的时候,再搬运一部分,保证每次操作都不至于太慢
在这个搬运过程中,相当于内部维护了两套内存,一套是旧的数据,一套是新的数据
插入操作,就只往新数据中插
查找操作,就需要同时查旧的数据和新的数据
当完全搬运完成,再删除旧数据
死锁:锁导致了线程被阻塞,该锁被释放了之后,对应的线程才会结束阻塞,但是有的时候可能这个锁永远也释放不了。于是该线程也就无法结束阻塞状态了。
产生死锁的场景:
1.如果一个线程针对一把锁,连续尝试加锁两次,并且该锁不是可重入锁的时候。
2.两个线程,两把锁(双方互不释放资源,仍然需要对象的另一把锁)
3.多个线程和多个锁 =》哲学家问题
站在教科书的角度,总结出死锁的四个必要条件:
1.互斥使用。如果一个锁被一个线程占用的时候,别的线程就会阻塞等待(锁的基本设定)
2.不可抢占/不可剥夺。线程1如果获取到一把锁,此时线程2不能强行把锁给抢过来
3.请求和保持。当资源的请求者在请求其他资源的时候,同时要保持之前的资源(线程获取到锁1之后,再尝试获取锁2,此时仍然保持对锁1的持有)
4.循环等待。线程1,先尝试获取锁1 和锁2 线程2 尝试获取锁2和锁1 这样的情况就是循环等待。
重点记第四条,和写代码相关。
死锁的常见面试题:谈谈对于死锁的理解
不要背!!!
如何避免死锁?
银行家算法非常复杂,不太适合实际开发的时候使用。
站在开发的角度:
1.尽量避免复杂的设计,避免在某个锁的代码中再尝试获取其他锁。
如果实在需要进行锁的嵌套使用,一方面要保证持有锁的时间足够短,代码足够简单,另一方面要保证按照统一的顺序(先获取锁1 再获取锁2)来进行加锁。
这样的固定顺序,其实就是破坏了死锁的“循环等待”