Java程序的开发运行过程为: 我们利用 JDK (调用 Java API)开发Java程序,编译成字节码或者打包程序 然后可以用 JRE 则启动一个JVM实例,加载、验证、执行 Java 字节码以及依赖库, 运行Java程序 而JVM 将程序和依赖库的Java字节码解析并变成本地代码执行,产生结果
性能调优我们可采用的手段和方式包括:
使用JDWP或开发工具做本地/远程调试
系统和JVM的状态监控,收集分析指标
性能分析: CPU使用情况/内存分配分析
内存分析: Dump分析/GC日志分析
调整JVM启动参数,GC策略等等
字节码
Java bytecode由单字节(byte) 的指令组成,理论上最多支持256个操作码(opcode )实际上Java只使用了200左右的操作码,还有一些操作码则保留给调试操作。
根据指令的性质,主要分为四个大类:
1.栈操作指令,包括与局部变量交互的指令:所有的计算都发生在栈上
2.程序流程控制指令
3.对象操作指令,包括方法调用指令
4.算术运算以及类型转换指令
生成字节码示例:vi Hello.java
public class Hello {
public static void main(String[] args) {
Hello obj = new Hello();
}
}
使用javac进行编译,生成字节码文件:javac Hello.java
使用javap -c Hello查看字节码文件(反编译,使用ASM和Javassist之类的字节码操作工具地实现字节码编辑和修改)
将 class 文件和java源文件归档到一个名为hello.jar 的档案中,通过 e 选项指定jar的启动类 Hello
jar cvfe hello.jar Hello Hello.class Hello.java
java ‐jar hello.jar 然后通过 ‐jar 选项来执行jar包:
Compiled from "Hello.java"
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class Hello
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
}
运算的时候有一个本地变量表,如上面的#1,可以在本地变量表中找到具体意义。运算时将变量从本地变量表加载到栈上,进行运算。
使用javap -c -verbose Hello,可以查看更详细的字节码,包括常量池等内容。
Classfile /root/Hello.class
Last modified Feb 22, 2022; size 272 bytes
MD5 checksum adc98c8855d7cc843b3556c4e922d2ac
Compiled from "Hello.java"
public class Hello
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // Hello
#3 = Methodref #2.#13 // Hello."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 Hello.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 Hello
#15 = Utf8 java/lang/Object
{
public Hello();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class Hello
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "Hello.java"
算数操作与类型转换
方法调用的指令
invokestatic:顾名思义,这个指令用于调用某个类的静态方法,这是方法调用指令中最快的一个。
invokespecial:用来调用构造函数,但也可以用于调用同一个类中的private方法,以及可见的超类方法。
invokevirtual:如果是具体类型的目标对象,invokevirtual 用于调用公共,受保护和package级的私有方法。
invokeinterface:当通过接口引用来调用方法时,将会编译为invokeinterface指令。
invokedynamic,:JDK7 新增加的指令,是实现“动态类型语言”( Dynamically TypedLanguage)支持而进行的升级改进,同时也是JDK8以后支持lambda表达式的实现基础。
JVM类加载器
类的生命周期:
1.加载(Loading) :找Class文件
2.验证(Verification) :验证格式、依赖
3.准备(Preparation) :静态字段、方法表
4.解析(Resolution) :符号解析为引用
5.初始化(Initialization) :构造器、静态变量赋值、静态代码块
6.使用(Using )
7.卸载(Unloading)
类加载的时机
1.当虚拟机启动时,初始化用户指定的主类,就是启动执行的main方法所在的类;
2.当遇到用以新建目标类实例的new指令时,初始化new指令的目标类,就是new一个类的时候要初始化;
3.当遇到调用静态方法的指令时,初始化该静态方法所在的类;
4.当遇到访问静态字段的指令时,初始化该静态字段所在的类;
5.子类的初始化会触发父类的初始化;
6.如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
7.使用反射API对某个类进行反射调用时,初始化这个类,其实跟前面一样,反射调用要么是已经有实例了,要么是静态方法,都需要初始化;
8.当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。
不会初始化(可能会加载)
1.通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
2.定义对象数组,不会触发该类的初始化。
3.常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
4.通过类名获取Class对象,不会触发类的初始化,Hello.class 不会让Hello类初始化。
5.通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
Class.forName("jvm.Hello" )默认会加载Hello类。
6.通过ClassLoader默认的loadClass方法,也不会触发初始化动作(加载了,但是不初始化)。
三类加载器
1.启动类加载器(bootstrap class loader): 它用来加载 Java 的核心类,是用原生 C++代码来实现的,并不继承自 java.lang.ClassLoader(负责加载JDK中 jre/lib/rt.jar里所有的class)。它可以看做是JVM自带的,我们再代码层面无法直 接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个 null 。举例来说,java.lang.String是由启动类加载器加载的,所以
String.class.getClassLoader()就会返回null。但是后面可以看到可以通过命令行
参数影响它加载什么。
2.扩展类加载器(extensions class loader):它负责加载JRE的扩展目录,lib/ext 或者由java.ext.dirs系统属性指定的目录中的JAR包的类,代码里直接获取它的父
类加载器为null(因为无法拿到启动类加载器)。
3.应用类加载器(app class loader):它负责在JVM启动时加载来自Java命令的 classpath或者cp选项、java.class.path系统属性指定的jar包和类路径。在应用程 序代码里可以通过ClassLoader的静态方法getSystemClassLoader()来获取应用 类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。
扩展类加载器和应用类加载器都是继承制URLClassLoader(Java9以前):
类加载机制有三个特点:
1. 双亲委托:当一个自定义类加载器需要加载一个类,比如java.lang.String,它很 懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载 器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动 类加载器已经加载了某个类比如java.lang.String,所有的子加载器都不需要自己 加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException异常。
2. 负责依赖:如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类 或接口,也会去尝试加载这些依赖项。
3. 缓存加载:为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加
载,那么它会缓存这个加载结果,不会重复加载。
加载器的加载路径:
显示当前ClassLoader加载了哪些Jar
import java.lang.reflect.Field;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
public class JvmClassLoaderPrintPath {
public static void main(String[] args) {
// 启动类加载器,虽然拿不到启动类加载器,但是可以获取URL
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
System.out.println("启动类加载器");
for(URL url : urls) {
System.out.println(" ==> " +url.toExternalForm());
}
// 扩展类加载器
printClassLoader("扩展类加载器", JvmClassLoaderPrintPath.class.getClassLoader().getParent());
// 应用类加载器
printClassLoader("应用类加载器", JvmClassLoaderPrintPath.class.getClassLoader());
}
public static void printClassLoader(String name, ClassLoader CL){
if(CL != null) {
System.out.println(name + " ClassLoader ‐> " + CL.toString()); printURLForClassLoader(CL);
}else{
System.out.println(name + " ClassLoader ‐> null");
}
}
public static void printURLForClassLoader(ClassLoader CL){
Object ucp = insightField(CL,"ucp"); Object path = insightField(ucp,"path");
ArrayList ps = (ArrayList) path;
for (Object p : ps){
System.out.println(" ==> " + p.toString());
}
}
private static Object insightField(Object obj, String fName) {
try {
Field f = null;
if(obj instanceof URLClassLoader){
f = URLClassLoader.class.getDeclaredField(fName);
}else{
f = obj.getClass().getDeclaredField(fName);
}
f.setAccessible(true);
return f.get(obj);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
在类的启动命令行参数加上 ‐XX:+TraceClassLoading 或者 ‐verbose 即 可,注意需要加载java命令之后,要执行的类名之前,不然不起作用。
java -verbose Hello
java -XX:+TraceClassLoading Hello
怎么自己指定加载哪些类
参数 ‐Dsun.boot.class.path 表示我们要指定启动类加载器加载什么, 最基础的东西都在rt.jar这个包了里,所以一般配置它就够了。需要注意的是因为在 windows系统默认JDK安装路径有个空格,所以需要把整个路径用双引号括起来,如 果路径没有空格,或是Linux/Mac系统,就不需要双引号了。
参数 ‐Djava.ext.dirs 表示扩展类加载器要加载什么,一般情况下不需要的话可以直接配置为空即可。
java -Dsun.boot.class.path=/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.322.b06-1.el7_9.x86_64/jre/lib/rt.jar -verbose Hello
自定义类加载器
JVM内存模型
如果是原生数据类型的局部变量,那么它的内容就全部保留在线程栈上。
如果是对象引用,则栈中的局部变量槽位中保存着对象的引用地址,而实际的对象内容保存在堆中。
对象的成员变量与对象本身一起存储在堆上,不管成员变量的类型是原生数值,还是对象引用。
类的静态变量则和类定义一样都保存在堆中。
方法中使用的原生数据类型和对象引用地址在栈上存储;对象、对象成员与类定义、静态变量在堆上。
堆内存又称为“共享堆”,堆中的所有对象,可以被所有线程访问,只要他们能拿到对象的引用地址。
如果一个线程可以访问某个对象时,也就可以访问该对象的成员变量。
如果两个线程同时调用某个对象的同一方法,则它们都可以访问到这个对象的成员变量,但每个线程的局部变量副本是独立的。
堆内存是所有线程共用的内存空间,JVM将Heap内存分为年轻代( Young generation)和老年代(Old generation,也叫Tenured)两部分。
年轻代还划分为3个内存池,新生代(Edenspace)和存活区( Survivor space ),在大部分GC算法中有2个存活区(S0, S1),在我们可以观察到的任何时刻,S0和S1总有一个是空的,但一般较小,也不浪费多少空间。
Non-Heap本质上还是Heap,只是一般不归GC管理,里面划分为3个内存池。
Metaspace,以前叫持久代( 永久代,Permanentgeneration), Java8换了个名字叫Metaspace.对象的结构,常量池。
CCS, Compressed Class Space,存放class信息的,和Metaspace有交叉。
CodeCache,存放JIT编译器编译后的本地机器代码。
JVM启动参数
以-开头为标准参数,所有的JVM都要实现这些参数,并且向后兼容。
-D设置系统属性。
以-X开头为非标准参数,基本都是传给JVM的,默认JVM实现这些参数的功能,但是并不保证所有JVM实现都满足,且不保证向后兼容。可以使用java-X命令来查看当前JVM支持的非标准参数。
以-XX:开头为非稳定参数,专门用于控制JVM的行为,跟具体的JVM实现有关,随时可能会在下个版本取消。
-XX:+-Flags形式,+-是对布尔值进行开关。
-XX:key=value形式,指定某个选项的值。
1.系统属性参数
2.运行模式参数
1. -server: 设置JVM使用server模式,特点是启动速度比较慢,但运行时性能和内存管理效率很高,适用于生产环境。在具有64位能力的jDK环境下将默认启用该模式,而忽略-client参数。
2.-client : jDK1.7之前在32位的x86机器上的默认值是-client选项。设置JVM使用client模式,特点是启动速度比较快,但运行时性能和内存管理效率不高,通常用于客户端应用程序或者PC应用开发和调试。此外,我们知道JVM加载字节码后,可以解释执行,也可以编译成本地代码再执行,所以可以配置JVM对字节码的处理模式:
3. - Xint:在解释模式(interpreted mode )下运行,-Xint 标记会强制JVM解释执行所有的字节码,这当然会降低运行速度,通常低10倍或更多。
4. -Xcomp: -Xcomp 参数与-Xint正好相反,JVM 在第一次使用时会把所有的字节码编译成本地代码,从而带来最大程度的优化。[注意预热 )
5. -Xmixed: -Xmixed 是混合模式,将解释模式和编译模式进行混合使用,有JVM自己决定,这是JVM的默认模式,也是推荐模式。我们使用java -version可以看到mixed mode等信息。
3.堆内存设置参数
-Xmx,指定最大堆内存。如-Xmx4g.这只是限制了Heap部分的最大值为4g。这个内存不包括栈内存,也不包括堆外使用的内存。
-Xms,指定堆内存空间的初始大小。如-Xms4g。而且指定的内存大小,并不是操作系统实际分配的初始值,而是GC先规划好,用到才分配。专用服务器上需要保持-Xms和-Xmx -致,否则应用刚启动可能就有好几个FullGC。当两者配置不一致时,堆内存扩容可能会导致性能抖动。
-Xmn,等价于-XX:NewSize,使用G1垃圾收集器不应该设置该选项,在其他的某些业务场景下可以设置。官方建议设置为-Xmx的1/2 ~ 1/4.
-XX: MaxPermSize=size, 这是JDK1.7之前使用的。Java8默认允许的Meta空间无限大,此参数无效。
-XX: MaxMetaspaceSize=size, Java8默认不限制Meta空间,一般不允许设置该选项。
-XX: MaxDirectMemorySize=size, 系统可以使用的最大堆外内存,这个参数跟-Dsun.nio.MaxDirectMemorySize效果相同。
-Xss,设置每个线程栈的字节数。例如-Xss1m指定线程栈为1MB,, 与-XX:ThreadStackSize=1m等价
4. GC设置参数
-XX: +UseG1GC:使用G1垃圾回收器
-XX: +UseConcMarkSweepGC: 使用CMS垃圾回收器
-XX: +UseSerialGC: 使用串行垃圾回收器
-XX: +UseParallelGC: 使用并行垃圾回收器
// Java 11+
-XX: +UnlockExperimentalVMOptions -XX:+UseZGC
// Java 12+
-XX: +UnlockExperimentalVMOptions -XX:+UseShenandoahGC
5.分析诊断参数
-XX: +-HeapDumpOnOutOfMemoryError 选项,当OutOfMemoryError产生,即内存溢出(堆内存或持久代)时,自动Dump堆内存。
示例用法: java -XX:+HeapDumpOnOutOfMemoryError -Xmx256m ConsumeHeap
-XX: HeapDumpPath 选项,与HeapDumpOnOutOfMemoryError搭配使用,指定内存溢出时Dump文件的目录。
如果没有指定则默认为启动Java程序的工作目录。
示例用法: java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/ ConsumeHeap自动Dump的hprof文件会存储到/usr/ocal/目录下。
-XX: OnError 选项,发生致命错误时(fatal error)执行的脚本。
例如,写一个脚本来记录出错时间,执行一些命令,或者curl一下某个在线报警的url.
示例用法: java -XX:OnError=' gdb - %p" MyApp
可以发现有一个%p的格式化字符串,表示进程PID。
-XX: OnOutOfMemoryError 选项,抛出OutOfMemoryError错误时执行的脚本。
-XX: ErrorFile=filename 选项,致命错误的日志文件名,绝对路径或者相对路径。
-Xdebug -Xrunjdwp:transport=dt socket,server=y,suspend=n,address=1506, 远程调试
6. JavaAgent参数
Agent 是 JVM 中的一项黑科技, 可以通过无侵入方式来做很多事情,比如注入 AOP 代码,执行统 计等等,权限非常大。这里简单介绍一下配置选项,详细功能需要专门来讲。
设置 agent 的语法如下:
-agentlib:libname[=options] 启用 native 方式的 agent, 参考 LD_LIBRARY_PATH 路径。
-agentpath:pathname[=options] 启用 native 方式的 agent。
-javaagent:jarpath[=options] 启用外部的 agent 库, 比如 pinpoint.jar 等等。
-Xnoagent 则是禁用所有 agent。
以下示例开启 CPU 使用时间抽样分析:
JAVA_OPTS="-agentlib:hprof=cpu=samples,file=cpu.samples.log"