1. Java虚拟机介绍

JVM在整个jdk(java 运行环境)中处于最底层,负责与操作系统的交互,用来屏蔽操作系统环境,提供一个完整的Java运行环境,因此也就虚拟计算机。Java虚拟机有自己想象中的硬件,如处理器、堆栈、寄存器等,还具有相应的指令系统。操作系统装入JVM是通过jdk中Java.exe来完成,通过下面4步来完成JVM环境.
(1)创建JVM装载环境和配置
(2)装载JVM.dll
(3)初始化JVM.dll并挂界到JNIENV(JNI调用接口)实例
(4) 调用JNIEnv实例装载并处理class类。

2. Java虚拟机的结构


几部分比较重要的java虚拟机的结构如下:

  1. JVM寄存器

    所有的CPU均包含用于保存系统状态和处理器所需信息的寄存器组。如果虚拟机定义义较多的寄存器,便可以从中得到更多的信息而不必对栈或内存进行访问,这有利于提高运行速度。然而,如果虚拟机中的寄存器比实际CPU的寄存器多,在实现虚拟机时就会占用处理器大量的时间来用常规存储器模拟寄存器,这反而会降低虚拟机的效率。针对这种情况,JVM只设置了4个最为常用的寄存器。它们是:pc程序计数器,optop操作数栈顶指针 ,frame当前执行环境指针, vars指向当前执行环境中第一个局部变量的指针, 所有寄存器均为32位。pc用于记录程序的执行。optop,frame和vars用于记录指向Java栈区的指针。

  2. JVM栈结构

    作为基于栈结构的计算机,Java栈是JVM存储信息的主要方法。当JVM得到一个java字节码应用程序后,便为该代码中一个类的每一个方法创建一个栈框架(java类中的每一个方法都对应一个栈框架),以保存该方法的状态信息。每个栈框架包括以下三类信息:(局部变量、执行环境、操作数栈)。 局部变量用于存储一个类的方法中所用到的局部变量。vars寄存器指向该变量表中的第一个局部变量。执行环境用于保存解释器对Java字节码进行解释过程中所需的信息。它们是:上次调用的方法、局部变量指针和操作数栈的栈顶和栈底指针。执行环境是一个执行一个方法的控制中心。例如:如果解释器要执行iadd(整数加法),首先要从frame寄存器中找到当前执行环境,而后便从执行环境中找到操作数栈,从栈顶弹出两个整数进行加法运算,最后将结果压入栈顶。 操作数栈用于存储运算所需操作数及运算的结果。

  3. JVM堆

    Java类的实例所需的存储空间是在堆上分配的。解释器具体承担为类实例分配空间的工作。解释器在为一个实例分配完存储空间后,便开始记录对该实例所占用的内存区域的使用。一旦对象使用完毕,便将其回收到堆中。在Java语言中,只有new语句为一对象申请和释放内存。对内存进行释放和回收的工作是由Java运行系统承担的。这允许Java运行系统的设计者自己决定碎片回收的方法。在SUN公司开发的Java解释器和Hot Java环境中,碎片回收用后台线程的方式来执行。

  4. JVM存储区

    JVM有两类存储区:常量缓冲池和方法区。常量缓冲池用于存储类名称、方法和字段名称以及串常量。方法区则用于存储Java方法的字节码。对于这两种存储区域具体实现方式在JVM规格中没有明确规定。这使得Java应用程序的存储布局必须在运行过程中确定,依赖于具体平台的实现方式。JVM是为Java字节码定义的一种独立于具体平台的规格描述,是Java平台独立性的基础。目前的JVM还存在一些限制和不足,有待于进一步的完善,但无论如何,JVM的思想是成功的。对比分析:如果把Java原程序想象成我们的C++原程序,Java原程序编译后生成的字节码就相当于C++原程序编译后的80x86的机器码(二进制程序文件),JVM虚拟机相当于80x86计算机系统,Java解释器相当于80x86CPU。在80x86CPU上运行的是机器码,在Java解释器上运行的是Java字节码。 Java解释器相当于运行Java字节码的“CPU”,但该“CPU”不是通过硬件实现的,而是用软件实现的。Java解释器实际上就是特定的平台下的一个应用程序。只要实现了特定平台下的解释器程序,Java字节码就能通过解释器程序在该平台下运行,这是Java跨平台的根本。当前,并不是在所有的平台下都有相应Java解释器程序,这也是Java并不能在所有的平台下都能运行的原因,它只能在已实现了Java解释器程序的平台下运行。

3. JVM原理

JVM是java的核心和基础,在java编译器和os平台之间的虚拟处理器。它是一种利用软件方法实现的抽象的计算机基于下层的操作系统和硬件平台,可以在上面执行java的字节码程序。

java程序运行的一个全过程图例:
面试准备三:JVM虚拟机一 - 图1
java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。

4. Java虚拟机内存结构

**
1346642971_7810.jpg

JDK1.7内存区域划分
面试准备三:JVM虚拟机一 - 图3
JDK1.8内存区域划分
面试准备三:JVM虚拟机一 - 图4
上面提到并介绍过的不在讲,重点讲解直接内存和元数据区

  • 直接内存
    直接内存并不是JVM运行时数据区的一部分, 但也会被频繁的使用: 在JDK 1.4引入的NIO提供了基于Channel与Buffer的IO方式, 它可以使用Native函数库直接分配堆外内存, 然后使用DirectByteBuffer对象作为这块内存的引用进行操作, 这样就避免了在Java堆和Native堆中来回复制数据, 因此在一些场景中可以显著提高性能.
    本机直接内存的分配不会受到Java堆大小的限制(即不会遵守-Xms、-Xmx等设置), 但既然是内存, 则肯定还是会受到本机总内存大小及处理器寻址空间的限制, 因此动态扩展时也会出现OutOfMemoryError异常.
  • 元数据区域
    元数据区域取代了1.7版本及以前的永久代。元数据和永久代本质上都时方法区的实现。
    参数设置:-XX:MetaspaceSize=18m
    -XX:MaxMetaspaceSize=60m

从上图可以看到,JVM管理的内存区域主要分为以下几部分:

  1. 方法区(Method Area):所有线程共享;用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译的代码等。这个区域的数据是比较固定的,可认为是”永久代”(Permanent Generation)。运行时常量池(Runtime Constant Pool):方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
  2. Java 堆(Heap):虚拟机管理内存最大的一块,被所有线程共享,存放对象实例。垃圾收集器主要管理JAVA 堆。
  3. Java栈(JVM Stacks):线程私有、生命周期与线程相同。主要作用是描述JAVA方法执行的过程:方法被执行时会创建一个栈帧(stack frame),用于存放方法执行时的局部变量、操作栈、动态链接、方法出口等信息。方法从调用到执行完成的过程,就是一个栈帧在虚拟机栈的入栈、出栈过程。

    Java栈有三个区域: 局部变量区、运行环境区、操作数区。
    局部变量区
    每个Java方法使用一个固定大小的局部变量集。它们按照与vars寄存器的字偏移量来寻址。局部变量都是32位的。长整数和双精度浮点数占据了两个局部变量的空间,却按照第一个局部变量的索引来寻址。(例如,一个具有索引n的局部变量,如果是一个双精度浮点数,那么它实际占据了索引n和n+1所代表的存储空间)虚拟机规范并不要求在局部变量中的64位的值是64位对齐的。虚拟机提供了把局部变量中的值装载到操作数栈的指令,也提供了把操作数栈中的值写入局部变量的指令。
    运行环境区
    在运行环境中包含的信息用于动态链接,正常的方法返回以及异常捕捉。
    操作数栈区
    机器指令只从操作数栈中取操作数,对它们进行操作,并把结果返回到栈中。选择栈结构的原因是:在只有少量寄存器或非通用寄存器的机器(如Intel486)上,也能够高效地模拟虚拟机的行为。操作数栈是32位的。它用于给方法传递参数,并从方法接收结果,也用于支持操作的参数,并保存操作的结果。例如,iadd指令将两个整数相加。相加的两个整数应该是操作数栈顶的两个字。这两个字是由先前的指令压进堆栈的。这两个整数将从堆栈弹出、相加,并把结果压回到操作数栈中。
    每个原始数据类型都有专门的指令对它们进行必须的操作。每个操作数在栈中需要一个存储位置,除了long和double型,它们需要两个位置。操作数只能被适用于其类型的操作符所操作。例如,压入两个int类型的数,如果把它们当作是一个long类型的数则是非法的。在Sun的虚拟机实现中,这个限制由字节码验证器强制实行。但是,有少数操作(操作符dupe和swap),用于对运行时数据区进行操作时是不考虑类型的。

  4. PC寄存器(Program Counter Register):占用较小的内存空间,是当前线程执行的字节码的行号指示器。每个线程都会有独立的程序计数器。存放的是字节码指令的地址。

  5. 本地方法栈(Native Method Stack):作用与JAVA栈类似,区别在于用于执行本地方法。

    当一个线程调用本地方法时,它就不再受到虚拟机关于结构和安全限制方面的约束,它既可以访问虚拟机的运行期数据区,也可以使用本地处理器以及任何类型的栈。例如,本地栈是一个C语言的栈,那么当C程序调用C函数时,函数的参数以某种顺序被压入栈,结果则返回给调用函数。在实现Java虚拟机时,本地方法接口使用的是C语言的模型栈,那么它的本地方法栈的调度与使用则完全与C语言的栈相同。

20140505201330750.jpeg

4.1. 哪些区域是共享的?哪些是私有的?

Java栈、本地方法栈、PC寄存器是随用户线程的启动和结束而建立和销毁的,每个线程都有独立的这些区域。而方法区、堆是被整个JVM进程中的所有线程共享的。

4.2. 方法区保存什么?会被回收吗?

方法区不是只保存的方法信息和代码,同时在一块叫做运行时常量池的子区域还保存了Class文件中常量表中的各种符号引用,以及翻译出来的直接引用。通过堆中的一个Class对象作为接口来访问这些信息。虽然方法区中保存的是类型信息,但是也是会被回收的,只不过回收的条件比较苛刻:

  1. 该类的所有实例都已经被回收
  2. 加载该类的ClassLoader已经被回收
  3. 该类的Class对象没有在任何地方被引用(包括Class.forName反射访问)

    4.3. 方法区中常量池的内容不变吗?

    方法区中的运行时常量池保存了Class文件中静态常量池中的数据。除了存放这些编译时生成的各种字面量和符号引用外,还包含了翻译出来的直接引用。但这不代表运行时常量池就不会改变。比如运行时可以调用String的intern方法,将新的字符串常量放入池中。
  1. public class RuntimeConstantPool {
  2. public static void main(String[] args) {
  3. String s1 = new String("hello");
  4. String s2 = new String("hello");
  5. System.out.println("Before intern, s1 == s2: " + (s1 == s2));
  6. s1 = s1.intern();
  7. s2 = s2.intern();
  8. System.out.println("After intern, s1 == s2: " + (s1 == s2));
  9. }
  10. }

4.4 JVM内部如何划分的?常量池在哪?

在Java的内存分配中,总共3种常量池:

4.4.1.字符串常量池(String Constant Pool):

4.4.1.1.字符串常量池在Java内存区域的哪个位置?

  • 在JDK6.0及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中;
  • 在JDK7.0版本,字符串常量池被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。

    4.4.1.2.字符串常量池是什么?

  • 在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。

  • 在JDK6.0中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;
  • 在JDK7.0中,StringTable的长度可以通过参数指定:

    1. -XX:StringTableSize=66666

    4.4.1.3.字符串常量池里放的是什么?

  • 在JDK6.0及之前版本中,String Pool里放的都是字符串常量;

  • 在JDK7.0中,由于String#intern()发生了改变,因此String Pool中也可以存放放于堆内的字符串对象的引用。关于String在内存中的存储和String#intern()方法的说明,可以参考我的另外一篇博客:

需要说明的是:字符串常量池中的字符串只存在一份!
如:

  1. String s1 = "hello,world!";
  2. String s2 = "hello,world!";

即执行完第一行代码后,常量池中已存在 “hello,world!”,那么 s2不会在常量池中申请新的空间,而是直接把已存在的字符串内存地址返回给s2。(这里具体的字符串如何分配就不细说了,可以看我的另一篇博客)

4.4.2. class常量池(Class Constant Pool):

4.4.2.1. class常量池简介:

  • 我们写的每一个Java类被编译后,就会形成一份class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References);
  • 每个class文件都有一个class常量池。

    4.4.2.2. 什么是字面量和符号引用:

  • 字面量包括:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等;

  • 符号引用包括:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符。

    4.4.3. 运行时常量池(Runtime Constant Pool):

  • 运行时常量池存在于内存中,也就是class常量池被加载到内存之后的版本,不同之处是:它的字面量可以动态的添加(String#intern()),符号引用可以被解析为直接引用

  • JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

    4.5. 所有的对象实例都在堆上分配吗?

    随着逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术使得“所有对象都分配在堆上”也变得不那么绝对。
    所谓逃逸就是当一个对象的指针被多个方法或线程引用时,我们称这个指针发生逃逸。一般来说,Java对象是在堆里分配的,在栈中只保存了对象的指针。
    假设一个局部变量在方法执行期间未发生逃逸(暴露给方法外),则直接在栈里分配,之后继续在调用栈里执行,方法执行结束后栈空间被回收,局部变量就也被回收了。这样就减少了大量临时对象在堆中分配,提高了GC回收的效率。
    另外,逃逸分析也会对未发生逃逸的局部变量进行锁省略,将该变量上拥有的锁省略掉。启用逃逸分析的方法时加上JVM启动参数:-XX:+DoEscapeAnalysis?EscapeAnalysisTest。

    4.6. 访问堆上的对象有几种方式?

  • 指针直接访问

    栈上的引用保存的就是指向堆上对象的指针,一次就可以定位对象,访问速度比较快。但是当对象在堆中被移动时(垃圾回收时会经常移动各个对象),栈上的指针变量的值也需要改变。目前JVM HotSpot采用的是(指针直接访问)。
    1346211743_4024.png

  • 句柄间接访问

    栈上的引用指向的是句柄池中的一个句柄,通过这个句柄中的值再访问对象。因此句柄就像二级指针,需要两次定位才能访问到对象,速度比直接指针定位要慢一些,但是当对象在堆中的位置移动时,不需要改变栈上引用的值。
    1346211743_4024.png

4.7. 为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?

栈是运行时的单位,而堆是存储的单元
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据,堆解决的是数据存储的问题,即数据怎么放,放在哪儿
在java中一个线程就会相应有一个线程栈与之对应,这点很容易理解,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当前线程(或程序)相关的信息。包括局部变量、程序运行状态、方法返回值等等,而堆只负责存储对象信息。

  1. 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰。分而治之的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。
  2. 堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。这种共享的收益是很多的。一方面这种共享提供了一种有效的数据交互方式(如:共享内存),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。
  3. 栈因为运行时的需要,比如保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增长,因此就会限制住栈存储内容的能力,而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。
  4. 面向对象就是堆和栈的完美结合。其实,面向对象方式的程序与以前结构化的程序在执行上没有任何区别。但是,面向对象的引入,使得对待问题的思考方式发生了 改变,而更接近于自然方式的思考。当我们把对象拆开,你会发现,对象的属性其实就是数据,存放在堆中;而对象的行为(方法),就是运行逻辑,放在栈中。我 们在编写对象的时候,其实就是编写了数据结构,也编写了处理数据的逻辑。

    5. Java虚拟机结束生命周期条件

  5. 执行了System.exit()方法

  6. 程序正常执行结束
  7. 程序在执行过程中遇到了异常或错误而异常终止
  8. 由于操作系统出现错误而导致Java虚拟机进程终止

    6. 对象是如何被创建的,简单介绍一下创建的过程

    Java的对象创建大致有如下四种方式:
  • new关键字 这应该是我们最常见和最常用最简单的创建对象的方式。
  • 使用newInstance()方法 这里包括Class类的newInstance()方法和Constructor类的newInstance()方法(前者其实也是调用的后者)。
  • 使用clone()方法 要使用clone()方法我们必须实现实现Cloneable接口,用clone()方法创建对象并不会调用任何构造函数。即我们所说的浅拷贝
  • 反序列化 要实现反序列化我们需要让我们的类实现Serializable接口。当我们序列化和反序列化一个对象,JVM会给我们创建一个单独的对象,在反序列化时,JVM创建对象并不会调用任何构造函数。即我们所说的深拷贝

上面的四种创建对象的方法除了第一种使用new指令之外,其他三种都是使用invokespecial(构造函数的直接调用)。这里我们只说new创建对象的方式,关于invokespecial的内容将在后续文章中介绍。下面我们来看看当虚拟机遇到new指令的时候对象是如何创建的。
1. 类加载检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过的,如果没有,则必须先执行相应的类加载过程,关于类加载机制和类加载器的详细内容将在后续文章中介绍。
2. 分配内存
在类加载检查通过后,虚拟机就将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定(如何确定在下一节对象内存布局时再详细讲解),为对象分配空间的任务具体便等同于从Java堆中划出一块大小确定的内存空间,可以分如下两种情况讨论:

  • Java堆中内存绝对规整 所有用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)
  • Java堆中的内存不规整 已被使用的内存和空闲的内存相互交错,那就没有办法简单的进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时(说明一下,CMS收集器可以通过UseCMSCompactAtFullCollection或CMSFullGCsBeforeCompaction来整理内存),就通常采用空闲列表。关于垃圾收集器的具体内容将在下一篇文章中介绍。
除如何划分可用空间之外,另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并非线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。解决这个问题有如下两个方案:

  • 对分配内存空间的动作进行同步 实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
  • 把内存分配的动作按照线程划分在不同的空间之中进行 即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB ,Thread Local Allocation Buffer),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完,分配新的TLAB时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

3. 初始化
内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB的话,这一个工作也可以提前至TLAB分配时进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用。
4. 设置对象头
接下来,虚拟机要设置对象的信息(如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息)并存放在对象的对象头(Object Header)中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。关于对象头的具体内容,在下一节再详细介绍。
5. 执行<init>方法
在上面工作都完成之后,在虚拟机的视角来看,一个新的对象已经产生了。但是在Java程序的视角看来,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零值。所以一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

7. 对象在JVM中怎么存储的?

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)
1. 对象头
HotSpot虚拟机的对象头包括两部分信息:

  • 对象自身的运行时数据 “Mark Word” 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下图所示:image.png
  • 类型指针 类型指针即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身,这点我们在下一节讨论。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。

2. 实例数据
实例数据是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。
3. 对齐填充
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头部分正好似8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

一个对象占多大内存?为什么?

上面说到对象头在32位系统上占用8bytes64位系统上占用16bytes。

  1. System.out.println("sizeOf(new Object())="+sizeOf(new Object()));

使用命令 java -javaagent:test.jar -XX:=-UseCompressedOops -jar test.jar
得到结果:sizeOf(new Object())=16
原生类型(primitive type)的内存占用如下:

Primitive Type Memory Required(bytes)
boolean 1
byte 1
short 2
char 2
int 4
float 4
long 8
double 8

引用类型在32位系统上每个占用4bytes, 在64位系统上每个占用8bytes。
对齐填充计算为:
(对象头 + 实例数据 + padding) % 8等于0且0 <= padding < 8
指针压缩对象:
占用的内存大小收到VM参数UseCompressedOops的影响。

  • 1)对对象头的影响

开启(-XX:+UseCompressedOops)对象头大小为12bytes(64位机器)。

  1. static class A {
  2. int a;
  3. }

A对象占用内存情况:
关闭指针压缩: 16+4=20不是8的倍数,所以+padding/4=24
image.png
开启指针压缩: 12+4=16已经是8的倍数了,不需要再padding。
image.png

  • 2) 对reference类型的影响

64位机器上reference类型占用8个字节,开启指针压缩后占用4个字节。

  1. static class B2 {
  2. int b2a;
  3. Integer b2b;
  4. }

B2对象占用内存情况:
关闭指针压缩: 16+4+8=28不是8的倍数,所以+padding/4=32
image.png
开启指针压缩: 12+4+4=20不是8的倍数,所以+padding/4=24
image.png

  • 3) 数组对象的影响

64位机器上,数组对象的对象头占用24个字节,启用压缩之后占用16个字节。之所以比普通对象占用内存多是因为需要额外的空间存储数组的长度。
先考虑下new Integer[0]占用的内存大小,长度为0,即是对象头的大小:
未开启压缩:24bytes
image.png
开启压缩后:16bytes
image.png
接着计算new Integer[1],new Integer[2],new Integer[3]和new Integer[4]就很容易了:
未开启压缩:
image.png
开启压缩:
image.png
拿new Integer[3]来具体解释下:
未开启压缩:24(对象头)+83=48,不需要padding;
开启压缩:16(对象头)+3
4=28,+padding/4=32,其他依次类推。
自定义类的数组也是一样的,比如:

  1. static class B3 {
  2. int a;
  3. Integer b;
  4. }

new B3[3]占用的内存大小:
未开启压缩:48
开启压缩后:32

  • 4) 复合对象的影响

计算复合对象占用内存的大小其实就是运用上面几条规则,只是麻烦点。分两种情况
①对象本身的大小
直接计算当前对象占用空间大小,包括当前类及超类的基本类型实例字段大小、引用类型实例字段引用大小、实例基本类型数组总占用空间、实例引用类型数组引用本身占用空间大小;但是不包括超类继承下来的和当前类声明的实例引用字段的对象本身的大小、实例引用数组引用的对象本身的大小。

  1. static class B {
  2. int a;
  3. int b;
  4. }
  5. static class C {
  6. int ba;
  7. B[] as = new B[3];
  8. C() {
  9. for (int i = 0; i < as.length; i++) {
  10. as[i] = new B();
  11. }
  12. }
  13. }

未开启压缩:16(对象头)+4(ba)+8(as引用的大小)+padding/4=32
开启压缩:12+4+4+padding/4=24
②当前对象占用的空间总大小
递归计算当前对象占用空间总大小,包括当前类和超类的实例字段大小以及实例字段引用对象大小。
递归计算复合对象占用的内存的时候需要注意的是:对齐填充是以每个对象为单位进行的,看下面这个图就很容易明白。
image.png
手动计算下C对象占用的全部内存是多少,主要是三部分构成:C对象本身的大小+数组对象的大小+B对象的大小。
未开启压缩:
(16 + 4 + 8+4(padding)) + (24+ 83) +(16+8)3 = 152bytes
开启压缩:
(12 + 4 + 4 +4(padding)) + (16 + 43 +4(数组对象padding)) + (12+8+4(B对象padding))3= 128bytes

指针压缩是如何实现的?如何证明开启指针压缩节省了内存?

由于在64位CPU下, 指针的宽度是64位的, 而实际的heap区域远远用不到这么大的内存, 使用64bit来存对象引用会造成浪费, 所以应该做点事情来节省资源.

如何做?

  • CPU 使用的虚拟地址是64位的, 访问内存时, 必须使用64位的指针访问内存对象
  • java对象是分配于具体的某个内存位置的, 对其访问必须使用64位地址
  • 对java对象内的引用字段进行访问时, 必须经过虚拟机这一层, 操作某个对象引用不管是getfield还是putfield, 都是由虚拟机来执行. 或者简单来说, 要改变java对象某个引用字段, 必须经过虚拟机的参与.

细心的你从上面一定可以看出一点线索, 由于存一个对象引用和取一个对象引用必须经过虚拟机, 所以完全可以在虚拟机这一层做些手脚. 对于外部来说, putfield提供的对象地址是64位的, 经过虚拟机的转换, 映射到32位, 然后存入对象; getfield指定目标对象的64位地址和其内部引用字段的偏移, 取32位的数据, 然后反映射到64位内存地址. 对于外部来说, 只看见64位的对象放进去, 拿出来, 内部的转换是透明的.

什么是 OOP

在堆中,32位的对象引用(指针)占4个字节,而64位的对象引用占8个字节。也就是说,64位的对象引用大小是32位的2倍。64位JVM在支持更大堆的同时,由于对象引用变大却带来了性能问题:

  • 增加了GC开销:64位对象引用需要占用更多的堆空间,留给其他数据的空间将会减少,从而加快了GC的发生,更频繁的进行GC。
  • 降低CPU缓存命中率:64位对象引用增大了,CPU能缓存的oop将会更少,从而降低了CPU缓存的效率。

为了能够保持32位的性能,oop必须保留32位。那么,如何用32位oop来引用更大的堆内存呢?答案是——压缩指针(CompressedOops)。
OOP = “ordinary object pointer” 普通对象指针。 启用CompressOops后,会压缩的对象:

  • 每个Class的属性指针(静态成员变量)
  • 每个对象的属性指针
  • 普通对象数组的每个元素指针

当然,压缩也不是万能的,针对一些特殊类型的指针,JVM是不会优化的。 比如指向PermGen的Class对象指针,本地变量,堆栈元素,入参,返回值,NULL指针不会被压缩。
1)CompressedOops原理:
64位地址分为堆的基地址+偏移量,当堆内存<32GB时候,在压缩过程中,把偏移量/8后保存到32位地址。在解压再把32位地址放大8倍,所以启用CompressedOops的条件是堆内存要在4GB*8=32GB以内
CompressedOops,可以让跑在64位平台下的JVM,不需要因为更宽的寻址,而付出Heap容量损失的代价。 不过它的实现方式是在机器码中植入压缩与解压指令,可能会给JVM增加额外的开销。
2)零基压缩优化(Zero Based Compressd Oops)
零基压缩是针对压解压动作的进一步优化。 它通过改变正常指针的随机地址分配特性,强制堆地址从零开始分配(需要OS支持),进一步提高了压解压效率。要启用零基压缩,你分配给JVM的内存大小必须控制在4G以上,32G以下。

总结

如果GC堆大小在4G以下,直接砍掉高32位,避免了编码解码过程;
如果GC堆大小在4G以上32G以下,则启用UseCompressedOop;
如果GC堆大小大于32G,压指失效,使用原来的64位
使用-XX:=-UseCompressedOops关闭指针压缩

Oracle JDK从6 update 23开始在64位系统上会默认开启压缩指针。 32位HotSpot VM是不支持UseCompressedOops参数的,只有64位HotSpot VM才支持。

对于大小在4G和32G之间的堆,应该使用压缩的oop

8. 对象头信息里面有哪些东西?

HotSpot 虚拟机的对象头包括两部分(非数组对象)信息,如下图所示:
image.png

  • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳、对象分代年龄,这部分信息称为“Mark Word”;Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据自己的状态复用自己的存储空间;
  • 第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;
  • 如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通 Java 对象的元数据信息确定Java 对象的大小,但是从数组的元数据中无法确定数组的大小。这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为32bit 和 64bit。

9. 堆分为哪几部分,默认年龄多大进入老年代

  1. 堆内存中分为年轻代与老年代,年轻代又分为Eden区与Survivor区.新对象的创建会分配在年轻代。<br />** 进入老年带条件有如下几条:**
  1. 迭代年龄判断

    在对象的对象头信息中存储着对象的迭代年龄,迭代年龄会在每次YoungGC之后对象的移区操作中增加,每一次移区年龄加一.当这个年龄达到15(默认)之后,这个对象将会被移入老年代.
    可以通过 XX:MaxTenuringThreshold 来设置年龄值

  2. 大对象直接进入老年代

    有一些占用大量连续内存空间的对象在被加载伊始就会直接进入老年代.这样的大对象一般是一些数组,长字符串之类的对象.
    可以通过 -XX:PretenureSizeThreshold来设置大对象的限制

  3. YoungGC之后需要移区的对象放不下

    在进行移区的时候,可能需要移区的对象大于所移区的空间大小,那么这些对象会被直接放入老年代,毕竟总不能在对象还被引用的时候就对其进行回收.那么在这里我们需要讲述一下对象的几种引用类型.下面引用类型等级依次向下

  • 强引用:平常的代码创建对象都属于强引用,之后当对象变为垃圾对象才会被回收
  • 软引用:被SoftReference这个类包裹起来的对象,在进行垃圾收集发现剩余空间不够的时候,全部已创建软引用对象会被一次性回收,这种引用类型常用于对内存比较敏感的缓存中
  • 弱引用:被WeekReference这个类包裹起来的对象,每次进行垃圾收集操作的时候都会将弱引用对象一次性回收,基本不使用
  • 虚引用:又称幽灵引用,随时都会被回收
  1. 对象动态年龄判断

    此策略发生在Survivor区,当Survivor区中的一批对象的总大小大于Survivor区空间大小的一半,在这个区域中,对象年龄大于这批对象的最大年龄的所有对象会被移入老年代

    10. Java的内存模型,Java8,Java9做了什么修改

Java8引入了元空间,改动比较大
Java9将G1改为默认的垃圾回收算法

11. JRE/JDK/JVM是什么关系

JRE(JavaRuntimeEnvironment,Java运行环境) 也就是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户只需要运行已开发好的java程序,安装JRE即可。

JDK(Java Development Kit) 是程序开发者用来来编译、调试java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是 安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

JVM(JavaVirtualMachine,Java虚拟机) 是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。

12. 内存溢出和内存泄漏分别指什么

内存泄露:指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,无法释放已经申请到的内存空间,它始终占用内存。即被分配的对象可达但已无用
内存溢出:指程序运行过程中无法申请到足够的内存而导致的一种错误。内存溢出通常发生于OLD段或Perm段垃圾回收后,仍然无内存空间容纳新的Java对象的情况(OOM)。比如你为程序申请了一个integer,但是只给它存了long才能存下的数,就是内存溢出。内存溢出就是你要求被分配的内存超出了系统能给你的内存,系统不能满足你的需求,于是产生溢出。
从定义上可以看出内存泄露是内存溢出的一种诱因,不是唯一因素。**

12.1 为什么要了解内存泄露和内存溢出

1、内存泄露一般是代码设计存在缺陷导致的,通过了解内存泄露的场景,可以避免不必要的内存溢出和提高自己的代码编写水平;
2、通过了解内存溢出的几种常见情况,可以在出现内存溢出的时候快速的定位问题的位置,缩短解决故障的时间。

12.2 内存泄露分类

  1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
  2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
  3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次。
  4. 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

    12.3. 内存溢出和内存泄露的联系

    内存泄露会最终会导致内存溢出。
    相同点:都会导致应用程序运行出现问题,性能下降或挂起。
    不同点:1) 内存泄露是导致内存溢出的原因之一,内存泄露积累起来将导致内存溢出。
    2) 内存泄露可以通过完善代码来避免,内存溢出可以通过调整配置来减少发生频率,但无法彻底避免。

    12.4 内存泄漏和内存溢出如何解决

    12.4.1 内存泄漏

    内存泄漏对象有下面两个特点:
    1)首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;
    2)其次,这些对象是无用的,即程序以后不会再使用这些对象。
    如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。关于内存泄露的处理页就是提高程序的健壮型,因为内存泄露是纯代码层面的问题。

    12.4.2. 内存溢出:

  • (1)java.lang.OutOfMemoryError: PermGen space

PermGen space 的全称是 Permanent Generation space, 是指内存的永久保存区域。这块内存主要是被JVM存放Class和Meta信息的,Class在被Loader时就会被放到PermGenspace中,它和存放类实例(Instance)的Heap区域不同,GC不会在主程序运行期对PermGen space进行清理。
JVM由XX:PermSize设置非堆内存初始值,默认是物理内存的1/64;
JVM由XX:MaxPermSize设置最大非堆内存的大小,默认是物理内存的1/4。
方案:
优化程序,释放垃圾
主要思路就是避免程序体现上出现的情况。避免死循环,防止一次载入太多的数据,提高程序健壮型及时释放。因此,从根本上解决Java内存溢出的唯一方法就是修改程序,及时地释放没用的对象,释放内存空间。
该错误常见场合:
a) 应用中有很多Class,web服务器对JSP进行precompile时。
b) Webapp下用了大量的第三方jar,其大小超过了JVM默认的大小(4M)时。

  • (2) java.lang.OutOfMemoryError:Java heap space

在JVM中如果98%的时间是用于GC且可用的Heap size 不足2%的时候将抛出此异常信息。
JVM初始分配的内存由-Xms指定,默认是物理内存的1/64;
JVM最大分配的内存由-Xmx指定,默认是物理内存的1/4。
导致OutOfMemoryError异常的常见原因有以下几种:

  1. 内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
  2. 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
  3. 代码中存在死循环或循环产生过多重复的对象实体;
  4. 使用的第三方软件中的BUG;
  5. 启动参数内存值设定的过小;

    12.4.3 内存泄漏排查案例

    某个业务系统在一段时间突然变慢,我们怀疑是因为出现内存泄露问题导致的,于是踏上排查之路。

  6. 确定频繁Full GC现象

首先通过“虚拟机进程状况工具:jps”找出正在运行的虚拟机进程,最主要是找出这个进程在本地虚拟机的唯一ID(LVMID,Local Virtual Machine Identifier),因为在后面的排查过程中都是需要这个LVMID来确定要监控的是哪一个虚拟机进程。
同时,对于本地虚拟机进程来说,LVMID与操作系统的进程ID(PID,Process Identifier)是一致的,使用Windows的任务管理器或Unix的ps命令也可以查询到虚拟机进程的LVMID。
jps命令格式为:

  1. jps [ options ] [ hostid ]
  2. 使用命令如下:
  3. 使用jpsjps -l
  4. 使用psps aux | grep tomat

找到你需要监控的ID(假设为20954),再利用“虚拟机统计信息监视工具:jstat”监视虚拟机各种运行状态信息。
jstat命令格式为:

  1. jstat [ option vmid [interval[s|ms] [count]] ]
  2. 使用命令如下:
  3. jstat -gcutil 20954 1000

意思是每1000毫秒查询一次,一直查。gcutil的意思是已使用空间站总空间的百分比。
image.png
jstat执行结果
查询结果表明:这台服务器的新生代Eden区(E,表示Eden)使用了28.30%(最后)的空间,两个Survivor区(S0、S1,表示Survivor0、Survivor1)分别是0和8.93%,老年代(O,表示Old)使用了87.33%。程序运行以来共发生Minor GC(YGC,表示Young GC)101次,总耗时1.961秒,发生Full GC(FGC,表示Full GC)7次,Full GC总耗时3.022秒,总的耗时(GCT,表示GC Time)为4.983秒。

  1. 找出导致频繁Full GC的原因

分析方法通常有两种:
1)把堆dump下来再用MAT等工具进行分析,但dump堆要花较长的时间,并且文件巨大,再从服务器上拖回本地导入工具,这个过程有些折腾,不到万不得已最好别这么干。
2)更轻量级的在线分析,使用“Java内存影像工具:jmap”生成堆转储快照(一般称为headdump或dump文件)。
jmap命令格式:

  1. jmap [ option ] vmid
  2. 使用命令如下:
  3. jmap -histo:live 20954

查看存活的对象情况,如下图所示:
image.png
存活对象
数据不正常,十有八九就是泄露的。
image.png
可以看出HashTable中的元素有5000多万,占用内存大约1.5G的样子。这肯定不正常。

  1. 定位到代码

定位带代码,有很多种方法,比如前面提到的通过MAT查看Histogram即可找出是哪块代码。——我以前是使用这个方法。 也可以使用BTrace,我没有使用过。
举例:
一台生产环境机器每次运行几天之后就会莫名其妙的宕机,分析日志之后发现在tomcat刚启动的时候内存占用比较少,但是运行个几天之后内存占用越来越大,通过jmap命令可以查询到一些大对象引用没有被及时GC,这里就要求解决内存泄露的问题。
Java的内存泄露多半是因为对象存在无效的引用,对象得不到释放,如果发现Java应用程序占用的内存出现了泄露的迹象,那么我们一般采用下面的步骤分析:

  1. 用工具生成java应用程序的heap dump(如jmap)
  2. 使用Java heap分析工具(如MAT),找出内存占用超出预期的嫌疑对象
  3. 根据情况,分析嫌疑对象和其他对象的引用关系。
  4. 分析程序的源代码,找出嫌疑对象数量过多的原因。


Java代码导致OutOfMemoryError错误的解决:**
需要重点排查以下几点:

  1. 检查代码中是否有死循环或递归调用。
  2. 检查是否有大循环重复产生新对象实体。
  3. 检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
  4. 检查List、MAP等集合对象是否有使用完后,未清除的问题。List、MAP等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收。