多线程
近期在学习多线程,整理了近期所学,留存
线程基础
线程的概念
- 顺序控制流程,CPU中的最小调度单位
并发和并行
- 并发:一个时间段内同时执行,不用是多个CPU 可以是单个cpu
- 并行:一个时间点内同时执行
线程的生命周期
-
初始化
-
runnable状态
- 就绪
- 运行中状态:当线程获取到时间片后,此时会调用run方法这个时候叫运行中
-
Time_waiting:限期等待,释放时间片
-
waiting:无限期等待
-
Blocked:阻塞状态,只有碰到synchronized关键字并且线程没有抢占到锁的时候才会出现
-
终止状态(terminated):当线程执行完run方法后,被jvm回收掉
线程的创建方式
-
继承Thread类
- 自定义一个类,继承Thread类
- 重写run方法
- new对象
- 调用start方法->调用Thread的start0方法,start0方法是native修饰的本地方法,最终会调用底层操作系统的方法
-
实现Runnable接口
- 自定义一个类,实现Runnable接口
- 重写run方法
- new对象,创建一个任务
- 创建一个线程,承载创建好的任务
- 调用start方法
-
实现Callable接口
- 自定义一个类,实现Callable接口
- 重写call方法
- new对象 创建一个任务
- 创建好的任务需要由线程池去执行,不能使用第一种方式去创建线程承载任务了,要通过线程池的方式去创建线程,然后执行该任务
- 跟Runnable的区别:带有返回值,并且可以往外抛异常
-
线程池
- 底层也是通过继承Thread类去创建线程
线程的启动和停止
-
启动:调用start方法
-
停止
- stop方法 已废弃 过于暴力 会把没有执行完的代码直接结束
- interrupt方法 1 返回中断标志位 2 唤醒阻塞状态的线程
- isInterrupted:返回中断标识位
- interrupted:1 返回中断标志位 2 复位 将其改成false
线程安全性
原子性:一个操作不可以被中断,线程A执行共享变量的时候不会被其他线程所干扰
可见性:多个线程对于共享变量的操作是否可见
有序性:程序编译之后的代码是否就是程序所写顺序
解决办法:加锁
线程锁
synchronized
-
作用范围
- 修饰普通方法,范围就是一个对象
- 修饰静态方法,范围就是一个类
- 修饰代码块,范围可以是一个对象也可以是一个类
-
锁的升级
-
JDK1.6之前只有重量级锁,导致其他前程抢占不到锁的时候会直接进行阻塞,消耗大量性能
-
JDK1.6以后,锁的状态分为无锁,偏向锁,轻量级锁,重量级锁,当有多个线程存在的时候偏向锁马上升级成轻量级锁,若是有线程抢占到锁后其余线程去抢占的时候会先升级成轻量级锁,然后经过一定次数的自旋和多次cas后升级成重量级锁,若还是无法抢占到锁,则进入阻塞状态
- 无锁状态标志位:01 是否偏向锁:0
- 偏向锁状态标志位:01 是否偏向锁:1
- 轻量级锁状态标识位:00
- 重量级锁状态标识位:10
-
JDK1.6以前自旋次数是10次,JDK1.6以后自旋次数由jvm自己去决定,根据线程之前抢占锁的结果若是之前抢占到锁的话 会适当延长自旋次数,若是之前没有成功的话会直接转换成重量级锁取消自旋的消耗 直接进行阻塞
-
-
锁的存储
-
概念:java中对象的结构分为对象头,实例数据,填充数据,我们所说的锁存储在对象头中
-
对象头(32位)
- 无锁:对象头有25bit用来存储对象的hashcode,4bit用来存储分代年龄,1bit用来存储偏向锁的标识,2bit存储锁的状态标识位01
- 偏向锁:对象头中23bit用来存储线程ID,2bit用来存储epoch,4bit用来存储分代年龄,1bit用来存储偏向锁的标识,2bit用来存储锁的状态标识位01
- 轻量级锁:30bit直接存储指向栈中锁记录的指针,2bit存储锁的状态标识为00
- 重量级锁:30bit存储指向重量级锁的指针,2bit存储锁的状态标识为10
- GC标记:开辟了30bit的空间但是没有使用,2bit存储锁的状态标识位11
-
-
锁的问题:AB两个线程同时持有对方需要的锁,但线程还未走完所以无法释放锁,会产生死锁
- 解决办法:采用线程之间的通信,使用wait或者notify其中wait的时候会释放锁,并且进入阻塞
Volatile关键字
1 可以防止指令重排序问题 解决了有序性问题
2 操作共享变量的时候对所有线程可见 解决了可见性问题
衍生
- 硬件层面的解决方法:使用高速缓存,但是会导致数据一致性问题,所以采用了总线锁和缓存锁(MESI协议),但是上锁后性能有所消耗所以引进了写缓存和无效队列
- 软件层面:用synchronized 或者使用volatile关键字解决指令重排序和数据一致性问题
该关键字无法解决原子性问题
线程之间的通信
wait
notify/notifyall
locksupport.park/locksupport.unpark
CountDownLatch
Happens-before
Java为了使开发人员更好理解JMM 提出了happens-before概念
- 对于开发人员来说:如果一个操作发生在另一个操作之前,那么第一个操作的执行结果对第二个操作可见,而且第一个操作的执行顺序在第二个操作之前
- 对于编译器来说:JMM允许两个操作存在happens-before关系,不要求JAVA平台的实现必须按照happens-before关系指定的顺序来执行,如果重排序后的结果与happens-before关系来执行的结果一致,那么这种重排序是允许的
程序顺序规则::在一个线程内,按照程序控制流顺序,书写在前面的操作先行发生于书写在后面的操作
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁也就是一个unlock操作先行发生于后面对同一个锁的lock操作
Volitale变量规则:对一个volatile变量的写操作,先行发生于后面对这个变量的读操作
传递性规则:如果A happens-before B,且B happens-before C,那么 A happens-before C。
start规则:Thread对象的start()方法先行发生于此线程的每一个动作,也就是start之前的代码都会先执行
join规则:如果线程A执行操作ThreadB.join()并成功返回,那 么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功 返回。
ThreadLocal
概念:ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,
这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了
线程安全(线程隔离机制)
创建方式
- ThreadLocal=new ThreadLocal();
- ThreadLocal threadLocal=ThreadLocal.withInitial(()->new Object());
hash冲突
- 采用线性探测法,环形寻找 或者本身加上魔数后会使得分布更加均匀,重复降低
- 魔数:0x61c88647,跟斐波那契数列有关
引用
- 强引用:也就是new出来的一个对象,在程序运行中宁愿抛出OutofMemoryException异常也不会回收强引用的存活着的对象,如果对象设置了为null,表明对象不是存活着的 就会被GC回收
- 软引用:内存充足的时候不会去回收该对象,内存不足的时候回收该对象
- 弱引用:不管内存冲不充足,只要进行GC的时候 都会回收弱引用的对象
- 虚引用:虚引用是必须配合 ReferenceQueue 使用,任何时候都会被GC回收
解决内存泄漏:remove方法/threadLocal中set方法里面若是碰到key为null的情况下会清理脏数据,大幅度降低了内存泄露的概率
ReentrantLock
使用:ReentrantLock lock=new ReentrantLoack();lock.lock/lock.unlock
与synchronized区别
- 1 ReentrantLock使用更灵活
- 2 synchronized 非公平锁 ReentrantLock可以是非公平也可以是公平锁
- 3 ReentrantLock是一个可重入锁,有读写锁的概念,读读不互斥 读写互斥 写写互斥
锁的分类
-
重入锁/不可重入锁
- 重入锁:在外层获取到锁后在递归下可以继续获取锁
- 不可重入锁:每次都要重新获取锁
-
公平锁/非公平锁
- 公平锁:不可以插队,只能在队列中一个个来,优先将锁分配给排队时间最长的线程
- 非公平锁:不考虑线程排队的情况,直接尝试是否可以获取锁,若是没有获取到锁则直接去队尾等待(synchronized体现在唤醒的时候是随机唤醒)
-
乐观锁/悲观锁
- 乐观锁:认为每次在执行的时候不会有别的线程在修改数据,当要修改数据的时候根据版本号字段来判断数据是否已经修改,如果当前版本号与上一次的版本号不一致的情况下则不进行修改,重复进行读,写,比较操作(cas操作,cas其实就是一个原子操作)
- 悲观锁:在每次读取数据的时候都认为有别的线程在修改数据,所以每次读取数据的时候都会先上一把锁,这样别的线程想获取数据的时候 都会先进入阻塞状态,等待锁的释放(ReentrantLock)
-
共享锁/独占锁
- 共享锁:每次可以多个线程持有锁比如令牌桶
- 独占锁:互斥锁,每次只有一个线程持有锁(ReentrantLock)
设计一把锁
- 设计互斥资源也就是共享资源
- 设计存储等待线程的数据结构 一般是FIFO
- 设计公平性 是非公平还是公平
- 设计是否重入
FIFO/LIFO
- FIFO:first in first out 先进先出 队列
- LIFO:last in last out 后进先出 栈
公平/非公平
- 非公平性体现在抢占锁的时候执行了两次抢占锁的方法
- 公平性体现在抢占锁的时候判断是否有线程在排队,若是有则不抢占锁若是没有则进行抢占锁的操作
Condition
- 提供了线程的通信机制 await/signal 配合Lock一起使用
- 实现步骤:1. 线程1调用reentrantLock.lock时,尝试获取锁。如果成功,则返回,从AQS的队列中移除线程;否则阻塞,保持在AQS的等待队列中。
- 线程1调用await方法被调用时,对应操作是被加入到Condition的等待队列中,等待signal信号;同时释放锁。
- 锁被释放后,会唤醒AQS队列中的头结点,所以线程2会获取到锁。
- 线程2调用signal方法,这个时候Condition的等待队列中只有线程1一个节点,于是它被取出来,并被加入到AQS的等待队列中。注意,这个时候,线程1 并没有被唤醒,只是被加入AQS等待队列。
- signal方法执行完毕,线程2调用unLock()方法,释放锁。这个时候因为AQS中只有线程1,于是,线程1被唤醒,线程1恢复执行。
- 发送signal信号只是将Condition队列中的线程加到AQS的等待队列中。只有到发送signal信号的线程调用reentrantLock.unlock()释放锁后,这些线程才会被唤醒。
可以看到,整个协作过程是靠结点在AQS的等待队列和Condition的等待队列中来回移动实现的,Condition作为一个条件类,很好的自己维护了一个等待信号的队列,并在适时的时候将结点加入到AQS的等待队列中来实现的唤醒操作。 - 应用:BlockedQueue
死锁
互斥 共享资源只能被一个线程占用
占有且等待,线程T1获取到共享资源X在等待共享资源Y的时候,不释放共享资源X
不可抢占其他线程不能强行抢占线程T1占有的资源
循环等待,线程T1等待线程T2占有的资源,线程2等待线程T1占有的资源,就是循环等待
并发工具
ConutDownLatch:类似一个计数器的作用,当初始值变成0的唤醒aqs中等待的线程,并且是一个传递性的唤醒 使用的锁是共享锁
- 计数器的作用,只要计数还没到0的时候则一直阻塞,初始化对象的时候初始一个state值,判断state的值与0是否相等,若是不相等,则该线程直接进入到阻塞队列中进行等待
- 唤醒的时候是传递唤醒,从之前阻塞的地方继续往下执行,然后执行setHeadAndPropagate方法一个个唤醒阻塞的线程
Semaphore:类似限流,与CountDownLatch代码类似,使用的锁也是共享锁
- 主要是限流的作用,初始化对象的时候初始一个state值,当有线程执行的时候state-1直到state=0的时候,再有线程进去执行的时候这个线程会进入到阻塞队列进行等待
- 唤醒的时候是传递唤醒,从之前阻塞的地方继续往下执行,然后执行setHeadAndPropagate方法一个个唤醒阻塞的线程
CHM:ConcurrentHashMap和HashMap
JDK1.7:分段锁+new Entry+链表
JDK1.8:new Node+链表(链表长度大于等于8并且数组的长度大于等于64的时候转换为红黑树)
Hash冲突的解决方法
- 线性探测法
- 链地址法
- 再hash法
- 建立公共溢出区:冲突的key都放在公共溢出区中
HashMap
-
初始化:new HashMap()和new HashMap(cap)
- 不带参数的时候默认初始化长度为16
- 带参数的话则会进行一次扩容,确保长度为2的幂次,主要是通过tableSizeFor()方法进行一系列的位运算以及或运算得到2的幂次数,并且是离入参最近的2的幂次数
-
put方法,内部调用putVal方法
-
第一次put会触发扩容机制,初始化Node数组返回一个长度为2的幂次长度的数组
-
获取tab[index]的数据,index=(数组长度-1)&hash(key),hash运算是key的哈希值与高16位做异或运算(扰动函数),这么做的好处是减少hash冲突的概率,增加随机性
- 如果没有值则直接newNode,创建一个新的node
- 1 如果对应位置有值的话,则需要判断对应位置的node的hash是否相等并且对应node下的key的hash是否相等或者对应key的值是否相等,若是都相等则直接覆盖对应值
- 2 若是上述1不满足则表示hash冲突了,首先判断是否是树节点,若是则是树节点表示之前处理过hash冲突了,直接在对应链表上继续新增节点
- 3 若是1 2不满足,则死循环构造链表来处理hash冲突,并且判断是否需要转红黑树,或者链表已经存在的话则重新判断第一步是否满足,链表节点添加成功或者值覆盖成功则结束死循环
- 记录modCount的值:表示当前新增是线程不安全的,需要记录修改次数,当有别的线程进来修改hashmap的时候(remove操作),若是modCount不相等则会抛出ConcurrentModificationException异常
-
-
get方法:直接根据key的hash与length-1做与运算获取下标值,如果key的hash相等,并且key的地址以及key的值相等则返回对应的值,或者存在链表则判断是否是红黑树结构如果是则循环红黑树获取对应值如果不是树结构则循环链表获取对应值返回,如果都不满足则返回null
-
允许key为null,key为null则内部定死下标为0
-
为什么每次扩容都是2的幂次,如果不是2的幂次会怎样,为什么不设置容量默认是16
- 减少hash冲突的概率,使得散列更加均匀
- 计算数组下标的时候是用hash&length-1计算的下标,那么length-1的二进制的最后一位一定是1,也就是说做与运算的时候有可能是奇数也有可能是偶数,如果length-1为偶数的话,对应的二进制的最后一位为0,也就是说做与运算的时候只能得到偶数,这样永远没法算出奇数的下标,会浪费一半的空间
- 为什么不设置容量的时候 默认是16:猜想是经验使然,这个值不宜太小也不宜太大,太小的话就频繁触发扩容机制,影响效率,太大则浪费空间
ConcurrentHashMap
-
CHM不允许key以及value为null,如果为null则抛出空指针异常,线程安全的结构,底层使用了大量的cas以及synchronized关键字确保线程安全
-
初始化:new ConcurrentHashMap() 或者 new ConcurrentHashMap(cap)
- 若是没有设置初始值,后续扩容的时候 初始化长度为16
- 若是设置了初始值,初始化长度为1.5*cap+1 也就是 cap+(cap>>>1)+1
线程池
概念:一种池化技术 主要是为了:1 为了避免频繁创建和销毁线程,并且实现线程复用2 实现了对线程的统一管理(7大核心参数)
拒绝策略
- AbortPolicy 直接抛异常
- CallerRunsPolicy 若是线程池还在运行则执行拒绝的任务
- DiscardOldestPolicy 将最早进入队列的任务删除,空出一个位置加入该任务
- DiscardPolicy 队列满了丢任务但是不抛异常
7大核心参数
- 核心线程数 corePoolSize
- 最大线程数 maximumPoolSize
- 任务结束后存活时间,当线程池的数量超过corePoolSize时,多余的空闲线程的存活时间 keepAliveTime
- 拒绝策略 默认abort 直接丢弃并且抛出异常
- 时间单位 TimeUnit
- 阻塞队列 BlockingQueue
- 线程工厂 ThreadFactory
创建方式
- new newFixedThreadPool(int n) 创建固定长度的线程池
- new newScheduledThreadPool(int n)提供可以定时执行任务的线程的池子
- new newCachedThreadPool()动态的创建线程数,没有任务的时候,池子里面没有线程,来任务的时候,才会去创建线程
- new newSingleThreadExecutor() 只创建单个线程的线程池
- new newWorkStealingPool(int n) 最大程度的满足任务执行需求,向操作系统申请足够的线程(JDK1.8 新增的)
- 最原始的方式 new ThreadPoolExecutor()可控制的参数比较多
五种状态
- RUNNING:-1 可以接受新任务,并且也能运行阻塞队列中的任务
- SHUTDOWN:0 不可以接受新任务,但是可以运行阻塞队列中的任务
- STOP:1 不可以接受新任务,也不能运行阻塞队列中的任务
- TIDYING:2
- TERMINATED:3
线程池总结
1 当工作线程小于核心线程数,则不添加 临时工
2 工作线程大于核心线程数但是小于最大线程数,则添加临时工
3 工作线程大于最大线程数则执行拒绝策略