对于开发人员来说,如何不了解 Java 虚拟机,那真的是很难写得一手好代码,同时也很难查得一手好 bug,因为我们所写的程序不可不出现 bug,那么对 Java 虚拟机非常熟悉的开发者来说,我们 Java 应用程序出现了 bug,通过日志排查问题易如反掌。
JVM 优化也是面试重灾区,求职者在求职面试中,是必问的问题。我们学习不是为了面试而面试,但是学会 JVM 核心知识,在面试过程和工作中会成为我们的亮点。
思考一下
学习一项知识总该知道为什么学习吧。有人会说,这些写代码好像又用不上,貌似所有的事情JVM都替我们做好了。那就,思考一下为什么要学习JVM虚拟机结构。
那你是否遇到这样的困惑:堆内存该设置多大?OutOfMemoryError 异常到底是怎么引起的?如何进行 JVM 调优?JVM 的垃圾回收是如何?甚至创建一个 String 对象,JVM 都做了些什么?
内存结构图
Java 虚拟机在运行 Java 程序时,把它所管理的内存划分为若干个不同的数据区域,主要包括以下五个部分:程序计数器、Java 堆、Java 虚拟机栈、方法区和本地方法栈。如图所示:
其中方法区和堆为所有线程共享的内存区域,也就是说所有的线程都可以共享。虚拟机栈、本地方法栈以及程序计数器为线程私有的内存区域,也就是说每个线程都会拥有自己独立,即线程隔离的。
那么这五块内存分别做什么的呢?接下来我们来逐个探讨一下。
各个区域详解
堆
对于大多数应用程序来说,Java 堆是 Java 虚拟机需要重点关注的一块区域,也是 Java 虚拟机内存里面最大的一块内存空间,绝大部分对象都是存储在堆内存里面的。Java 堆是用来存放对象实例及数组,也就是说我们代码中通过 new 关键字 new 出来的对象都存放在这里。所以这里也就成为了垃圾回收器的主要活动营地了,于是它就有了一个别名叫做 GC 堆,并且单个 JVM 进程有且仅有一个 Java 堆。
堆内存又做了细分,主要可以分成新生代和老年代。 Java 堆具体内存结构示如图所示:
从上图可以看出 Java 堆并不是单纯的一整块区域,实际上 Java 堆是根据对象存活时间的不同,Java 堆还被分为年轻代、老年代两个区域,年轻代还被进一步划分为 Eden 区、From Survivor 0、To Survivor 1 区。并且默认的虚拟机配置比例是 Eden:from :to = 8:1:1 。
虚拟机栈
Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
虚拟机栈是线程独享的,当创建一个线程的时候,就会创建一个虚拟机栈,虚拟机栈由栈帧组成。每一次的方法调用都会创建一个栈帧,然后去压阵。当方法返回的时候,则对应着栈桢出栈操作,也就是每调用一次方法就会往里面加入一个元素。当方法返回的时候,则会把这个元素弹出。
栈帧用于存储局部变量表、动态链接、操作数和方法出口等信息,当方法被调用时,栈帧入栈,当方法调用结束时,栈帧出栈。
局部变量表:存放了编译期可知的基本类型、对象引用类型和returnAddress类型(指向一条字节码指令的地址。即程序就是存储在方法区的字节码指令,而 returnAddress 类型的值就是指向特定指令内存地址的指针)
操作数栈:操作数是一个后入先出栈,JVM 所有的操作码都是对操作数栈上的数据进行操作,对于每一个方法的调用,JVM会建立一个操作数栈,以供计算使用。方法里面的代码在执行的时候,会从局部变量表或者是对象实例的字段里面复制变量或者是常量,然后放到操作数栈里面,当计算的时候,会使用一系列的指令,往操作数栈里面放入数据或者是取走数据进行计算。可以认为操作数栈是用来存放临时数据的地方。
方式返回地址:即本方法执行后下一步指令的地址,方法正常退出时,调用者 PC 计数器的值就可以作为返回地址,异常退出时,返回地址是要通过异常处理器来确定。
动态链接:运行期间转化为直接引用,就称为动态链接。Class字节码的常量持中存有大量的符号引用,在运行期才将符号引用变成直接引用(也就是指向数据),可以是方法或者字段的引用。
虚拟机栈定义了两种异常类型:StackOverFlowError (栈溢出)和 OutOfMemoryError(内存溢出)。如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出 StackOverFlowError。不过大多数虚拟机都允许动态扩展虚拟机栈的大小,所以线程可以一直申请栈,直到内存不足时,抛出 OutOfMemoryError。
本地方法栈
本地方法栈和虚拟机栈的功能是类似的。虚拟机栈管理 Java 方法,而本地方法栈管理的是 Native 方法,本机方法都是由 C 语言区实现的。虚拟机规范中没有对本地方法栈中使用的语言、方式和数据结构作强制规定,具体的内容由虚拟机自由实现。甚至有的虚拟机(Sun HotSpot)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
程序计数器
程序计数器是一块较小的内存空间,它可以看做当前线程所执行字节码的行号指示器。程序计数器用来记录各个线程执行的字节码的地址,像分支、循环、跳转、异常、线程恢复等等操作,都需要依赖程序计数器。
大家想,为什么要有程序计数器这块空间呢?这是因为由于 Java 是一个多线程的语言,当执行的线程数量超过 CPU 核心的时候,线程之间就会根据时间片去争抢 CPU 资源。举个例子,某一个线程它的任务还没有执行完,但是 CPU 资源就被其他的线程抢占了,那之后如果又轮到这个线程去执行,得知道从哪里继续执行,所以为每一个线程都分配一个程序计数器,用来记录它下一条运行的指令是什么。
方法区
方法区与堆有很多共性:线程共享、内存不连续、可扩展、可垃圾回收,同样当无法再扩展时会抛出 OutOfMemoryError 异常。方法区主包括了四部分类信息,运行时常量值、字符串、常量池和静态变量。方法区主要作用是用来存放虚拟机加载的类相关的信息。
量池是用来做什么的呢,我们来看一下。
静态常量池:被称为是 class 文件常量池,它主要用来存放字面量。字面量主要包括了文本字符串和 final 修饰的常量。符号引用包括了类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。这部分信息是放在静态常量池的。
运行时常量池:当类加载到内存之后,JVM 会把静态常量池里面的内容放到运行时常量池里面去。那么运行时常量池里面存储的主要是编译期间的字面量和符号引用等等。
字符串常量池:字符串常量池可以理解成运行时常量池里面分出来的一部分,当类加载到内存的时候,字符串会存到字符串常量池里面去。
值得一提的是,方法区和堆之间是存在交集的,静态变量以及字符占常量池存储在堆里面,而类的相关信息以及运行时常量池存储在元空间里面,元空间是一块本地内存。
上图指的是 JDK 8 以及之后版本的内存分布,那么事实上不同 JDK 发行版,甚至是不同的 JDK 版本实现上是有差异的。比方说我们比较熟悉的 Oracle JDK,它是一个 HotSpot 虚拟机。
HotSpot 虚拟机在 JDK 7 之前,它使用永久代来实现方法区,这个方法区会发生我们常见的 java.lang.OutOfMemoryError: PermGen space 异常,注意是永久代异常信息,我们也可以通过启动参数来控制方法区的大小:-XX:PermSize 设置方法区最小空间,-XX:MaxPermSize 设置方法区最大空间 。在 JDK 7 之前的 HotSpot 虚拟机中,纳入字符串常量池的字符串被存储在永久代中,因此导致了一系列的性能问题和内存溢出错误。特别突出的例子就是 String 的 intern()方法。但是其他的虚拟机,比方说 JRockit 虚拟机或者是 IBM 的 J9 虚拟机,根本就没有永久代。
又比如说 HotSpot 虚拟机到了 JDK 7 的时候,在原先的基础上做了一些改进,把字符串常量池
移动到了堆里面去了,而符号引用被放到了本地内线里面去了,之后到了 JDK 8,HotSpot 虚拟机就直接把永久代给干掉了,用元空间取而代之。而且将老年代与元空间剥离,元空间放置于本地的内存中,因此元空间的最大空间就是系统的内存空间了,从而不会再出现像永久代的内存溢出错误了,也不会出现泄漏的数据移到交换区这样的事情。用户可以为元空间设置一个可用空间最大值,不设置默认根据类的元数据大小动态增加元空间的容量。对于一个 64 位的服务器端 JVM 来说,其默认的 –XX:MetaspaceSize 值为 21MB。也就是说默认的元空间大小是21MB。
JDK1.8 之后的方法区为何变化如此之大?之所以要使用元空间替代永久代,主要有两方面的原因:
一方面:是 Oracle 把 HotSpot 虚拟机和 JRockit 的虚拟机都收购了,而刚刚说了 JRockit 的虚拟机没有永久代,于是为了融合 HotSpot 虚拟机以及 JRockit 的虚拟机,所以干脆把永久代给去掉了。
另一方面:原因是永久代在使用的过程中还是挺容易发生问题的。如果你真正的上线过实际项目,相信你见过这种异常:java.lang.OutOfMemoryError: PermGen 。我们知道,JDK 8 之前使用永久代来实现方法区,而方法区存储的主要是一系列的常量和类以及方法的相关信息。这块区域其实是比较难以准确确定大小的,因为每个项目所加载的类和方法都是不太一样的。你的永久代设置的太小的话,就容易出现这一块区域的内存溢出,而适度的太大又会导致内存上的浪费,于是把持久代给去掉了,用元空间代替。
那么用云空间代替有什么好处呢?元空间是一块本地内存,理论上取决于操作系统可以分配的内存大小,这样去解决的永久代空间难以分配的问题。元空间占用系统内存,也就是说,只要不碰触到系统内存上限,方法区会有足够的内存空间。但这不意味着我们不对方法区进行限制,如果方法区无限膨胀,最终会导致系统崩溃。
案例演示
探讨完 JVM 内存结构之后,我们来通过一个案例,来看看 Java 是怎么样分配存储。
# 主函数类
public class PersonTest01 {
public static void main(String [] args){
Person person=new Person("itbbfx");
person.printInformation();
}
}
#Person 类
public class Person {
private String name;
public Person(String name){
this.name=name;
}
public void printInformation(){
System.out.println(this.name);
}
}
这段代码非常的简单,在 PersonTest01 的 main 方法中创建了一个 Person 对象,然后调用 printInformation 方法。
上面这个案例代码的内存分布大致如下图所示:
在启动的时候,首先将类加载的方法区。那么这里我们需要加在两个类,一个是 PersonTest01 类,一个 Person。当执行 Person person=new Person(“itbbfx”); 的时候,首先会创建一个局部变量 person 放在栈里面,然后这个person会指向到一个引用。而真正的这个对象存储在堆里面,最后在执行 printInformation 方法。
当然上图并没有详细描写栈帧的细节。事实上执行每一个方法的时候,都会有一个进栈和出栈的操作。也就是调用 printInformation 方法的时候,会创建一个栈帧,进到栈里面去,当 printInformation 代码返回的时候,栈帧又会从站里面弹出来。
那么这里我们只是举了一个非常简单的例子,大家也要知道静态变量、常量是存放在哪里的,可以参考前面的讲解去思考一下。
总结
最后简单总结一下,本课时详细分析了 JVM 的内存结构,并且带大家分析了每一块内存是做什么的,其中比较特殊的是方法区,它并不是一个物理上的区域,而是一个逻辑上的划分。这是一个 JVM 的规范。在 JDK 8 以及更高的版本里面,方法区有一部分是放在堆里面的,还有一部分是放在元空间里面的。