JVM虚拟机

-资料来源网络。

整体框架

程序计数器(Program Counter Register)

  • 内存空间小,线程私有
  • 字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令。
  • 分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成。
  • 如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。
  • 如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined)。

Java 虚拟机栈(VM Stack)

  • 线程私有,生命周期和线程一致。

  • 每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

  • 每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

  • 局部变量表

    • 存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)
    • 存储着方法相关的局部变量,包括各种基本数据类型及对象的引用地址等,内存空间可以在编译期间就确定,运行时不再改变。
  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。

  • OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。

  • 本地方法栈(Native Method Stack)

    • 区别于虚拟机栈的是, 虚拟机栈为虚拟机执行 Java 方法/字节码服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

Java 堆(Heap)

  • 线程共享,主要是存放对象实例和数组。
  • 这块区域是 JVM 所管理的内存中最大的一块。
  • 堆区主要用于存放对象实例及数组,所有new出来的对象都存储在该区域。
  • 内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)
  • 可以位于物理上不连续的空间,但是逻辑上要连续。

方法区(Method Area)

  • 线程共享。
  • 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

Question!

  • 堆内存溢出与栈内存溢出
    • 因为堆区主要用于存放对象实例及数组,所以递归new对象即可造成堆内存溢出。
    • 因为每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,所以在方法里递归调用自己即可造成栈内存溢出。
  • 堆空间内存分配(默认情况下)

    • Old Gen : Young Gen = 2:1
    • Eden : Survivor = 8:2
    • From : To = 1:1
  • String 对象的两种创建方式

    1
    2
    3
    String str1 = "abcd";						//指向常量池
    String str2 = new String("abcd"); //指向堆空间
    System.out.println(str1 == str2); //false
  • String.intern()方法

    • String.intern() 是一个 Native 方法
    • 它的作用是如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
  • String s1 = new String(“abc”); 这句话创建了几个对象?

    • 两个,一个位于常量池,一个在堆中,然后 Java 栈的 str1 指向Java堆上的”abc”。

垃圾回收(Garbage Collection)

垃圾回收关注的是Java堆方法区,因为程序计数器、虚拟机栈、本地方法栈的生命周期是和线程同步的,随着线程的销毁,他们占用的内存会自动释放,而我们只有在程序处于运行期才知道哪些些对象会创建,这部分内存的分配和回收都是动态的。

对象是否是垃圾的判断方法

引用计数算法(Reference Counting Collector)

堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1(a = b, b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。

难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。

根搜索算法(Tracing Collector)

定义一系列名为GC Roots的对象作为起点,从起点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,则说明该对象不可用,这时Java虚拟机可以对这些对象进行回收。

GC Roots

  • Java虚拟机栈中引用的对象:比如方法里面定义这种局部变量 User user= new User();
  • 静态属性引用的对象:比如 private static User user = new User();
  • 常量引用的对象:比如 private static final User user = new User();
  • 本地方法栈中引用的对象

在JDK1.2之后引入了四个概念:强引用、软引用、弱引用、虚引用。
强引用:new出来的对象都是强引用,GC无论如何都不会回收,即使抛出OOM异常。
软引用:当JVM内存不足时回收。
弱引用只要GC,立马回收,不管内存是否充足。
虚引用:可以忽略不计。它唯一的作用就是做一些跟踪记录,辅助finalize函数的使用。

第一次标记:对象进行根搜索之后,如果发现没有与GC Roots 相连接的引用链,就会被第一次标记并进行筛选,所谓筛选,就是检查此对象是否有必要执行finalize方法,如果对象定义了该方法并且没有执行过,那么该对象就会被放入到一个队列F-Queue,随后会有一个低优先级的线程去执行这个队列里面对象的finalize方法。

第二次标记:JVM 将对F-Queue队列里面的对象进行第二次标记

如果对象不想被回收,那么就得在finalize方法里面拯救自己,否则,这些对象就真的会被回收。

Java的堆内存(Java Heap Memory)

  • Java的堆内存基于Generation算法(Generational Collector)划分为新生代、年老代和持久代。
  • 新生代又被进一步划分为EdenSurvivor区,最后Survivor由FromSpace(Survivor0)和ToSpace(Survivor1)组成(8:1:1)。所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制。
  • 几乎所有新生成的对象首先都是放在年轻代的。
  • 当Eden区满了或放不下了,这时候其中存活的对象会复制到From区。如果存活下来的对象From区都放不下,则这些存活下来的对象全部进入年老代,之后Eden区的内存全部回收掉。
  • 之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了,这时候将会把Eden区和From区存活下来的对象复制到To区(同理,如果存活下来的对象To区都放不下,则这些存活下来的对象全部进入年老代),之后回收掉Eden区和From区的所有内存。
  • 默认情况下,当对象被复制了15次(这个次数可以通过:-XX:MaxTenuringThreshold来配置),就会进入年老代了。
  • 当年老代满了或者存放不下将要进入年老代的存活对象的时候,就会发生一次Full GC。

Minor GC / Scavenge GC

对新生代进行回收,不会影响到年老代。因为新生代的 Java 对象大多死亡频繁,所以 Minor GC 非常频繁,一般在这里使用速度快、效率高的算法,使垃圾回收能尽快完成。

Full GC / Major GC

对整个堆进行回收,包括新生代和老年代。由于Full GC需要对整个堆进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数,导致Full GC的原因包括:老年代被写满、永久代(Perm)被写满和System.gc()被显式调用等。

垃圾收集算法

  • 复制算法
  • 标记-删除算法
  • 标记-压缩算法
  • 分代收集算法

Question!

  • 如果对象的引用被置为null,垃圾收集器是否会立即释放对象占用的内存?
    • 不会,因为垃圾回收需要进行二次标记。第一次标记结束后,会对该对象进行检查,是否有必要执行finalize方法,如果对象定义了该方法并且没有执行过,那么该对象就会被放入到一个队列F-Queue,如果对象不想被回收,那么就得在finalize方法里面拯救自己,否则,这些对象就真的会被回收。
  • finalize()方法工作原理
    • 一旦对象被第一次标记后,即会对该对象进行检测,若有必要执行该对象的finalize方法(该对象已经覆盖该方法且未被执行过),那么该对象并不会立即被回收,而是进入F-Queue队列准备进行第二次检测,若在该对象的finalize方法里该对象未将自己加入到引用链中,那么该对象被回收。
  • JVM 垃圾回收机制中,何时触发 MinorGC 等操作?
    • Minor GC是发生在新生代的GC,新生代包括三块内存区域 Eden 区,From 区与 To 区。对象优先在 eden 创建并分配内存,当 eden 区内存无法为一个新对象分配内存时,就会触发 Minor GC。
  • 对象如何晋升到老年代
    • 大对象直接进入老年代。比如很长的字符串,或者很大的数组。
    • 默认情况下,当新生代对象被复制了15次(通过:-XX:MaxTenuringThreshold来配置),就会进入年老代了。
    • Minor GC时, From、To区无法完全装下所有复制的对象时直接进入老年代。

类加载机制

  • JVM将字节码文件加载到内存中, 并将这些静态数据转换成方法区中的运行时数据结构,在堆(并不一定在堆中,HotSpot在方法区中)中生成一个代表这个类的java.lang.Class 对象作为方法区类数据的访问入口
  • JVM类加载机制分为五个部分:加载,验证,准备,解析,初始化。(使用、卸载)
  • JVM类加载机制也可分为三个部分:加载,连接,初始化。
  • 为了支持动态绑定解析这个过程可以发生在初始化阶段之后

加载

  • 通过类的全限定名(Fully Qualified Name,将.全部替换为/)来获取定义此类的二进制字节流
  • 将这个类字节流代表的静态存储结构转为方法区的运行时数据结构
  • 中生成一个代表此类的java.lang.Class对象,作为访问方法区这些数据结构的入口。

这个过程主要就是类加载器完成。

校验

  • 文件格式的验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
  • 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
  • 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
  • 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候,主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

准备

  • 为类变量分配内存,并将其初始化为默认值
  • 在初始化的时候才会给变量赋值。

    • 特例

      public static final int value = 123; //此时value的值在准备阶段过后就是123。

    • 静态绑定

      • 在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。可以理解为程序编译期的绑定。java当中的方法只有final,static,private和构造方法是前期绑定的。
    • 动态绑定/运行时绑定

      • 在运行时根据具体对象的类型进行绑定。在java中,几乎所有的方法都是后期绑定的。

解析

  • 把类型中的符号引用转换为直接引用

  • 符号引用所引用的目标并不一定要已经加载到内存中。

  • 直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。

    • 如果有了直接引用,那引用的目标必定已经在内存中存在。
  • 四种解析

    • 类或接口的解析
      • 判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
    • 字段解析
      • 对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束。
    • 类方法解析
      • 对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。
    • 接口方法解析
      • 与类方法解析步骤类似,因为接口不会有父类,因此,只递归向上搜索父接口就行了。

初始化

1
2
3
4
5
6
- 初始化阶段是执行类构造器<clinit>()方法的过程。
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
- <clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
- <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
- 接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口鱼类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
- 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

系统自带的类加载器分为三种:

  • 启动类加载器
  • 扩展类加载器
  • 应用程序类加载器

双亲委派机制

  • 当某个类加载器需要加载某个.class文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
  • 只要加载字节码的类加载器不同,那这两个类就必定不相等。
  • 双亲委派机制可以保证核心.class不能被篡改。通过双亲委派机制方式,不会去篡改核心.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全。

Question!

  • 反射中,Class.forName()ClassLoader.loadClass()区别
    • Class.forName() 默认执行类加载过程中的连接与初始化动作,一旦执行初始化动作,静态变量就会被初始化为程序员设置的值,如果有静态代码块,静态代码块也会被执行。
    • ClassLoader.loadClass() 默认只执行类加载过程中的加载动作,后面的动作都不会执行。