0%

Java JVM Study I

知识犹如人体的血液一样宝贵。——高士其

每种语言都有其独特的优势和特点。对于Java来说,其独特之处主要可以从六个方面来说:

  • 平台无关性
  • 面向对象
  • GC(垃圾回收)
  • 类库
  • 语言特性和异常处理

而Java的JVM最值得学习的点,是:JVM内存结构模型和GC

Java的平台无关性如何实现

平台无关性,实际上就是java代码”编译一次,到处执行”这个特点的简称。

3DqNYq.png

可以说,生成”.class”文件,是java跨平台的基础。

为什么JVM不把java解析成机器码然后执行呢?

主要是两个原因,一个是如果解析成机器码,需要大量准备工作,每次执行都需要根据机器的内部构造去设定值。另一个原因是,解析成class文件,可以让JVM虚拟机发挥更大的作用,比如Scala语言也可以通过解析成class文件来通过JVM虚拟机运行。

JVM如何加载class文件

Java是跨平台的,这意味着 Java 开发出来的程序经过编译后,可以在 Linux 上运行,也可以在 Windows 上运行;可以在 PC、服务器上运行,也可以在手机上运行;可以在 X86 的 CPU 上运行,也可以在 ARM 的 CPU 上运行。

不同操作系统,特别是不同 CPU 架构,是不可能执行相同的指令的。Java之所以拥有能够在不同平台运行的神奇特性,就是因为 Java 编译的字节码文件不是直接在底层的系统平台上运行的,而是在 Java 虚拟机 JVM 上运行,JVM 屏蔽了底层系统的不同,为 Java 字节码文件构造了一个统一的运行环境。JVM 本质上也是一个应用程序,启动以后加载执行 Java 字节码文件。

JVM的是内存中的虚拟机。

JVM的详细的内存结构模型如图:

JVM详细内存结构模型

只要符合class Loader的格式要求就能加载。

因为java执行效率不如C和C++,有时候需要用Native Interface去调用别的语言的库。

Class.forName()作用:返回与给定字符串名称相关联的类或者接口对应的class对象。

类从编译到执行的过程

  • 编译器将**.java源文件编译为.class字节码文件
  • ClassLoader将字节码转化为JVM的Class对象
  • JVM利用Class对象实例化Robot对象

对上图进行简化之后可以得到:

JVM简化内存结构模型

什么是反射?

理论上来说:

Java反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性;这种动态获取信息以及动态调用对象方法的功能称为Java语言的反射机制。

但是这里回答比较理论,最好可以列举几个常用的反射函数,或者写反射的例子。

重要的部分:Class、Class.forName、newInstance()、Method、getDeclareMethod、setAccessible()、invoke()

NOTE:

  • “getDeclardeMethod”可以获取public, private, protected的方法,但是不能获取继承的方法和实现接口的方法
  • “getMethod”可以获取public方法,此外还可以获取继承的方法和实现接口的方法
  • 反射就是把java中的各种成分映射成java对象,比如这里映射成了class,filed,method

ClassLoader

类从编译到执行的过程

以Robot.java这个源文件的执行流程举例:

  • 编译器将Robot.java源文件编译为Robot.class字节码文件
  • ClassLoader将字节码转换为JVM中的Class对象
  • JVM利用Class对象实例化为Robot对象

谈谈ClassLoader

ClassLoader在Java中有着非常重要的作用,他主要工作在Class装载的加载阶段,它的主要作用是从系统外部获得Class二进制数据流。它是Java的核心组件,所有Class都将由ClassLoader进行加载,ClassLoader负责通过将Class文件里的二进制数据流装载进系统,然后交给Java虚拟机进行连接、初始化等操作。

ClassLoader是一个抽象类(abstract class)。

  • BootStrapClassLoader:C++编写,加载核心库java.*
  • ExtClassLoader:Java编写,加载扩展库javax.*
  • AppClassLoader:Java编写,加载程序所在目录,类路径
  • 自定义ClassLoader:Java编写,定制化加载

回答流程:ClassLoader的作用 -> 本质 -> 种类

类加载器(ClassLoader)的双亲委派机制

首先需要明确,不同的ClassLoader加载类的方式和路径有所不同,为了提高效率,各自有各自的分工,各自有各自负责的区块,使得逻辑更加明确,我们才能够有各种能够相互共存的ClassLoader。

实际上不同的ClassLoader在执行任务的时候都是各司其职,所谓我们需要一个机制,让各种类加载器之间能够相互协作,形成一个整体,这个机制就是双亲委派机制。

整体原理如图:

3ISDZ8.png

  1. 自底向上检查类是否已经加载
  2. 自顶向下尝试加载类

为什么要用双亲委派机制去加载类?

双亲委派机制能避免多份同样字节码的加载。

比如常用的System.out.println(),我们其实只需要一个System的静态class,而且只需要一份,然后反复用即可。如果不用委托的方式,类A用的时候要加载一次,类B再用的时候又要再加载一份System字节码。这样内存中就会存在多份System字节码。相反如果用了委托机制,每次使用的时候都会由下往上逐层查找,看哪一个ClassLoader装载过这个类。对于System类来说,因为它是属于Bootstrap ClassLoader管辖范围内的,假设A是第一次调用,那么这时候会用Bootstrap ClassLoader将System类装载进去。在B调用的时候,也会逐层查找。查找过程会发现之前已经被Bootstrap加载过了,就会直接拿过来用即可了。

loadClass和forName的区别

类的加载方式

  • 隐式加载:new,这种方法比较常用。程序运行过程中通过new定义对象的时候,隐式调用类加载器,加载对应的类到JVM中。使用了new之后不再需要调用class对象的newInstance()方法再去获得对象的实例了。
  • 显示加载:loadClass、forName等。对于显示加载来说,在我们获取到了class对象之后,需要调用class对象的newInstance()方法来生成对象的实例。

此外,new支持调用带参数的构造器来生成对象实例。而class对象的newInstance()方法则不支持传入参数,需要反射,调用构造器的newInstance()方法才能支持参数。

类的装载过程

之前我们说的类的装载和加载都是一个意思(都是表示加载)。但是这里为了区分,我们用装载表示class对象的生成过程,加载只是其中的一个部分

Java类装载有三个部分:

Java类装载过程

loadClass和forName的区别

这两个方法俩都是显示加载。

首先,他们俩都能在运行的时候知道某个类的所有属性和方法。

但是他俩存在区别:

  • Class.forName得到的class是已经初始化完成的
  • Classloader.loadClass得到的class是还没有链接的,还没有初始化的。

loadClass和forName的区别在实际中的作用:比如我们要在程序中连接MySQL,这个时候需要加载driver。此时我们就不能用loadClass而必须用forName。

为什么呢?因为MySQL的jar包里面的代码,Driver初始化是写在static代码块里的,所以必须调用forName才能执行它,进而实例化Driver对象,创建数据库驱动。

那么什么时候要用到loadClass呢?——Spring IOC中,在资源加载器获取要读入的资源的时候,其读取一些bean的配置文件的时候,如果以classpath的方式加载,就需要使用classLoader.loadClass来加载了。因为Spring IOC大量使用了延时加载,也就是懒加载。为了加快加载速度,Spring IOC大量使用了延时加载技术,而使用classLoader不需要执行类的初始化代码和链接步骤,这样可以加快类的初始化速度,把类的初始化工作留到实际使用这个类的时候再去做。

Java内存模型(Java Memory Model)

Java内存模型具体指的实际就是上面的”Runtime Data Area”,或者叫”运行期数据区”

内存简介

计算机所有程序都是在内存中运行的,只是这个”内存”包括虚拟内存,也包括”硬盘”这样的外存支持。

在程序运行的过程中,需要不断将内存的逻辑地址和物理地址进行映射,找到相关的指令和数据去执行。

内存简介

地址空间的划分

内核是主要的操作系统程序与C运行时的空间,包含用于连接计算机硬件,调度程序与提供联网和虚拟内存服务的逻辑和基于C的进程。

用户空间才是Java进程实际运行时使用的内存空间。

如图:

3IRg4e.png

32位系统进程最多可以访问3GB,内核代码可以访问所有物理内存。64位系统进程最多可以访问512GB,内核代码同样可以访问所有物理内存。

线程独占部分

哪些是线程独占的,哪些部分又是线程共享的呢?

以JDK8为例进行分析(JDK其他版本也有类似之处)。

JDK8内存模型

下面对每一块线程独占的部分都进行介绍:

程序计数器(Program Counter Register,即PC)

  1. 它是一块较小的内存空间,它可以看做是当前线程所执行的字节码行号指示器(逻辑)

  2. 在虚拟机的概念模型里,字节码解释器工作时,通过改变计数器的值来选取下一条需要执行的字节码指令。包括分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成。

  3. 由于JVM的多线程是通过线程之间的来回切换,并且分配处理器执行时间的方式来实现的,所以在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,为了确保线程在切换后能回到正确的位置,每条线程都需要有一个独立的程序计数器,并且各条线程之间的计数器互不影响,独立存储。我们称这样的内存为”线程私有”的内存,这个计数器的值也和线程之间是一对一的关系。

  4. 如果一个线程正在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,那么如果正在执行的是Native方法,则计数器值会为"Undefined"

  5. 此外,由于只是记录行号,程序计数器不会存暴掉,即程序计数器不会存在内存泄漏的问题。

小总结:程序计数器是逻辑计数器,而不是物理计数器。为了线程切换后都能回到正确的执行位置,每个线程都有一个独立的程序计数器,它是线程独立的,并且只为Java方法计数。Native方法对应的程序计数器则是Undefined。使用程序计数器,不用担心会发生内存泄漏的问题。

  • 当前线程所执行的字节码行号指示器
  • 改变计数器的值来选取下一条需要执行的字节码指令
  • 和线程一一对应
  • 只对Java方法计数,如果是Native方法则计数器值为Undefined
  • 不会发生内存泄漏

Java虚拟机栈(Stack)

Java虚拟机栈也是线程私有的,是Java方法执行的内存模型。每个方法被执行的时候都会创建一个栈帧,结构如下图:

3IqmNR.png

图中的局部变量表和操作数栈有什么区别?

  • 局部变量表:包含方法执行过程中的所有变量,包含this引用、所有方法参数,其他局部变量(包括布尔值、Byte、char、long、short、int、float、double等等类型)
  • 操作数栈:在执行字节码指令过程中被用到,这种方式类似原生CPU寄存器。大部分JVM字节码把时间花费在操作数栈的操作上,包括入栈、出栈、复制、交换、产生消费变量

栈本身是一个后进先出的数据结构。因此当前执行的方法在栈的顶部,每次方法调用时一个新的栈帧创建,并压入栈顶。当方法正常返回或抛出未捕获的异常时,栈帧就会出栈。除了栈帧的压栈和出栈,栈不能被直接操作。

解读Java可能出现的异常

java.lang.StackOverflowError

递归为什么会引发java.lang.StackOverflowError异常?

递归执行次数过多,栈帧过高。每次调用递归,都会创建一个对应的栈帧,并把建立的栈帧压入虚拟机栈中。

  1. 如果递归层数过高,不断调用自身,每新调用一次方法,就会生成一个栈帧。
  2. 它会保存当前方法的栈帧状态
  3. 栈帧上下文切换的时候会切换到最新的方法栈帧当中

如果递归调用过多,则会产生过多的栈帧,栈帧超过虚拟栈深度限制,就会报错。

解决的方法主要是限制递归的次数,或者可以直接用循环替换递归。

java.lang.OutOfMemoryError

虚拟机栈过多会引发java.lang.OutOfMemoryError异常。当虚拟机栈可以动态扩展时,如果无法申请足够多的内存,就会抛出这个异常。

如果虚拟机栈可以动态扩展,并超出内存,就会报这个错误。

本地方法栈

  • 与虚拟机栈类似,主要作用于标注了native的方法

带有native关键字的方法,比如之前的forName0()之类的方法,用的是本地方法栈。

小总结

虚拟机栈是Java虚拟机自动管理的。栈类似一个集合,但是有容量限制,由多个栈帧组合而成。编写代码的时候,每调用一个方法,Java虚拟机就为其分配一块空间,就增加一层栈帧。而当方法调用结束后,对应的栈帧就会被自动释放掉。这就是为什么栈不需要GC,但是堆需要

线程共享部分

从之前”JDK8内存模型”这张图中可以看到,JVM里线程共享的主要是两个部分:MataSpace和Java堆

元空间(MetaSpace)与永久代(PermGen)的区别

JDK8之后,MetaSpace开始把类的元数据放在本地堆内存中,这段区域在JDK7以及以前,都是属于永久代的。

元空间和永久代都用来存储class的相关信息,包括class对象的方法和filed等。

元空间永久代都是方法区的实现,实现方法不同。方法区只是一种JVM的规范。

Java7之后,把原先位于方法区的字符串常量池移动到了Java堆中,并且在JDK8之后,使用源空间替代了永久代。

这一替代不仅是名字上的替代,两者最大的区别是:元空间使用本地内存,而永久代使用的是JVM的内存。这样设置的一个好处就是解决了内存不足的问题,java.lang.OutOfMemoryError:PemGen space这个错误将不复存在。因为此时MetaSpace的大小取决于本地内存的大小。本地内存有多大,MetaSpace就有多大。当然,实际运行的时候不可能放任MetaSpace的壮大,JVM在运行的时候会根据需要动态地设置其大小。

MetaSpace相比PermGen的优势

  • 字符串常量池存在永久代中,容易出现性能问题和内存溢出
  • 永久代类和方法的信息大小难以确定,给永久代的大小指定带来困难
  • 永久代会为GC带来不必要的复杂性,而且回收效率可能较低
  • 用元空间方便HotSpot与其他JVM如Jrockit的集成

重点记住:元空间和永久代的主要区别,是前者内存空间主要使用的是本机内存。MetaSpace没有了字符串常量池,它在JDK7中已经被移动到了堆中。MetaSpace其他存储的东西,包括类文件,在JVM运行时候的数据结构,以及class相关的内容,如method,filed,大体上都与永久代一样,只是划分上更加合理。比如类元素的生命周期与类加载器一致,每个类加载器(classLoader)都会分配一个单独的存储空间。

Java堆(Heap)

对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有内存共享的一块内存区域。在虚拟机启动时创建此内存区域的唯一目的,就是存放对象实例,几乎所有的对象实例都在这里分配内存。

以一个32位处理器的Java内存布局为例:

32位处理器的Java内存布局

可以看到,Java堆会占用非常大的一块内存。

此外,Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为GC堆。

如果从内存回收的角度看,由于现在的收集器基本都采用分代收集的算法,所以Java堆中还可以细分为新生代和老年代。

可以参见下图:

GC堆

Java内存模型常考题解析

JVM三大性能条有参数:-Xms -Xmx -Xss的含义

在调用java指令执行程序的时候,我们可以使用这三个参数去调整Java堆和线程所占内存的大小。它们的作用分别是:

  • -Xss:规定了每个线程虚拟机栈(堆栈)的大小
  • -Xms:堆的初始值
  • -Xmx:堆能达到的最大值

-Xss一般来说256K就够了,此配置会影响此进程中并发线程数的大小。

-Xms为初始的Java堆的大小,即该进程刚创建出来的时候它的专属Java堆的大小。一旦对象容量超过了Java堆的初始容量,Java堆会自动扩容,扩容至-Xmx的大小。

在很多情况下,我们通常将-Xms和-Xmx设置成一样大,因为当Heap不够用而发生扩容时,会发生内存抖动,影响程序运行时的稳定性。

当程序运行比较慢,或者频繁报出内存方面的错误的时候,可以试着从这三个参数入手,尝试调整堆和线程的大小,以求程序性能可以达到要求,但是在程序性能还能满足要求的时候没必要调整。

Java内存模型中堆和栈的区别——内存分配策略

  • 静态存储:编译时确定每个数组目标在运行时的存储空间需求。这种方式不允许程序中有可变数据结构的存在,也不允许有递归和嵌套的结构出现,因为它们都会导致编译程序无法计算准确的存储空间。
  • 栈式存储:数据区需求在编译时未知,运行时模块入口前确定。这种方式也可以被称为动态的存储方式。分配过程和栈一样,先进后出的方式。
  • 堆式存储:编译时或运行时模块入口都无法确定,动态分配。比如可变长度串和对象实例。

Java内存模型中堆和栈的区别

  • 两者联系:引用对象、数组时,栈里定义变量保存堆中目标的首地址。

创建好的数组和对象都会保存在堆中,想要引用堆中的某个对象或者数组,可以在栈中定义一个特殊的变量,让栈中的这个变量保存目标对象或者数组在堆中的首地址,由此一来,栈中的这个变量就是数组或者对象的引用变量,以后就可以在程序中使用栈的这个引用变量来访问堆中的数组或者对象。引用变量相当于是为数组或者对象起的一个名称。

引用变量也是一个普通变量,在程序运行到其作用域之外后就会被释放掉。而数组和对象本身在堆中分配,即使程序运行到了其范围之外,它们本身也不会被释放掉。它们在没有引用变量指向的时候,才会变成垃圾,在后面被GC掉。

如图:

堆和栈的区别

具体区别:

  • 管理方式:栈可以自动释放,堆需要GC
  • 空间大小:栈比堆小。栈存储地址,堆在Java程序中占的空间总是比较大,因为要存储对象实例和数组。
  • 碎片相关:栈产生的碎片远小于堆。堆虽然有GC,但是不是实时的,随着使用就会积攒起来碎片。而栈的操作基本都是一一对应的,而且每一个最小单位和堆空间里面复杂的结构不同。所以栈在使用过程中很少出现内存碎片。
  • 分配方式:栈空间支持静态和动态分配两种方式,而堆仅支持动态分配。而且栈空间分配的内存完全不需要考虑释放的问题,而堆空间虽然有GC,但是我们还是要考虑其垃圾释放的问题。
  • 效率:栈的效率比堆高

栈空间只有两个操作:入栈和出栈。

栈空间相比堆空间,弱点是灵活度不高,尤其是在动态网页的时候。而堆空间优点在于动态分配,实现方式可能是动态链表的结构,所以其管理的时候操作方式也比栈空间复杂很多。但是也使得堆空间的效率不如栈空间,而且要低很多。

元空间(Meta Space)、堆、线程独占部分间的联系——内存角度

以一段程序为例:

样例程序

hw就是存在于虚拟机栈上面的一个变量,它指向我们真正创建好的HelloWorld实例。我们通过对hw的引用就可以定位到堆中的HelloWorld实例的地址,调用它的setName方法将”test”赋值到name这个成员变量里面。

样例程序执行过程:

样例程序执行过程

面试官可能会抓住不同版本的JDK的差异来做文章,其中比较有代表性的就是String的intern()方法。它的差异主要体现在JDK6和JDK6之后。

解释一下JDK6和JDK6之后版本的intern()方法的区别?

JDK版本问题

总的来说,JDK6和JDK6+版本的区别在于JDK6中通过intern()方法仅会在字符串常量池里添加字符串对象,而JDK6+不仅可以向池中添加字符串对象,还能添加字符串对象在堆中的引用。

可以举个例子,用JDK6,调用intern()方法,当字符串常量池被挤爆了之后,会报java.lang.OutOfMemoryError:PemGen space这个错。