本篇文章主要讲述是并发编程前需要学习的一些基础知识,如Java程序执行过程、线程的状态、CPU的性能优化方法及带来的问题和解决方案等,这些在后面的并发编程中是必不可少的,特别是CPU的缓存、指令重排相关问题都与Java的并发编程有着重要联系。本篇博客主要参考网易云微专业 - Java高级开发工程师的慕课视频,视频的知识体系是比较完整的,不过在知识深度的讲解上还不够,所以会参考其他大佬博客的文章,由于时间原因可能主要以引用为主,希望学完并发编程部分自己能够更大的提升!
一、Java程序的运行分析
下面主要介绍Java类从编译到运行的过程:【编译 - 加载 - 运行】及此过程中资源的分配情况
编译
- Java编译即由
.java
源文件到.class
字节码文件的过程,编译时如果发现依赖类没有被编译,则会先编译依赖类,编译完的字节码文件主要包含两部分:常量池和方法字节码(还有其他的文件源信息,JDK版本等),其中(main)方法字节码如下如所示:
-
编译命令
- 编译:
javac Demo1.java
- 反编译:
javap -v Demo1.class > Demo1.txt
- (反编译后的文件中有相关的JVM指令操作,可以参考
JVM指令码表
)
- 编译:
-
访问标识(Demo1类):
-
常量池类信息表:
加载
- 加载类到JVM内存中(如将方法字节码加载到JVM方法区),根据字节码文件执行相关指令,发现未加载的依赖类时,会先加载依赖类(可参考书籍
码出高效
中的类加载过程)
运行
-
- JVM创建线程来执行代码,线程在创建的时候会分配相关的资源
- 独占空间(线程私有):
- 虚拟机栈:一个线程对应一个栈,一个栈对应多个栈帧,栈帧即为方法对应的操作
- 程序计数器:记录当前线程的字节码执行位置
- 内存区域:创建线程独占空间
-
- 在执行的过程中,程序计数器会记录方法区中执行的方法的字节码指令位置,并且在该方法
栈帧
中利用局部变量表
和操作栈
执行及记录相关指令,如果调用其他方法,则会在该线程栈中开辟新的栈帧(即每个方法对应一个栈帧)执行上述类似的过程。
- 在执行的过程中,程序计数器会记录方法区中执行的方法的字节码指令位置,并且在该方法
二、线程基础
下面主要简单描述了线程的各个状态及安全的线程终止方式,部分已经有学习及记录过,在此不再重复,可参考以前的博客。
线程状态
线程的终止方式
stop()
方法:不推荐,会导致线程安全问题,是一个强制中止线程的行为interrupt()
方法:如果遇到运行中或阻塞中的线程,会抛出InterruptException异常,由 开发者自行捕获和处理。标识符
:如在线程1中有flag标识符,另外的线程修改线程1的flag状态,达到终止线程的目的, 而不是拦腰截断。
三、CPU缓存及内存屏障
3.1 CPU的性能优化 - 缓存
由于CPU和内存的运行速度相差很大,为了提高CPU的性能,在CPU到主内存间加入多个高速缓存,如L1,L2,L3三级缓存,尽量让CPU从高速缓存中获取数据,从而提高其性能。
- L1 Cache(一级缓存):分为
数据缓存
和指令缓存
。容量通常为:32-4096KB - L2 Cache(二级缓存):由于L1高速缓存容量的限制,为了再次提高CPU的运算速度,在CPU外部防止一高速存储器,即二级缓存。
- L3 Cache(三级缓存):L3缓存可以进一步降低延迟,提升大数据量计算时处理器的性能。具有较大L3缓存的处理器提供更有效的
文件系统缓存行为
及较短信息
和处理器队列长度
。一般是多核共享一个L3缓存
。
缓存一致性协议(总线索和MESI协议)
多CPU读取同样的数据,进行不同的运算后,最终写入主存的以哪个CPU为准?为了应对这种高速缓存回写的场景,常见的解决缓存一致性的方法有两种,总线锁和
MESI
。
-
- 总线锁:总线锁(Bus Locking)实现是当一个CPU对缓存中的数据进行操作的时候,往总线中发送一个Lock信号。这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。但是总线锁会导致CPU的性能下降,因而出现了另外一种CPU缓存一致性协议:
MESI
。
- 总线锁:总线锁(Bus Locking)实现是当一个CPU对缓存中的数据进行操作的时候,往总线中发送一个Lock信号。这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。但是总线锁会导致CPU的性能下降,因而出现了另外一种CPU缓存一致性协议:
-
- MESI:CPU厂商制定了
MESI协议
,规定了每条缓存有个状态位
,同时定义了以下四个状态;
- 修改态(Modified):此cache行已经被修改过(脏行),内容已经不同于主存,为此cache专有;
- 专有态(Exclusive):此cache行内容同于主存,但不出现在其它cache中;
- 共享态(Shared):此cache行内容同于主存,但也出现在其他cache中;
- 无效态(Invalid):此cache行内容无效(空行);
- MESI:CPU厂商制定了
CPU的读取遵循下面几点:
- 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
- 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
- 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M。
多处理器时,单个CPU对缓存中的数据进行了改动,需要通知给其他CPU,即
CPU既要控制自己的读写操作,还要监听其他CPU发出的通知,从而保证最终一致性
。
3.2 CPU性能优化 - 运行时指令重排
由于可能存在多个CPU共享缓存同块缓存区的情况,如果某个CPU写缓存的时候缓存被其他CPU占用的情况,会将该CPU的读缓存先执行,即进行
指令重排序
,以此来提高CPU的性能,但是重排需要遵守as-if-serial语义
:无论怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。也就是说,编译器和处理器不会对存在数据依赖关系的操作做重排序。
3.2.1 重排序的分类
- 编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 处理器重排序
- 指令重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存重排序:不改变单线程结果的情况下,缓存可以改变写入的变量的顺序。
3.2.2 重排序对单线程的影响
- 重排序不会改变单线程程序的执行结果,所有的重排序都遵守
as-if-serial
语义。对于存在数据依赖关系的曹祖,编译器和处理器都不会进行重排序。
3.2.3 重排序对多线程的影响
- 多线程情况下,对不存在数据依赖(相对于当前线程而言)的操作进行重排序,也会影响结果。比如下面代码,如果存在读线程和写线程,对于各自的线程而言,1、2和3、4都不是数据依赖的关系,所以2可能先于1执行,那么读线程可能会读到1未执行时的a的值,此为重排序引起的并发问题。
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
}
}
}
3.2.4 数据竞争(博客)
- 数据竞争是指未同步的情况下,多个线程对同一个变量的读写竞争,它的定义如下:
-
- 在一个线程中写一个变量
-
- 在另一个线程中读同一个变量
-
- 上述的两个操作未正确同步
-
- 如果存在数据竞争,程序的执行结果是没有保证的。但是如果正序是正确的同步的,就不存在数据竞争,比如上面的代码中,共享变量flag和a的读写在可能存在两个线程中,并且读写方法未同步,存在数据竞争。如果读写方法是同步方法,则没有数据竞争关系,程序的执行将具有顺序一致性。
3.2.5 竞态条件(Java并发编程实战)
- 当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。大多数的竞态条件的本质是:基于一种可能失效的观察结果来做出判断或者执行某个计算,其常见类型的主要为:先检查后执行、读取-修改-写入,其观察的结果可能变得无效,从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)
- 竞态条件与数据竞争的描述类似,都是简述了多线程并发下可能存在问题的场景,笔者觉得竞态条件描述有点抽象,数据竞争的描述更具体些,可以很好的带入到各个出现问题的案例中。
3.3 存在的问题
-
CPU高速缓存下的问题:缓存中的数据与主存中的数据不是实时同步的,各CPU间缓存的数据也不是实时同步的。
在同一时间点,各个CPU看到同一内存地址中的数据的值可能是不一致的
-
CPU指令重排序下的问题:虽然遵守了
as-if-serial
语义,但是仅仅在单CPU自己执行的情况下保证结果正确。多核多线程
中,指令逻辑无法分辨因果关联,可能会出现乱序执行
,导致程序运行结果错误。
3.4 内存屏障
为了解决上面出现的问题,CPU厂商为处理器提供了两个内存屏障指令,一旦内存数据被推送到缓存,就会有消息协议来确保所有的缓存会对所有共享数据保持一致,使得CPU或编译器在对内存进行操作的时候,严格按照一定的顺序来执行,也就是说在
Store Barrier
之前的指令和Load Barrier
之后的指令不会由于系统优化等原因导致乱序。
- 写内存屏障(Store Memory Barrier):在指令后插入
Store Barrier
,能让写入缓存中的最新数据更新写入主内存,即强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行并刷入内存,让其他线程可见。保证了内存写操作。强制写入主内存
,CPU就不会因为性能考虑而去对指令重排,而是严格按照执行顺序执行。
- 读内存屏障(Load Memory Barrier):在指令前插入
Load Barrier
,可以让高速缓存中的数据失效,强制从新主内存中加载数据。即强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。保证了内存读操作。强制读取主存内容
,让CPU缓存与主内存保持一致,避免了缓存导致的一致性问题。
- 完全内存屏障(full memory barrier):保障了早于内存屏障的内存读写操作的结果提交到内存后,再执行晚于屏障的读写操作。
3.5 本节小结
本章节主要介绍了CPU的性能优化方法及出现的相关问题和处理方法,为后面JVM线程安全问题做铺垫。
四、线程通信
要实现多个线程之间的协同,如线程执行先后顺序、获取某个线程的执行结果的等。涉及到线程之心啊的相互通信,主要分为以下四种:文件共享、网络共享、变量共享和JDK提供的线程协调API(wait/notify、park/unpark)
4.1 文件共享
4.2 网络共享
暂略。
4.3 变量共享
4.4 JDK线程协作API
在Java中典型的案例即:生产者 - 消费者模式
三种API:
- suspend和resume
- 已经被弃用,在同步代码块中或者先后顺序有误时会导致死锁
- suspend后不会释放锁,容易造成死锁问题,而wait则会释放
- wait和notify/notifyAll
- 只能在同步代码块中调用,否则会抛出异常
- 调用wait后会释放锁和CPU,进入waiting状态等待被唤醒
- 虽然notify会自动解锁,但是对顺序有要求,如果在notify调用后才调用wait,线程会永远处于WAITING状态。
- park和unpark
- 对顺序无要求,类似于颁发许可证书的情况,线程在
park
的时候,如果存在许可,则立即返回(类似于正常唤醒),如果没有许可,则会进入阻塞状态。许可只有一个,不可累加,即多次调用unpark
不可累加许可。 - 不会释放锁,在同步代码块中会造成死锁,比如消费者获取到对象锁后调用
LockSupport.park()
方法,此时锁被占用,生产者无法获取到对象锁,也无法调用LockSupport.unpack(oneThread)
唤醒。
- 对顺序无要求,类似于颁发许可证书的情况,线程在
伪唤醒:
Java官方建议在循环中检查等待条件,原因是处理等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件而是用If语句,程序就会在没有满足结束条件的情况下退出。伪唤醒是指线程并非因为notify、notifyAll、unpark等api调用而唤醒,这涉及到更底层的因素,会造成程序逻辑的错误。
4.5 本节小结
本节主要对线程的通信进行简单介绍,并且着重介绍了JDK中的线程协作API,在很多JDK多线程开发工具类中都是底层实现,需要重点掌握。
五、线程封闭
多线程访问共享可变数据时,涉及到线程间数据同步的问题,而有时候我们并不希望数据被共享,所以有了线程封闭的概念。 数据都被封闭在各自的线程中,就不需要进行同步操作,这种通过将数据封闭在线程中而避免实用同步的技术称为线程封闭。
5.1 ThreadLocal
ThreadLocal是Java中的一种特殊变量,是一个线程级别的变量。在ThreadLocal中,每个线程都有自己独立的一个变量,JVM在其底层中维护了一个Map<ThreadName,value>,为每个线程分配了一个副本,线程与线程间的副本之间彼此独立,互不影响,从而消除了线程间的竞争条件,在并发模式下是绝对安全的变量。
- 用法:
ThreadLocal<T> var = new ThreadLocal<T>()
- 应用场景:
- 多线程下多个方法需要使用相同的变量时(不互相依赖),可以减少代码量
- 线程中多个方法使用某变量,可以替换方法传参的做法。(即成员变量的作用)
5.2 栈封闭(局部变量表)
局部变量的固有属性之一就是封闭在线程中,它们位于执行线程的栈中,例如在JVM操作栈中,每个方法对应一个栈帧,栈帧中有局部变量表和操作栈,其他线程无法获取到当前栈帧中的局部变量表。
六、线程池原理
线程在Java中的一个对象,更是操作系统的资源。
6.1 为什么要用线程池
线程是不是越多越好?
-
- 线程的创建、销毁需要时间。如果创建时间 + 销毁时间 > 执行任务时间就是很不划算的。
-
- Java对象占用堆内存,操作系统线程占用系统内存,根据jvm规范,一个线程默认最大栈大小是1M,这个栈空间是需要从系统内存中分配的。线程过多,会消耗很多的内存。
-
- 线程过多时,操作系统需要频繁地切换上下文,从而影响性能,即CPU的大部分时间都被花费在线程切换上了。
从上面三点可以知道,线程并不是越多越好的,不能够无限制的创建,线程池的推出,就是为了方便控制线程数量。
6.2 线程池的原理 - 概念
- 线程池管理器:用于创建并管理线程池,包括创建线程池,销毁线程池,添加新任务;
- 工作线程:线程池中线程,在没有任务时处于等待状态,可以循环的执行任务。
- 任务接口:每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等,例如线程需要实现的
Runnable
接口或者Callable
接口等; - 任务队列:用于存放没有处理的任务,比如线程达到核心线程数量后,会将多出的线程存放在任务队列中,作为一种缓存机制存在。
- 拒绝策略:当线程数量超过最大线程数后执行的策略,一共有四种策略:抛异常、抛弃、利用当前传递的管理线程执行、丢弃最早的任务,将其放入任务队列中。
6.3 线程池API
接口定义和实现类
- 接口
Executor
:最上层接口,定义了执行任务的方法execute
ExecutorService
:继承自Executor接口,扩展你了Callable、Future、关闭方法ScheduledExecutorService
:继承自ExecutorService,增加了定时任务相关的方法
- 实现类
ThreadPoolExecutor
:基础、标准的线程池实现。submit
:提交任务到线程池getPoolSize
:获取当前线程池数量getQueue().size()
:获取当前队列中的等待线程数量
ScheduledThreadPoolExecutor
:继承自ThreadPoolExecutor
,实现了定时任务相关的方法。
常见线程池类型
-
创建方法:
Executors.newFixedThreadPool()
和new ThreadPoolExecutor()
,阿里巴巴开发手册中强制规定使用第二种方式创建,由于Executors中的任务队列和最大线程数量都默认为无界,在大量请求下会导致OOM。 -
ThreadPoolExecutor(标准线程池):通过自定义
new
,并配置相关参数,如核心线程数量、最大线程数量、线程存活时间、阻塞队列等,如果阻塞队列为定长队列,还需要配置拒绝策略
。一般是
// 此处的拒绝策略用Lambda表示式实现,即`new RejectedExecutionHandler()`并重写`rejectedExecution`方法
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 5, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(3), (r, executor) -> System.err.println("有任务被拒绝执行了"));
- FixedThreadPool(定长线程池,最大线程数 = 核心线程数)- 长期较少的任务
LinkedBlockingQueue
:基于链表的无界阻塞队列,利用AQS实现并发操作。
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
- CachedThreadPool(缓存(弹性)线程池,起始无线程,线程有存活时间,无线程数量限制)- 当任务提交到线程池时,如果有空闲线程则直接调用,没有则创建新线程。适合短期较多的异步任务,任务大小无法控制、动态变化因素比较多的场景。
SynchronousQueue
:无缓存阻塞队列,队列始终为空,必须等待提交的任务被消费者消费后才会继续提交,故任务的提交是顺序的,但是结束顺序是不确定的。底层采用CAS实现并发操作,有公平(TransferQueue)和非公平(TransferStack)两种模式。
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
- SingleThreadPool(单线程线程池)- 顺序执行的任务
- ScheduledThreadpool(定时器线程池)- 周期性执行的任务
线程数量
生产环境中,一般CPU的使用率达到80%则表示CPU充分被利用,少于80%则可能需要开多些线程,使用率太高则表示线程数量太多,太低则表示线程数量过少。
如何确定合适数量的线程?
- 计算型任务:纯内存操作,占用CPU比较高的,一般线程数量为cpu数量的1-2倍
- IO型任务:网络操作、数据库操作、文件操作,可能会有IO阻塞,一般需要开启较多线程。
- tomcat默认线程数量:200
- CacheThreadPool自动增减线程数
七、总结
以上都是Java并发的基础操作,在后续AQS、关键字Synchronize、Volatile底层都有相关操作,特别是CPU内存屏障及指令重排,需要有较深入的理解。