Java 虚拟机

Java虚拟机


一、运行时数据区域


JVM执行Java程序时会把内存分为多个数据区域,其中:

堆、方法区(包括运行时常量池)和直接内存被线程共享

每个线程有自己的程序计数器、本地方法栈和虚拟机栈


程序计数器

可以看作是当前线程正在执行的那条字节码指令的地址,如果执行的是本地方法,程序计数器的值是undefined

字节码解释器通过改变计数器的值来选取下一条需要执行的字节码指令

每条线程有独立的程序计数器,所以在线程切换后能恢复到正确的执行位置

程序计数器主要的两个作用:

  1. 字节码解释器通过改变计数器的值依次读取指令,从而实现代码的流程控制
  2. 多线程下,程序计数器记录当前线程的执行位置,切换回来后可以知道上次运行到哪

程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。


Java虚拟机栈

Java虚拟机栈为每个即将运行的Java方法创建一个栈帧

Java内存可以粗糙分为堆内存和栈内存,栈就是虚拟机栈

Java虚拟机栈是由一个个栈帧组成,每个栈帧中都有局部变量表、操作数栈、动态链接、方法出口信息。

局部变量表随着栈帧的创建而创建,大小在编译时就确定,运行时大小不会改变

存放了编译器可知的8种基本数据类型、对象的引用(不同于对象本身,可能是对象某个位置的引用)

方法需要创建局部变量时,就将值放入局部变量表

Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧。

方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

随着线程的创建而创建,随着线程的死亡而死亡


本地方法栈

虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务,本地方法栈是描述本地方法运行过程的内存模型。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。


虚拟机管理内存中最大的一块。**此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。**在虚拟机启动时创建。

Java堆是垃圾收集器管理的主要区域,所以也被称作GC堆(Garbage Collection Heap)

Java堆会被进一步划分(新生代、老年代、永久代,其中前两种使用的是Java程序可以使用的堆内存),这样可以更好地回收内存和分配内存。永久代是对JVM方法区的实现

因为是被所有线程共享的,所以访问要注意同步的问题

对于主流虚拟机,堆是可扩展的。如果线程请求分配内存,但是堆满了,内存也无法扩展,就会抛出OutOfMemoryError异常

JDK1.8中移除了永久代,用元空间(Metaspace)取代。元空间使用物理内存,永久代使用JVM堆内存

方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中


方法区

是堆的一个逻辑部分,和Java堆不一样。存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译的代码等。

方法区被称为“永久代”,因为其中的信息一般都需要长久存在。对方法区的主要回收目标是:对常量池回收;对类的卸载

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

运行时常量池是方法区的一部分,存放编译期间生成的常量(字面量符号引用

字面量包括:文本字符串,final常量,基本数据类型

符号引用:类的完全限定名,字段名和描述符,方法名和描述符

受到方法区内存的限制,无法申请到内存时会抛出OutOfMemoryError异常

JDK1.7之后的版本将运行时常量池从方法区移到了Java堆中


直接内存

不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区,但会被频繁使用,也会出现内存超出

直接内存申请空间耗费更高的性能

直接内存读取 IO 的性能要优于普通的堆内存。


Java对象的创建过程

类加载检查 – 分配内存 – 初始化零值 – 设置对象头 – 执行init方法

类加载检查:遇到new指令,先去检查能否在常量池中定位到类的符号引用,并检查其代表的类是否被加载、解析和初始化过,如果没有,要先执行相应的类加载过程

分配内存:通过类加载检查后为新生对象分配内存,所需内存大小在类加载完成后就确定,从Java堆中分出一块确定大小的区域,分配方式有指针碰撞空闲列表两种

两种方式取决于堆是否规整,取决于垃圾收集器的算法是“标记-清除”还是“标记-整理”

指针碰撞:用过的内存放一边,没用过的放一边,只要沿着没用过的地方移动指针就行

空闲列表:虚拟机会维护一个列表,记录哪些内存块可用。分配内存时找一块足够大的内存块分配给对象,更新列表

初始化零值:将对象分配到的内存都初始化为零值,保证对象的实例可以不赋初始值就使用

设置对象头:虚拟机对对象进行必要的设置,将对象的相关信息如:是哪个类的实例、对象的哈希码等放到对象头中

执行init方法:虚拟机已经产生一个新的对象,但是Java程序中才刚刚开始,init执行后一个新的对象才算产生


对象的内存布局

分为3块:对象头、实例数据和对其填充

对象头包括:自身的运行时数据(哈希码,GC分代年龄)、类型指针(指向类元数据的指针,确定对象是哪个类的实例)

实例数据:对象的成员变量

对其填充:起占位作用

访问对象的方式:

创建对象时在两个地方分配了内存。引用存在Java虚拟机栈的局部变量表中,但是实际对象存储在堆中

句柄访问:引用类型的变量存储了对象的句柄地址,通过访问句柄池找到对象,包括对象实例数据(java堆)和对象所属类的数据(方法区)

直接指针访问:对象的变量直接存放Java堆中对象的地址,但是Java堆中的对象实例还需要额外存储方法区中对象所属类信息的地址。

hotspot采用直接指针访问,只要一次寻址速度比前者快一倍,但是需要额外策略来存储地址。


常量池

直接使用双引号声明出来的 String 对象会直接存储在常量池中, new出来的在堆内存中。

  • Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
  • 两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。

二、垃圾收集策略


关注Java堆和方法区的内存,因为只有运行时才知道创建了哪些对象,这部分内存的分配和回收都是动态的

判断对象是否存活


如果一个对象不被任何对象引用,就是无效对象,会被回收

引用计数器法

对象头维护一个counter计数器,被引用一次+1,引用失效-1,如果计数器为0该对象就无效

实现简单,判定效率搞。但主流Java虚拟机没有采用这个方法,因为很难解决对象间循环引用的问题 – 如果两个对象互相引用,计数器都不为0,就无法被回收。

可达性分析法

所有和GC Roots直接或间接关联的对象都是有效对象,否则是无效对象

GC Roots 指:

  • Java虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中常量引用的对象
  • 方法区中静态属性引用的对象

不包括堆中对象引用的对象,所以不会有循环引用

引用的种类


强引用 Strong

类似Object obj = new Object()就是强引用,只要强引用存在。GC永远不会回收被引用的对象。但是,如果我们错误地保持了强引用,比如:赋值给了 static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏。

软引用 Soft

软引用是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

弱引用 Weak

弱引用的强度比软引用更弱一些。当 JVM 进行垃圾回收时,无论内存是否充足,都会回收只被弱引用关联的对象。

虚引用 Phantom

虚引用也称幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响。它仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制,比如,通常用来做所谓的 Post-Mortem 清理机制。

回收堆中无效对象


判断finalize()是否有必要执行

对于不可达的对象,会判断finalize()方法是否有必要执行,如果已经被执行过(任何一个对象的 finalize() 方法只会被系统自动调用一次)或者该方法没有被对象重写,对象就会真的被回收。

如果被判定为有必要执行finalize()方法,就会放入一个F-Queue队列以较低优先级执行方法,但不确保会执行结束。

对象重生或死亡

之所以提finalize(),是因为如果在方法中将this赋给某一引用,对象就重生了。否则对象就会被GC清除。

回收方法区内存


方法区中主要清除2类垃圾:废弃常量、无用的类

判定废弃常量

对于常量,只要常量池中的常量没有被任何的变量或对象引用,就会被清除

判定无用的类

条件较为苛刻:

  • 该类的所有对象都被清除
  • 加载该类的classLoader已经被回收
  • 该类的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问到该类的方法

一个类被虚拟机加载进方法区,在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载到方法区时被创建,在方法区该类被删除时清除。

垃圾收集算法


常见的垃圾收集算法

见: https://github.com/doocs/jvm/blob/master/docs/03-gc-algorithms.md

三、内存分配与回收策略


对象的内存分配就是在堆上分配

对象优先在Eden分配


多数情况下对象在新生代Eden区进行分配,Eden区内存不够时,虚拟机将发起一次Minor GC。

  • Minor GC:回收新生代(包括 Eden 和 Survivor 区域),因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • Major GC / Full GC: 回收老年代,出现了 Major GC,经常会伴随至少一次的 Minor GC,但这并非绝对。Major GC 的速度一般会比 Minor GC 慢 10 倍 以上。

大对象直接进入老年代


大对象指需要大量连续内存空间的Java对象,例如很长的字符串或数据

一个大对象能够存入 Eden 区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及大量的复制,就会造成效率低下。

虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。

长期存活的对象将进入老年代


JVM给每个对象定义一个年龄计数器,每次发生Minor GC存活时,存活下的对象年龄+1,超过一定值就转移到老年代

使用 -XXMaxTenuringThreshold 设置新生代的最大年龄,只要超过该参数的新生代对象都会被转移到老年代中去。

动态对象年龄判定


如果新生代Survivor中相同年龄所有对象的总和大于Survivor空间的一半,将年龄>=该年龄的对象直接放入老年代。

空间分配担保


JDK 6 Update24 之后

只要老年代的连续空间大于历次晋升的平均大小,就进行Minor GC,否则进行Full GC。

通过清除老年代中飞起数据来扩大老年代空闲空间,为新生代作担保,这个过程就是分配担保。


会触发JVM进行Full GC的情况

System.gc()的调用

老年代空间不足

发生空间分配担保


四、JVM 类加载

JVM的“无关性”


谈论 JVM 的无关性,主要有以下两个:

  • 平台无关性:任何操作系统都能运行 Java 代码
  • 语言无关性: JVM 能运行除 Java 以外的其他代码

Java 源代码首先需要使用 Javac 编译器编译成 .class 文件,然后由 JVM 执行 .class 文件,从而程序开始运行。

JVM 只认识 .class 文件,它不关心是何种语言生成了 .class 文件,只要 .class 文件符合 JVM 的规范就能运行。 目前已经有 JRuby、Jython、Scala 等语言能够在 JVM 上运行。它们有各自的语法规则,不过它们的编译器 都能将各自的源码编译成符合 JVM 规范的 .class 文件,从而能够借助 JVM 运行它们。

类加载的时机


详细查看:https://github.com/doocs/jvm/blob/master/docs/08-load-class-time.md

接口的加载过程

接口加载过程与类加载过程稍有不同。

当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会初始化。

类加载的过程


类加载过程包括5个阶段:加载、验证、准备、解析、初始化

加载

“加载”是“类加载”过程的一个阶段,不能混淆这两个名词。在加载阶段,虚拟机需要完成 3 件事:

  • 通过类的全限定名获取该类的二进制字节流。
  • 将二进制字节流所代表的静态结构转化为方法区的运行时数据结构
  • 在内存中创建一个java.lang.Class对象,作为方法区这个类的各种数据的访问入口

二进制字节流(Class文件)从哪获取:

  • 压缩包,如jar、war包
  • 网络中,applet
  • 由jsp文件生成对应的class
“数组类”和“非数组类”加载比较

在Java中数组是一个Java类

  • 数组类本身不通过类加载器创建,是由Java虚拟机直接创建的,再由类加载器创建数组中的元素类
  • 非数组类加载阶段可以使用系统提供的引导类加载器,也可以由用户自定义的加载器完成
注意事项
  • 虚拟机没有规定class对象(java.lang.Class)的存储位置,对于Hotspot来说,class对象虽然是对象,但是存在方法区中。
  • 加载和连接阶段交叉进行,加载没有完成就可能开始连接了。

验证


验证阶段的重要性

保证class文件的字节流包含的信息符合当前虚拟机的要求。不会危害虚拟机自身的安全。

验证的过程

文件格式验证、元数据验证、字节码验证、符号引用验证

准备


准备阶段是正式为类变量(或称“静态成员变量”)分配内存并设置初始值的阶段。这些变量(不包括实例变量)所使用的内存都在方法区中进行分配。

类变量的初始值通常是数据类型的零值(0, null)

但如果这个类变量被声明为常量,就会被初始化为所指定的值

public static final int value = 123; 在准备阶段,jvm会将value赋值为123

解析


解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。

直接引用可以是:

  • 直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)

  • 相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)

  • 一个能间接定位到目标的句柄

直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

初始化


类初始化阶段是类加载过程的最后一步,是执行类构造器()方法的过程。

()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)中的语句产生

() 方法不需要显式调用父类构造器,虚拟机会保证在子类的 () 方法执行之前,父类的 () 方法已经执行完毕。

由于父类的 () 方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

() 方法不是必需的,如果一个类没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 () 方法。

接口中不能使用静态代码块,但接口也需要通过 () 方法为接口中定义的静态成员变量显式初始化。但接口与类不同,接口的 () 方法不需要先执行父类的 () 方法,只有当父接口中定义的变量使用时,父接口才会初始化。

虚拟机会保证一个类的 () 方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 () 方法。

五、Java类加载器


双亲委派模型

什么是双亲委派模型

双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。(父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码)

在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。