Java作为一种平台无关性的语言,其主要依靠于Java虚拟机——JVM,我们写好的代码会被编译成class文件,再由JVM进行加载、解析、执行,而JVM有统一的规范,所以我们不需要像C++那样需要程序员自己关注平台,大大方便了我们的开发。另外,能够运行在JVM上的并只有Java,只要能够编译生成合乎规范的class文件的语言都是可以跑在JVM上的。而作为一名Java开发,JVM是我们必须要学习了解的基础,也是通向高级及更高层次的必修课;但JVM的体系非常庞大,且术语非常多,所以初学者对此非常的头疼。本系列文章就是笔者自己对于JVM的核心知识(内存结构、类加载、对象创建、垃圾回收等)以及性能调优的学习总结,另外未特别指出本系列文章都是基于HotSpot虚拟机进行讲解。
JVM包含了非常多的知识,比较核心的有 内存结构 、 类加载 、 类文件结构 、 垃圾回收 、 执行 引擎 、 性能调优 、 监控 等等这些知识,但所有的功能都是围绕着 内存结构 展开的,因为我们编译后的代码信息在运行过程中都是存在于JVM自身的内存区域中的,并且这块区域相当的智能,不需要C++那样需要我们自己手动释放内存,它实现了 自动垃圾回收机制 ,这也是Java广受喜爱的原因之一。因此,学习JVM我们首先就得了解其内存结构,熟悉包含的东西,才能更好的学习后面的知识。
如上图所示,JVM运行时数据区(即内存结构)整体上划分为 线程私有 和 线程共享 区域, 线程私有 的区域生命周期与线程相同, 线程共享 区域则存在于整个运行期间 。而按照JVM规范细分则分为 程序计数器 、 虚拟机栈 、 本地方法栈 、 方法区 和 堆 五大区域(直接内存不属于JVM)。注意这只是规范定义需要存在的区域,具体的实现则不在规范的定义中。
如其名,这个部件就是用来记录程序执行的地址的,循环、跳转、异常等等需要依靠它。为什么它是线程私有的呢?以单核CPU为例,多线程在执行时是轮流执行的,那么当线程暂停后恢复就需要程序计数器恢复到暂停前的执行位置继续执行,所以必然是每个线程对应一个。由于它只需记录一个执行地址,所以它是五大区域中唯一一个不会出现 OOM(内存溢出) 的区域。另外它是控制我们JAVA代码的执行的,在调用 native 方法时该计数器就没有作用了,而是会由 操作系统的计数器 控制。
虚拟机栈是方法执行的内存区域,每调用一个方法都会生成一个 栈帧 压入栈中,当方法执行完成才会弹出栈。栈帧中又包含了 局部变量表 、 操作数栈 、 动态链接 、 方法出口 。其中局部变量表就是用来存储局部变量的( 基本类型值 和 对象的引用 ),每一个位置32位,而像long/double这样的变量则需要占用两个槽位;操作数栈则类似于缓存,用于存储 执行引擎 在计算时需要用到的局部变量;动态链接这里暂时不讲,后面的章节会详细分析;方法出口则包含 异常出口 和 正常出口 以及 返回地址 。下面来看三个方法示例分别展示 栈 和 栈帧 的运行原理。
public class ClassDemo1 { public static void main(String[] args) { new ClassDemo1().a(); } static void a() { new ClassDemo1().b(); } static void b() { new ClassDemo1().c(); } static void c() {} }
如上所示的方法调用 入栈出栈 的过程如下:
public class ClassDemo2 { public int work() { int x = 3; int y = 5; int z = (x + y) * 10; return z; } public static void main(String[] args) { new ClassDemo2().work(); } }
上面只是一简单的计算程序,通过 javap -c ClassDemo2.class 命令反编译后看看生成的字节码:
public class cn.dark.ClassDemo { public cn.dark.ClassDemo(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public int work(); Code: 0: iconst_3 1: istore_1 2: iconst_5 3: istore_2 4: iload_1 5: iload_2 6: iadd 7: bipush 10 9: imul 10: istore_3 11: iload_3 12: ireturn public static void main(java.lang.String[]); Code: 0: new #2 // class cn/dark/ClassDemo 3: dup 4: invokespecial #3 // Method "<init>":()V 7: invokevirtual #4 // Method work:()I 10: pop 11: return }
主要看到work方法中,挨个来解释(字节码指令释义可以参照 这篇文章 ): 执行引擎 首先通过 iconst_3 将常量3存入到操作数栈中,然后通过 istore_1 将该值从操作数栈中取出并存入到 局部变量表的1号位 (注意局部变量表示从0号开始的,但0号位默认存储了this变量);接着常量5执行同样的操作,完成后局部变量表中就存了3个变量(this、3、5);之后通过 iload 指令将局表变量表对应位置的变量加载到操作数栈中,因为这里有括号,所以先加载两个变量到操作数栈并执行括号中的加法,即调用 iadd 加法指令(所有二元算数指令会从操作数栈中取出顶部的两个变量进行计算,计算结果自动加入到栈中);接着又将常量10压入到栈中,继续调用 imul 乘法指令,完成后需要通过 istore 命令再将结果存入到局部变量表中,最后通过 ireturn 返回(不管我们方法是否定义了返回值都会调用该指令,只是当我们定义了返回值时,首先会通过 iload 指令加载局部变量表的值并返回给调用者)。以上就是栈帧的运行原理。
该区域同样是线程私有,每个线程对应会生成一个栈,并且每个栈默认大小是 1M ,但也不是绝对,根据操作系统不同会有所不一样,另外可以用-Xss控制大小,官方文档对该该参数解释如下:
既然可以控制大小,那么这块区域自然就会存在 内存不足 的情况,对于栈当内存不足时会出现下面两种异常:
为什么会有两种异常呢?在周志明的《深入理解Java虚拟机》一书中讲到,在单线程环境下只会出现 StackOverflowError 异常,即栈帧填满了栈或局部变量表过大;而 OutOfMemoryError 只有当多线程情况下,无节制的创建多个栈才会出现,因为操作系统对于 每个进程 是有内存限制的,即超出了进程可用的内存,无法创建新的栈。
通过上文我们知道同一个线程内每个方法的调用会对应生成相应的 栈帧 ,而栈帧又包含了 局部变量表 和 操作数栈 等内容,那么当方法间传递参数时是否可以优化,使得它们共享一部分内存空间呢?答案是肯定的,像下面这段代码:
public int work(int x) throws Exception{ int z =(x+5)*10;// 参数会按照顺序放到局部变量表 Thread.sleep(Integer.MAX_VALUE); return z; } public static void main(String[] args)throws Exception { JVMStack jvmStack = new JVMStack(); jvmStack.work(10);//10 放入操作数栈 }
在main方法中首先会把10放入操作数栈然后传递给work方法,作为参数,会按照顺序放入到局部变量表中,所以x会放到局部变量表的1号位(0号位是this),而此时通过HSDB工具查看这时的栈调用信息会发现如下情况:
如上图所示,中间一小块用红框圈起来的就是两个栈帧共享的内存区域,即work的局部变量表和main的操作数栈的一部分。
和虚拟机栈是一样的,只不过该区域是用来执行本地本地方法的,有些虚拟机甚至直接将其和虚拟机栈合二为一,如HotSpot。(通过上面的图也可以看到,最上面显示了Thread.sleep()的栈帧信息,并标记了native)
该区域是线程共享的区域,用来存储已被虚拟机加载的 类信息 、 常量 、 静态变量 、 即时编译器编译后的代码 等数据。该区域在JDK1.7以前是以 永久代 方式实现的,存在于堆中,可以通过-XX:PermSize(初始值)、-XX:MaxPermSize(最大值)参数设置大小;而1.8以后以 元空间 方式实现,使用的是 直接内存 (但 运行时常量池 和 静态变量 仍放在堆中),可以通过-XX:MetaspaceSize(初始值)、-XX:MaxMetaspaceSize(最大值)控制大小,如果不设置则只受限于本地内存大小。为什么会这么改变呢?因为方法区和堆都会进行 垃圾回收 ,但是方法区中的信息相对比较 静态 ,回收难以达到成效,同时需要占用的空间大小更多的取决于我们class的大小和数量,即对该区域难以设置一个合理的大小,所以将其直接放到 本地内存 中是非常有用且合理的。
在方法区中还存在常量池(1.7后放入堆中),而常量池也分了几种,常常让初学者比较困惑,比如 静态常量池 、 运行时常量池 、 字符串常量池 。 静态常量池 就是指存在于我们的class文件中的常量池,通过 javap -v ClassDemo.class 反编译上面的代码可以看到该常量池:
Constant pool: #1 = Methodref #5.#26 // java/lang/Object."<init>":()V #2 = Class #27 // cn/dark/ClassDemo #3 = Methodref #2.#26 // cn/dark/ClassDemo."<init>":()V #4 = Methodref #2.#28 // cn/dark/ClassDemo.work:()I #5 = Class #29 // java/lang/Object #6 = Utf8 <init> #7 = Utf8 ()V #8 = Utf8 Code #9 = Utf8 LineNumberTable #10 = Utf8 LocalVariableTable #11 = Utf8 this #12 = Utf8 Lcn/dark/ClassDemo; #13 = Utf8 work #14 = Utf8 ()I #15 = Utf8 x #16 = Utf8 I #17 = Utf8 y #18 = Utf8 z #19 = Utf8 main #20 = Utf8 ([Ljava/lang/String;)V #21 = Utf8 args #22 = Utf8 [Ljava/lang/String; #23 = Utf8 MethodParameters #24 = Utf8 SourceFile #25 = Utf8 ClassDemo.java #26 = NameAndType #6:#7 // "<init>":()V #27 = Utf8 cn/dark/ClassDemo #28 = NameAndType #13:#14 // work:()I #29 = Utf8 java/lang/Object
静态常量池中就是存储了 类和方法的信息 、 符号引用 以及 字面量 等东西,当类加载到内存中后,JVM就会将这些内容存放到 运行时常量池 中,同时会将 符号引用 (可以理解为对象方法的 定位描述符 )会被解析为 直接引用 (即对象的内存地址)存入到 运行时常量池 中(因为在类加载之前并不知道符号引用所对应的对象内存地址是多少,需要用符号替代)。而 字符串常量池 网上争议比较多,我个人理解它也是 运行时常量池 的一部分,专门用于存储字符串常量,这里先简单提一下,稍后会详细分析字符串常量池。
这个区域是垃圾回收的重点区域,对象都存在于堆中(但随着JIT编译器的发展和逃逸分析技术的成熟,对象也不一定都是存在于堆中),可以通过-Xms(最小值)、-Xmx(最大值)、-Xmn(新生代大小)、-XX:NewSize(新生代最小值)、-XX:MaxNewSize(新生代最大值)这些参数进行控制。
在堆中又分为了 新生代 和 老年代 ,新生代又分为 Eden空间 、 From Survivor空间 、 To Survivor空间 。详细内容后面文章会详细讲解,这里不过多阐述。
直接内存也叫堆外内存,不属于JVM运行时数据区的一部分,主要通过 DirectByteBuffer 申请内存,该对象存在于堆中,包含了对堆外内存的引用;另外也可以通过 Unsafe 类或其它 JNI 手段直接申请内存。它的大小受限于本地内存的大小,也可以通过-XX:MaxDirectMemorySize设置,所以这一块也会出现 OOM 异常且较难排查。
这个区域不是虚拟机规范中的内容,所有官方的正式文档中也没有明确指出有这一块,所以这里只是根据现象推导出结论。什么现象呢?有一个关于字符串对象的高频面试题:下面的代码究竟会创建几个对象?
String str = "abc"; String str1 = new string("cde");
我们先不管这个面试题,先来思考下面代码的输出结果是怎样的(以下试验基于JDK8,更早的版本结果会有所不同):
String s1 = "abc"; String s2 = "ab" + "c"; String s3 = new String("abc"); String s4 = new StringBuilder("ab").append("c").toString(); System.out.println("s1 == s2:" + (s1 == s2)); System.out.println("s1 == s3:" + (s1 == s3)); System.out.println("s1 == s4:" + (s1 == s4)); System.out.println("s1 == s3.intern:" + (s1 == s3.intern())); System.out.println("s1 == s4.intern:" + (s1 == s4.intern()));
输出结果如下:
s1 == s2:true s1 == s3:false s1 == s4:false s1 == s3.intern:true s1 == s4.intern:true
上面的输出结果和你想象的是否一样呢?为什么呢?一个个来分析。
使用String类的 intern 方法动态添加字符串常量到运行时常量池中(intern方法在1.6和1.7及以后的实现不相同,1.6字符串常量池放于永久代中,intern会把 首次 遇到的字符串实例复制永久代中并返回永久代中的引用,而1.7及以后常量池也放入到了堆中,intern也不会再复制实例,只是在常量池中记录 首次 出现的实例引用)。
上面的意思很明确,1.7以后intern方法首先会去字符串常量池寻找对应的字符串,如果找到了则返回对应的引用,如果没有找到则先会在字符串常量池中创建相应的对象。因此,上面s4和s4调用intern方法时都是返回s1的引用。
看到这里,相信各位读者基本上也都能理解了,对于开始的面试题应该也是心中有数了,最后再来验证刚刚说的“第三个对象”的问题,先看下面代码:
String s4 = new StringBuilder("ab").append("c").toString(); System.out.println(s4 == s4.intern());
这里结果是true。为什么呢?别急,再来看另外一段代码:
String s3 = new String("abc"); String s4 = new StringBuilder("ab").append("c").toString(); System.out.println(s3 == s3.intern()); System.out.println(s4 == s4.intern());
这里结果是两个false,和你心中的答案是一致的么?上文刚刚说了intern会先去字符串常量池找,找到则返回引用,否则在字符创常量池创建一个对象,所以第一段代码结果等于true正好说明了通过StringBuilder拼接的字符串会存到字符串常量池中;而第二段代码中,在StringBuilder拼接字符串之前已经优先使用new创建了字符串,也就会在字符串常量里创建“abc”对象,因此s4.intern返回的是该常量的引用,和s4不相等。你可能会说是因为优先调用了s3.intern方法,但即使你去掉这一段,结果还是一样的,也刚好验证了new String("abc")会创建两个对象(在此之前没有定义“abc”字面量,就会在字符串常量池创建对象,然后堆中创建String对象并引用该常量,否则只会创建堆中的String对象)。
本文是JVM系列的开篇,主要分析JVM的运行时数据区、简单参数设置和字节码阅读分析,这也是学习JVM及性能调优的基础,读者需要深刻理解这些内容以及哪些区域会发生 内存溢出 (只有程序计数器不会内存溢出),另外关于运行时常量池和字符串常量池的内容也需要理解透彻。