JVM 原理学习总结
这篇总结主要是基于我之前 JVM 系列文章而形成的的。主要是把重要的知识点用自己的话说了一遍,可能会有一些错误,还望见谅和指点。谢谢
JVM 介绍和源码
首先 JVM 是一个虚拟机,当你安装了 jre,它就包含了 jvm 环境。JVM 有自己的内存结构,字节码执行引擎,因此 class 字节码才能在 jvm 上运行,除了 Java 以外,Scala,groovy 等语言也可以编译成字节码而后在 jvm 中运行。JVM 是用 c 开发的。
JVM 内存模型
内存模型老生常谈了,主要就是线程共享的堆区,方法区,本地方法栈。还有线程私有的虚拟机栈和程序计数器。
堆区存放所有对象,每个对象有一个地址,Java 类 jvm 初始化时加载到方法区,而后会在堆区中生成一个 Class 对象,来负责这个类所有实例的实例化。
栈区存放的是栈帧结构,栈帧是一段内存空间,包括参数列表,返回地址,局部变量表等,局部变量表由一堆 slot 组成,slot 的大小固定,根据变量的数据类型决定需要用到几个 slot。
方法区存放类的元数据,将原来的字面量转换成引用,当然,方法区也提供常量池,常量池存放 - 128 到 127 的数字类型的包装类。
字符串常量池则会存放使用 intern 的字符串变量。
JVM OOM 和内存泄漏
这里指的是 oom 和内存泄漏这类错误。
oom 一般分为三种,堆区内存溢出,栈区内存溢出以及方法区内存溢出。
堆内存溢出主要原因是创建了太多对象,比如一个集合类死循环添加一个数,此时设置 jvm 参数使堆内存最大值为 10m,一会就会报 oom 异常。
栈内存溢出主要与栈空间和线程有关,因为栈是线程私有的,如果创建太多线程,内存值超过栈空间上限,也会报 oom。
方法区内存溢出主要是由于动态加载类的数量太多,或者是不断创建一个动态代理,用不了多久方法区内存也会溢出,会报 oom,这里在 1.7 之前会报 permgem oom,1.8 则会报 meta space oom,这是因为 1.8 中删除了堆中的永久代,转而使用元数据区。
内存泄漏一般是因为对象被引用无法回收,比如一个集合中存着很多对象,可能你在外部代码把对象的引用置空了,但是由于对象还被集合给引用着,所以无法被回收,导致内存泄漏。测试也很简单,就在集合里添加对象,添加完以后把引用置空,循环操作,一会就会出现 oom 异常,原因是内存泄漏太多了,导致没有空间分配新的对象。
常见调试工具
命令行工具有 jstack jstat jmap 等,jstack 可以跟踪线程的调用堆栈,以便追踪错误原因。
jstat 可以检查 jvm 的内存使用情况,gc 情况以及线程状态等。
jmap 用于把堆栈快照转储到文件系统,然后可以用其他工具去排查。
visualvm 是一款很不错的 gui 调试工具,可以远程登录主机以便访问其 jvm 的状态并进行监控。
class 文件结构
class 文件结构比较复杂,首先 jvm 定义了一个 class 文件的规则,并且让 jvm 按照这个规则去验证与读取。
开头是一串魔数,然后接下来会有各种不同长度的数据,通过 class 的规则去读取这些数据,jvm 就可以识别其内容,最后将其加载到方法区。
JVM 的类加载机制
jvm 的类加载顺序是 bootstrap 类加载器,extclassloader 加载器,最后是 appclassloader 用户加载器,分别加载的是 jdk/bin ,jdk/ext 以及用户定义的类目录下的类(一般通过 ide 指定),一般核心类都由 bootstrap 和 ext 加载器来加载,appclassloader 用于加载自己写的类。
双亲委派模型,加载一个类时,首先获取当前类加载器,先找到最高层的类加载器 bootstrap 让他尝试加载,他如果加载不了再让 ext 加载器去加载,如果他也加载不了再让 appclassloader 去加载。这样的话,确保一个类型只会被加载一次,并且以高层类加载器为准,防止某些类与核心类重复,产生错误。
defineclass findclass 和 loadclass
类加载 classloader 中有两个方法 loadclass 和 findclass,loadclass 遵从双亲委派模型,先调用父类加载的 loadclass,如果父类和自己都无法加载该类,则会去调用 findclass 方法,而 findclass 默认实现为空,如果要自定义类加载方式,则可以重写 findclass 方法。
常见使用 defineclass 的情况是从网络或者文件读取字节码,然后通过 defineclass 将其定义成一个类,并且返回一个 Class 对象,说明此时类已经加载到方法区了。当然 1.8 以前实现方法区的是永久代,1.8 以后则是元空间了。
JVM 虚拟机字节码执行引擎
jvm 通过字节码执行引擎来执行 class 代码,他是一个栈式执行引擎。这部分内容比较高深,在这里就不献丑了。
编译期优化和运行期优化
编译期优化主要有几种
1 泛型的擦除,使得泛型在编译时变成了实际类型,也叫伪泛型。
2 自动拆箱装箱,foreach 循环自动变成迭代器实现的 for 循环。
3 条件编译,比如 if(true) 直接可得。
运行期优化主要有几种
1 JIT 即时编译
Java 既是编译语言也是解释语言,因为需要编译代码生成字节码,而后通过解释器解释执行。
但是,有些代码由于经常被使用而成为热点代码,每次都编译太过费时费力,干脆直接把他编译成本地代码,这种方式叫做 JIT 即时编译处理,所以这部分代码可以直接在本地运行而不需要通过 jvm 的执行引擎。
2 公共表达式擦除,就是一个式子在后面如果没有被修改,在后面调用时就会被直接替换成数值。
3 数组边界擦除,方法内联,比较偏,意义不大。
4 逃逸分析,用于分析一个对象的作用范围,如果只局限在方法中被访问,则说明不会逃逸出方法,这样的话他就是线程安全的,不需要进行并发加锁。
1
JVM 的垃圾回收
1 GC 算法:停止复制,存活对象少时适用,缺点是需要两倍空间。标记清除,存活对象多时适用,但是容易产生随便。标记整理,存活对象少时适用,需要移动对象较多。
2 GC 分区,一般 GC 发生在堆区,堆区可分为年轻代,老年代,以前有永久代,现在没有了。
年轻代分为 eden 和 survior,新对象分配在 eden,当年轻代满时触发 minor gc,存活对象移至 survivor 区,然后两个区互换,等待下一场 gc,
当对象存活的阈值达到设定值时进入老年代,大对象也会直接进入老年代。
老年代空间较大,当老年代空间不足以存放年轻代过来的对象时,开始进行 full gc。同时整理年轻代和老年代。
一般年轻代使用停止复制,老年代使用标记清除。
3 垃圾收集器
serial 串行
parallel 并行
它们都有年轻代与老年代的不同实现。
然后是 scanvage 收集器,注重吞吐量,可以自己设置,不过不注重延迟。
cms 垃圾收集器,注重延迟的缩短和控制,并且收集线程和系统线程可以并发。
cms 收集步骤主要是,初次标记 gc root,然后停顿进行并发标记,而后处理改变后的标记,最后停顿进行并发清除。
g1 收集器和 cms 的收集方式类似,但是 g1 将堆内存划分成了大小相同的小块区域,并且将垃圾集中到一个区域,存活对象集中到另一个区域,然后进行收集,防止产生碎片,同时使分配方式更灵活,它还支持根据对象变化预测停顿时间,从而更好地帮用户解决延迟等问题。
JVM 的锁优化
在 Java 并发中讲述了 synchronized 重量级锁以及锁优化的方法,包括轻量级锁,偏向锁,自旋锁等。详细内容可以参考我的专栏:Java 并发技术指南