Java多线程
线程的基础知识
线程和进程的区别
进程:
程序是由指令和数据组成,指令需要运行,数据需要读写,指令加载到CPU,数据加载到内存。进程就是用来加载指令、管理内存的
当一个程序被运行,即开启了一个进程,进程是系统资源分配的最小单位
线程:
一个线程就是一个执行流,将指令流中的指令交给CPU执行
一个进程存在多个线程
线程是操作系统执行指令的最小单位
区别:
- 进程就是正在运行的程序实例,进程包含了线程,每个线程执行不同任务
- 不同进程使用内存空间独立,进程中的线程共享内存空间
- 线程更轻量级
并行和并发
单核CPU微观角度其实就是串行,宏观是并行
一般会将线程轮流使用CPU的做法成为并发
并发:
同一时间应对多件事情的能力
并行:
同一时间动手做多件事情的能力
创建线程的方式
- 集成Thread,重写run方法
- 实现Runnable接口,重写run方法
- 实现Callable接口,重新call方法,配合FutureTask类
- 线程池创建线程(项目中使用)
Runnable和Callable的区别
- Runnable接口run方法没有返回值
- Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取一步执行的结果
- call方法允许抛出异常,而run方法异常只能内部消化,不能继续上抛
启动线程的时候,可以使用run方法吗,它和start方法区别?
run方法调用其实就是普通方法调用,并没有开启新线程,它的作用主要是封装线程执行的代码,可以被调用多次
start方法用来启动线程,通过该线程调用run方法的逻辑。start方法只能调用一次
线程状态
线程状态参考枚举类:Thread.state
public enum State {
NEW, // 尚未启动的线程
RUNNABLE,// 可运行的线程状态
BLOCKED,//阻塞等待
WAITING,//等待
TIMED_WAITING,// 具有指定等待时间的等待线程
TERMINATED;// 已终止的线程状态,已执行完成
}
状态转换
线程顺序执行
join方法
等待线程运行结束
t.join()阻塞调用此方法的线程进入timed_waiting,直到线程t执行完成后,此线程再继续执行
notify和notifyAll区别
notifyAll:唤醒所有wait的线程
notify:随机唤醒一个wait线程
wait和sleep区别
共同点:
- wait()、wait(long)和sleep(long)都是让当前CPU放弃执行权,进入阻塞状态
不同点:
-
方法归属不用
wait属于Object类成员方法,sleep属于Thread类静态方法
-
醒来时机不同
-
sleep(long)和wait(long)的线程等待响应毫秒会自动醒来
-
wait(long)和wait()还可以被notify唤醒,wait()不唤醒则一直等待下去
-
都可以被打断唤醒
-
-
锁特性不同
- wait方法调用必须先获取wait对象的锁(搭配synchronized使用),而sleep则无限制
- wait方法执行后会释放对象锁,允许其他线程获取对象锁
- sleep在synchronized代码块执行,并不会释放对象锁
如何停止一个正在运行的线程
-
使用退出标志,使线程正常退出,也就是run方法执行完毕线程终止
-
使用stop方法强行终止(不推荐,已弃用)
-
使用interrupt方法中断线程
- 打断阻塞的线程,线程抛出interruptedException异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
线程并发安全
synchronized
synchronized【对象锁】采用互斥的方式让同一时间最多只能一个线程持有对象锁,其他线程再想获取【对象锁】时就会阻塞
底层实现原理
Monitor翻译为监视器,由JVM提供,c++语言实现
Monitor结构
- WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程
- EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
- Owner:存储当前获取锁的线程,只能有一个线程获取
总结:
- synchronized采用互斥的方式让同一时刻至多只有一个线程能持有对象锁
- 它的底层由Monitor实现的,Monitor是JVM级别的对象(C++实现),线程获取锁需要使用对象(锁)关联Monitor
- 在Monitor内部有三个属性,分别是owner、entityLisy、waitset
- 其中owner是关联的是获得锁的线程,并且只能关联一个线程;entityList关联处于阻塞状态的线程;waitList关联处于Waiting状态的线程
对象内存结构
在HotSpot虚拟机中,对象在内存中存储的布局分为3块:对象头,实例数据、对齐填充
MarkWord
Monitor实现的锁属于重量级锁,简述下锁升级
- Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换,进程上下文切换、成本较高,性能较低
- JDK1.6引入:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争和基本没有竞争的场景下因使用传统锁机制带来的性能开销问题
每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁之后,该对象头的Mark Work中就被设置指向Monitor对象的指针
轻量级锁
在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同线程交替执行代码块中的代码。这种情况下重量级锁没必要,因此JVM引入了轻量级锁的概念
加锁流程
- 在线程栈中创建一个Lock Record,将其obj字段指向锁对象
- 通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁
- 如果是当前线程已经持有该锁,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用
- 如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁
解锁过程
- 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record
- 如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue
- 如果Lock Record的Mark word不为null,则利用CAS指令将对象头的mark word恢复为无锁状态。如果失败则膨胀为重量级锁
偏向锁
轻量级锁在没有竞争时,每次重入仍然需要执行CAS操作
JDK6引入偏向锁:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有
锁升级
synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争三种情况
描述 | |
---|---|
重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换、成本较高,性能比较低 |
轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性 |
偏向锁 | 一段很长的时间内都是只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令 |
一旦发生了竞争,都会升级为重量级锁
JMM(Java内存模型)
定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
总结:
- JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
- 线程之间是相互隔离的,它们之间的交互是需要通过主内存
CAS
全程Compare And Swap(比较再交换),它提现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性
在JUC包下实现的很多类都用到了CAS操作:
- AbstractQueuedSynchronizer(AQS框架)
- AtomicXXX类
总结:
- 因为没有加锁,所以线程不会陷入阻塞,效率较高
- 如果竞争激烈,重试频繁发生,效率会受影响
底层实现
CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令
ReentrantLock实现原理就是CAS锁
乐观锁和悲观锁
- CAS是基于乐观锁的思想,最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,吃点亏再重试呗
- synchronized基于悲观锁的思想,最悲观的估计,得防着其他线程来修改共享变量,我上了锁那你们都别想改,我改完了释放锁你们才有机会
volatile
变量(类的成员变量、类的静态成员变量)被volatile修饰之后,具备两层含义:
- 保证线程间的可见性:能都防止编译器优化发生,让一个线程对共享变量的修改对另一个线程可见
- 禁止进行指令重排序:在读写共享变量时加入不同的屏障,组织其他读写操作越过屏障,从而达到阻止重排序的效果
使用技巧
- 写变量让volatile修饰的变量在代码最后位置
- 读变量让volatile修饰的变量在代码最开始位置
AQS
全程AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架
AQS与synchronized的区别
synchronized | AQS |
---|---|
关键字,C++实现 | java实现 |
悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
锁竞争激烈都死重量级锁,性能差 | 锁竞争激烈提供多解决方案 |
AQS常见的实现类:
- ReentrantLock 阻塞式锁
- Semaphore 信号量
- CountDownLatch 倒计时锁
工作机制
总结:
- AQS内部维护了一个先进先出的双向队列,队列中存储排队的线程
- 在AQS内部有一个state属性,它相当于一个线程抢占的资源,默认是0(无锁状态),如果队列中有一个线程成功修改了state为1,则当前线程获取到了锁资源
-
多个线程共同去抢这个资源是如何保证原子性呢?
在设置state的值的时候,采用CAS来保证操作的原子性
-
AQS是公平锁吗,还是非公平锁?
- 新的线程与队列中的线程共同来抢资源,是非公平锁
- 新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁
ReentrantLock
翻译过来就是可重入锁,相对于synchronized它具备一下特点:
- 可中断
- 可设置超时时间
- 可设置公平锁
- 支持多个条件变量
- 与synchronized一样,支持重入
实现原理
主要利用AQS队列和CAS来实现。它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,则表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在多线程反问的情况下,公平锁表现出较低的吞吐量
// 非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
// 公平锁 传true
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
synchronized和Lock
-
语法层面
synchronized是关键字,由jvm提供,C++实现
Lock是接口,源码由JDK提供,用java实现
synchronized退出同步代码块会自动释放锁,而Lock需要手动调用unlock方法释放锁
-
功能层面
二者均属于悲观锁,都具备基本的互斥、同步、锁重入的功能
Lock提供了许多synchronized不具备的功能,例如公平锁,可打断,可超时,多条件变量
Lock由适合不同场景的实现,如ReentrantLock、ReentrantReadWriteLock(读写锁)
-
性能层面
在没有竞争时,synchronized做了很多优化,如偏向锁、轻量级锁、性能不赖
在竞争激烈时,Lock的实现通常会提供更好的性能()
死锁
一个线程同时获取多把锁这时候就容易产生死锁
如何诊断死锁
- 使用JDK自带工具:jps和jstack
- jps:输出jvm中运行的进程状态信息
- jstack:查看java进程内线程的堆栈信息
首先jps查询运行的java进程,然后jstack -l java进程ID,会输出死锁信息
-
jconsole工具
jdk bin目录下运行jconsole工具,选择要检查的java进程,工具上方选择线程,左下角会有【检查死锁】按钮
-
VisualVM:故障处理工具
监控线程、内存情况、查看内存对象等
ConcurrentHashMap
线程安全的高效Map集合
底层数据结构:
- 1.7 采用分段数组+链表
- 1.8 采用数组+链表/红黑树
1.7中的ConcurrentHashMap
put流程:
1.8中的ConcurrentHashMap
放弃了Segment臃肿的设计,采用CAS+Synchronized来保证并发安全
- CAS控制数组节点的添加
- synchronized只锁定当前链表或红黑树的首节点,只要hash不冲突,就不会产生并发问题,效率得到提升
并发程序的根本原因
Java并发程序三大特性:
- 原子性:操作要么全部执行,要么全部不执行(synchronized、lock)
- 可见性:一个线程对共享变量的修改对另一个线程可见(volatile、synchronized、lock)
- 有序性:指令重排问题-为了提高程序运行效率,可能对代码进行优化,它不保证程序执行语句和代码中顺序一致,但最终结果和代码顺序执行结果一致(volatile)
线程池
线程池核心参数
public ThreadPoolExecutor(int corePoolSize,// 核心线程数
int maximumPoolSize,// 最大线程数
long keepAliveTime,// 非核心线程空闲时间
TimeUnit unit,// 空闲时间单位
BlockingQueue<Runnable> workQueue,// 等待队列,核心线程占满,新线程加入队列等待,队列也满了则通过非核心线程执行
ThreadFactory threadFactory,// 线程工厂,定制线程对象的创建,如名字、是否守护线程等
RejectedExecutionHandler handler)// 拒绝策略,所有线程繁忙且队列已满,则触发拒绝策略
线程池执行原理
拒绝策略:
- AbortPolicy:直接抛出异常,默认策略
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务
- DiscardPolicy:直接丢弃任务
线程池常见阻塞队列
workQueue-当没有空闲核心线程时,新来的任务加入此队列排队,队列满了则创建非核心线程来执行任务
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO
- LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO
- DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
- SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
LinkedBlockingQueue | ArrayBlockingQueue |
---|---|
默认无界,支持有界 | 强制有界 |
底层是链表 | 底层是数组 |
是懒惰的,创建节点的时候添加数据 | 提起初始化Node数组 |
入队会生成新Node | Node需要提前创建好 |
两把锁(头尾) | 一把锁 |
如何确定核心线程数
-
IO密集型任务
核心线程数大小设置为2N+1
-
CPU密集型任务
核心线程数设置为N+1
N为CPU核数
线程池种类
在java.util.Executors类中提供了大量创建连接池的静态方法,常见就有四种
-
固定线程数的线程池
newFixedThreadPool(),它其实就是设置核心线程数和最大线程数一样,阻塞队列是LinkedBlockingQueue,存储容量是Integer最大值,
适用于:任务量已知,相对耗时的任务
-
单线程的线程池
newSingleThreadExecutor(),它只会用唯一的工作线程来执行任务,保证所有任务都按照指定顺序FIFO执行
- 核心线程和最大线程数都是1
- 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
适用于:按照顺序执行的任务
-
可缓存的线程池
newCachedThreadPool()
- 核心线程数是0,最大线程数是Integer.MAX_VALUE
- 阻塞队列是SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
适用于:任务数比较密集,但每个任务执行时间较短
-
提供“延迟”和“周期执行”功能的ThreadPoolExecutor
ScheduleThreadPoolExecutor()
为什么不建议用Executors创建线程池
Executors返回的线程池对象的弊端如下:
- FixedThreadPool和SingleThreadPool阻塞队列长度是Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM
- CacheThreadPool允许创建线程数量为Integer.MAX_VALUE,可能会创建大量线程,从而导致OOM
推荐使用ThreadPoolExecutor的方式创建线程,通过7个核心参数更加明确线程池的运行规则,规避资源耗尽的风险
使用场景
线程池使用场景
CountDownLatch
CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才执行)
- 其中构造参数用来初始化等待计数值
- await()用来等待计数归零
- countDown()用来让计数减一
场景一
es数据批量导入,使用线程池+CountDownLatch分批批量导入,避免OOM
场景二
数据汇总,比如查询订单信息、商品信息、物流信息等这几块信息都在不同的微服务中,如何完成这个业务?
线程池+future来提升性能
场景三
异步调用,避免下一级方法影响上一级方法,可使用异步线程调用下一方法,可以提升方法响应时间
如何控制某个方法允许并发访问线程的数量
Semaphore信号量,是JUC下的一个工具类,底层是AQS,我们可以通过其限制执行的线程数量
使用场景:
通常同于那些资源有明确访问数量限制的场景,常用于限流
使用步骤
- 创建Semaphore对象,可以给一个容量
- semaphore.acquire():请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
- Semaphore.release():释放一个信号量,此时信号量个数+1
ThreadLocal
多线程中对于解决线程安全的一个操作类,它为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal同时实现了线程内的资源共享
基本使用
- set(value)设置值
- get()获取值
- remove()清除值
实现原理
本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离
- 每个线程内都有一个ThreadLocalMap类型的成员变量,用来存储资源对象
- 调用set方法,就是以ThreadLocal自己作为key,资源对象作为value,放入当前线程的ThreadLocalMap集合
- 调用get方法,以ThreadLocal自己作为key,到当前线程中查找关联资源值
- remove方法同理
ThreadLocal内存泄漏
ThreadLocalMap中的key是弱引用,值为强引用;key会被GC回收,关联value的内存并不会释放,建议主动remove释放k-v
扩展:Java四种引用类型:强、软、弱、虚
弱引用表示一个对象处于可能有用且非必须的状态,在GC线程扫描内存区域时,一旦发现弱引用,就会回收,无关内存区域是否足够,发现即回收