引言
本文的使用方法:
本文从头读到尾就是一个虚拟机大部分知识点的框架,就像一颗搜索树一样,我们想要了解哪一部分知识,就从根节点开始搜索,直到找到我们想要了解的知识所在的叶节点或者子树。不过如果把所有的知识都在一篇文章中列出来那文章就太长啦,很容易把握不住整体框架,所以本文中对于知识点的详细介绍都以链接的形式给出,大家可以通过本文回忆 JVM 相关的知识,遇到想不起来的点可以点开相应的链接查看,这样像考试一样的学习方式,可以加深我们的印象,记忆效果将远远好于盯着文字硬背。
Content
- 说说 Java 的内存管理机制
- 说说 Java 虚拟机程序执行
- 说说虚拟机性能监控及故障处理
- 说说 JIT 优化
- 说说 Java 的内存模型(JMM)
- 项目推荐
说说 Java 的内存管理机制
和 C++ 相比,Java 的内存管理机制可谓是一大特色,程序员们不需要自己去写代码手动释放内存了,甚至你想自己干虚拟机都不给你干这个事情的机会(就是说,我们是没有办法自动触发 GC 的),虚拟机全权包办了 Java 的内存控制权力。这看起来挺美好的,不过也意味着,一旦虚拟机疏忽了(感觉不能赖虚拟机,毕竟虚拟机也不知道你能把程序写成那样啊……),发生了内存泄漏,问题都不好查,所以知道虚拟机到底是怎么管的内存就十分重要啦。
虚拟机对内存的管理,其实就是收拾哪些存放我们不会再用的对象的内存,把它们清了拿来放新的对象。所以它首先需要研究下以下几个问题:
- 这堆报废了的对象到底被放哪了?(Java 堆和方法区)
- 5 个数据区域:程序计数器、Java 虚拟机栈、本地方法栈、Java 堆、方法区。
- 这堆放报废对象的地方会不会内存泄漏?或者换一个洋气点的叫法,会不会 OOM?(每个区的 OOM)
- 对象是咋被放到这些地方的?(堆中对象的创建)
- 对象被安置好了之后虚拟机怎么再次找到它?(堆中对象的访问)
知道对象都放哪了,虚拟机就知道去哪里找报废的对象了,接下来就涉及到了 Java 的一大超级特色:垃圾收集(GC)了,垃圾收集,正如其名,就是把这些报废的对象给清了,腾出来地方放新对象,它主要关心以下几个事情:
- 哪些内存需要回收?
- 放对象的地方需要垃圾回收:Java 堆和方法区。
- 什么时候回收?(判断对象的生死)
- 判断对象报废了没的算法(重点):引用计数法 和 可达性分析法。
- 如何回收?
- GC 算法原理(垃圾收集算法)
- 基础:标记 - 清除算法
- 解决效率问题:复制算法
- 解决空间碎片问题:标记 - 整理算法
- 进化:分代收集算法
- GC 算法的真正实现:
- 7 个葫芦娃,哦不,垃圾收集器
- 新生代:Serial、ParNew、Parallel Scavenge
- 老年代:Serial Old、Parallel Old、CMS
- 全能:G1
- HotSpot 虚拟机如何高效实现 GC 算法
说完了对象是怎么被回收的,现在才算是把 Java 的内存管理机制需要用到的小零件给补全了。也就是说,Java 的内存管理流程应该是这样滴:
根据新对象是什么对象给对象找个地放
发现内存中没地放这个新对象了就进行 GC 清理出来点地方
真找不着地了就抛 OOM ……
虚拟机一般都用的是进化版的 GC 算法,也就是分代收集算法,也就是说,虚拟机 Java 堆中的内存是分为新生代和老年代的,那么给新对象找地方放的时候放哪呢?具体怎么放呢?放好了之后的对象会不会换个地呆呀?GC 什么时候进行?清理哪呢?……预知 Java 的内存管理机制的详情如何,可以看看我的往期文章。
到此为止,Java 的内存管理机制也就说的差不多了。现在,我们已经知道一个对象是如何在虚拟机的操控下,在内存中走一遭的了。可是首先,对象肯定是根据我们写的类创建的,那么我们写的类到底是如何变为内存中的对象的呢?而且,我们创建对象当然是为了执行它里面的方法呀,那么这个方法是怎么被执行的呢?想要回答这些问题,就需要我们研究一下 Java 虚拟机是如何执行我们的程序的了。
说说 Java 虚拟机程序执行
想要执行 Java 程序,必然要先将 Java 代码编译成字节码文件,也就是 Class 文件,这个编译的过程我们暂且不谈,主要说一下如果执行这个 Class 文件,所以首先我们要先来了解一下 Class 文件的组成结构。
在了解了组成结构之后,接下来需要考虑的事情是,我们该怎么把这个 .class 文件加载进内存,让它变成方法区(Java 8 后变为了 Metaspace 元空间)的一个 Class 对象呢?(类的加载)。
虚拟机的类加载机制说头可就多了,大家都喜欢揪着这问,其实主要就下面这 3 个过程:
- 类加载的时机:在程序第一次主动引用类的时候。
- 什么是主动引用和被动引用?
- 什么是显式加载和隐式加载?
- 类的生命周期:加载 —— 验证 —— 准备 —— 解析 —— 初始化 —— 使用 —— 卸载
- 类加载器
- 如何判断两个类 “相等”?
- 类加载器的分类?
- 什么双亲委派模型?
- 破坏双亲委派模型?
- 实现 Java 类的热替换
- 如何自定义类加载器?
- 需要保留双亲委派模型:extends ClassLoader,重写 findClass()
- 破坏双亲委派模型:直接重写 loadClass()
将类加载到内存之后,接下来就要考虑如何执行这个类中的方法了。我们知道 5 大内存区域中的 Java 虚拟机栈是服务与 Java 方法的内存模型,那么我们首先应该了解一下 虚拟机栈的栈帧到底是怎样的结构,虚拟机栈的栈帧结构包括如下几个部分:
- 局部变量表(重要)
- 操作数栈 & 动态连接 & 方法返回地址
了解了辅助方法执行的 Java 虚拟机栈的结构后,接下来就要考虑 Java 类中方法的调用了。就像将大象放进冰箱,方法的调用也不是上来就之间执行方法的,而是分为以下两个步骤:
- 方法调用:确定被调用的方法是哪一个
- 基于栈的解释执行:真正的执行方法的字节码
为什么还要加一个方法调用的步骤呢?因为一切方法调用都是在 Class 文件中以常量池中的符号引用存储的,这就导致了不是我们想要执行哪个方法就能立刻执行的,因为我们首先需要根据这个符号引用(其实就一字符串)找到我们想要执行的方法,而这一过程就叫做方法调用。当找到这个方法之后,我们才会开始执行这个方法,也就是基于栈的解释执行。
想要调用一个方法,我们先来看一下虚拟机中有哪些指令可以进行方法调用:方法调用字节码指令。
这些字节码会触发不同的方法调用,总体来说,有以下几种:
- 解析调用
- 分派调用(没有在解析调用中将符号引用转化为直接引用的方法就只能靠分派调用了)
- 静态分派(方法重载)
- 动态分派(方法重写)
确定了要调用的方法具体是哪一个了之后,就可开始基于栈的解释执行了,这个时候,方法才真正的被执行。
此外,还需要了解一下 Java 的动态类型语言支持。
说说虚拟机性能监控及故障处理
常用的 JDK 命令行工具:JDK 命令行工具。
JVM 常见的参数设置已经设置经验可见:JVM 常见参数设置。
虚拟机调优案例分析可见:虚拟机调优案例分析。
说说 JIT 优化
JIT (Just In Time),也就是即时编译,首先我们需要知道 什么是 JIT?
然后,对于 HotSpot 虚拟机内的即时编译器运作过程,我们可以通过以下 5 个问题来研究它:
- 为什么要使用解释器与编译器并存的架构?
- 为什么虚拟机要实现两个不同的 JIT 编译器?
- 什么是虚拟机的分层编译?
- 如何判断热点代码,触发编译?
- 什么是热点代码?(两种)
- 什么是 “多次” 执行?
- HotSpot 采用的是基于计数器的热点探测方法,并且为了对两种热点代码进行探测,每个方法有 2 个计数器
- 方法调用计数器
- 回边计数器
- HotSpot 热点代码探测流程
- 热点代码编译的过程?
此外,JIT 并不是简单的将热点代码编译成机器码就收工的,它还会对代码的执行进行优化,主要有以下几种经典的优化技术:
- 公共子表达式消除【语言无关】
- 数组范围检查消除【语言相关】
- 方法内联【最重要】
- 逃逸分析【最前沿】
说说 Java 的内存模型(JMM)
这部分内容主要与并发编程的内容相关,所以详细介绍会跳到另一个 repo:Java-Concurrency-in-Practice。
Java 的内存模型主要就是研究一个变量的值是怎么在主内存、线程的工作内存和 Java 线程(执行引擎)之间倒腾的。就是说虽然 Java 内存模型规定了所有变量都存储在主内存中,但是每个线程都有一个自己的工作内存,里面存着从主内存拷贝来的变量副本,Java 线程要对变量进行修改,都是先在自己的工作内存中进行,然后再把变化同步回主内存中去。
这样做是由于计算机的存储设备和处理器的运算速度有着几个数量级的差距,所以需要在主内存和 Java 线程间加入一个工作内存作为缓冲,但这也同时会导致主内存和工作内存间的缓存一致性问题,所以当两个工作内存中关于同一个变量的值发生冲突时,需要一定的访问规则来确定主内存以怎样的顺序同步这个变量,也就是说该听哪个工作内存的。而 Java 的内存模型的主要目标就是定义这个规则,即虚拟机如何将变量存储到内存或是从内存中取出的。
简单的来讲,就是掌握 Java 内存模型中的 8 个原子操作,并且知道 Java 内存间是如何通过这 8 个操作进行变量传递的。
其实 Java 的内存模型就是围绕着在并发的过程中如何处理 原子性、可见性、有序性 这 3 个特征建立的。同时 Java 除了可以依靠 volatile 和 synchronized 来保证有序性外,它自己本身还有一个 Happens-Before 原则,依靠这个原则,我们就可以判断并发环境下的两个操作是否可能存在冲突了。