JavaGuide延伸文章总结汇总
1.Java基础
1.1AOT提前编译技术
问题描述 | 具体内容 |
---|---|
利用AOT提前编译技术优化Java程序冷启动和内存占用问题 | 基于静态编译构建微服务应用 (qq.com) |
见图说话:上图是Java程序的启动过程分析图,其中红色是JVM加载到内存,浅蓝色是类加载过程,浅绿色是字节码解释执行过程,黄色是GC垃圾对象回收过程,白色是运行时JIT即时编译器对执行频率较高的代码进行编译优化,绿色是JIT编译后的代码。
问题抛出:Java程序明显存在启动速度慢和占用内存高的问题,前者原因是Java程序启动时会经历JVM加载、解释执行、即时编译等多个阶段,冷启动问题严重;后者原因是JVM本身占用一定的内存,而且由于相比于一些编译型语言其编译优化动作后置到了运行时,非常容易出现实际加载的代码比实际运行需要的代码多很多的情况,造成了一些无效内存占用。
解决方案:提前编译(Ahead-of-Time Compilation,AOT Compilation)或者叫静态编译,核心思想就是将Java程序的编译阶段提前到程序启动前,然后在编译阶段进行代码编译优化,让程序启动既巅峰,消除冷启动,降低运行时内存开销。
实现技术:GraalVM高性能多语言运行时平台,GraalVM中通过提供Truffle解释器实现框架,让开发人员可以使用Truffle提供的API快速实现特定语言的解释器从而实现对上图中各种编程语言所写的程序都能进行编译运行的效果,从而成为一个多语言运行时平台。相比于JVM运行时方式,静态编译在运行之前会先对程序解析编译,然后生成一个跟运行时环境强相关的native image可执行文件,最后直接执行该文件即可启动程序进行执行。
技术难题:Substrate VM通过上下文不敏感的指向分析(Points-to Analysis)来对应用程序做静态分析,其可以在不需要运行程序的情况下,基于源程序分析给出所有可能的可达函数列表然后作为后续编译阶段的输入对程序进行静态编译
。该过程由于静态分析的局限性,无法覆盖Java中的反射、动态代理、JNI调用等动态特性。这也造成了很多的Java框架由于在实现过程中使用了大量的上述特性,因此,都难以直接基于Substrate VM完成对自身所有代码的静态分析,需要通过额外的外部配置来解决静态分析本身的不足。
对比特点:可以看出,AOT的主要优势在于启动时间、内存占用和打包体积。JIT的主要优势在于具备更高的极限处理能力,可以降低请求的最大延迟。两者各有优点,只能说AOT更适合当下的云原生场景,对微服务架构的支持也比较友好。除此之外,AOT编译无法支持Java的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。然而,很多框架和库(如 Spring、CGLIB)都用到了这些特性。如果只使用AOT编译,那就没办法使用这些框架和库了,或者说需要针对性地去做适配和优化。
1.2魔法类Unsafe应用
问题描述 | 具体内容 |
---|---|
Unsafe允许Java程序执行一些低级、不安全的底层操作,如直接访问和自主管理内存资源、提升Java程序运行效率等,是个牛掰的魔术类 | Java魔法类:Unsafe应用解析 - 美团技术团队 (meituan.com) |
1 | java -Xbootclasspath/a: ${path} // 其中path为调用Unsafe相关方法的类所在jar包路径 |
基本介绍:Unsafe类为一单例实现,提供静态方法getUnsafe获取Unsafe实例,当且仅当调用getUnsafe方法的类为引导类加载器所加载时才合法,否则抛出SecurityException异常。其一,从getUnsafe
方法的使用限制条件出发,通过Java命令行命令-Xbootclasspath/a
把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中,使得A被引导类加载器加载,从而通过Unsafe.getUnsafe
方法安全的获取Unsafe实例。其二,通过反射获取单例对象theUnsafe。
内存操作:通常,我们在Java中创建的对象都处于堆内内存中,堆内内存是由JVM所管控的Java进程内存,并且它们遵循JVM的内存管理机制,JVM会采用垃圾回收机制统一管理堆内存。与之相对的是堆外内存,存在于JVM管控之外的内存区域,Java中对堆外内存的操作,依赖于Unsafe提供的操作堆外内存的native方法。
使用堆外内存的原因:
- 对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在GC时减少回收停顿对于应用的影响。
- 提升程序I/O操作的性能。通常在I/O通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。
典型应用:DirectByteBuffer是Java用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在Netty、MINA等NIO框架中应用广泛。DirectByteBuffer对于堆外内存的创建、使用、销毁等逻辑均由Unsafe提供的堆外内存API来实现。下图为DirectByteBuffer构造函数,创建DirectByteBuffer的时候,通过Unsafe.allocateMemory分配内存、Unsafe.setMemory进行内存初始化,而后构建Cleaner对象用于跟踪DirectByteBuffer对象的垃圾回收,以实现当DirectByteBuffer被垃圾回收时,分配的堆外内存一起被释放。
那么如何通过构建垃圾回收追踪对象Cleaner实现堆外内存释放呢?
Cleaner继承自Java四大引用类型之一的虚引用PhantomReference
(众所周知,无法通过虚引用获取与之关联的对象实例,且当对象仅被虚引用引用时,在任何发生GC的时候,其均可被回收),通常PhantomReference与引用队列ReferenceQueue结合使用,可以实现虚引用关联对象被垃圾回收时能够进行系统通知、资源清理等功能。如下图所示,当某个被Cleaner引用的对象将被回收时,JVM垃圾收集器会将此对象的引用放入到对象引用中的pending链表中,等待Reference-Handler进行相关处理。其中,Reference-Handler为一个拥有最高优先级的守护线程,会循环不断的处理pending链表中的对象引用,执行Cleaner的clean方法进行相关清理工作
。
所以当DirectByteBuffer仅被Cleaner引用(即为虚引用)时,其可以在任意GC时段被回收。当DirectByteBuffer实例对象被回收时,在Reference-Handler线程操作中,会调用Cleaner的clean方法根据创建Cleaner时传入的Deallocator来进行堆外内存的释放。
CAS:什么是CAS? 即比较并替换,实现并发算法时常用到的一种技术。CAS操作包含三个操作数——内存位置、预期原值及新值。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS是一条CPU的原子指令(cmpxchg指令
),不会造成所谓的数据不一致问题,Unsafe提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg。
典型应用:CAS在java.util.concurrent.atomic相关类、Java AQS、CurrentHashMap等实现上有非常广泛的应用。如下图所示,AtomicInteger的实现中,静态字段valueOffset即为字段value的内存偏移地址
,valueOffset的值在AtomicInteger初始化时,在静态代码块中通过Unsafe的objectFieldOffset方法获取。在AtomicInteger中提供的线程安全方法中,通过字段valueOffset的值可以定位到AtomicInteger对象中value的内存地址,从而可以根据CAS实现对value字段的原子操作。
线程调度:这部分,包括线程挂起、恢复、锁机制等方法。如上源码说明中,方法park、unpark即可实现线程的挂起与恢复,将一个线程进行挂起是通过park方法实现的,调用park方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark可以终止一个挂起的线程,使其恢复正常。
典型应用:Java锁和同步器框架的核心类AbstractQueuedSynchronizer,就是通过调用LockSupport.park()
和LockSupport.unpark()
实现线程的阻塞和唤醒的,而LockSupport的park、unpark方法实际是调用Unsafe的park、unpark方式来实现。
Class相关:此部分主要提供Class和它的静态字段的操作相关方法,包含静态字段内存定位、定义类、定义匿名类、检验&确保初始化等。
典型应用:在Lambda表达式实现中,通过invokedynamic
指令调用引导方法
生成调用点,在此过程中,会通过ASM动态生成字节码
,而后利用Unsafe的defineAnonymousClass
方法定义实现相应的函数式接口的匿名类,然后再实例化此匿名类,并返回与此匿名类中函数式方法的方法句柄关联的调用点;而后可以通过此调用点实现调用相应Lambda表达式定义逻辑的功能。下面以如下图所示的Test类来举例说明。
Test类编译后的class文件反编译后的结果如下图一所示(删除了对本文说明无意义的部分),我们可以从中看到main方法的指令实现、invokedynamic指令调用的引导方法BootstrapMethods、及静态方法lambda$main$0
(实现了Lambda表达式中字符串打印逻辑)等。在引导方法执行过程中,会通过Unsafe.defineAnonymousClass生成如下图二所示的实现Consumer接口的匿名类。其中,accept方法通过调用Test类中的静态方法lambda$main$0
来实现Lambda表达式中定义的逻辑。而后执行语句consumer.accept("lambda")
其实就是调用下图二所示的匿名类的accept方法。
对象操作:此部分主要包含对象成员属性相关操作及非常规的对象实例化方式等相关方法。
典型应用:我们通常所用到的创建对象的方式,从本质上来讲,都是通过new机制来实现对象的创建。但是,new机制有个特点就是当类只提供有参的构造函数且无显示声明无参构造函数时,则必须使用有参构造函数进行对象构造。而Unsafe中提供allocateInstance
方法,仅通过Class对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM安全检查等
。它抑制修饰符检测,也就是即使构造器是private修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance在java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。如下图所示,在Gson反序列化时,如果类有默认构造函数,则通过反射调用默认构造函数创建实例,否则通过UnsafeAllocator来实现对象实例的构造,UnsafeAllocator通过调用Unsafe的allocateInstance实现对象的实例化,保证在目标类无默认构造函数时,反序列化不受影响
。
数组相关:这部分主要介绍与数据操作相关的arrayBaseOffset与arrayIndexScale这两个方法,两者配合起来使用,即可定位数组中每个元素在内存中的位置。
典型应用:这两个与数据操作相关的方法,在java.util.concurrent.atomic 包下的AtomicIntegerArray(可以实现对Integer数组中每个元素的原子性操作)中有典型的应用,如下图AtomicIntegerArray源码所示,通过Unsafe的arrayBaseOffset、arrayIndexScale分别获取数组首元素的偏移地址base及单个元素大小因子scale。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的getAndAdd方法即通过checkedByteOffset方法获取某数组元素的偏移地址,而后通过CAS实现原子性操作。
2.Java并发
2.1创建线程的唯一方式
问题描述 | 具体内容 |
---|---|
浅谈Java线程的创建方式是经典八股演员了,说Runnable、Callable、ExecutorService、CompletableFuture、FutureTask、Timer等其实说的都是创建线程任务的方式,根本上底层都是调用Thread.start创建启动的线程 | 大家都说Java有三种创建线程的方式!并发编程中的惊天骗局! (qq.com) |
要点说明:任务和线程,到底是怎么产生绑定关系的呢?大家可以去看Thread
类提供的构造器,应该会发现这个构造函数:
当new Thread对象并传入一个任务时,内部会调用init()
方法,把传入的任务target
传进去,同时还会给线程起个默认名字,即Thread-x
,这个x
会从0
开始(线程名字也可以自定义)。而当大家去尝试继续跟进init()
方法时,会发现它在做一系列准备工作,如安全检测、设定名称、绑定线程组、设置守护线程……,当init()
方法执行完成后,就可以调用Thread.start()
方法启动线程啦。启动线程时,最终会调用到start0()
这个JNI
方法,转而会去调用JVM
的本地方法,即C/C++
所编写的方法,我这里就大致总结一下大体过程。
①Thread
在类加载阶段,就会通过静态代码块去绑定Thread
类方法与JVM
本地方法的关系:
执行完这个registerNatives()
本地方法后,Java
的线程方法,就和JVM
方法绑定了,如start0()
这个方法,会对应着JVM_StartThread()
这个C++
函数等(具体代码位于openjdk\jdk\src\share\native\java\lang\Thread.c
这个文件)。
②当调用Thread.start()
方法后,会先调用Java
中定义的start0()
,接着会找到与之绑定的JVM_StartThread()
这个JVM
函数执行(具体实现位于openjdk\hotspot\src\share\vm\prims\jvm.cpp
这个文件)。
③JVM_StartThread()
函数最终会调用os::create_thread(...)
这个函数,这个函数依旧是JVM
函数,毕竟Java
要实现跨平台特性,而不同操作系统创建线程的内核函数,也有所差异,如Linux
操作系统中,创建线程最终会调用到pthread_create(...)
这个内核函数。
④创建出一条内核线程后,接着会去执行Thread::start(...)
函数,接着会去执行os::start_thread(thread)
这个函数,这一步的作用,主要是让Java
线程,和内核线程产生映射关系,也会在这一步,把Runnable
线程体,顺势传递给OS
的内核线程(具体实现位于openjdk\hotspot\src\share\vm\runtime\Thread.cpp
这个文件)。
⑤当Java
线程与内核线程产生映射后,接着就会执行载入的线程体(线程任务),也就是Java
程序员所编写的那个run()
方法。
2.2CompletableFuture原理与实践
问题描述 | 具体内容 |
---|---|
随着订单量的持续上升,美团外卖各系统服务面临的压力也越来越大。作为外卖链路的核心环节,商家端提供了商家接单、配送等一系列核心功能,业务对系统吞吐量的要求也越来越高。而商家端API服务是流量入口,所有商家端流量都会由其调度、聚合,对外面向商家提供功能接口,对内调度各个下游服务获取数据进行聚合,具有鲜明的I/O密集型(I/O Bound)特点。在当前日订单规模已达千万级的情况下,使用同步加载方式的弊端逐渐显现,因此我们开始考虑将同步加载改为并行加载的可行性。 | CompletableFuture原理与实践-外卖商家端API的异步化 - 美团技术团队 (meituan.com) |
解决问题:CompletableFuture是由Java 8引入的,在Java8之前我们一般通过Future实现异步。
- Future用于表示异步计算的结果,只能通过阻塞或者轮询的方式获取结果,而且不支持设置回调方法,Java 8之前若要设置回调一般会使用guava的
ListenableFuture
,回调的引入又会导致臭名昭著的回调地狱
(下面的例子会通过ListenableFuture的使用来具体进行展示)。 - CompletableFuture对Future进行了扩展,可以通过设置回调的方式处理计算结果,同时也支持组合操作,支持进一步的编排,同时一定程度解决了回调地狱的问题。
下面将举例来说明,我们通过ListenableFuture、CompletableFuture来实现异步的差异。假设有三个操作step1、step2、step3存在依赖关系,其中step3的执行依赖step1和step2的结果。
Future(ListenableFuture)的实现(回调地狱)如下:
CompletableFuture的实现如下:
显然,CompletableFuture的实现更为简洁,可读性更好。
CompletableFuture定义:CompletableFuture实现了两个接口(如上图所示):Future
、CompletionStage
。Future表示异步计算的结果,CompletionStage用于表示异步执行过程中的一个步骤(Stage),这个步骤可能是由另外一个CompletionStage触发的,随着当前步骤的完成,也可能会触发其他一系列CompletionStage的执行。从而我们可以根据实际业务对这些步骤进行多样化的编排组合,CompletionStage接口正是定义了这样的能力,我们可以通过其提供的thenAppy
、thenCompose
等函数式编程方法来组合编排这些步骤。
CompletableFuture定义:下面我们通过一个例子来讲解CompletableFuture如何使用,使用CompletableFuture也是构建依赖树的过程。一个CompletableFuture的完成会触发另外一系列依赖它的CompletableFuture的执行。如上图所示,这里描绘的是一个业务接口的流程,其中包括CF1\CF2\CF3\CF4\CF5共5个步骤,并描绘了这些步骤之间的依赖关系,每个步骤可以是一次RPC调用、一次数据库操作或者是一次本地方法调用等,在使用CompletableFuture进行异步化编程时,图中的每个步骤都会产生一个CompletableFuture对象,最终结果也会用一个CompletableFuture来进行表示。根据CompletableFuture依赖数量,可以分为以下几类:零依赖
、一元依赖
、二元依赖
和多元依赖
。
零依赖CompletableFuture的创建:如上图红色链路所示,接口接收到请求后,首先发起两个异步调用CF1、CF2,主要有三种方式:
1 | ExecutorService executor = Executors.newFixedThreadPool(5); |
第三种方式的一个典型使用场景,就是将回调方法转为CompletableFuture,然后再依赖CompletableFure的能力进行调用编排,示例如下:
1 |
|
一元依赖CompletableFuture的创建:如上图红色链路所示,CF3,CF5分别依赖于CF1和CF2,这种对于单个CompletableFuture的依赖可以通过thenApply、thenAccept、thenCompose等方法来实现,代码如下所示:
1 | CompletableFuture<String> cf3 = cf1.thenApply(result1 -> { |
二元依赖CompletableFuture的创建:如上图红色链路所示,CF4同时依赖于两个CF1和CF2,这种二元依赖可以通过thenCombine等回调来实现,如下代码所示:
1 | CompletableFuture<String> cf4 = cf1.thenCombine(cf2, (result1, result2) -> { |
多元依赖CompletableFuture的创建:如上图红色链路所示,整个流程的结束依赖于三个步骤CF3、CF4、CF5,这种多元依赖可以通过allOf
或anyOf
方法来实现,区别是当需要多个依赖全部完成时使用allOf
,当多个依赖中的任意一个完成即可时使用anyOf
,如下代码所示:
1 | CompletableFuture<Void> cf6 = CompletableFuture.allOf(cf3, cf4, cf5); |
CompletableFuture原理:CompletableFuture中包含两个字段:result和stack。result用于存储当前CF的结果,stack(Completion)表示当前CF完成后需要触发的依赖动作(Dependency Actions),去触发依赖它的CF的计算,依赖动作可以有多个(表示有多个依赖它的CF),以栈(Treiber stack)的形式存储,stack表示栈顶元素。
这种方式类似“观察者模式”
,依赖动作(Dependency Action)都封装在一个单独Completion子类中。下面是Completion类关系结构图。CompletableFuture中的每个方法都对应了图中的一个Completion的子类,Completion本身是观察者
的基类。
- UniCompletion继承了Completion,是一元依赖的基类,例如thenApply的实现类UniApply就继承自UniCompletion。
- BiCompletion继承了UniCompletion,是二元依赖的基类,同时也是多元依赖的基类。例如thenCombine的实现类BiRelay就继承自BiCompletion。
CompletableFuture设计思想:按照类似“观察者模式”的设计思想,原理分析可以从“观察者”和“被观察者”两个方面着手。由于回调种类多,但结构差异不大,所以这里单以一元依赖中的thenApply为例,不再枚举全部回调类型。如下图所示:
- 被观察者
- 每个CompletableFuture都可以被看作一个被观察者,其内部有一个
Completion
类型的链表成员变量stack
,用来存储注册到其中的所有观察者
。当被观察者执行完成后会弹栈stack属性,依次通知注册到其中的观察者
。上面例子中步骤fn2就是作为观察者被封装在UniApply中。 - 被观察者CF中的result属性,用来存储返回结果数据。这里可能是一次RPC调用的返回值,也可能是任意对象,在上面的例子中对应步骤fn1的执行结果。
- 每个CompletableFuture都可以被看作一个被观察者,其内部有一个
- 观察者
- CompletableFuture支持很多回调方法,例如thenAccept、thenApply、exceptionally等,
这些方法接收一个函数类型的参数f,生成一个Completion类型的对象(即观察者)
,并将入参函数f赋值给Completion的成员变量fn,然后检查当前CF是否已处于完成状态(即result != null),如果已完成直接触发fn,否则将观察者Completion加入到CF的观察者链stack中,再次尝试触发,如果被观察者未执行完则其执行完毕之后通知触发。- 观察者中的dep属性:指向其对应的CompletableFuture,在上面的例子中dep指向CF2。
- 观察者中的src属性:指向其依赖的CompletableFuture,在上面的例子中src指向CF1。
- 观察者Completion中的fn属性:用来存储具体的等待被回调的函数。这里需要注意的是不同的回调方法(thenAccept、thenApply、exceptionally等)接收的函数类型也不同,即fn的类型有很多种,在上面的例子中fn指向fn2。
- CompletableFuture支持很多回调方法,例如thenAccept、thenApply、exceptionally等,
CompletableFuture整体流程:这里仍然以thenApply为例来说明一元依赖的流程:
- 将观察者Completion注册到CF1,此时CF1将Completion压栈。
- 当CF1的操作运行完成时,会将结果赋值给CF1中的result属性。
- 依次弹栈,通知观察者尝试运行。
初步流程设计如上图所示,这里有几个关于注册与通知的并发问题,大家可以思考下:
Q1:在观察者注册之前,如果CF已经执行完成,并且已经发出通知,那么这时观察者由于错过了通知是不是将永远不会被触发呢 ?
A1:不会。在注册时检查依赖的CF是否已经完成
。如果未完成(即result == null)则将观察者入栈,如果已完成(result != null)则直接触发观察者操作。
Q2:在”入栈“前会有”result == null“的判断,这两个操作为非原子操作,CompletableFufure的实现也没有对两个操作进行加锁,完成时间在这两个操作之间,观察者仍然得不到通知,是不是仍然无法触发?
A2:不会。入栈之后再次检查CF是否完成
,如果完成则触发。
Q3:当依赖多个CF时,观察者会被压入所有依赖的CF的栈中,每个CF完成的时候都会进行,那么会不会导致一个操作被多次执行呢 ?如下图所示,即当CF1、CF2同时完成时,如何避免CF3被多次触发。
A3:CompletableFuture的实现是这样解决该问题的:观察者在执行之前会先通过CAS操作设置一个状态位,将status由0改为1
。如果观察者已经执行过了,那么CAS操作将会失败,取消执行。
通过对以上3个问题的分析可以看出,CompletableFuture在处理并行问题时,全程无加锁操作,极大地提高了程序的执行效率
。我们将并行问题考虑纳入之后,可以得到完善的整体流程图如下所示:
CompletableFuture支持的回调方法十分丰富,但是正如上一章节的整体流程图所述,他们的整体流程是一致的。所有回调复用同一套流程架构,不同的回调监听通过策略模式
实现差异化。
我们以thenCombine为例来说明二元依赖:
thenCombine操作表示依赖两个CompletableFuture。其观察者实现类为BiApply,如上图所示,BiApply通过src和snd两个属性关联被依赖的两个CF,fn属性的类型为BiFunction。与单个依赖不同的是,在依赖的CF未完成的情况下,thenCombine会尝试将BiApply压入这两个被依赖的CF的栈中,每个被依赖的CF完成时都会尝试触发观察者BiApply,BiApply会检查两个依赖是否都完成
,如果完成则开始执行。这里为了解决重复触发的问题,同样用的是上一章节提到的CAS操作,执行时会先通过CAS设置状态位,避免重复触发。
CompletableFuture最佳实践:
1.线程阻塞问题:要合理治理线程资源,最基本的前提条件就是要在写代码时,清楚地知道每一行代码都将执行在哪个线程上。下面我们看一下CompletableFuture的执行线程情况。CompletableFuture实现了CompletionStage接口,通过丰富的回调方法,支持各种组合操作,每种组合场景都有同步和异步两种方法。
同步方法(即不带Async后缀的方法)有两种情况。
如果注册时被依赖的操作已经执行完成,则直接由当前线程执行
。如果注册时被依赖的操作还未执行完,则由回调线程执行
。
异步方法(即带Async后缀的方法):可以选择是否传递线程池参数Executor运行在指定线程池中;当不传递Executor时,会使用ForkJoinPool中的共用线程池CommonPool(CommonPool的大小是CPU核数-1,如果是IO密集的应用,线程数可能成为瓶颈)。
2.异步回调要传线程池:前面提到,异步回调方法可以选择是否传递线程池参数Executor,这里我们建议强制传线程池,且根据实际情况做线程池隔离。当不传递线程池时,会使用ForkJoinPool中的公共线程池CommonPool,这里所有调用将共用该线程池,核心线程数=处理器数量-1(单核核心线程数为1),所有异步回调都会共用该CommonPool,核心与非核心业务都竞争同一个池中的线程,很容易成为系统瓶颈
。
手动传递线程池参数可以更方便的调节参数,并且可以给不同的业务分配不同的线程池,以求资源隔离,减少不同业务之间的相互干扰。
3.线程池循环引用会导致死锁:代码块所示,doGet方法第三行通过supplyAsync向threadPool1请求线程,并且内部子任务又向threadPool1请求线程。threadPool1大小为10,当同一时刻有10个请求到达,则threadPool1被打满,子任务请求线程时进入阻塞队列排队,但是父任务的完成又依赖于子任务,这时由于子任务得不到线程,父任务无法完成。主线程执行cf1.join()进入阻塞状态,并且永远无法恢复。为了修复该问题,需要将父任务与子任务做线程池隔离,两个任务请求不同的线程池,避免循环依赖导致的阻塞
。
4.异步RPC调用注意不要阻塞IO线程池:服务异步化后很多步骤都会依赖于异步RPC调用的结果,这时需要特别注意一点,如果是使用基于NIO(比如Netty)的异步RPC,则返回结果是由IO线程负责设置的,即回调方法由IO线程触发,CompletableFuture同步回调(如thenApply、thenAccept等无Async后缀的方法)如果依赖的异步RPC调用的返回结果,那么这些同步回调将运行在IO线程上,而整个服务只有一个IO线程池,这时需要保证同步回调中不能有阻塞等耗时过长的逻辑,否则在这些逻辑执行完成前,IO线程将一直被占用,影响整个服务的响应。
5.异常处理:由于异步执行的任务在其他线程上执行,而异常信息存储在线程栈中,因此当前线程除非阻塞等待返回结果,否则无法通过try\catch捕获异常。CompletableFuture提供了异常捕获回调exceptionally,相当于同步调用中的try\catch。使用方法如下所示:
2.3Java动态线程池实现原理
问题描述 | 具体内容 |
---|---|
Java线程池技术在业务实践开发中应用非常广泛,线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。 | Java线程池实现原理及其在美团业务中的实践 - 美团技术团队 (meituan.com) |
总体设计:Java中的线程池核心实现类是ThreadPoolExecutor
,本章基于JDK 1.8的源码来分析Java线程池的核心设计与实现。ThreadPoolExecutor实现的顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦
。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分。ExecutorService接口增加了一些能力:(1)扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法;(2)提供了管控线程池的方法,比如停止线程池的运行。AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可
。最下层的实现类ThreadPoolExecutor实现最复杂的运行部分,ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。
线程池在内部实际上构建了一个生产者消费者模型
,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(1)直接申请线程执行该任务;(2)缓冲到队列中等待线程执行;(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收
。
生命周期管理:线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部来维护。线程池内部使用一个变量维护两个值:运行状态(runState)
和线程数量 (workerCount)
。在具体实现中,线程池将运行状态(runState)、线程数量 (workerCount)两个关键参数的维护放在了一起,如下代码所示:
1 | private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); |
ctl
这个AtomicInteger类型,是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段,它同时包含两部分的信息:线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),高3位保存runState,低29位保存workerCount
,两个变量之间互不干扰。用一个变量去存储两个值,可避免在做相关决策时,出现不一致的情况,不必为了维护两者的一致,而占用锁资源
。通过阅读线程池源代码也可以发现,经常出现要同时判断线程池运行状态和线程数量的情况。线程池也提供了若干方法去供用户获得线程池当前的运行状态、线程个数。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多。
1 | private static int runStateOf(int c) { return c & ~CAPACITY; } //计算当前运行状态 |
ThreadPoolExecutor的运行状态有5种,分别为:
其生命周期转换如下入所示:
任务执行机制:
(1)任务调度:任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。首先,所有任务的调度都是由execute方法完成的,这部分完成的工作是:检查现在线程池的运行状态
、运行线程数
、运行策略
,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:
- 首先检测线程池运行状态,如果不是
RUNNING
,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。 - 如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
- 如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
- 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
- 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。
(2)任务缓冲:任务缓冲模块是线程池能够管理任务的核心部分。线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列
来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用
。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。
(3)任务申请:由上文的任务分配部分可知,任务的执行有两种可能:一种是任务直接由新创建的线程执行。另一种是线程从任务队列中获取任务然后执行,执行完任务的空闲线程会再次去从队列中申请任务再去执行。第一种情况仅出现在线程初始创建的时候,第二种是线程获取任务绝大多数的情况
。线程需要从任务缓存模块中不断地取任务执行,帮助线程从阻塞队列中获取任务,实现线程管理模块和任务管理模块之间的通信。这部分策略由getTask方法实现,其执行流程如下图所示:
getTask这部分进行了多次判断,为的是控制线程的数量,使其符合线程池的状态
。如果线程池现在不应该持有那么多线程,则会返回null值。工作线程Worker会不断接收新任务去执行,而当工作线程Worker接收不到任务的时候,就会开始被回收。
(4)任务拒绝:任务拒绝模块是线程池的保护部分,线程池有一个最大的容量,当线程池的任务缓存队列已满,并且线程池中的线程数目达到maximumPoolSize时,就需要拒绝掉该任务,采取任务拒绝策略,保护线程池。
1 | public interface RejectedExecutionHandler { |
线程管理机制:
(1)Worker线程:线程池为了掌握线程的状态并维护线程的生命周期,设计了线程池内的工作线程Worker。我们来看一下它的部分代码:
1 | private final class Worker extends AbstractQueuedSynchronizer implements Runnable{ |
Worker这个工作线程,实现了Runnable接口,并持有一个线程thread,一个初始化的任务firstTask。thread是在调用构造方法时通过ThreadFactory来创建的线程,可以用来执行任务;firstTask用它来保存传入的第一个任务,这个任务可以有也可以为null。如果这个值是非空的,那么线程就会在启动初期立即执行这个任务,也就对应核心线程创建时的情况;如果这个值是null,那么就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是非核心线程的创建。
线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张Hash表去持有线程的引用
,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否在运行。
Worker是通过继承AQS,使用AQS来实现独占锁这个功能。没有使用可重入锁ReentrantLock,而是使用AQS,为的就是实现不可重入的特性去反应线程现在的执行状态。
1.lock方法一旦获取了独占锁,表示当前线程正在执行任务中。2.如果正在执行任务,则不应该中断线程。3.如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断。4.线程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;如果线程是空闲状态则可以安全回收。
(2)Worker线程增加:增加线程是通过线程池中的addWorker方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在上个步骤完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。addWorker方法有两个参数:firstTask
、core
。firstTask参数用于指定新增的线程执行的第一个任务,该参数可以为空;core参数为true表示在新增线程时会判断当前活动线程数是否少于corePoolSize,false表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize,其执行流程如下图所示:
(3)Worker线程回收:线程池中线程的销毁依赖JVM自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被JVM回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可
。Worker被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当Worker无法获取到任务,也就是获取的任务为空时,循环会结束,Worker会主动消除自身在线程池内的引用。
1 | try { |
线程回收的工作是在processWorkerExit方法完成的。
事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。
(4)Worker线程执行任务:在Worker类中的run方法调用了runWorker方法来执行任务,runWorker方法的执行过程如下:
1.while循环不断地通过getTask()方法获取任务。2.getTask()方法从阻塞队列中取任务。3.如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态。4.执行任务。5.如果getTask结果为null则跳出循环,执行processWorkerExit()方法,销毁线程。
业务实践1:快速响应用户请求。
描述:用户发起的实时请求,服务追求响应时间。比如说用户要查看一个商品的信息,那么我们需要将商品维度的一系列信息如商品的价格、优惠、库存、图片等等聚合起来,展示给用户。
分析:从用户体验角度看,这个结果响应的越快越好,如果一个页面半天都刷不出,用户可能就放弃查看这个商品了。而面向用户的功能聚合通常非常复杂,伴随着调用与调用之间的级联、多级级联等情况,业务开发同学往往会选择使用线程池这种简单的方式,
将调用封装成任务并行的执行,缩短总体响应时间
。另外,使用线程池也是有考量的,这种场景最重要的就是获取最大的响应速度去满足用户,所以应该不设置队列去缓冲并发任务(对于C端实时场景,不建议使用有界队列,推荐使用SynchronousQueue,达到failfast效果),调高corePoolSize和maxPoolSize去尽可能创造多的线程快速执行任务。
业务实践2:快速处理批量任务
- 描述:离线的大量计算任务,需要快速执行。比如说,统计某个报表,需要计算出全国各个门店中有哪些商品有某种属性,用于后续营销策略的分析,那么我们需要查询全国所有门店中的所有商品,并且记录具有某属性的商品,然后快速生成报表。
- 分析:这种场景需要执行大量的任务,我们也会希望任务执行的越快越好。这种情况下,也应该使用多线程策略,并行计算。但与响应速度优先的场景区别在于,
这类场景任务量巨大,并不需要瞬时的完成,而是关注如何使用有限的资源,尽可能在单位时间内处理更多的任务,也就是吞吐量优先的问题。所以应该设置队列去缓冲并发任务,调整合适的corePoolSize去设置处理任务的线程数
。在这里,设置的线程数过多可能还会引发线程上下文切换频繁的问题,也会降低处理任务的速度,降低吞吐量。
实际问题:线程池使用面临的核心的问题在于:线程池的参数并不好配置。一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。
追求参数设置合理性:调研了以上业界方案后,我们并没有得出通用的线程池计算方式。并发任务的执行情况和任务类型相关,IO密集型和CPU密集型的任务运行起来的情况差异非常大,但这种占比是较难合理预估的,这导致很难有一个简单有效的通用公式帮我们直接计算出结果。
线程池参数动态化:尽管经过谨慎的评估,仍然不能够保证一次计算出来合适的参数,那么我们是否可以将修改线程池参数的成本降下来,这样至少可以发生故障的时候可以快速调整从而缩短故障恢复的时间呢?基于这个思考,我们是否可以将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,线程池参数动态化前后的参数修改流程对比如下:
动态化线程池整体设计:动态化线程池的核心设计包括以下三个方面:
- 简化线程池配置:线程池构造参数有8个,但是最核心的是3个:
corePoolSize
、maximumPoolSize
,workQueue
,它们最大程度地决定了线程池的任务分配和线程分配策略。考虑到在实际应用中我们获取并发性的场景主要是两种:(1)并行执行子任务,提高响应速度。这种情况下,应该使用同步队列,没有什么任务应该被缓存下来,而是应该立即执行。(2)并行执行大批次任务,提升吞吐量。这种情况下,应该使用有界队列,使用队列去缓冲大批量的任务,队列容量必须声明,防止任务无限制堆积。所以线程池只需要提供这三个关键参数的配置,并且提供两种队列的选择,就可以满足绝大多数的业务需求,Less is More。 - 参数可动态修改:为了解决参数不好配,修改参数成本高等问题。
在Java线程池留有高扩展性的基础上,封装线程池,允许线程池监听同步外部的消息,根据消息进行修改配置
。将线程池的配置放置在平台侧,允许开发同学简单的查看、修改线程池配置。 - 增加线程池监控:对某事物缺乏状态的观测,就对其改进无从下手。在线程池执行任务的生命周期添加监控能力,帮助开发同学了解线程池状态。
动态线程池功能架构:
动态调参:支持线程池参数动态调整、界面化操作;包括修改线程池核心大小、最大核心大小、队列长度等;参数修改后及时生效。
任务监控:支持应用粒度、线程池粒度、任务粒度的Transaction监控;可以看到线程池的任务执行情况、最大任务执行时间、平均任务执行时间、95/99线等。
负载告警:线程池队列任务积压到一定值的时候会通过大象(美团内部通讯工具)告知应用开发负责人;当线程池负载数达到一定阈值的时候会通过大象告知应用开发负责人。
操作监控:创建/修改和删除线程池都会通知到应用的开发负责人。
操作日志:可以查看线程池参数的修改记录,谁在什么时候修改了线程池参数、修改前的参数值是什么。
权限校验:只有应用开发负责人才能够修改应用的线程池参数。
动态线程池参数动态化:JDK原生线程池ThreadPoolExecutor提供了如下几个public的setter方法,如下图所示:
JDK允许线程池使用方通过ThreadPoolExecutor的实例来动态设置线程池的核心策略,以setCorePoolSize为方法例,在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略
。对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idel的时候也会被回收;对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务,setCorePoolSize具体流程如下:
线程池内部会处理好当前状态做到平滑修改,其他几个方法限于篇幅,这里不一一介绍。重点是基于这几个public方法,我们只需要维护ThreadPoolExecutor的实例,并且在需要修改的时候拿到实例修改其参数即可。
基于以上的思路,我们实现了线程池参数的动态化、线程池参数在管理平台可配置可修改,其效果图如下图所示:
用户可以在管理平台上通过线程池的名字找到指定的线程池,然后对其参数进行修改,保存后会实时生效。目前支持的动态参数包括核心数、最大值、队列长度等。除此之外,在界面中,我们还能看到用户可以配置是否开启告警、队列等待任务告警阈值、活跃度告警等等。
线程池监控告警:除了参数动态化之外,为了更好地使用线程池,我们需要对线程池的运行状况有感知,比如当前线程池的负载是怎么样的?分配的资源够不够用?任务的执行情况是怎么样的?是长任务还是短任务?基于对这些问题的思考,动态化线程池提供了多个维度的监控和告警能力,包括:线程池活跃度
、任务的执行Transaction(频率、耗时)
、Reject异常
、线程池内部统计信息
等等,既能帮助用户从多个维度分析线程池的使用情况,又能在出现问题第一时间通知到用户,从而避免故障或加速故障恢复。
(1)负载监控和告警:线程池负载关注的核心问题是:基于当前线程池参数分配的资源够不够
。对于这个问题,我们可以从事前和事中两个角度来看。事前,线程池定义了“活跃度”这个概念,来让用户在发生Reject异常之前能够感知线程池负载问题,线程池活跃度计算公式为:线程池活跃度 = activeCount/maximumPoolSize
。这个公式代表当活跃线程数趋向于maximumPoolSize的时候,代表线程负载趋高。事中,也可以从两方面来看线程池的过载判定条件,一个是发生了Reject异常,一个是队列中有等待任务(支持定制阈值)。以上两种情况发生了都会触发告警,告警信息会通过大象推送给服务所关联的负责人。
(2)任务级精细化监控:在传统的线程池应用场景中,线程池中的任务执行情况对于用户来说是透明的。比如在一个具体的业务场景中,业务开发申请了一个线程池同时用于执行两种任务,一个是发消息任务、一个是发短信任务,这两类任务实际执行的频率和时长对于用户来说没有一个直观的感受,很可能这两类任务不适合共享一个线程池,但是由于用户无法感知,因此也无从优化。动态化线程池内部实现了任务级别的埋点,且允许为不同的业务任务指定具有业务含义的名称,线程池内部基于这个名称做Transaction打点
,基于这个功能,用户可以看到线程池内部任务级别的执行情况,且区分业务,任务监控示意图如下图所示:
(3)运行时状态实时查看:用户基于JDK原生线程池ThreadPoolExecutor提供的几个public的getter方法,可以读取到当前线程池的运行状态以及参数,如下图所示:
动态化线程池基于这几个接口封装了运行时状态实时查看的功能,用户基于这个功能可以了解线程池的实时状态,比如当前有多少个工作线程,执行了多少个任务,队列中等待的任务数等等。效果如下图所示:
3.JVM分析调优
3.1使用JDK自带工具查看JVM情况
问题描述 | 具体内容 |
---|---|
JDK自带了很多命令行甚至是图形界面工具,帮助我们查看JVM的一些信息。 | 分析定位Java问题,一定要用好这些工具(一)-Java业务开发常见错误100例-极客时间 (geekbang.org) |
为了测试这些工具,我们先来写一段代码:启动10个死循环的线程,每个线程分配一个10MB左右的字符串,然后休眠10秒。可以想象到,这个程序会对GC造成压力。
1 | //启动10个线程 |
修改pom.xml,配置spring-boot-maven-plugin插件打包的Java程序的main方法类:
1 | <plugin> |
然后使用java -jar
启动进程,设置JVM参数,让堆最小最大都是1GB:
1 | java -jar common-mistakes-0.0.1-SNAPSHOT.jar -Xms1g -Xmx1g |
完成这些准备工作后,我们就可以使用 JDK 提供的工具,来观察分析这个测试程序了。
首先,使用jps
得到Java进程列表,这会比使用ps来的方便:
1 | ➜ ~ jps |
然后,可以使用jinfo
打印JVM
的各种参数:
1 | ➜ ~ jinfo 23864 |
可以发现,我们设置JVM参数的方式不对,-Xms1g和-Xmx1g这两个参数被当成了Java程序的启动参数,整个JVM目前最大内存是4GB左右,而不是1GB。因此,当我们怀疑JVM的配置很不正常的时候,要第一时间使用工具来确认参数。除了使用工具确认JVM参数外,你也可以打印VM参数和程序参数:
1 | System.out.println("VM options"); |
把JVM参数放到-jar之前,重新启动程序,可以看到如下输出,从输出也可以确认这次JVM参数的配置正确了:
1 | ➜ target git:(master) ✗ java -Xms1g -Xmx1g -jar common-mistakes-0.0.1-SNAPSHOT.jar test |
然后,启动另一个重量级工具jvisualvm
观察一下程序,可以在概述面板再次确认JVM参数设置成功了:
继续观察监视面板可以看到,JVM的GC活动基本是10秒发生一次,堆内存在250MB到900MB之间波动,活动线程数是22。我们可以在监视面板看到JVM的基本情况,也可以直接在这里进行手动GC
和堆Dump
操作:
同样,如果没有条件使用图形界面(毕竟在Linux服务器上,我们主要使用命令行工具),又希望看到GC趋势的话,我们可以使用jstat
工具。jstat工具允许以固定的监控频次输出JVM的各种监控指标
,比如使用-gcutil
输出GC和内存占用汇总信息,每隔5秒输出一次,输出100次,可以看到Young GC比较频繁,而Full GC基本10秒一次:
1 | ➜ ~ jstat -gcutil 23940 5000 100 |
其中,S0表示Survivor0区占用百分比,S1表示Survivor1区占用百分比,E表示Eden区占用百分比,O表示老年代占用百分比,M表示元数据区占用百分比,YGC表示年轻代回收次数,YGCT表示年轻代回收耗时,FGC表示老年代回收次数,FGCT表示老年代回收耗时。
jstat命令的参数众多,包含-class
、-compiler
、-gc
等。继续来到线程面板可以看到,大量以Thread开头的线程基本都是有节奏的10秒运行一下,其他时间都在休眠,和我们的代码逻辑匹配:
点击面板的线程Dump按钮,可以查看线程瞬时的线程栈:
通过命令行工具jstack
,也可以实现抓取线程栈的操作:
1 | ➜ ~ jstack 23940 |
3.2使用MAT分析OOM问题
问题描述 | 具体内容 |
---|---|
对于排查OOM问题、分析程序堆内存使用情况,最好的方式就是分析堆转储。 | 加餐5:分析定位Java问题,一定要用好这些工具(二)-Java 业务开发常见错误 100 例-极客时间 (geekbang.org) |
堆转储,包含了堆现场全貌和线程栈信息(Java 6 Update 14 开始包含)。使用jstat等工具虽然可以观察堆内存使用情况的变化,但是对程序内到底有多少对象、哪些是大对象还一无所知,也就是说只能看到问题但无法定位问题。而堆转储,就好似得到了病人在某个瞬间的全景核磁影像,可以拿着慢慢分析。
Java的OutOfMemoryError是比较严重的问题,需要分析出根因,所以对生产应用一般都会这样设置JVM参数,方便发生OOM时进行堆转储:
1 | -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=. |
我们提到的jvisualvm工具,同样可以进行一键堆转储后,直接打开这个dump查看。但是,jvisualvm的堆转储分析功能并不是很强大,只能查看类使用内存的直方图,无法有效跟踪内存使用的引用关系
,所以我更推荐使用Eclipse的Memory Analyzer(也叫做 MAT)做堆转储的分析。
使用MAT分析OOM问题,一般可以按照以下思路进行:
- 通过
支配树
功能或直方图
功能查看消耗内存最大的类型,来分析内存泄露的大概原因; - 查看那些消耗内存最大的类型、详细的对象明细列表,以及它们的
引用链
,来定位内存泄露的具体点; - 配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我们理清程序逻辑和参数;
- 辅助使用查看
线程栈
来看OOM问题是否和过多线程有关,甚至可以在线程栈看到OOM最后一刻出现异常的线程。
比如,我手头有一个OOM后得到的转储文件java_pid29569.hprof,现在要使用MAT的直方图、支配树、线程栈、OQL等功能来分析此次OOM的原因。
首先,用MAT打开后先进入的是概览信息界面,可以看到整个堆是437.6MB:
如图所示,工具栏的第二个按钮可以打开直方图,直方图按照类型进行分组,列出了每个类有多少个实例,以及占用的内存
。可以看到,char[]数组占用内存最多,对象数量也很多,结合第二位的String类型对象数量也很多,大概可以猜出(String使用char[]作为实际数据存储)程序可能是被字符串占满了内存,导致OOM。
我们继续分析下,到底是不是这样呢。在char[]上点击右键,选择List objects->with incoming references
,就可以列出所有的 char[]实例,以及每个char[]的整个引用关系链:
随机展开一个char[],如下图所示:
接下来,我们按照红色框中的引用链来查看,尝试找到这些大char[]的来源:
- 在①处看到,这些char[]几乎都是10000个字符、占用20000字节左右(char是UTF-16,每一个字符占用2字节);
- 在②处看到,char[]被String的value字段引用,说明char[]来自字符串;
- 在③处看到,String被ArrayList的elementData字段引用,说明这些字符串加入了一个ArrayList中;
- 在④处看到,ArrayList又被FooService的data字段引用,这个ArrayList整个RetainedHeap列的值是431MB。
Retained Heap(深堆)代表对象本身和对象关联的对象占用的内存,Shallow Heap(浅堆)代表对象本身占用的内存
。比如,我们的FooService中的data这个ArrayList对象本身只有16字节,但是其所有关联的对象占用了431MB内存。这些就可以说明,肯定有哪里在不断向这个List中添加String数据,导致了OOM。
左侧的蓝色框可以查看每一个实例的内部属性,图中显示FooService有一个data属性,类型是ArrayList。
如果我们希望看到字符串完整内容的话,可以右键选择Copy->Value
,把值复制到剪贴板或保存到文件中:
这里,我们复制出的是10000个字符a(下图红色部分可以看到)。对于真实案例,查看大字符串、大数据的实际内容对于识别数据来源,有很大意义:
看到这些,我们已经基本可以还原出真实的代码是怎样的了。其实,我们之前使用直方图定位FooService,已经走了些弯路。你可以点击工具栏中第三个按钮(下图左上角的红框所示)进入支配树界面
。这个界面会按照对象保留的Retained Heap倒序直接列出占用内存最大的对象。可以看到,第一位就是FooService,整个路径是FooSerice->ArrayList->Object[]->String->char[]
(蓝色框部分),一共有21523个字符串(绿色方框部分):
这样,我们就从内存角度定位到FooService是根源了。那么,OOM的时候,FooService是在执行什么逻辑呢?
为解决这个问题,我们可以点击工具栏的第五个按钮(下图红色框所示)。打开线程视图,首先看到的就是一个名为main的线程(Name 列),展开后果然发现了FooService:
先执行的方法先入栈,所以线程栈最上面是线程当前执行的方法,逐一往下看能看到整个调用路径。因为我们希望了解FooService.oom()方法,看看是谁在调用它,它的内部又调用了谁,所以选择以FooService.oom()方法(蓝色框)为起点来分析这个调用栈。
往下看整个绿色框部分,oom()方法被OOMApplication的run方法调用,而这个run方法又被SpringAppliction.callRunner方法调用。看到参数中的CommandLineRunner你应该能想到,OOMApplication其实是实现了CommandLineRunner接口,所以是SpringBoot应用程序启动后执行的。
以FooService为起点往上看,从紫色框中的Collectors和IntPipeline,你大概也可以猜出,这些字符串是由Stream操作产生的。再往上看,可以发现在StringBuilder的append操作的时候,出现了OutOfMemoryError异常(黑色框部分),说明这这个线程抛出了OOM异常。
我们看到,整个程序是Spring Boot应用程序,那么FooService是不是Spring的Bean呢,又是不是单例呢?如果能分析出这点的话,就更能确认是因为反复调用同一个FooService的oom方法,然后导致其内部的ArrayList不断增加数据的。
点击工具栏的第四个按钮(如下图红框所示),来到OQL界面
。在这个界面,我们可以使用类似SQL的语法,在dump中搜索数据(你可以直接在MAT帮助菜单搜索OQL Syntax,来查看OQL的详细语法)。
比如,输入如下语句搜索FooService的实例:
1 | SELECT * FROM org.geekbang.time.commonmistakes.troubleshootingtools.oom.FooService |
可以看到只有一个实例,然后我们通过List objects功能搜索引用FooService的对象:
可以看到,一共两处引用:
- 第一处是,OOMApplication使用了FooService,这个我们已经知道了。
- 第二处是一个ConcurrentHashMap。可以看到,这个HashMap是DefaultListableBeanFactory的
singletonObjects
字段,可以证实FooService是Spring容器管理的单例Bean。
到现在为止,我们虽然没看程序代码,但是已经大概知道程序出现OOM的原因和大概的调用栈了。我们再贴出程序来对比一下,果然和我们看到得一模一样:
1 |
|
到这里,我们使用MAT工具从对象清单
、大对象
、线程栈
等视角,分析了一个OOM程序的堆转储。可以发现,有了堆转储,几乎相当于拿到了应用程序的源码+当时那一刻的快照,OOM的问题无从遁形。
3.3使用Arthas分析高CPU问题
问题描述 | 具体内容 |
---|---|
Arthas是阿里开源的Java诊断工具,相比JDK内置的诊断工具,要更人性化,并且功能强大,可以实现许多问题的一键定位,而且可以一键反编译类查看源码,甚至是直接进行生产代码热修复,实现在一个工具内快速定位和修复问题的一站式服务。 | 加餐5:分析定位Java问题,一定要用好这些工具(二)-Java 业务开发常见错误 100 例-极客时间 (geekbang.org) |
首先,下载并启动Arthas:
1 | curl -O https://alibaba.github.io/arthas/arthas-boot.jar |
启动后,直接找到我们要排查的JVM进程,然后可以看到Arthas附加进程成功:
1 | [INFO] arthas-boot version: 3.1.7 |
输出help命令,可以看到所有支持的命令列表。今天,我们会用到dashboard、thread、jad、watch、ognl命令,来定位这个HighCPUApplication进程。
dashboard命令用于整体展示进程所有线程、内存、GC等情况
,其输出如下:
可以看到,CPU高并不是GC引起的,占用CPU较多的线程有8个,其中7个是ForkJoinPool.commonPool。ForkJoinPool.commonPool是并行流默认使用的线程池。所以,此次CPU高的问题,应该出现在某段并行流的代码上。
接下来,要查看最繁忙的线程在执行的线程栈,可以使用thread -n
命令。这里,我们查看下最忙的8个线程:
1 | thread -n 8 |
可以看到,由于这些线程都在处理MD5的操作,所以占用了大量CPU资源。我们希望分析出代码中哪些逻辑可能会执行这个操作,所以需要从方法栈上找出我们自己写的类,并重点关注。
由于主线程也参与了ForkJoinPool的任务处理,因此我们可以通过主线程的栈看到需要重点关注 org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication类的doTask方法。
接下来,使用jad
命令直接对HighCPUApplication类反编译:
1 | jad org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication |
可以看到,调用路径是main->task()->doTask()
,当doTask方法接收到的int参数等于某个常量的时候,会进行1万次的MD5操作,这就是耗费CPU的来源。那么,这个魔法值到底是多少呢?
你可能想到了,通过jad命令继续查看User类即可。这里因为是Demo,所以我没有给出很复杂的逻辑。在业务逻辑很复杂的代码中,判断逻辑不可能这么直白,我们可能还需要分析出doTask的“慢”会慢在什么入参上。
这时,我们可以使用watch
命令来观察方法入参。如下命令,表示需要监控耗时超过100毫秒的doTask方法的入参,并且输出入参,展开2层入参参数:
1 | watch org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication doTask '{params}' '#cost>100' -x 2 |
可以看到,所有耗时较久的doTask方法的入参都是0,意味着User.ADMN_ID常量应该是0。
最后,我们使用ognl
命令来运行一个表达式,直接查询User类的ADMIN_ID静态字段来验证是不是这样,得到的结果果然是0:
1 | ognl '@org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.User@ADMIN_ID' |
需要额外说明的是,由于monitor、trace、watch等命令是通过字节码增强技术
来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此诊断结束要执行shutdown
来还原类或方法字节码,然后退出Arthas。
在这个案例中,我们通过Arthas工具排查了高CPU的问题:
- 首先,通过
dashboard + thread
命令,基本可以在几秒钟内一键定位问题,找出消耗CPU最多的线程和方法栈; - 然后,直接
jad
反编译相关代码,来确认根因; - 此外,如果调用入参不明确的话,可以使用
watch
观察方法入参,并根据方法执行时间来过滤慢请求的入参。
可见,使用Arthas来定位生产问题根本用不着原始代码,也用不着通过增加日志来帮助我们分析入参,一个工具即可完成定位问题、分析问题的全套流程。