Java虚拟机 101 - 一个粗浅的概述

文章目录

初学JVM的笔记。包括Class文件、类加载机制、Java虚拟机内存各个区域等。

链接:

Java8虚拟机规范

参数设置文档

--

Class文件

一个Java文件从编码完成到最终执行,一般主要包括两个过程:编译和运行。其中编译就是把写好的java文件,通过javac命令编译成Class文件(字节码.class文件),运行则是把编译生成的.class文件交给Java虚拟机执行。

Java虚拟机的语言无关性

在Java技术发展之初,设计者们就考虑并实现了让其他语言运行在Java虚拟机上的可能性。在发布规范文档时就把Java的规范拆成了《Java语言规范》和《Java虚拟机规范》。Java虚拟机不与任何语言绑定,它只与Class文件(.class字节码)有关。Kotlin、Clojure、Groovy、JRuby、JPython、Scala等语言都运行在Java虚拟机上。

Class文件概述

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有任何分隔符。Class文件采用一种类似C语言结构体的伪结构来存储数据,这种伪结构只有“无符号数”和“表”两种数据类型。

文件结构:

  1. 每个Class文件头四个字节为“魔数”,用来标识这个文件是否为一个Class文件(很多文件格式标准中都是用魔数来标识的,比如gif图片或者jpeg图片在开头都有魔数)。

  2. 之后是主次版本号。高版本的jdk能运行之前的,但不能运行之后的版本。

  3. 之后是常量池。常量池是Class文件结构中与其他项目关联最多的数据。常量池中每一个常量都是一个表。主要存放两大类常量:字面量和符号引用。

    • 字面量是文本字符串、final常量值等。
    • 当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
  4. 之后是访问标志。用于识别一些类或者接口层次的访问信息。比如,这个是类还是接口;是否为public类型;是否定义为abstract类型;如果是类是否被声明为final等。

  5. 之后是类索引(this_class)和父类索引(super_class)和接口索引集合(interfaces)。Class文件中由这三项数据来确定该类型的继承关系。

  6. 字段表。用于描述接口或者类中声明的变量。包括类级变量和实例级变量,但不包括在方法内部声明的局部变量。有一些信息适合使用标志位来表示,比如访问修饰符、实例变量还是类变量等;还有一些只能引用常量池中的常量,比如字段的名字、字段的数据类型。

  7. 方法表。方法表结构与字段表类似,不过是记录方法的。

    • 在Java语言中,要重载一个方法,除了要与原方法具有相同的名称之外,还要求拥有一个与原方法不同的特征签名。而特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,因此无法仅仅依靠返回值来对一个方法进行重载。
    • 在Class文件格式中,特征签名的范围更大一些,如果两个方法有相同的名称和特征签名,但返回值不同,那么也可以合法共存于一个Class文件中。
  8. 属性表。Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。

类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

一个类的生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。其中验证、准备、解析被称为连接(Linking)。

其中,解析阶段在某些情况下可以在初始化之后再开始,这是为了支持Java的运行时绑定特性。开始加载阶段的时机并没有被强制约束,但是对初始化阶段做了严格要求,规定在且只在六种情况下初始化需要立即执行。

  1. 加载阶段,需要完成三件事:

    • 通过一个类的全限定名来获取定义此类的二进制字节流。

    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

    • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

  2. 验证。这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。包括,文件格式验证、元数据验证、字节码验证、符号引用验证。

  3. 准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

    • 初始值通常为对应数据类型的零值。
    • 这些变量所使用的内存都应当在方法区中进行分配,但方法区本身是一个逻辑上的区域。
    • 这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
  4. 解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。对于类或接口的解析、字段解析、方法解析、接口方法解析均有相应的规则。

  5. 初始化阶段。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

    • 初始化阶段就是执行类构造器<clinit>()方法的过程。

    • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问(可以i=0,不能System.out.print(i))。

类加载器

上面类加载阶段的加载阶段中,“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

在Java虚拟机的角度,只有两种不同的类加载器:启动类加载器(BootstrapClassLoader),用C++实现,是虚拟机的一部分;另一种是其他所有类加载器,由Java实现,全部继承Java.lang.ClassLoader。

双亲委派机制

站在Java开发人员的角度,自jdk1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。JDK 8及之前的绝大多数Java程序都会使用到以下3个系统提供的类加载器进行加载:

  • 启动类加载器(Bootstrap Class Loader)。存放在JAVA_HOME/lib目录,或者被-Xbootclasspath参数指定。
  • 扩展类加载器(Extension Class Loader)。负责加载JAVA_HOME/lib/ext目录中,或者被指定的路径。这是一种Java系统类库的扩展机制。
  • 应用程序加载器(Application Class Loader)。有些场合也称它为系统类加载器,它负责加载用户类路径(ClassPath)上所有类库。

各个类加载器之间的层次关系被称为类加载器的“双亲委派模型”,该模型要求除了顶层的启动类加载器之外,其余的类加载器都应有自己的父类加载器。这里的父子关系一般不是以继承关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

协作关系“通常”为:启动类加载器 <= 扩展类加载器 <= 应用程序加载器 <= [自定义类加载器 自定义类加载器 ....]

”通常“指的是,这个并不是一个具有强制约束力的模型,而是Java设计者给开发者推荐的一种类加载器实现的最佳实践。

在该模型中,如果一个类加载器收到了类加载请求,他不会自己尝试去加载这个类,而是把这个请求委派给父类加载器去完成,所有的加载请求最终都应该传送到最顶层的启动类加载器,当父类加载器反馈自己无法完成这个加载请求时,就让子加载器去完成加载。

这样子的好处是:Java中的类随着它的类加载器一起具备了一种带有优先级层次的关系,比如java.lang.Object类就每次委派给启动类加载器加载了,就不会出现多个不同的Object类了。

这个机制的实现很简单,就是java.lang.ClassLoader的loadClass()方法.

Java虚拟机内存各个区域

有些区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

程序计数器

一块较小的内存空间,当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。各种控制流基础功能都依赖这个计数器来完成。

多线程是通过线程轮流切换来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。因此这类内存区域为线程私有的。

这个区域是唯一一个没有规定任何OutOfMemoryError的区域。

Java虚拟机栈

这个区域也是线程私有的。描述Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,用于存放局部变量表、操作数栈、动态连接、方法出口等信息。

一个方法被调用到执行完毕,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示。局部变量表的内存空间在编译期间完成,方法运行期间不改变它的大小(即局部变量槽的数量)。

两类异常情况:线程请求的栈深度大于虚拟机运行的深度,StackOverflowError;当栈扩展时无法申请足够内存,OutOfMemoryError。

本地方法栈(Native Method Stacks)

本地方法指的大概是Java的作用域达不到了,回去调用c语言的库。进入本地方法栈,再调用本地方法接口。

虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

Java虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构都没有强制规定,例如Hot-Spot虚拟机就把本地方法栈与虚拟机栈合二为一。

也会有StackOverflowError和OutOfMemoryError。

Java堆

是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,所有的对象实例以及数组都应当在堆上分配。(随着Java语言的发展,由于即使编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致Java对象实例都分配在堆上不是那么绝对了。)

堆内存的大小可以调节。如果Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

-Xms 设置初始化内存分配大小 默认1/64内存

-Xmx设置最大分配内存 默认1/4内存

比如-Xms1m

Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

垃圾收集GC

Java堆也是垃圾收集器管理的内存区域。垃圾收集大部分时基于分代收集理论设计的,但是现在HotSpot里面也出现了不采用分代设计的新垃圾收集器。

分代假说大概是,收集器将Java对划分不同区域,然后将回收对象依据其熬过垃圾收集过程的次数分配到不同区域之中存储。

如果一个区域中大多数对象都难以熬过垃圾收集过程,就把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果是长寿的对象,就把它们集中起来,再低频率地进行回收。

因此会有新生代、老年代、永久代;Minor GC、Major GC、Full GC等术语。

-XX:+PrintGCDetails打印出来GC信息。

几种常见的GC算法

  • 标记-复制算法。每次GC都会将Eden活的对象移到幸存区to,幸存区from区里的对象也进入to区,然后这个to区就叫from区了,每次谁空谁是to。默认当一个对象经历了15次GC还没死,就移入老年区。通过-XX:MaxTenuringThreshold=999来设定这个参数。对象存活度较低的时候使用这种方法。

  • 标记清除法。最早出现。扫描一次标记活着的对象,再扫描一次清楚没有标记的对象。缺点,两次扫描,浪费时间,而且会产生内存碎片。优点是不需要额外空间。

  • 标记整理(mark-compact)。扫描一次标记活着的对象,再次扫描,向一段移动存活的对象。

方法区

是线程共享的内存区域,它用于存储所有定义的方法的信息、已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

虽然Java虚拟机规范中把方法区描述为堆的一个逻辑部分,但是它被别称为“非堆”(Non-Heap),目的是与Java堆区分开来。

在JDK6以前,当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,用永久代来实现方法区,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作,因此有许多人喜欢把方法区叫做永久代。JDK8以后就完全废弃了永久代的概念,改用元空间,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

Java虚拟机规范对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。以前Sun公司的Bug列表中,曾出现过若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。根据Java虚拟机规范的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

运行时常量池

是方法区的一部分。之前Class文件中,有一项信息就是常量池表。用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

运行时常量池是方法区的一部分,因此当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OOM异常出现。

JDK1.4中加入了NIO类,引入了一种基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

本机直接内存的分配不会受到Java堆大小的限制,但是会受到本机总内存的限制。服务器管理员可能在配置各个内存区域时让它们的总和大于本机的物理内存,于是导致动态扩展时出现OOM异常。

对象创建过程

当Java虚拟机遇到一条字节码new指令时,首先回去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,就先执行前述类加载过程

类加载检查通过后,虚拟机就为新生对象分配内存,对象所需内存大小在类加载完成后就完全确定。

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值。接下来,虚拟机还要对对象进行必要的设置,比如这个对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码等,这些信息存放在对象的对象头中。

上面工作完成后,从虚拟机视角来看,一个新的对象就产生了。之后就是执行类构造器<clinit>()方法的过程了。

其他

Jprofiler

分析Dump内存文件,快速定位内存泄漏。

获得堆中数据

获得大的对象

-XX:+HeapDumpOutOfMemoryError

JMM

Java memory model

主内存,然后每个线程有自己的工作内存。JMM就作为缓存一致性协议,用于定义数据读写的规则。

JMM对JVM的八种指令的使用制定了规则。

后续

打算看:

  • 《Java性能权威指南》。其中包括:使用 JDK 中自带的工具收集 Java 应用的性能数据,理解 JIT 编译器的优缺点,调优 JVM 垃圾收集器以减少对程序的影响,学习管理堆内存和 JVM 原生内存的方法,了解如何最大程度地优化 Java 线程及同步的性能,等等。
  • 《深入理解Java虚拟机》。略翻了一下都挺有收获。
comments powered by Disqus