多线程知识分享
1、线程和进程的区别
进程:可以理解为运行的程序,如idea
线程:cpu调度的最小单位,一个进程包含多个线程。
2、并行与并发
并行:多核cpu运行 多线程时,真正的在同一时刻运行。
并发:单核cpu运行多线程时,时间片进行很快的切换。线程轮流执行cpu,在同一个时间段内运行。
使用多线程的优点
优点:充分利用cpu资源,发挥多核cpu强大的能力。
多线程难点
- 多线程的执行结果不确定,受到cpu调度的影响
- 多线程的安全问题
- 线程资源宝贵,依赖线程池操作线程,线程池的参数设置问题
- 多线程执行是动态的,同时的,难以追踪过程
- 多线程的底层是操作系统层面的,源码难度大
多线程的实现方式
- 继承Thread类 (可以说是 将任务和线程合并在一起)
- 实现Runnable接口 (可以说是 将任务和线程分开了)
- 实现Callable接口 (利用FutureTask执行任务)
- 使用线程池工具类,一般不推荐
- 自定义线程池
①和②一般不推荐,没有返回值。
关于多线程的实现,其实也可以从源码里看到具体的一些介绍
线程的六种状态
new:初始状态
running:运行中
blocked:阻塞状态
waiting:等待
timed-waiting:等待,可设置等待时间
terminated:终止状态
线程的生命周期
解决多线程安全问题,使用锁
synchronized和lock
synchronized是java里的关键字,是jvm级别的,可作用于方法和代码块上;synchronized不能显示的释放锁,不能响应中断,除非线程执行时发生异常;
synchronized的原理,可通过javac将java文件编译class文件,执行javap -c 编译后的文件名,就可以看到字节码了。
借个图
lock是api级别的,作用于代码块;lock是一个接口,ReentrantLock是它的具体实现。ReentrantLock的实现依赖AQS,感兴趣的可以看看。
CountDownLatch、CyclicBarrier、Semaphore都是基于AQS实现的常用并发工具类(https://www.cnblogs.com/tuyang1129/p/12670014.html
https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html)
ReentrantLock可以响应中断,可以知道有没有成功获取锁;Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,
因此使用 Lock 时需要在 finally 块中释放锁。
死锁
造成死锁的原因可以概括成三句话:
- 当前线程拥有其他线程需要的资源
- 当前线程等待其他线程已拥有的资源
- 都不放弃自己拥有的资源
如果所有线程以固定的顺序来获得锁,那么程序中就不会出现锁顺序死锁问题!
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用!同步代码块最好仅被用于保护那些涉及共享状态的操作!
使用显式Lock锁,在获取锁时使用tryLock()方法。当等待超过时限的时候,tryLock()不会一直等待,而是返回错误信息。
使用tryLock()能够有效避免死锁问题
死锁检测
JDK提供了两种方式来给我们检测:
- JconsoleJDK自带的图形化界面工具,使用JDK给我们的的工具JConsole
- Jstack是JDK自带的命令行工具,主要用于线程Dump分析。
提到多线程,就必须提到java内存模型
jmm 体现在以下三个方面
- 原子性 保证指令不会受到上下文切换的影响
- 可见性 保证指令不会受到cpu缓存的影响
- 有序性 保证指令不会受并行优化的影响
volatile
volatile 关键字是 Java 虚拟机提供的的最轻量级的同步机制,它作为一个修饰
符出现,用来修饰变量。用来保证变量对所有线程可见性。
用 volatile 修饰变量时
线程 1 对变量进行操作时,会把变量变化的值强制刷新的到主内存。当线程 2获取值时,会把自己的内存里的值过期掉,
之后从主内存中读取。所以每次都能获取最新的值。
volatile 的内存屏故障是在读写操作的前后各添加一个 StoreStore 屏障,也就是四个位置,来保证重排序时不能把
内存屏障后面的指令重排序到内存屏障之前的位置。
记住:volatile 并不能解决原子性,如果需要解决原子性问题,需要使用
synchronzied 或者 lock或者Atomic包下的原子类。
无锁--CAS
CAS的全称是:⽐较并交换(Compare And Swap)。在CAS中,有这样三个值:
V:要更新的变量(var)
E:预期值(expected)
N:新值(new)
⽐较并交换的过程如下:
判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程
更新了V,则当前线程放弃更新,什么都不做。
所以这⾥的预期值E本质上指的是“旧值”。
CAS是⼀种原⼦操作。那么Java是怎样来使⽤CAS的呢?我们知道,在
Java中,如果⼀个⽅法是native的,那Java就不负责具体实现它,⽽是交给底层的
JVM使⽤c或者c++去实现。
在Java中,有⼀个 Unsafe 类,它在 sun.misc 包中。它⾥⾯是⼀些 native ⽅法。
无锁的效率是要高于之前的锁的,由于无锁不会涉及线程的上下文切换
cas是乐观锁的思想,sychronized是悲观锁的思想
cas适合很少有线程竞争的场景,如果竞争很强,重试经常发生,反而降低效率
juc并发包下包含了实现了cas的原子类
AtomicInteger/AtomicBoolean/AtomicLong
AtomicIntegerArray/AtomicLongArray/AtomicReferenceArray
AtomicReference/AtomicStampedReference/AtomicMarkableReference
CAS存在ABA问题,即比较并交换时,如果原值为A,有其他线程将其修改为B,在有其他线程将其修改为A。
此时实际发生过交换,但是比较和交换由于值没改变可以交换成功
解决方式
AtomicStampedReference/AtomicMarkableReference
上面两个类解决ABA问题,原理就是为对象增加版本号,每次修改时增加版本号,就可以避免ABA问题
或者增加个布尔变量标识,修改后调整布尔变量值,也可以避免ABA问题
JUC并发集合
ConcurrentHashMap--对应的是单线程的HashMap
CopyOnWriteArrayList--对应的是单线程的ArrayList
ConcurrentLinkedQueue
ThreadLocal
ThreadLocal提供了线程的局部变量,每个线程都可以通过set()和get()来对这个局部变量进行操作,
但不会和其他线程的局部变量进行冲突,实现了线程的数据隔离。
ThreadLocal原理:
- 每个Thread维护着一个ThreadLocalMap的引用
- ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储
- 调用ThreadLocal的set()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值是传递进来的对象
- 调用ThreadLocal的get()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象
- ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。
正因为这个原理,所以ThreadLocal能够实现“数据隔离”,获取当前线程的局部变量值,不受其他线程影响~
关于ThreadLocal内存泄漏的问题
线程池
线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后
启动这些任务,如果线程数量超过了最大数量超出数量的线程排队等候,等其它线程执行完毕,
再从队列中取出任务来执行。他的主要特点为:线程复用;控制最大并发数;管理线程。
线程池的工作流程
当调用 execute() 方法添加一个任务时,线程池会做如下判断:
a) 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
b) 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
c) 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要
创建非核心线程立刻运行这个任务;
d) 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池
会抛出异常 RejectExecutionException。
e 当一个线程完成任务时,它会从队列中取下一个任务来执行。
f. 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运
行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它
最终会收缩到 corePoolSize 的大小。
线程池的参数
- 指定核心线程数量
- 指定最大线程数量
- 允许线程空闲时间
- 时间对象单位
- 阻塞队列
- 线程工厂
- 任务拒绝策略
corePoolSize:核心线程大小,当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使
有其他空闲线程可以处理任务也会创新线程,等到工作的线程数大于核心线程数时就不会在创建了。如
果调用了线程池的 prestartAllCoreThreads 方法,线程池会提前把核心线程都创造好,并启动
maximumPoolSize:线程池允许创建的最大线程数。如果队列满了,并且以创建的线程数小于最大线
程数,则线程池会再创建新的线程执行任务。如果我们使用了无界队列,那么所有的任务会加入队列,
这个参数就没有什么效果了
keepAliveTime:线程池的工作线程空闲后,保持存活的时间。如果没有任务处理了,有些线程会空
闲,空闲的时间超过了这个值,会被回收掉。如果任务很多,并且每个任务的执行时间比较短,避免线
程重复创建和回收,可以调大这个时间,提高线程的利用率
unit:keepAliveTIme的时间单位,可以选择的单位有天、小时、分钟、毫秒、微妙、千分之一毫秒和
纳秒。类型是一个枚举 java.util.concurrent.TimeUnit ,这个枚举也经常使用,有兴趣的可以看一
下其源码
workQueue:工作队列,用于缓存待处理任务的阻塞队列,常见的有4种,本文后面有介绍
threadFactory:线程池中创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的
名字
handler:饱和策略,当线程池无法处理新来的任务了,那么需要提供一种策略处理提交的新任务,默
认有4种策略
线程池中常见5种工作队列
任务太多的时候,工作队列用于暂时缓存待处理的任务,jdk中常见的5种阻塞队列:
ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按照先进先出原则对元素进行排
序
LinkedBlockingQueue:是一个基于链表结构的阻塞队列,此队列按照先进先出排序元素,吞吐量通
常要高于ArrayBlockingQueue。静态工厂方法 Executors.newFixedThreadPool 使用了这个队列。
SynchronousQueue :一个不存储元素的阻塞队列,每个插入操作必须等到另外一个线程调用移除操
作,否则插入操作一直处理阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法
Executors.newCachedThreadPool 使用这个队列
PriorityBlockingQueue:优先级队列,进入队列的元素按照优先级会进行排序
拒绝任务策略:
- 直接抛出异常
- 使用调用者的线程来处理
- 直接丢掉这个任务
- 丢掉最老的任务
jdk默认的是直接抛出异常
线程池的状态
ThreadPoolExecutor提供了shutdown()和shutdownNow()两个方法来关闭线程池
- 调用shutdown()后,线程池状态立刻变为SHUTDOWN,而调用shutdownNow(),线程池状态立刻变为STOP。
- shutdown()等待任务执行完才中断线程,而shutdownNow()不等任务执行完就中断了线程。
合理地配置线程池
要想合理的配置线程池,需要先分析任务的特性,可以从以下几个角度分析:
任务的性质:CPU密集型任务、IO密集型任务和混合型任务
任务的优先级:高、中、低
任务的执行时间:长、中、短
任务的依赖性:是否依赖其他的系统资源,如数据库连接。
性质不同任务可以用不同规模的线程池分开处理。CPU密集型任务应该尽可能小的线程,如配置cpu数
量+1个线程的线程池。由于IO密集型任务并不是一直在执行任务,不能让cpu闲着,则应配置尽可能多
的线程,如:cup数量*2。混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密
集型任务,只要这2个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐
量。可以通过 Runtime.getRuntime().availableProcessors() 方法获取cpu数量。优先级不同任务
可以对线程池采用优先级队列来处理,让优先级高的先执行。
使用队列的时候建议使用有界队列,有界队列增加了系统的稳定性,如果采用无解队列,任务太多的时
候可能导致系统OOM,直接让系统宕机。