1. Java程序运行原理
1. JVM运行时数据区
- JVM运行时数据区可以分为两部分:线程共享部分和线程独占部分。线程共享部分主要包括方法区和堆内存;线程独占部分主要包括虚拟机栈,本地方法栈和程序计数器。
- 线程独占:每个线程都会有它独立的空间,随线程生命周期而创建和销毁;
- 线程共享:所有线程都能访问这块内存数据,随虚拟机或GC而创建和销毁。
- 方法区:JVM用来存储加载的类信息、常量、静态变量、编译后的代码等数据;
- 堆内存:分为老年代和新生代,新生代又分为Eden区、From Survivor和To Survivor。堆内存在JVM启动时创建,主要作用是存放对象实例。它是垃圾回收器主要管理的区域。如果该区域满了,就会出现OutOfMemoryError。
- 虚拟机栈:每个线程都在这个空间有一个私有的空间。线程栈由多个栈桢组成,一个线程会执行一个或多个方法,一个方法对应一个栈桢。栈桢的内容包含:局部变量表、操作数栈、动态链接、方法返回地址、附加信息等。栈内存默认最大是1M,超出则抛出StackOverflowError;
- 本地方法栈:功能与虚拟机栈类似,主要作用是为虚拟机使用Native本地方法二准备的;
- 程序计数器:记录当前线程执行字节码的位置,存储的是字节码指令地址,如果执行Native方法,则计数器为空。每个线程都在这个空间有一个私有的空间,占用内存空间非常少。CPU同一时间,只会执行一条线程中的指令。JVM多线程会轮流切换并分配CPU执行时间的方式。为了线程切换后,需要通过程序计数器,来恢复正确的执行位置。
- 线程在执行字节码指令时,其主要是在虚拟机栈中进行数据的操作,其中有两个非常重要的区域:局部变量表和操作数栈。局部变量表指的就是一个方法栈桢中的所有的局部变量,而操作数栈是一个临时的栈区域,在进行数据的运算时都是在操作数栈中通过不断的pop和push栈顶的元素来进行的,在操作完成之后,然后将其推入到局部变量表中。
2. 线程状态
1. 线程状态简介
- New: 上位启动的线程的线程状态;
- Runnable: 可运行线程的线程状态,等待CPU调度;
- Blocked: 线程阻塞等待监视器锁定的线程状态;处于synchronized同步代码块或方法中被阻塞。
- Waiting: 等待线程的线程状态。下列不带超时的方式:Object.wait、Thread.join、LockSupport.park;
- Timed Waiting: 具有指定等待时间的等待线程的线程状态。下列带超时的方式:Thread.sleep、Object.wait、Thread.join、LockSupport.parkNanos、LockSupport.parkUntil;
- Terminated: 终止线程的线程状态。线程正常完成执行或者出现异常。
3. 线程终止
1. 不确定的线程终止-stop
- stop: 中止线程,并且清除监控器锁的信息,但是可能导致线程安全问题,JDK不建议使用;
- stop方法执行时,被终止的线程会直接被强制中断,即使其已经在执行一段锁定的代码,也会从当前执行点退出,而不执行后续代码,这就会导致被锁定的数据产生不一致的问题;
2. 正确的线程中止-interrupt
- 如果目标线程在调用Object class的wait()、wait(long)或wait(long, int)方法、join()、join(long, int)、sleep(long, int)方法时被阻塞,那么interrupt()会生效,该线程的中断状态将被清除,抛出InterruptedException异常;
- 如果目标线程是被I/O或者NIO中的Channel所阻塞,同样I/O操作会被中断或者返回特殊异常值,达到中止线程的目的。
- 如果上述两个条件都不满足,那么就会设置线程的中断状态。
3. 正确的线程中止-标志位
- 代码逻辑中,增加一个判断,用来控制线程执行的中止。
private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
try {
while (flag) {
System.out.println("运行中");
Thread.sleep(1000L);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
Thread.sleep(3000L);
flag = false;
System.out.println("程序运行结束");
}
4. 内存屏障和CPU缓存
1. CPU性能优化手段-缓存
- 为了提高程序运行的性能,现代CPU在很多方面对程序进行了优化。例如:CPU告诉缓存。尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能;
- L1 Cache: (一级缓存)是CPU第一层高速缓存,分为数据缓存和指令缓存。一般服务器CPU的L1缓存的容量通常在32-4096KB;
- L2 Cache: 由于L1级告诉缓存容量的限制,为了再次提高CPU的运算速度,在CPU外部放置一告诉存储器,即二级缓存;
- L3 Cache: 现在的都是内置的。而它的实际作用是,L3缓存的应用可以进一步降低内存延迟,同时提升大数据量计算时处理器的性能。具有较大L3缓存的处理器提供更有效的文件系统缓存行为及较短消息和处理器队列长度。一般是多核共享一个L3缓存。
- CPU在读取数据时,先在L1中查找,再从L2查找,再从L3查找,然后是内存,再后是外存储器(硬盘)。
2. 缓存同步协议
- 在这种高速缓存回写的场景下,CPU厂商制定了一个缓存一致性协议:MESI协议。
- 修改态(Modified):此cache行已被修改过(脏行),内容已不同于主存,为此cache专有;
- 专有态(Exclusive):此cache行内容同于主存,但不出现于其它cache中;
- 共享态(Shared):此cache行内容同于主存,但也出现于其它cache中;
- 无效态(Invalid):此cache行内容无效(空行)。
- 对处理器时,单CPU对缓存中的数据进行了修改,需要通知给其它CPU。也就是意味着,CPU处理要控制自己的读写操作,还要监听其它CPU发出的通知,从而保证最终一致性。
3. CPU性能优化手段-运行时指令重排
- 指令重排的场景:当CPU写缓存时发现缓存区块正被其他CPU占用,为了提高CPU处理性能,可能将后面的读缓存命令优先执行;
- 指令重拍并非随意重排,需要遵守as-if-serial语义;
- as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。也就是说:编译器和处理器不能对存在数据依赖关系的操作做重排序。
4. 内存屏障
- 写内存屏障(Store Memory Barrier):在指令后插入Store Barrier,能让写入缓存中的罪行数据写入主内存,让其他线程可见。强制写入主内存,这种显示调用,CPU就不会因为性能考虑而去对指令重排;
- 读内存屏障(Load Memory Barrier):在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据。强制读取主内存内容,让CPU缓存与主内存保持一致,避免缓存导致的一致性问题。
5. 线程通信
1. 线程协作
- 对于线程协作,最典型的应用场景是生产者-消费者模型,其中涉及到的最主要的两个操作就是线程阻塞和线程唤醒,即当消费者没有产品可消费时,进入等待,而生产者生产好产品后唤醒等待中的消费者。
2. 被启用的suspend和resume
- 调用suspend挂起目标线程,通过resume可以恢复线程执行。
- suspend和resume容易产生死锁的情景:
- 如果suspend在一个同步代码块中执行,那么当前线程就会进入等待状态,但是其不会释放锁,此时,如果调用resume方法的线程需要拿到这个锁之后才能调用,那么其永远无法拿到这个锁,也就永远无法调用resume方法,从而产生死锁;
- 如果某个线程在调用resume方法之后,需要被唤醒的线程才开始调用suspend方法,那么该线程将永远无法被唤醒;
3. wait/notify机制
- 这些方法只能由同一对象锁的持有者线程调用,也就是写在同步代码块里面,否则会抛出IllegalMonitorStateException;
- wait方法导致当前线程等待,加入该对象的等待集合中,并且放弃当前持有的对象锁;
- notify/notifyAll方法唤醒一个或所有正在等待这个对象锁的线程;
- 注意:虽然wait能够自动解锁,但是对顺序有要求,如果在notify被调用之后,才开始wait方法的调用,线程将永远进入WAITING状态;
4. park/unpark机制
- 线程调用park则等待“许可”,unpark方法为指定线程提供“许可”;
- 不要求park和unpark方法的调用顺序,即对于park方法而言,只要当前存在“许可”,那么其就可以往下执行,无论该“许可”是之前已有的还是后面提供的;
- 多次调用unpark之后,再调用park,线程会直接运行;
- 多次调用不会叠加“许可”,也就是说,连续多次调用park方法,第一次会拿到“许可”直接运行,后续调用会进入等待;
- park是不能释放锁的,也就是说,如果在同步代码块中调用park方法,那么当前线程是不会释放当前的锁的。
5. 伪唤醒
- 对于多线程的条件判断,官方建议应该在循环中检查等待条件,原因是处于等待状态的线程可能会受到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出;
- 伪唤醒是指线程并非因为notify、notifyAll、unpark等api调用而唤醒,是更底层的原因导致的。
// wait
synchronized(obj) {
while (<条件判断>) {
obj.wait();
}
// do something
}
// park
while (<条件判断>) {
LockSupport.park();
}
// do something
6. 线程封闭
1. 概念
- 多线程访问共享可变数据时,涉及到线程间数据同步的问题。并不是所有的时候,都要用到共享数据,所以线程封闭的概念就提出来了;
- 数据被封闭在个子的线程之中,就不需要同步,这种通过将数据封闭在线程中而避免使用同步的技术称为线程封闭;
- 线程封闭的具体体现有:ThreadLocal、局部变量。
2. ThreadLocal
- ThreadLocal是一个线程级别的变量,每个线程都有一个ThreadLocal就是每个线程都拥有了自己独立的一个变量,竞争条件就被彻底消除了,在并发模式下是绝对安全的变量;
- 用法:ThreadLocal
var = new ThreadLocal<>(); - ThreadLocal会自动在每个线程上创建一个T的副本,副本之间彼此独立,互不影响;
- 可以用ThreadLocal存储一些参数,以便在线程中多个方法中使用,用来代替方法传参的做法。
3. 栈封闭
- 局部变量的股友属性之一就是封闭在线程中,她们位于执行线程的栈中,其他线程无法访问这个栈。
7. 线程池应用
1. 为什么要用线程池
- 线程在Java中是一个对象,更是操作系统的资源,线程创建、销毁需要时间。如果”创建时间+销毁时间>任务执行时间”就很不合算;
- Java对象占用堆内存,操作系统线程占用系统内存,根据jvm规范,一个线程默认最大栈大小为1M,这个栈空间是需要从系统内存中分配的。线程过多,会消耗很多的内存;
- 操作系统需要频繁的切换线程上下文,影响性能;
2. 线程池原理
- 线程池管理器:用于创建并管理线程池,包括创建线程池,销毁线程池,添加新任务;
- 工作线程:线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
- 任务接口:每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等;
- 任务队列:用于存放没有处理的任务,提供一种缓冲机制。
3. ExecutorService
- awaitTermination(long timeout, TimeUnit unit)监测ExecutorService是否已经关闭,知道所有任务完成执行,或发生超时,或当前线程被中断;
- invokeAll(Collection<? extends Callable
> tasks)执行给定的任务集合,主线程会等待所有任务执行完毕后返回结果; - invokeAll(Collection<? extends Callable
> tasks, long timeout, TimeUnit unit)执行给定的任务集合,执行完毕或者超时后,返回结果,其他任务终止; - invokeAny(Collection<? extends Callable
> tasks)执行给定的任务,任意一个执行成功则返回结果,其他任务终止; - invokeAny(Collection<? extends Callable
> tasks, long timeout, TimeUnit unit)执行给定的任务,任意一个执行成功或者超时后,则返回结果,其他任务终止; - isShutdown()如果此线程池已关闭,则返回true;
- isTerminated()如果关闭后所有任务都已完成,则返回true;
- shutdown()优雅关闭线程池,之前提交的任务将被执行,但是不会接受新的任务;
- shutdownNow()尝试停止所有正在执行的任务,停止等待任务的处理,并返回等待执行任务的列表;
- submit(Callable
task)提交一个用于执行的Callable返回任务,并返回一个Future,用于获取Callable执行结果; - submit(Runnable task)提交可运行任务以执行,并发回一个Future对象,执行结果为null
- submit(Runnable, T result)提交可运行任务以执行,并返回Future,执行结果为传入的result。
4. ScheduledExecutorService
- schedule()创建一个一次性任务,过了延迟时间就会被执行;
- scheduleWithFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)创建并执行一个周期性的任务,过了给定的初始延迟时间,会第一次被执行,执行过程中发生了异常,那么任务就停止。接下来的任务会在上次任务执行完毕后,延迟给定的时间,然后再继续执行。它与scheduleWithFixedDealy的区别在于,它是以上一个任务执行结束时间为标准的;
- scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)创建并执行一个周期性任务,过了给定的初始延迟时间,会第一次被执行,然后会以给定的周期时间执行。执行过程中发生了异常,那么任务就停止。执行时长超过了周期时间,那么下一次任务会延迟启动,不会和当前任务并行执行。它和scheduleAtFixedRate区别在于,它是固定周期的。
5. Executors工具类
- newFixedThreadPool(int nThreads)创建一个固定大小、任务队列容量无界的线程池。核心线程数=最大线程数;
- newCachedThreadPool()创建的是一个大小无界的缓冲线程池。它的任务队列是一个同步队列。任务加入到池中,如果池中有空闲线程,则用空闲线程执行,如无则创建新线程执行。池中的线程空闲超过60秒,将被销毁释放。线程数随任务的多少变化。适用于执行耗时较小的异步任务。池的核心线程数为0,最大线程数为Integer.MAX_VALUE;
- newSingleThreadExecutor()只有一个线程来执行无界任务队列的单一线程池。该线程池确保任务按加入的顺序一个一个依次执行。当唯一的线程因任务异常中止时,将创建一个新的线程来继续执行后续的任务。与newFixedThreadPool(1)的区别在于,单一线程池的大小在newSingleThreadExecutor()方法中硬编码,不能再改变的;
- newScheduledThreadPool(int corePoolSize)能定时执行任务的线程池。该池的核心线程数由参数指定,最大线程数为Integer.MAX_VALUE。
6. 线程池原理
1、 是否达到核心线程数量?没达到,创建一个工作线程来执行任务;
2、 工作队列是否已满?没满,则将新提交的任务存储在工作队列里;
3、 是否达到线程池最大数量?没达到,则创建一个新的工作线程来执行任务;
4、 最后,执行拒绝策略来处理这个任务。
7. 线程数量
- 计算型任务:cpu数量的1-2倍;
- IO型任务:相对比计算型任务,需要多一些线程,要根据IO阻塞时长进行考量决定。如tomcat中默认的最大线程数为200;
- 也可考虑根据需要在一个最小数量和最大数量间自动增减线程数。
- 生产环境中cpu利用率如果达到80%左右是比较合理的一个值,如果太小了,说明cpu没有充分利用,如果太高了,则说明线程池数量可能过大,cpu运转不过来。