苍穹之边,浩瀚之挚,眰恦之美; 悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》
这些年,随着CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年。
我们都知道的是,程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
由此可见,虽然现在我们几乎所有的程序都默默地享受着这些成果,但是实际应用程序设计和开发过程中,还是有很多诡异问题困扰着我们。
每当提起Java性能优化,你是否有想过,真正需要我们优化的是什么?或者说,指导我们优化的方向和目标是否明确?甚至说,我们所做的一切,是否已经达到我们的期望了呢?接下来,我们来详细探讨一下。
性能优化根据优化的方向和目标来说,大致可以分为业务优化和技术优化。业务优化产生的影响是非常巨大的,一般最常见的就是业务需求变更和业务场景适配等,当然这是产品和项目管理的工作范畴。而对于我们开发人员来说,我们需要关注的和直接与我们相关的,主要是通过一系列的技术手段,来完成我们对既定目标的技术优化。其中,从技术手段方向来看,技术优化主要可以从复用优化,结果集合优化,高效实现优化,算法优化,计算优化,资源冲突优化和JVM优化等七个方面着手。
一般来说,技术优化基本都集中在计算机资源和存储资源的规划上,最直接的就是对于服务器和业务应用程序相关的资源做具体的分析,在照顾性能的前提下,同时也兼顾业务需求的要求,从而达到资源利用最优的状态。一味地强调利用空间换时间的方式,只看计算速度,不考虑复杂性和空间的问题,确实有点不可取。特别是在云原生时代下和无服务时代,虽然模糊和减少了开发对这些问题的距离,但是我们更加需要了解和关注这些问题的实质。
特别指出的是,JVM优化。由于使用Java编写的应用程序,本身Java是运行在JVM虚拟机上的,这就意味着它会受到JVM的制约。对于JVM虚拟机的优化。一定程度上会提升Java应用程序的性能。如果参数配置不当,导致内存溢出(OOM异常)等问题,甚至引发比这更严重的后果。
由此可见,正确认识和掌握JVM结构相关知识,对于我们何尝不是一个进阶的技术方向。当然,JVM虚拟机这一部分的内容,相对编写Java程序来说,更加比较枯燥无味,概念比较多且抽象,需要我们要有更多的耐心和细心。我们都知道,一颗不浮躁的心,做任何事都会收获不一样的精彩。
在开始这一部分内容之前,我们先来看一下,在Java中,Java程序是如何运行的,最后又是如何交给JVM托管的?
作为一名 Java 程序员,你应该知道,Java 代码有很多种不同的运行方式。比如说可以在开发工具中运行,可以双击执行 jar 文件运行,也可以在命令行中运行,甚至可以在网页中运行。当然,这些执行方式都离不开 JRE,也就是 Java 运行时环境。
实际上,JRE 仅包含运行 Java 程序的必需组件,包括 Java 虚拟机以及 Java 核心类库等。我们 Java 程序员经常接触到的 JDK(Java 开发工具包)同样包含了 JRE,并且还附带了一系列开发、诊断工具。
然而,运行 C++ 代码则无需额外的运行时。我们往往把这些代码直接编译成 CPU 所能理解的代码格式,也就是机器码。
Java 作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此,直接在硬件上运行这种复杂的程序并不现实。所以呢,在运行 Java 程序之前,我们需要对其进行一番转换。
这个转换具体是怎么操作的呢?当前的主流思路是这样子的,设计一个面向 Java 语言特性的虚拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码。这里顺便说一句,之所以这么取名,是因为 Java 字节码指令的操作码(opcode)被固定为一个字节。
并且,我们同样可以将其反汇编为人类可读的代码格式(如下图的最右列所示)。不同的是,Java 版本的编译结果相对精简一些。这是因为 Java 虚拟机相对于物理机而言,抽象程度更高。
Java 虚拟机可以由硬件实现[1],但更为常见的是在各个现有平台(如 Windows_x64、Linux_aarch64)上提供软件实现。这么做的意义在于,一旦一个程序被转换成 Java 字节码,那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的“一次编写,到处运行”。
虚拟机的另外一个好处是它带来了一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收,这部分内容甚至催生了一波垃圾回收调优的业务。
除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。
从 class 文件到内存中的类,按先后顺序需要经过加载、链接以及初始化三大步骤。其中,链接过程中同样需要验证;而内存中的类没有经过初始化,同样不能使用。那么,是否所有的 Java 类都需要经过这几步呢?
我们知道 Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。在上一篇中,我已经详细介绍过了 Java 的基本类型,它们是由 Java 虚拟机预先定义好的。
至于另一大类引用类型,Java 将其细分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除(我会在专栏的第二部分详细介绍),因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。
说到字节流,最常见的形式要属由 Java 编译器生成的 class 文件。除此之外,我们也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序 Java applet)字节流。这些不同形式的字节流,都会被加载到 Java 虚拟机中,成为类或接口。为了叙述方便,下面我就用“类”来统称它们。
无论是直接生成的数组类,还是加载的类,Java 虚拟机都需要对其进行链接和初始化。
其实,Java 虚拟机将字节流转化为 Java 类的过程,就是我们常说的Java类的创建过程。这个过程可分为加载、链接以及初始化三大步骤:
从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。
如果你熟悉 X86 的话,你会发现这和段式内存管理中的代码段类似。而且,Java 虚拟机同样也在内存中划分出堆和栈来存储运行时数据。
不同的是,Java 虚拟机会将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用 C++ 写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器。
在运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。
当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。
从硬件视角来看,Java 字节码无法直接执行。因此,Java 虚拟机需要将字节码翻译成机器码。
启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。
除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。
在 Java 虚拟机中,这个潜规则有个特别的名字,叫双亲委派模型。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。
在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。
扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。
应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。
Java 9 引入了模块系统,并且略微更改了上述的类加载器1。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。
除了加载功能之外,类加载器还提供了命名空间的作用。在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
在 HotSpot 里面,上述翻译过程有两种形式:
前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot 默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
HotSpot 采用了多种技术来提升启动性能以及峰值性能,刚刚提到的即时编译便是其中最重要的技术之一。
即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。
理论上讲,即时编译后的 Java 程序的执行效率,是可能超过 C++ 程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。
举个例子,我们知道虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管它有很多个目标方法,但在实际运行过程中它可能只调用其中的一个。这个信息便可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的 C++ 程序更高的性能。
为了满足不同用户场景的需要,HotSpot 内置了多个即时编译器:C1、C2 和 Graal。
从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。
为了不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。HotSpot 会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。
在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。
从组成结构上看,一个Java 虚拟机(HotSpot 为例),主要包括指令集合,指令解析器,程序执行指令 等3个方面,其中:
一般来说,任何一个Java虚拟机都会包含这三个方面的,但是具体的有各有所不同:
除此之外,每个特定的主机操作系统都需要自己的 JVM 和运行时实现。
Java 虚拟机提供了一系列的垃圾回收机制(Garbage Collection),又或者说是垃圾回收器(Garbage Collector),其中常见的垃圾回收器如下:
在常见的垃圾回收中,我们一般采用引用计数法和可达性分析两种方式来确定垃圾是否产生,其中:
一般来说,当成功区分出内存中存活对象和死亡对象之后,GC接着就会执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够可用的内存空间为新的对象分配内存。
目前,在JVM中采用的垃圾收集算法主要有:
JVM调优涉及到两个很重要的概念:吞吐量和响应时间。jvm调优主要是针对他们进行调整优化,达到一个理想的目标,根据业务确定目标是吞吐量优先还是响应时间优先。
调优的前提是熟悉业务场景,先判断出当前业务场景是吞吐量优先还是响应时间优先。调优需要建立在监控之上,由压力测试来判断是否达到业务要求和性能要求。 调优的步骤大致可以分为:
熟悉业务场景,了解当前业务系统的要求,是吞吐量优先还是响应时间优先;
选择合适的垃圾回收器组合,如果是吞吐量优先,则选择ps+po组合;如果是响应时间优先,在1.8以后选择G1,在1.8之前选择ParNew+CMS组合;
规划内存需求,只能进行大致的规划。
CPU选择,在预算之内性能越高越好;
根据实际情况设置升级年龄,最大年龄为15;
根据需要设定相关的JVM日志参数:
-Xloggc:/path/name-gc-%t.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogs=5
-XX:GCLogFileSize=20M
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCCauses
其中需要注意的是:
-XX:+UseGCLogFileRotation:GC文件循环使用
-XX:NumberOfGCLogs=5:使用5个GC文件
-XX:GCLogFileSize=20M:每个GC文件的大小
上面这三个参数放在一起代表的含义是:5个GC文件循环使用,每个GC文件20M,总共使用100M存储日志文件,当5个GC文件都使用完毕以后,覆盖第一个GC日志文件,生成新的GC文件。
当cpu经常飙升到100%的使用率,那么证明有线程长时间占用系统资源不进行释放,需要定位到具体是哪个线程在占用,定位问题的步骤如下(linux系统):
1.使用top命令常看当前服务器中所有进程(jps命令可以查看当前服务器运行java进程),找到当前cpu使用率最高的进程,获取到对应的pid;
2.然后使用top -Hp pid,查看该进程中的各个线程信息的cpu使用,找到占用cpu高的线程pid
3.使用jstack pid打印它的线程信息,需要注意的是,通过jstack命令打印的线程号和通过top -Hp打印的线程号进制不一样,需要进行转换才能进行匹配,jstack中的线程号为16进制,而top -Hp打印的是10进制。
当内存飙高一般都是堆中对象无法回收造成,因为java中的对象大部分存储在堆内存中。其实也就是常见的oom问题(Out Of Memory),一般:
1.jinfo pid,可以查看当前进行虚拟机的相关信息列举出来
2.jstat -gc pid ms,多长毫秒打印一次gc信息,打印信息如下,里面包含gc测试,年轻代/老年带gc信息等
3. jmap -histo pid | head -20,查找当前进程堆中的对象信息,加上管道符后面的信息以后,代表查询对象数量最多的20个
4. jmap -dump:format=b,file=xxx pid,可以生成堆信息的文件,但是这个命令不建议在生产环境使用,因为当内存较大时,执行该命令会占用大量系统资源,甚至造成卡顿。建议在项目启动时添加下面的命令,在发生oom时自动生成堆信息文件:-XX:+HeapDumpOnOutOfMemory。如果需要在线上进行堆信息分析,如果当前服务存在多个节点,可以下线一个节点,生成堆信息,或者使用第三方工具,阿里的arthas。
除此之外,我们还可以使用 jvisualvm是jdk自带的图形化分析工具,可以对运行进程的线程,堆进行详细分析。但是这种分析工具可以对本地代码或者测试环境进行监控分析,不建议在线上环境使用该工具,因为它会占用系统资源。如果必须要在线上执行,建议当前服务存在多个节点,然后下线其中一个节点进行问题分析。也可以使用第三方收费的图形分析界面jprofiler。
⚠️[注意事项] :
在日常JVM调优常用参数主要如下:
-Xmn:年轻代大小
-Xms:堆初始大小
-Xmx:堆最大大小
-Xss:栈大小
-XX:+UseTlab:使用tlab,默认打开,涉及到对象分配问题
-XX:+PrintTlab:打印tlab使用情况
-XX:+TlabSize:设置Tlab大小
-XX:+DisabledExplictGC:java代码中的System.gc()不再生效,防止代码中误写,导致频繁触动GC,默认不起用。
-XX:+PrintGC(+PrintGCDetails/+PrintGCTimeStamps) : 打印GC信息(打印GC详细信息/打印GC执行时间)
-XX:+PrintHeapAtGC打印GC时的堆信息
-XX:+PrintGCApplicationConcurrentTime: 打印应用程序的时间
-XX:+PrintGCApplicationStopedTime: 打印应用程序暂停时间
-XX:+PrintReferenceGC: 打印回收多少种引用类型的引用
-verboss:class : 类加载详细过程
-XX:+PrintVMOptions : 打印JVM运行参数
-XX:+PrintFlagsFinal(+PrintFlagsInitial) -version | grep : 查找想要了解的命令
-X:loggc:/opt/gc/log/path : 输出gc信息到文件
-XX:MaxTenuringThreshold : 设置gc升到年龄,最大值为15
-XX:PreTenureSizeThreshold 多大的对象判定为大对象,直接晋升老年代
-XX:+ParallelGCThreads 用于并发垃圾回收的线程
-XX:+UseAdaptiveSizePolicy 自动选择各区比例
-XX:+UseConcMarkSweepGC :使用CMS垃圾回收器
-XX:parallelCMSThreads : CMS线程数量
-XX:CMSInitiatingOccupancyFraction : 占用多少比例的老年代时开始CMS回收,默认值68%,如果频繁发生serial old,适当调小该比例,降低FGC频率
-XX:+UseCMSCompactAtFullCollection : 进行压缩整理
-XX:CMSFullGCBeforeCompaction :多少次FGC以后进行压缩整理
-XX:+CMSClassUnloadingEnabled :回收永久代
-XX:+CMSInitiatingPermOccupancyFraction :达到什么比例时进行永久代回收
-XX:GCTimeTatio : 设置GC时间占用程序运行时间的百分比,该参数只能是尽量达到该百分比,不是肯定达到
-XX:MaxGCPauseMills : GCt停顿时间,该参数也是尽量达到,而不是肯定达到
-XX:+UseG1 : 使用G1垃圾回收器
-XX:MaxGCPauseMills : GCt停顿时间,该参数也是尽量达到,G1会调整yong区的块数来达到这个值
-XX:+G1HeapRegionSize : 分区大小,范围为1M~32M,必须是2的n次幂,size越大,GC回收间隔越大,但是GC所用时间越长
在Java虚拟机中,JVM 内存区域主要分为线程私有、线程共享、直接内存三个区域,具体详情如下:
由此可见,在Java 虚拟机JVM运行时数据区中,【程序计数器、虚拟机栈、本地方法区】属于线程私有区域,【 JAVA 堆、方法区】属于线程共享区域,都需要JVM GC管理的,而直接内存不受JVM GC管理的。
首先,对于线程私有区域中的【程序计数器、虚拟机栈、本地方法区】,主要详情如下:
其次,对于线程共享区域中的【 JAVA 堆、方法区】,主要详情如下:
其中对于Java虚拟机JVM中的Java 堆主要分为【 新生代 、老年代 、永久代、元数据区】:
你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字。
Java 的内存模型是并发编程领域的一次重要创新,之后 C++、C#、Golang 等高级语言都开始支持内存模型。Java 内存模型里面,最晦涩的部分就是 Happens-Before 规则,接下来我们详细介绍一下。
在了解完Java 内存模型之后,我们再来具体学习一下针对于这些问题提出的Happens-Before 规则。如何理解 Happens-Before 呢?如果望文生义(很多网文也都爱按字面意思翻译成“先行发生”),那就南辕北辙了,Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。所以比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
Happens-Before 规则应该是 Java 内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的,具体如下:
在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。
Java 内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向 JVM 的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是 Happens-Before 规则。
对于一个开发人员来说,了解上述知识只是一个开始,更多的是我们在实际工作中如何运用。个人觉得,了解一些设计原则,并掌握这些设计原则,才能帮助我们写出高质量的代码。
当然,设计原则是代码设计时的一些经验总结。最大的一问题就就是:设计原则看起来比较抽象,其定义也比较模糊,不同的人对于同一个设计原则都会有不同的感悟。如果,我们只是单纯的抽象记忆这些定义,对于我们编程技术和代码设计的能力来说,并不会有什么实质性的帮助。
针对于每一个设计原则,我们需要掌握它能帮助我们解决什么问题和可以适合什么样的应用场景。可以这样说,设计原则是心法,设计模式是招式,而编程是实实在在的运用。常见的设计原则有:
综上所述,前面五种原则就是我们常说的SOLID原则,其他四种原则也是我们最常用的原则,这些设计原则都是我们的编程方法论。
Java 内存模型通过定义了一系列的 Happens-Before 操作,让应用程序开发者能够轻易地表达不同线程的操作之间的内存可见性。
在遵守 Java 内存模型的前提下,即时编译器以及底层体系架构能够调整内存访问操作,以达到性能优化的效果。如果开发者没有正确地利用 Happens-Before 规则,那么将可能导致数据竞争。
Java 内存模型是通过内存屏障来禁止重排序的。对于即时编译器来说,内存屏障将限制它所能做的重排序优化。对于处理器来说,内存屏障会导致缓存的刷新操作。
在设计Java代码的时候,遵循一些必要的设计原则,也能更好地帮助我们写出好的代码,减少内存开销,对于我们自我提升也有更好的帮助。
版权声明:本文为博主原创文章,遵循相关版权协议,如若转载或者分享请附上原文出处链接和链接来源。