前言
Java 作为一种平台无关性的语言, 其主要依靠于 Java 虚拟机 --JVM, 我们写好的代码会被编译成 class 文件, 再由 JVM 进行加载, 解析, 执行, 而 JVM 有统一的规范, 所以我们不需要像 C++ 那样需要程序员自己关注平台, 大大方便了我们的开发. 另外, 能够运行在 JVM 上的并只有 Java, 只要能够编译生成合乎规范的 class 文件的语言都是可以跑在 JVM 上的. 而作为一名 Java 开发, JVM 是我们必须要学习了解的基础, 也是通向高级及更高层次的必修课; 但 JVM 的体系非常庞大, 且术语非常多, 所以初学者对此非常的头疼. 本系列文章就是笔者自己对于 JVM 的核心知识 (内存结构, 类加载, 对象创建, 垃圾回收等) 以及性能调优的学习总结, 另外未特别指出本系列文章都是基于 HotSpot 虚拟机进行讲解.
正文
JVM 包含了非常多的知识, 比较核心的有内存结构, 类加载, 类文件结构, 垃圾回收, 执行 引擎, 性能调优, 监控等等这些知识, 但所有的功能都是围绕着内存结构展开的, 因为我们编译后的代码信息在运行过程中都是存在于 JVM 自身的内存区域中的, 并且这块区域相当的智能, 不需要 C++ 那样需要我们自己手动释放内存, 它实现了自动垃圾回收机制, 这也是 Java 广受喜爱的原因之一. 因此, 学习 JVM 我们首先就得了解其内存结构, 熟悉包含的东西, 才能更好的学习后面的知识.
内存结构
如上图所示, JVM 运行时数据区 (即内存结构) 整体上划分为线程私有和线程共享区域, 线程私有的区域生命周期与线程相同, 线程共享区域则存在于整个运行期间 . 而按照 JVM 规范细分则分为程序计数器, 虚拟机栈, 本地方法栈, 方法区和堆五大区域(直接内存不属于 JVM). 注意这只是规范定义需要存在的区域, 具体的实现则不在规范的定义中.
1. 程序计数器
如其名, 这个部件就是用来记录程序执行的地址的, 循环, 跳转, 异常等等需要依靠它. 为什么它是线程私有的呢? 以单核 CPU 为例, 多线程在执行时是轮流执行的, 那么当线程暂停后恢复就需要程序计数器恢复到暂停前的执行位置继续执行, 所以必然是每个线程对应一个. 由于它只需记录一个执行地址, 所以它是五大区域中唯一一个不会出现 OOM(内存溢出)的区域. 另外它是控制我们 JAVA 代码的执行的, 在调用 native 方法时该计数器就没有作用了, 而是会由操作系统的计数器控制.
2. 虚拟机栈
虚拟机栈是方法执行的内存区域, 每调用一个方法都会生成一个栈帧压入栈中, 当方法执行完成才会弹出栈. 栈帧中又包含了局部变量表, 操作数栈, 动态链接, 方法出口. 其中局部变量表就是用来存储局部变量的(基本类型值和对象的引用), 每一个位置 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 控制大小, 官方文档对该该参数解释如下:
既然可以控制大小, 那么这块区域自然就会存在内存不足的情况, 对于栈当内存不足时会出现下面两种异常:
栈溢出(StackOverflowError)
内存溢出(OutOfMemoryError)
为什么会有两种异常呢? 在周志明的《深入理解 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 的操作数栈的一部分.
3. 本地方法栈
和虚拟机栈是一样的, 只不过该区域是用来执行本地本地方法的, 有些虚拟机甚至直接将其和虚拟机栈合二为一, 如 HotSpot.(通过上面的图也可以看到, 最上面显示了 Thread.sleep()的栈帧信息, 并标记了 native)
4. 方法区
该区域是线程共享的区域, 用来存储已被虚拟机加载的类信息, 常量, 静态变量, 即时编译器编译后的代码等数据. 该区域在 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 就会将这些内容存放到运行时常量池中, 同时会将符号引用 (可以理解为对象方法的定位描述符) 会被解析为直接引用 (即对象的内存地址) 存入到运行时常量池中(因为在类加载之前并不知道符号引用所对应的对象内存地址是多少, 需要用符号替代). 而字符串常量池网上争议比较多, 我个人理解它也是运行时常量池的一部分, 专门用于存储字符串常量, 这里先简单提一下, 稍后会详细分析字符串常量池.
5. 堆
这个区域是垃圾回收的重点区域, 对象都存在于堆中 (但随着 JIT 编译器的发展和逃逸分析技术的成熟, 对象也不一定都是存在于堆中), 可以通过 - Xms(最小值),-Xmx(最大值),-Xmn(新生代大小),-XX:NewSize(新生代最小值),-XX:MaxNewSize(新生代最大值) 这些参数进行控制.
在堆中又分为了新生代和老年代, 新生代又分为 Eden 空间, From Survivor 空间, To Survivor 空间. 详细内容后面文章会详细讲解, 这里不过多阐述.
6. 直接内存
直接内存也叫堆外内存, 不属于 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
上面的输出结果和你想象的是否一样呢? 为什么呢? 一个个来分析.
s1 == s2: 字面量 "abc" 会首先去字符串常量池找是否有 "abc" 这个字符串, 如果有直接返回引用, 如果没有则创建一个新对象并返回引用; s2 你可能会觉得会创建 "ab","c" 和 "abc" 三个对象, 但实际上首先会被编译器优化为 "abc", 所以等同于 s1, 即直接从字符串常量池返回 s1 的引用.
s1 == s3:s3 是通过 new 创建的, 所以这个 String 对象肯定是存在于堆的, 但是其中的 char[]数组是引用字符创常量池中的 s1, 如果在这之前没有定义的话会先在常量池中创建 "abc" 对象. 所以这里可能会创建一个或两个对象.
s1 == s4:s4 通过 StringBuilder 拼接字符串对象, 所以看起来理所当然的 s1 != s4, 但实际上也没那么简单, 反编译上面的代码会可以发现这里又会被编译器优化为 s4 = "ab" + "c". 猜猜这下会创建几个对象呢? 抛开前面创建的对象的影响, 这里会创建 3 个对象, 因为与 s2 不同的是 s4 是编译器优化过后还存在 "+" 拼接, 因此会在字符创常量池创建 "ab","c" 以及 "abc" 三个对象. 前两个可以反编译看字节码指令或是通过内存搜索验证, 而第三个的验证稍后进行.
s1 == s3.intern/s4.intern: 这两个为什么是 true 呢? 先来看看周志明在《深入理解 Java 虚拟机》书中说的:
使用 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 及性能调优的基础, 读者需要深刻理解这些内容以及哪些区域会发生内存溢出(只有程序计数器不会内存溢出), 另外关于运行时常量池和字符串常量池的内容也需要理解透彻.
来源: https://www.cnblogs.com/yewy/p/13353167.html