Java并发基础

本篇文章主要讲述是并发编程前需要学习的一些基础知识,如Java程序执行过程、线程的状态、CPU的性能优化方法及带来的问题和解决方案等,这些在后面的并发编程中是必不可少的,特别是CPU的缓存、指令重排相关问题都与Java的并发编程有着重要联系。本篇博客主要参考网易云微专业 - Java高级开发工程师的慕课视频,视频的知识体系是比较完整的,不过在知识深度的讲解上还不够,所以会参考其他大佬博客的文章,由于时间原因可能主要以引用为主,希望学完并发编程部分自己能够更大的提升!

 

一、Java程序的运行分析

下面主要介绍Java类从编译到运行的过程:【编译 - 加载 - 运行】及此过程中资源的分配情况

编译

  • Java编译即由.java源文件到.class字节码文件的过程,编译时如果发现依赖类没有被编译,则会先编译依赖类,编译完的字节码文件主要包含两部分:常量池方法字节码(还有其他的文件源信息,JDK版本等),其中(main)方法字节码如下如所示:

image.png

  • 编译命令

    • 编译:javac Demo1.java
    • 反编译:javap -v Demo1.class > Demo1.txt
    • (反编译后的文件中有相关的JVM指令操作,可以参考JVM指令码表
  • 访问标识(Demo1类)image.png

  • 常量池类信息表image.png

加载

  • 加载类到JVM内存中(如将方法字节码加载到JVM方法区),根据字节码文件执行相关指令,发现未加载的依赖类时,会先加载依赖类(可参考书籍码出高效中的类加载过程)

运行

    1. JVM创建线程来执行代码,线程在创建的时候会分配相关的资源
    • 独占空间(线程私有):
      • 虚拟机栈:一个线程对应一个栈,一个栈对应多个栈帧,栈帧即为方法对应的操作
      • 程序计数器:记录当前线程的字节码执行位置
      • 内存区域:创建线程独占空间
    1. 在执行的过程中,程序计数器会记录方法区中执行的方法的字节码指令位置,并且在该方法栈帧中利用局部变量表操作栈执行及记录相关指令,如果调用其他方法,则会在该线程栈中开辟新的栈帧(即每个方法对应一个栈帧)执行上述类似的过程。 image.png

 

二、线程基础

下面主要简单描述了线程的各个状态及安全的线程终止方式,部分已经有学习及记录过,在此不再重复,可参考以前的博客。

线程状态

image.png

线程的终止方式

  • 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

    1. 总线锁:总线锁(Bus Locking)实现是当一个CPU对缓存中的数据进行操作的时候,往总线中发送一个Lock信号。这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。但是总线锁会导致CPU的性能下降,因而出现了另外一种CPU缓存一致性协议:MESI
    1. MESI:CPU厂商制定了MESI协议,规定了每条缓存有个状态位,同时定义了以下四个状态;
    • 修改态(Modified):此cache行已经被修改过(脏行),内容已经不同于主存,为此cache专有;
    • 专有态(Exclusive):此cache行内容同于主存,但不出现在其它cache中;
    • 共享态(Shared):此cache行内容同于主存,但也出现在其他cache中;
    • 无效态(Invalid):此cache行内容无效(空行);

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 重排序的分类

image.png

  • 编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 处理器重排序
    • 指令重排序:如果不存在数据依赖性处理器可以改变语句对应机器指令的执行顺序。
    • 内存重排序:不改变单线程结果的情况下,缓存可以改变写入的变量的顺序。
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 数据竞争(博客)
  • 数据竞争是指未同步的情况下,多个线程对同一个变量的读写竞争,它的定义如下:
      1. 在一个线程中写一个变量
      1. 在另一个线程中读同一个变量
      1. 上述的两个操作未正确同步
  • 如果存在数据竞争,程序的执行结果是没有保证的。但是如果正序是正确的同步的,就不存在数据竞争,比如上面的代码中,共享变量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):保障了早于内存屏障的内存读写操作的结果提交到内存后,再执行晚于屏障的读写操作。

image.png

3.5 本节小结

本章节主要介绍了CPU的性能优化方法及出现的相关问题和处理方法,为后面JVM线程安全问题做铺垫。

 

四、线程通信

要实现多个线程之间的协同,如线程执行先后顺序、获取某个线程的执行结果的等。涉及到线程之心啊的相互通信,主要分为以下四种:文件共享、网络共享、变量共享和JDK提供的线程协调API(wait/notify、park/unpark)

4.1 文件共享

image.png

4.2 网络共享

暂略。

4.3 变量共享

image.png

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 为什么要用线程池

线程是不是越多越好?

    1. 线程的创建、销毁需要时间。如果创建时间 + 销毁时间 > 执行任务时间就是很不划算的。
    1. Java对象占用堆内存,操作系统线程占用系统内存,根据jvm规范,一个线程默认最大栈大小是1M,这个栈空间是需要从系统内存中分配的。线程过多,会消耗很多的内存
    1. 线程过多时,操作系统需要频繁地切换上下文,从而影响性能,即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内存屏障及指令重排,需要有较深入的理解。