Java是一门面向对象的编程语言,在编程语言排行榜中常年排行前三,由于语言的优势和特点如今已经成为开发中最流行最受欢迎的编程语言,特别是在后台服务器开发领域,由于其跨平台性和庞大的生态圈,几乎没有其他语言能够撼动它的霸主地位。Java发展也是相当迅速,如今已经到Java 10 了,吸收了其他语言的一些优势和特点,融入了很多新的特性 。Java主要分为三个方向:
Java SE:Java Standard Edition,即Java标准版,也是Java技术的核心,是学习 Java ME和 Java EE的基础。用于开发和部署桌面、服务器的Java应用程序 ,也就是C/S架构开发。
Java EE:Java Enterprise Edition,即Java企业版,Java EE 是在 Java SE 的基础上构建的,它提供Web 服务、管理和通信 API ,是java技术应用最广泛的领域。主要用于B/S架构开发。
Java ME:Java Mirco Edition,即Java微型版,Java ME是对 Java SE 进行了精简,以提高运行效率,主要针对小型设备,移动设备以及嵌入式设备而构建,不过在移动设备上,现在基本已经被安卓代替了。
Java虚拟机是整个java平台的基石,是java技术实现硬件无关和操作系统无关的关键环节,是java语言生成极小体积的编译代码的运行平台,是保护用户机器免受恶意代码侵袭的保护屏障。可以说JVM就是整个java体系的核心,在java中碰到的绝大部分问题都可以在JVM中找到答案。
一 . 什么是JVM
想要弄清楚什么是java虚拟机,首先就得搞清楚计算机的虚拟化技术。虚拟化是将计算机的各种实体资源,CPU、网络、内存及磁盘等,予以抽象,然后使用软件的方式重新定义划分资源。相信做IT的都玩过VMware,在VMware中可以装多个虚拟机,多个虚拟机共享一台物理机硬件资源。实际上,软硬件资源并没有增加,只是提高了硬件的利用率,并且各个应用程序可以在各个独立的空间(客户机操作系统)内运行而互不影响。有了虚拟化之后,由于的应用程序由相应的客户机操作系统管理,且多个客户操作系统可以独立于主机的操作系统,运行在同一个硬件上,不需要适配硬件的特定体系结构。这通常通过增加一个虚拟化层来实现,该虚拟化层称为hypervisor或VMM(Virtual Machine Monitor 虚拟机监视器)
虚拟化层可以在不同层面对硬件资源进行抽象,如下图所示:
级别 | 例子 |
---|---|
应用程序级 | JVM |
操作系统级 | Docker |
硬件抽象级 | VMware |
从上图可以看出 JVM 是在应用程序级别的虚拟化,实际上 JVM(Java虚拟机)是一种抽象化的计算机,它有自己的指令集和执行引擎,通过在实际的计算机上仿真模拟计算机架构(如处理器、堆栈、寄存器等,相应的指令系统)来实现的。目的是为构建在其上运行的应用程序(java程序)提供一个运行环境。JVM可以解读指令代码并与操作系统进行交互。这种设计使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行,这也是Java 最具吸引力的特性之一。
二 . JVM的作用
JVM将自身定位应用程序和底层平台之间。底层平台是指操作系统(OS)和硬件。操作系统和硬件体系结构在不同的机器上可能不同,但是同一段Java程序可以不用做任何的代码修改就能在不同的机器上运行。也就是说java的 JVM 屏蔽了应用程序与底层平台的相关性,对外暴露出标准和规范。实际上JVM并不关心.class文件是由哪种编译器编译而成,只要符合.class文件的文件格式,就能在 JVM 中运行。下面来列举一下除了java之外的运行在JVM之上的比较流行的编程语言。
Groovy | JRuby | Scala | Fantom | Jython | |
---|---|---|---|---|---|
特点 | 面向对象 | 面向对象 | 多范式 | 面向对象 | 多范式 |
平台 | JVM | JVM | JVM | JVM,CLR | JVM |
执行速度 | 良好 | 良好 | 优秀 | 非常好 | 一般 |
与java的兼容性 | 优秀 | 优秀 | 优秀 | 良好 | 优秀 |
这些.class文件实际上包含了半编译的代码——字节码。之所以称之为半编译,是因为字节码并不像C/C++编译器编译的二进制文件一样可以直接执行。字节码要先被输入到JVM中,再通过 JVM 调用底层操作系统。所以字节码包含了JVM的指令、符号表和其他的辅助信息。实际上不管何种语言,能根据JVM的语法和结构约束编译生成字节码的编译器,都可以在 JVM 上执行。虽然java编译器生成的.class文件是平台独立的,但 JVM 与特定平台相关的,如下图所示。
三 . JVM,JRE,JDK的联系
JVM是JRE的一部分,JRE是我们安装运行Java程序的最基本运行环境。它和java类库以及运行java程序所需要的其他组件一起组成了JVM的一个实现。所以如果想运行java程序,只需安装JRE就够了。而JDK是JRE的超集,JDK是java开发工具包,里面包含了JRE,也包含了java编译器,调试器以及其他相关的java程序开发工具类。当我们需要开发和编译java源码时就需要用到JDK。
Java提供了Java虚拟机规范来让我们对JVM的工作原理有一个完整的认识。你可以从这里得到概念性知识,并开发一个自己的JVM;但这并不是一个简单的工作。现在市场上已经有很多JVM了。众多JVM产品中最有名的莫过于现在Oracle公司的HotSpot。后续章节中将深入探讨 JVM 的工作原理,如果没有特别说明,探讨的都是Oracle公司的HotSpot虚拟机。
四 . JVM的源码
在平常使用Java开发的过程中,我们写好代码然后再写一个main函数就可以把程序跑起来,一切是那么的简单,自然而然。但是好奇的朋友可能会去想 java 的 JVM 到底是怎么工作的,为什么main函数只能那么写?为什么运行main函数程序就能跑起来?为什么我们不能定义自己的main函数?在启动main函数的时候 JVM 底层到底发生了什么?
首先第一个问题,main函数的格式必须是 pubic static void main(String[] args) 吗?其实还有一种大同小异的写法,就是 public static void main(String…args) 。其余写法 JVM 都无法识别,会导致 JVM 找不到主函数而无法启动,那么其余几个关键字为什么要这么写?
1 . 为什么是public,因为java程序是通过jvm虚拟机调用的,所以main()函数要是想被调用,必须是public
2 . 为什么是static ,main函数是所有程序的入口,也就是main被调用时没有任何对象创建,不通过对象调用某一方法,只有将该方法定义为静态方法,所以main方法是一个静态方法,需要static修饰。
3 . 为什么是void,main函数对于 JVM 的调用来说,已经是最底层。由它调用的方法的返回值已经没有任何地方可去,因此,main方法返回值为空,即需用void修饰
4 . 为什么需要String[]参数,这其实是由于 jvm 驱动的源码决定的。
1 | mainID = (*env)->GetStaticMethodID(env, mainClass, "main", "([Ljava/lang/String;)V"); |
启动时会直接去找指定类名,指定参数类型的main函数。这句源码决定了,不写参数会导致找不到main函数。
(1). openJDK源码下载
OK,上述的第一个问题解决,其余的问题只能去 HotSpot 的源码中去寻找答案了。在安装jdk时,就已经安装了jvm,jdk中的jvm是 jdk1.8.0_121\jre\bin\server下jvm.dll ,但本身并不开源,只能通过openJDK来查看jdk的源码,这里可以通过官网下载。飞机票:http://download.java.net/openjdk/jdk8,官方下载网速较慢,故附上百度网盘:链接: https://pan.baidu.com/s/1ezWqQ_30n0FEhKFRVMshIg 密码: nbni。
这里还要提及的一点就是openJDK和Oracle/Sun JDK的区别,OpenJDK原是SunMicrosystems公司为Java平台构建的Java开发环境(JDK)的开源版本,并于2009年4月15日正式发布OpenJDK,后来甲骨文在 2010 年收购SunMicrosystem之后接管了这个项目。 Oracle/Sun JDK里面包含的JVM是HotSpotVM,HotSpot VM只有非常少量的功能没有在OpenJDK里,那部分在Oracle内部的代码库里。这些私有部分都不涉及JVM的核心功能。所以说,Oracle/Sun JDK与OpenJDK其实使用的是同一个代码库。值得注意的是,Oracle JDK只发布二进制安装包,而OpenJDK只发布源码。
下载完openJDK,解压得到目录结构如下图
下面对OpenJDK目录结构做个解释
1 | |——openjdk |
下面对各个文件夹解释
corba
全称 Common Object Request Broker Architecture,通用对象请求代理架构,是基于 对象-服务 机制设计得。内置了跨语言调用和分布式通讯接口,目前,通用的远程过程调用协议是 SOAP(Simple Object Access Protocol,简单对象访问协议),消息格式是 XML-RPC(存在 Json-RPC)
hotspot
全称 Java HotSpot Performance Engine,是 Java 虚拟机的一个实现,包含了服务器版和桌面应用程序版。利用 JIT 及自适应优化技术(自动查找性能热点并进行动态优化)来提高性能。使用 java -version
可以查看 Hotspot 的版本。
jaxp
全称 Java API for XML Processing,处理 XML 的Java API,是 Java XML 程序设计的应用程序接口之一,它提供解析和验证XML文档的能力。
jaxp 提供了处理 xml 文件的三种接口:
- DOM 接口(文档对象模型解析),位于
\openjdk\jaxp\src\org\w3c\dom
- SAX 接口(xml 简单 api 解析),位于
\openjdk\jaxp\src\org\xml\sax
- StAX 接口(xml 流 api),位于
\openjdk\jaxp\src\javax\xml
除了解析接口,JAXP还提供了XSLT接口用来对XML文档进行数据和结构的转换
Jaxws
全称 Java API for Web Services,JAX-WS 允许开发者选择 RPC-oriented(面向 RPC) 或者 message-oriented(消息通信,erlang 使用的就是消息通信,不过 Java 内存模型是内存共享)来实现自己的web services。
通过 Web Services 提供的环境,可以实现 Java 与其他编程语言的交互(事实上就是 thrift 所做的,任何一种语言都可以通过 Web Services 实现与其他语言的通信,客户端用一种语言,服务器端可以用其他语言)
jdk
主要关注 \jdk\src\share
路径下的包,classes包 Java 的实现,我们熟悉的Java.lang包就这个目录下,nativs是本地方法库,当java需要调用native方法时,就需要用到这下面的文件。back、instrument、javavm、npt、transport 几个部分都是 C++ 代码,是实现 java 的基础部分。
nashorn
Nashorn 项目的目的是基于 Java 在 JVM 上实现一个轻量级高性能的 JavaScript 运行环境。从Rhino而来, 是JRE 8里的JavaScript引擎, 基于 JSR 规范,Java 程序员可在 Java 程序中嵌入 JavaScript 代码
以上目录中,我们关注的是hotspot,打开hotspot目录,其中目录结构如下
1 | ├─agent Serviceability Agent的客户端实现 |
(hotspot各个目录的作用参考博客:https://www.cnblogs.com/dennyzhangdd/p/6734933.html)
(2). 启动java的main函数时到底发生了什么?
当我们要启动java的main函数时,首先需要创建一个 JVM实例 。我们都知道main函数是程序入口,那么启动 JVM 实例也是需要入口 main函数。那么 JVM 的启动入口在哪呢 ? 在 openjdk\hotspot\src\share\tools\launcher路径下,有一个 java.c 文件,这个文件中的main函数就是 JVM 的启动入口,整个过程大致分为以下几步
1、创建运行环境
2、设置虚拟机参数
3、设置线程栈大小
4、执行Java main方法
启动java的main函数时,首先会执行 JVM 的main函数,通过C语言实现,源码如下
1 | int main(int argc, char ** argv) |
由于main函数比较长,不方便全部贴出,可自行下载源码查看,下面主要分析一下main函数中几个重要的步骤
创建运行环境
源码如下
1 | CreateExecutionEnvironment(&argc, &argv, |
在main函数中调用创建环境的函数,这个函数是在openjdk\hotspot\src\os\windows\launcher的java_md.c文件中声明的(windows版本),源码如下
1 | void |
概括来说CreateExecutionEnvironment函数主要做了两件事情:
1.根据当前JRE环境的路径和系统版本寻找 jvm.cfg 文件,文件在 jdk 安装路径 jdk1.8.0_77\jre\lib\amd64\jvm.cfg 下,需要通过这个文件判断 JVM 的类型。
2.根据第一步确定的 JVM 类型,找到对应的 JVM.dll 文件。
执行完CreateExecutionEnvironment后,回到main函数,然后调用LoadJavaVM函数去加载 jvm.dll 文件,初始化虚拟机中的函数调用,即通过JVM中的方法调用JVM.dll文件中定义的函数,装载jvm.dll动态连接库(java.exe是java class文件的执行程序,但实际上java.exe程序只是一个执行的外壳。它会装载jvm.dll(windows下),这个动态连接库才是java虚拟机的实际操作处理所在。java.exe程序只负责查找和装载jvm.dll动态库,并调用它进行class文件执行处理)
设置虚拟机参数
加载完 jvm.dll 后就是虚拟机的各种参数设置
1 | /* |
这里主要关注ParseArguments方法,在上图的第5行。这个方法的主要作用就是调用AddOption将虚拟机的参数保存到 JavaVMOption 中 ,后续 Arguments 类会对 JavaVMOption 数据进行再次处理,并验证参数的合理性。主要的实现方法有两个
Arguments::parse_each_vm_init_arg 方法负责处理经过解析过的JavaVMOption数据,这个方法使用C++实现,定义在openjdk\hotspot\src\share\vm\runtime路径下的arguments.cpp中,源码如下
1 | // -Xmn for compatibility with other JVM vendors |
这里只列出几个常用的参数:
1、-Xmn:设置新生代的大小NewSize和MaxNewSize;
2、-Xms:设置堆的初始值InitialHeapSize,也是堆的最小值;
3、-Xmx:设置堆的MaxHeapSize,堆的最大值;
4、-Xminf and -Xmaxf :GC(垃圾回收)之后可用空间的最小值最大值
Arguments::check_gc_consistency 此方法主要用来验证GC策略的合理性,不合理的GC 策略将会导致 JVM 无法启动,并抛出错误信息,源码如下
1 | bool Arguments::check_gc_consistency() { |
java有四种类型的垃圾处理器:
串行垃圾回收器(Serial Garbage Collector)
Yong区和Old区:通过JVM参数 -XX:+UseSerialGC 可以使用串行垃圾回收器
串行垃圾回收器,对于单处理器系统真是绝佳上选,它为单线程环境设计,只使用一个单独的线程进行垃圾回收,通过冻结所有应用程序线程进行工作。冻结应用线程可能会导致应用程序卡顿,而出现不好的用户体验,因此这种垃圾回收器不适用于服务器环境。
并发标记扫描垃圾回收器(CMS Garbage Collector)
Yong 区:通过JVM参数 XX:+USeParNewGC 打开并发标记扫描垃圾回收器
Old 区:通过JVM参数 -XX:+UseConcMarkSweepGC 打开并发标记扫描垃圾回收器
CMS(-XX:+ UseConcMarkSweepGC)收集器在年老代中使用,专门收集那些在主要回收中不可能到达的年老对象。CMS 是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。CMS采用的基础算法是:标记—清除。
并行垃圾回收器(Parallel Garbage Collector)
Yong 区:通过JVM参数 -XX:+UseParallelGC 打开并行垃圾回收器
Old区:通过JVM参数 -XX:+UseParallelOldGC 控制
它是JVM的默认垃圾回收器。与串行垃圾回收器不同,它使用多线程并发进行垃圾回收。但相似的是,当执行垃圾回收的时候它也会冻结所有的应用程序线程,它的最大的优点就是它使用多个线程来扫描及压缩堆。它的缺点就是不管执行的是minor GC还是full GC它都会暂停应用线程。
G1垃圾回收器(G1 Garbage Collector)
Yong区和Old区:通过JVM参数 -XX:+UseG1GC 使用G1垃圾回收器
它在JDK 7update 4中首次引入,适用于堆内存很大(大于4G)的情况,他将堆内存分割成不同的区域,大小从1MB到32MB不等,并使用多个线程并发的对其进行垃圾回收
这里对 JVM 垃圾回收器的简要用法介绍,后续会详细说明。这里需要注意的是,从源码可以看到 JVM 的垃圾回收器如果相互之间混合使用会导致验证不通过,也就是说,JVM 中有些垃圾回收器是互斥的,不能同时使用。因此在设置GC调优参数时,需要留心。
设置线程栈大小
1 | if (threadStackSize == 0) { |
Java程序中,每个线程都有自己的Stack Space(堆栈),常见的递归调用时压入Stack Frame(栈帧)。当递归调用太深的时候,就有可能耗尽Stack Space,导致抛出StackOverflow的异常。可通过参数JVM启动参数 -Xss 设置线程栈的大小
调用 java main方法
1 | { /* Create a new thread to create JVM and invoke main method */ |
总算到这一步了,在上面一系列关于 JVM 的参数设置工作完成后,在java.c文件中 main函数的结尾处就会调用 java的main方法了。要调用 java的main方法,首先要找到 java的 main函数所在的类。其中主要的实现就是JavaMain方法了,下面来看一下它的源码
1 | if (jarfile != 0) { |
JavaMain方法比较长,如要查看完整代码,自行下载源码,这里我只贴出关键部分的代码。上面这个代码就是查找mainClassName的代码,也就是获得主函数的类名。通过源码最外层的 if…else 可以知道有两种方式去获得主函数的类名,一种是通过jar包中的META-INF/MANIFEST.MF文件中指定的主函数类,去获得主类名。另一种方式是直接通过类名,这种方式需要在启动的时候指定主类名。在获得主类名后,就是去获取主类里的main方法了。源码如下
1 | /* Get the application's main method */ |
从源码中可以看出,在获得主函数后,还对主函数做了一些校验,其中包括主函数的名字,参数,确保主函数是public修饰的等。随后就是执行主函数了,源码如下
1 | /* Invoke main method. */ |
至此 JVM 启动全部完成了,接下来就会进入 java 的主函数执行具体业务代码。概括来说 JVM 启动主要包括几个步骤 1. 通过jvm.cfg加载 jvm.dll 文件 创建 JVM环境。2. 设置 JVM 参数,主要就是 GC 策略的设置以及分代堆内存的设置,线程栈大小的设置。3 . 找到 java的主类,运行java main函数。
上图大致描述了 JVM 启动的主要步骤,仅做参考。
打赏一个呗