JVM(Java Virtual Machine)优化的已经很完善以至于大家都感觉不到他的存在。面对复杂并发业务通常架构上扩容加机器能够得到解决,但是到了一定阶段扩容仍然效果不佳的时候从jvm入手降低服务器内存、CPU使用是非常靠谱的选择。同时了解jvm有助于理解java应用的生命周期与运行环境,编写性能更好的代码与排查故障。
java代码经过编译生成符合规范的class字节码,字节码约定了jvm上程序执行的规范。安装java运行环境JRE(Java Runtime Environment)中即包含了jvm虚拟机,jvm有自己的内存结构、字节码执行引擎,因此字节码可以在jvm上执行。于此同时,jvm通常由C/C++编写,但是有多种实现比如IBM、Oracle、Android平台的jvm各不相同。只有符合规范的class字节码即可在jvm上自行,开发语言不一定是java,比如Scala、Kotlin、Groovy等都可以编程成符合jvm规范的class字节码。
那么对于Java程序来讲它是编译执行还是解释执行的?首先说java程序是编译执行还是解释执行都是不完整的。
众所周知我们把java语言的执行分为编译器和执行期,中间的过度产物是class字节码,而不是机器码。通过java字节码和jvm的抽象屏蔽了操作系统的内部细节即是“一次编写、到处执行”的基础。运行时通过jvm内嵌的解释器将字节码转换成机器码执行,这一过程显然是 解释执行 的。但是大多数jvm的具体实现比如Oracle的Hotspot JVM都有JIT(Just In Time)编译器,就是在运行过程中将部分热点代码编译成机器码直接执行,而这一部分就是 编译执行 了。
启动java程序的时候指定 -Xmixed
, -Xint
, -Xcomp
参数表示要求jvm虚拟机使用热点混合解释与编译,只使用解释,只使用编译。当然这里 -Xcomp
完全编译执行并不一定是性能最优选择,比如完全编译会增加应用启动时间,而且无法准确预估一些分支逻辑等。
初次之外,还有一种新的编译模式AOT(Ahead-of-Time Compilation),即直接将字节码编译成机器码,省去了JIT预热过程的开销。Oracle JDK 9就引入了AOT工具 jaotc
。
jaotc --output libHelloWorld.so HelloWorld.class jaotc --output libjava.base.so --module java.base java -XX:AOTLibrary=./libHelloWorld.so,./libjava.base.so HelloWorld
分层编译与AOT也不是互斥的,他们可以同时协作使用。
类加载是JDK1.0中就引入的概念,目的是为了从不同的来源加载class代码,比如支持浏览器端Java Applet。类的加载通过类加载器(Class-Loader)实现。 加载器读取字节码,创建对用的 java.lang.Class
实例代表对应的类,而加载器通常都是 java.lang.ClassLoader
类的实例。ClassLoader中类加载的相关方法:
方法 | 作用 |
---|---|
getParent() | 返回该类加载器的父类加载器 |
loadClass(String name) | 加载名称为name的类,返回的结果是java.lang.Class类的实例 |
findClass(String name) | 查找名称为name的类,返回的结果是java.lang.Class类的实例 |
findLoadedClass(String name) | 查找名称为name的已经被加载过的类,返回的结果是java.lang.Class类的实例 |
defineClass(String name, byte[] b, int off, int len) | 把字节数组b中的内容转换成 Java 类,返回的结果是 java.lang.Class类的实例。这个方法被声明为 final的 |
resolveClass(Class<?> c) | 链接指定的 Java 类 |
其中name参数为类名,需要注意内部类的表示$表示方法如 com.example.Sample$1
, com.example.Sample$Inner
。
除了引导类加载器外所有类都有一个父加载器,他们的树形关系如下图所示:
Java类加载器
public class Main { public static void main(String[] args) { ClassLoader classLoader = Main.class.getClassLoader(); while (classLoader!=null) { System.out.println(classLoader.toString()); classLoader = classLoader.getParent(); } } } // jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69 // jdk.internal.loader.ClassLoaders$PlatformClassLoader@38af3868
上面代码AppClassLoader就是应用类加载器,PlatformClassLoader就是扩展类加载器,对于父类是引导类加载器情况getParent()即返回null。对于加载器需要特别指出的是,java中 不同加载器加载相同类名存在于不同命名空间,相互强制类型转换会引发ClassCastException,也就是说加载器实例+类名才唯一确定了这个类 。
类加载器加载某个类的时候会先委派他的父加载器,依次递归,如果父加载器能能够加载该类的话则直接返回成功,只有父加载器无法成功加载该类的时候才会有自己加载,这种模式称为 双亲委派 。
class的加载分为 加载
, 链接
, 初始化
三个阶段, jvm规范,类加载 中有明确说明。链接阶段又分为验证、准备、和解析。
Java类生命周期
加载就是上面所说的将class从不同的源如jar、class文件、网络读取载入虚拟机。验证是jvm的安全保障如校验文件格式、元数据、符号引用等,否则会报VerifyError。准备阶段创建类或接口的静态变量设为默认值,后续初始化阶段中才真正赋值,如 public static int value=123;
准备阶段值为0,初始化时才为123。解析会将一些符号引用替换为直接引用(指向目标的指针、偏移量等,与jvm内存布局直接相关)插入到字节码中。
初始化阶段会 收集类中的所有变量的赋值动作和静态语句块(static{}块)中的语句并执行 。 什么时候进行类的初始化在jvm规范中并没有具体说明,但通常jvm实现时通常遵循:
public class ClassLoadProcess { public static ClassLoadProcess singleTon = new ClassLoadProcess(); public static int var1; public static int var2 = 0; public int var3 = 5; public ClassLoadProcess() { var1++; var2++; var3++; } public static ClassLoadProcess newInstance() { return singleTon; } public static void main(String[] args) { ClassLoadProcess instance = ClassLoadProcess.newInstance(); System.out.println("var1: " + instance.var1); System.out.println("var2: " + instance.var2); System.out.println("var3: " + instance.var3); } } // var1: 1 // var2: 0 // var3: 6
另外AOT这种技术可以将java直接编译成机器码,这个情况可以使用AppCDS(Application Class-Data Sharing)在Bootstap Class Loader中通过内存映射直接加载,跳过上述步骤。
jvm的内存实现会根据厂商的不同而有所不同,此处给出 jvm内存区域划分规范 。
程序计数器(PC Program Counter Register)。每个线程都有唯一的PC,指向当前执行jvm指令的地址。
JVM栈(Java Virtual Machine Stack)。每个线程都有一个栈结构,里面存储多个栈帧(Stack Frame),没一个对应了一次函数调用,当前执行函数对应的帧为活动帧,jvm对栈的操作只有压栈和出栈。栈中存储着局部变量表、操作数、动态链接、方法正常或异常退出。
堆(Heap)。堆内存被进程中的各个线程共享,堆也是java内存管理的核心区域,用来放置各种java对象实例,平时使用的 Xmx
类似的参数指定的就是这块内存的大小。java各种gc也是工作在此区域内,因此又被不同的垃圾收集器分为不同区域。
方法区(Method Area)。所有线程共享,存储程序Meta信息如类结构、常量池、字段、方法等。早期HotspotJVM中实现称为永久区(Permanent Generation),后来Oracle JDK 8将此移除,增加了元数据去(Metaspace)。
运行常量池(Run-Time Constant Pool)。存放各种常量信息,不管是编译时生成的字面量还是运行时决定的符号引用,比一般语言的符号表意义更广。
本地方法栈(Native Method Area)。与jvm栈很相似,没个线程都有,通常与jvm栈在同一个区域,取决于具体实现,规范中并未要求。本地方法栈运行了jvm虚拟机本身的一些功能如JIT Compiler,GC等。
jvm内存划分
上图是jvm大致的内存划分,还有一个直接内存区用于一些通信、缓存等,一个Code区用于存放JIT生成的代码信息等。OutOfMemoryError产生时表示已经没有空闲内存并且GC无法回收更多内存。那么,上述区域除了PC域外其他区域都有可能发生OOM,比如
OutOfMemoryError,堆内存不足。原因有很多,很可能是程序存在内存泄露,或者启动时指定的堆内存过小。
StackOverFlowError,发生这种错误表示jvm栈或本地栈内存不足,可能是方法存在递归调用而没有正确的终止