Java在诞生之初就提出了"Write once,Run Anywhere"
的口号,而这些都得益于JVM(Java Vritual Machine),可以提前在不同的运行环境(linux或windows等)上安装JDK之后,就可以让同一份代码在任何地方运行了。而这里的JDK(Java Development Kit)是Java语言、Java虚拟机和Java类库的统称。
一、JDK体系结构与Java跨平台特性
1.1 JDK体系结构
JDK是一个非常庞大的体系,里面包含了JVM、Java SE API(核心类库)、常用工具(javac等)以及Java语言等等一系列内容,其具体的体系结构图如下所示:
1.2 Java跨平台特性
Java的跨语言特性在文章开始已经提过了,通过在不同系统安装对应的JDK(主要是JVM)版本就可以实现,大概如下图所示:
二、JVM整体结构与内存模型
2.1 JVM整体结构
JVM称为Java虚拟机,作为一个虚拟机它首要的功能就是对应用程序的线程等分配内存空间,而JVM还要负责加载类、解析执行编译后的字节码文件,所以整个JVM主要就包含了运行时数据区、类加载子系统和字节码执行引擎。
大概的结构如下图所示:
在Java虚拟机外部,还有一些本地方法接口,这些接口也是JVM经常需要调用。
2.2 内存模型
Java虚拟机的运行时数据区显然是在内存中的,所以这一部分也成为JVM的内存模型,这里要与JMM(Java内存模型)区分开。
内存模型主要包含五部分的内容:堆、栈、本地方法栈、方法区(元空间)、程序计数器。
堆:JVM管理的最大一块内存空间,它是所有线程所共享的一块区域。在虚拟机启动的时候创建,该区域的唯一目的就是为了存放对象实例,几乎所有通过new
创建的实例对象都会被分配在该区域。
栈(虚拟机栈):也可以称为虚拟机线程栈,它是JVM中每个线程所私有的一块空间,每个线程都会有这么一块空间。它的生命周期是与线程的生命周期是绑定的。虚拟机栈描述了Java中方法执行时的内存模型,即每个方法被执行的时候,线程都会在自己的线程栈中同步创建一个栈帧(Stack Frame),用于存放局部变量表、操作数栈、动态连接和方法出口等信息,每个方法从调用到完成的过程,就对应着一个栈帧在线程栈中从入栈到出栈的过程。
本地方法栈:本地方法栈与虚拟机栈的作用是相似,不同的是虚拟机栈为JVM执行的Java方法服务,而本地方法栈为JVM调用的本地方法服务。HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。
程序计数器:只需要占用一小块的内存空间,每个线程都会有自己独立的程序计数器,主要功能就是记录当前线程执行到哪一行指令了,可以看作是当前线程所执行的字节码行号指示器。
方法区(元空间):在JDK 8之前,方法区也称之为永久代,这部分区域与堆一样,是所有线程所共享的,它主要用于存放被虚拟机加载的类型信息、常量、静态变量以及即时编译器编译后的代码缓存等数据。对于一个Class文件,除了版本、字段、方法、接口等描述信息外,还有常量池表,在上一篇文章《JVM类加载机制》中提到过,主要用于编译器生成的各种字面量和符号引用,而这部分内容在Class文件加载后是存放在方法区的运行时常量池中。这个运行时常量池自然还包括了字符串常量池,但需要注意的是,在JDK 7以后的版本中,字符串常量池和静态变量等被移至到了Java堆区,而到了JDK 8,抛弃了之前永久代的概念,通过在本地内存中实现了元空间(Meta-space)来代替永久代,并把JDK 7中永久代剩余内容(主要是类型信息)全部移至到了元空间。
所以,方法区是使用直接内存来实现,这与堆是不一样的,也就是堆和方法区用的并不是同一块物理内存。
直接内存:直接内存并不是JVM运行时数据区的一部分,其分配不会受Java堆大小的限制。
下面以一个列子来演示JVM内存模型的各个部分,创建一个测试类如下:
public class Test {
public int compute(){
int a = 4;
int b = 5;
int c = a * b * 6;
return c;
}
public static void main(String[] args) {
Test test = new Test();
int compute = test.compute();
System.out.println(compute);
}
}
将Test类编译之后,然后在控制台通过javap -c Test.class
命令输出Class文件对应的字节码指令,下面主要分析compute()
方法的字节码指令,结合JVM的内存模型。
public int compute();
Code:
0: iconst_4
1: istore_1
2: iconst_5
3: istore_2
4: iload_1
5: iload_2
6: imul
7: bipush 6
9: imul
下面是执行Test的main方法时,线程栈的模型,下面结合下面的模型和字节码指令来介绍运行时数据区的各个模块。
当执行Test类的main方法时,首先会将Test.class
文件加载进JVM的方法区,主要有常量池和方法定义,而常量池中此时包含一个compute()
的符号引用,而在执行main方法,当调用compute()
方法时,会将compute()
的符号引用变成该方法具体在方法区的内存地址(直接引用),因为这个过程是在程序运行时发生的,所以称为动态链接。
在执行main()
和compute()
方法时会分别在main线程栈中创建两个栈帧,因为main()
先调用,所以其栈帧处于栈的底部。
通过compute()
内存地址找到该方法包含的字节码指令就可以执行compute()
方法了。
1、iconst_4
表示将int类型数值4压入操作数栈中,此时compute()
栈帧中的操作数栈中就只有一个数字4
2、istore_1
表示将操作数栈顶的int类型数值放入到局部变量表的第二个槽位中(出栈),此时操作数栈就没有元素了。
注:局部变量表中,是以槽位为基本单位来存储数值,第一个槽位的索引下标为0,且存放的局部变量为当this引用
3、iconst_5
表示将int类型数值5压入操作数栈中
4、istore_2
表示将操作数栈顶的int类型数值放入到局部变量表的第三个槽位中
5、iload_1
表示将局部变量表的第二个槽位中int类型的数值放入到操作数栈的栈顶
6、iload_2
表示将局部变量表的第三个槽位中int类型的数值放入到操作数栈的栈顶
7、imul
表示将操作数栈的元素全部出栈交给CPU进行乘法运算,然后再将运算结果压入操作数栈的栈顶
第七步完成后,此时操作数栈里面的数值只有一个20
8、bipush 6
表示将单字节常量6放入到操作数栈的栈顶
9、imul
将操作数栈中的int类型的元素20和6出栈交给CPU进行乘法运算,然后将计算结果120压入操作数栈的栈顶
10、istore_3
表示将操作数栈顶的int类型数值放入到局部变量表的第四个槽位中
11、iload_3
表示将局部变量表的第四个槽位中int类型的数值放入到操作数栈的栈顶
12、ireturn
表示从当前方法返回int类型的数值,即操作数栈中的数值。
至此compute()
方法就执行完成了,执行完之后,需要知道将返回到哪里去继续执行,这就是方法出口的作用,它会记录方法执行完之后,跳转到哪里。
注:对于这些常用的字节码指令可以参考周志明的《深入理解Java虚拟机》
三、JVM内存参数
JVM内存模型中的这些区域,都是有大小限制的,当然也可以通过JVM提供的参数来设置这些区域所占内存的大小。
补充:在JVM堆中的实例对象,会按照它们的年龄或大小分为新生代或老年代,而新生代的内存空间又分为Eden区和Survivor区,如下图所示:
至于为什么要这样划分内存空间,会在下篇文章介绍JVM的垃圾收集机制时详细介绍。
下面介绍这些常用参数的含义:
-Xss:表示每个线程栈的大小
-Xms:表示堆空间的初始可用大小,默认为物理内存的1/64
-Xmx:表示堆空间的最大可以大小,默认为物理内存的1/4(这个参数在应用程序中是一定要指定的)
-Xmn:表示新生代(年轻代)的大小
-XX:NewRatio:默认为2,表示新生代占年老代的1/2,占整个堆内存的1/3。
-XX:SurvivorRatio:默认为8,表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。
-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1
, 即不限制, 或者说只受限于本地内存大小。
-XX:MetaspaceSize: 指定元空间触发Full Gc
的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发full gc
进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize
(如果设置了的话) 的情况下, 适当提高该值。
由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般推荐将这两个值都设置为256M。