Java虚拟机

Posted by Liber Sun on September 28, 2018

JAVA语言的编译

编译的主要目的,就是将人便于编写、阅读以及维护的高级语言所做的源代码程序,翻译为计算能够解读、运行的低级语言的程序,也就是可执行文件。

Java语言的源文件是一个java文件,要将一个java文件,转换为二进制文件一共要经过两个步骤。

FrontEnd:源代码–词法分析–>符号流–语法分析–>语法树–语义分析–>中间代码 BackEnd:中间代码–机器无关代码优化–>优化的中间代码–代码生成–>机器语言–机器相关代码优化–>优化的机器语言

首先经过前端编译器,将java文件编译成中间代码,这种中间代码就是class文件,即字节码文件。 然后,在经过后端编译器,将class字节码文件,编译成机器语言。

Java的前端编译器主要是javac, Eclipse JDT 中的增量式编译器 ECJ 等。 Java的后端编译器主要是各大虚拟机实现的,如HotSpot中的JIT编译器。

JAVA语言的反编译

前面讲过,我们可以通过编译器,把高级语言的源代码编译成低级语言,那么反之,我们亦可以通过低级语言进行反向工程,获取其源代码。这个过程,就叫做反编译。

我们虽然很难将机器语言反编译成源代码,但是,我们还是可以把中间代码进行反编译的。就像我们虽然不能把经过虚拟机编译后的机器语言进行反编译,但是我们把javac编译得到的class进行反编译还是可行的。

所以,我们说Java的反编译,一般是将class文件转换成java文件。

那么反编译有什么用呢?能让你对JAVA语言理解得更深刻一点。

JAVA语言提供了大量的语法糖,自动装箱拆箱、foreach(依赖于while和Iterator实现)、泛型(擦除)、switch的String形式、参数变长(转为数组)、enum(final类集成Eunm类)、内部类(生成两个class,outer.class与outer$inner.class)、断言(if实现)数值字面量(int i=1_000,编译时将_去掉)、try-with-resource(编译器为我们关闭)、Lambda表达式(lambda表达式的实现其实是依赖了一些底层的api,在编译阶段,编译器会把lambda表达式进行解糖,转换成调用内部api的方式,注意不是匿名内部类因为没有两个class)等。

这些语法糖JVM却是不认识的,所以在javac编译的时候,就会进行解糖,而得到的class就是解糖后的代码,将其反编译回来,我们就可以学习到这些语法糖是如何实现的。当然反编译别人的class代码,也是我们学习别人源码的机会。

JVM

组成

线程共享:堆(常量池),本地内存(元数据区,直接内存)

线程隔离:栈、本地方法栈、程序计数器

注意本地内存不位于JVM中,这是为了避免大量的类信息将JVM的空间占用。

程序计数器

记录当前线程运行的位置(行号)。

每个java方法都有一个栈。

栈中存储了

  • 局部量表,当变量为基本类型时,直接存储值;当变量为引用类型(数组、类)存储引用。
  • 操作数栈,
  • 指向常量池的引用
  • 存储方法执行完成后的返回地址

栈里面存储了基本数据类型的值,和对象的地址

本地方法栈

native方法分配的栈

堆存储 对象本身和数组

分为青年区(1/3)、老年区(2/3) 再加上常量池

赋值

int i=10;// 去栈中找10,若10存在,则i指向10;若不存在,则开辟空间存储10,然后i指向10
Person per=new Person();//栈中会存储per per指向堆中的对象实体 
int [] arr=new int [3];//栈中存储arr,arr指向推中的实体。

如果是对基本数据类型的数据进行操作,由于原始内容和副本都是存储实际值,并且是在不同的栈区,因此形参的操作,不影响原始内容。

如果是对引用类型的数据进行操作,分两种情况,一种是形参和实参保持指向同一个对象地址,则形参的操作,会影响实参指向的对象的内容。一种是形参被改动指向新的对象地址(如重新赋值引用),则形参的操作,不会影响实参指向的对象的内容。

垃圾回收

垃圾回收,涉及到三件事情:

  • 哪种内存需要回收
  • 什么时候回收
  • 怎么回收

回收的类型

垃圾收集主要是针对方法区进行。程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。

判断一个对象是否可回收

1.引用计数算法 2.可达性分析算法 3.方法区的回收,主要是对常量池的回收和对类的卸载。

  1. finalize()

引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

Java 提供了四种强度不同的引用类型。

1.强引用 被强引用关联的对象不会被回收。

使用 new 一个新对象的方式来创建强引用。

Object obj = new Object();

2.软引用 被软引用关联的对象只有在内存不够的情况下才会被回收。

使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联

3.弱引用 被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

使用 WeakReference 类来创建弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

4.虚引用 又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来创建虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

如何回收

分代回收

当代主流虚拟机(Hotspot VM)的垃圾回收都采用“分代回收”的算法。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。

Hotspot VM将内存划分为不同的物理区,就是“分代”思想的体现。如图所示,JVM内存主要由新生代、老年代构成。

1.新生代,大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。 新生代内又分三个区:一个Eden区,两个Survivor区(一般而言),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor区(中的一个)。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。

2.老年代(Old Generation):在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。

Minor GC 和 Full GC

  • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。 对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。
  • Major GC:清理老年代
  • Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

JVM实现平台无关性

平台无关性就是一种语言在计算机上的运行不受平台的约束,一次编译,到处执行(Write Once ,Run Anywhere)。

我们利用javac将java文件转为class文件,再利用JVM加载class文件生成二进制文件。

虽然JAVA是平台无关的,但是JVM却是平台相关的。不同的操作系统需要不同的JVM。

JVM

所以,Java之所以可以做到跨平台,是因为Java虚拟机充当了桥梁。他扮演了运行时Java程序与其下的硬件和操作系统之间的缓冲角色。

另外我们需要额外注意,JVM面向的是class文件,这意味着JVM不是一定必须与Java文件交互的。JVM运行的时候,并不依赖于Java语言。虚拟机并不关心字节码是有哪种语言编译而来的。 时至今日,商业机构和开源机构已经在Java语言之外发展出一大批可以在JVM上运行的语言了,如Groovy、Scala、Jython等。之所以可以支持,就是因为这些语言也可以被编译成字节码。

类的加载过程

类在运行期间第一次使用时是动态加载的,不是一次性加载所有类。一次性全部加载,会占用很多的内存。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载 七个阶段。

其中类的加载过程包括:加载、验证、准备、解析、初始化 五个阶段。

在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析的阶段则不确定。它在某些情况下,可以晚于初始化进行,这是为了支持java语言的运行时绑定。

静态绑定:编译时绑定。在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对java,简单的可以理解为程序编译期的绑定。java当中的方法只有final,static,private和构造方法是前期绑定的。 动态绑定:即运行时绑定。在运行时根据具体对象的类型进行绑定。在java中,几乎所有的方法都是动态绑定的。

加载

1.通过类名来获取其定义的二进制字节流。 2.将字节流所代表的静态存储结构转化为方法区的运行时数据结构。

方法区是是每个线程共享的,用于存储:被虚拟机加载的类信息、常量、静态变量。 3.在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区中的这些数据的入口。

其中二进制字节流可以从以下的方式获取:

  • 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础。
  • 从网络中获取,最典型的应用是 Applet。
  • 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
  • 由其他文件生成,例如由 JSP 文件生成对应的 Class 类。

类的加载机制

jvm是如何把数据加载到内存中的呢?是通过类加载器

Classloader是用来加载class的,它负责将Class的字节码形式转换为内存形式的Class对象。字节码来自于磁盘中的.class文件,来自jar包中的class文件,也可以来自远程服务器提供的字节流,字节码的本质是一个字节数组。(byte[]->class)

类加载器具有延迟加载的特点,及JVM 运行并不是一次性加载所需要的全部类的,它是按需加载,也就是延迟加载。程序在运行的过程中会逐渐遇到很多不认识的新类,这时候就会调用 ClassLoader 来加载这些类。加载完成后就会将 Class 对象存在 ClassLoader 里面,下次就不需要重新加载了。

比如你在调用某个类的静态方法时,首先这个类肯定是需要被加载的,但是并不会触及这个类的实例字段,那么实例字段的类别 Class 就可以暂时不必去加载,但是它可能会加载静态字段相关的类别,因为静态方法会访问静态字段。而实例字段的类别需要等到你实例化对象的时候才可能会加载

java提供了这么几种类加载器,同时这些加载器之间存在继承关系。

  • 启动类加载器(Bootstrap ClassLoader),复杂加载JVM运行时的核心类,常见的库包括java.util.*,java.io.*,java.nio.*,java.lang.*等。这些类通常位于JDK\jre\lib\rt.jar文件中,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库。启动类加载器是无法被Java程序直接引用的。

  • 扩展类加载器(Extenion ClassLoader),负责加载JVM扩展类,比如Swing,内置js引擎,xml解析器。这些类存放在JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类)。开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader),它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载.

  • 自定义类加载器

那些位于网络上静态文件服务器提供的 jar 包和 class文件,jdk 内置了一个 URLClassLoader,用户只需要传递规范的网络路径给构造器,就可以使用 URLClassLoader 来加载远程类库了。URLClassLoader 不但可以加载远程类库,还可以加载本地路径的类库,取决于构造器中不同的地址形式。ExtensionClassLoader 和 AppClassLoader 都是 URLClassLoader 的子类,它们都是从本地文件系统里加载类库。

双亲委派模式

如果一个类加载器得到了类的加载请求,首先他不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上。因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派模式图

这就是我们说得双亲委派模式,那么这种模式的好处是什么呢?

同一个类的定义:即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。

Java类随着它的类加载器(说白了,就是它所在的目录)一起具备了一种带有优先级的层次关系。例如,类java.lang.Object类存放在JDK\jre\lib下的rt.jar之中,因此无论是哪个类加载器要加载此类,最终都会委派给启动类加载器进行加载,这边保证了Object类在程序中的各种类加载器中都是同一个类

验证

验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求。 文件格式的验证、元数据的验证、字节码验证和符号引用验证。

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

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

当然如果用final形容的变量,在编译时Javac将会为value生成ConstantValue属性。

public static int value = 3//初始值为0
public static final int value = 3//初始值为3

在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。

解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。这就是我们当年学编译原理的“链接”阶段。

符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。

初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器()方法的过程,其中包含编译器自动收集的类变量的赋值(非final)和静态语句块中的语句。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。

错误:

public class Test {
    static {
        i = 0;                
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}

正确:

public class Test {
    static int i=1;//ida提示赋值是多余的,但是不会报错
    static {
        i=0;
        System.out.println(i);
    }
}

总结

整个类加载过程中,除了在加载阶段用户应用程序可以自定义类加载器参与之外,其余所有的动作完全由虚拟机主导和控制。到了初始化才开始执行类中定义的Java程序代码(亦及字节码),但这里的执行代码只是个开端,它仅限于()方法。类加载过程中主要是将Class文件(准确地讲,应该是类的二进制字节流)加载到虚拟机内存中,真正执行字节码的操作,在加载完成后才真正开始。