同一个 Java 文件用不同的 jdk 编译出的 class 文件是一样的吗?

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了同一个 Java 文件用不同的 jdk 编译出的 class 文件是一样的吗?相关的知识,希望对你有一定的参考价值。

例如openjdk和sunjdk之间,或者不同平台的jdk之间,或者jdk的不同版本之间?

我理解是不一样的,想到的有两处不一样的地方
1、每个class文件的开头几个字节中有标识jdk版本的数值信息,这个应该不一样,比如jdk5编译的是49,jdk6编译的是50。
2、有些编译器在编译时会进行优化,比如将static final的常量直接inline到使用该常量的地方
参考技术A 好像会有不一样的优化 参考技术B 编译出来的class是一样的,你可以写一条输出语句,然后获取class文件md5,然后换一个jdk同样编译后获取md5试一下。。。。jdk版本不同只是在于jdk工具方法的不同,跟你写的代码没关系

JVM运行时数据区

什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?

Java虚拟机是一个可以执行Java字节码的虚拟机进程。Java源文件被编译成能被Java虚拟机执行的字节码文件。
一旦Java代码被编译为Java字节码,便可以在不同平台上的Java虚拟机上运行。
不同平台用不同的JVM,因此JDK和JRE也不同

Write Once Run Anywhere
编译一次,到处运行

Java代码是怎么运行的?

Java代码被javac编译器编译为Java字节码,在Java虚拟机上运行。

javac编译过程:Person.java -> 词法分析器 -> tokens流 -> 语法分析器 -> 语法树/抽象语法树 -> 语义分析器 -> 注解抽象语法树 -> 字节码生成器 -> Person.class文件

Java虚拟机将运行时内存区域划分为五个部分,分别为方法区PC寄存器Java方法栈本地方法栈。Java程序编译而成的 class文件,需要先加载至方法区中,方能在Java虚拟机中运行。

为了提高运行效率,标准JDK中的HotSpot虚拟机采用的是一种混合执行的策略
解释执行 :在执行时才翻译成机器指令,无需保存不占内存。
即时编译 (just-in-time compilation,JIT):将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。HotSpot 装载了多个不同的即时编译器。

  • Client Complier(C1):更高的编译速度
  • Server Complier(C2):更好的编译质量

但即时编译类似预编译,编译之后的指令需要保存在内存中,这种方式吃内存,按照二八原则这种混合模式最恰当。

类加载

类的加载,由类加载器完成:

  1. 通过类的全限定名来获得二进制字节流
  2. 将这个字节流所代表的静态存储结构转换成方法区运行时的数据结构
  3. 并在堆内存区创建一个Java.lang.Class 对象,作为对方法区中这个数据结构的访问入口

类加载器分类:

  • Bootstrap ClassLoader : C++实现,加载java核心API, jre/lib/rt.jar 里所有的class 或 Xbootclassoath选项指定的jar包。
  • ExtClassLoader: 加载扩展API,jre/lib/ext 目录中 ,如 javax.* 或 -Djava.ext.dirs指定目录下的jar包。
  • APPClassLoader或SystemClassLoader:加载ClassPath下定义的class 及 Djava.class.path 所指定目录下的类和 jar包。
  • CustomClassLoader:通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。

类加载过程采用父类委托机制(双亲委派) :更好的保证了java平台的安全性(更优先加载jre的class文件),类的加载首先请求父类的加载器加载,父类加载器无法加载该类时才尝试从自己的类路径中加载该类,每层类加载器在加载时会判断是否已经加载过,如果加载过就不在重复加载,这样设计能够避免类重复加载核心类被篡改等情况发生。(父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码)

破坏双亲委派:
我就想加载我自己写的类

(1)tomcat
定义java.lang.ClassLoader的子类,重写loadClass方法,自定义加载class

(2)SPI机制
Service Provider Interface

Java 在核心类库中定义了许多接口,并且还给出了针对这些接口的调用逻辑,然而并未给出实现。开发者要做的就是定制一个实现类,在 META-INF/services 中注册实现类信息,以供核心类库使用。
java.sql.Driver 是最为典型的 SPI 接口,java.sql.DriverManager 通过扫包的方式拿到指定的实现类,完成 DriverManager的初始化。

SPI 是如何打破双亲委派模型的呢?
首先双亲委派原则本身并非 JVM 强制模型。

SPI 的调用方和接口定义方很可能都在 Java 的核心类库之中,而实现类交由开发者实现,然而实现类并不会被启动类加载器所加载,基于双亲委派的可见性原则,SPI 调用方无法拿到实现类。

SPI Serviceloader 通过线程上下文获取能够加载实现类的classloader,一般情况下是 application classloader,绕过了这层限制,逻辑上打破了双亲委派原则。

(3)OSGi
热更新、热部署

类的生命周期

当Java程序需要使用某个类时,JVM要保证这个类被装载、链接(校验、准备、解析)和初始化。

1、类的装载(Load)(查找和导入class文件):

  1. 通过类的全限定名来获得二进制字节流
  2. 将这个字节流所代表的静态存储结构转换成方法区运行时的数据结构
  3. 并在堆内存区创建一个Java.lang.Class 对象,作为对方法区中这个数据结构的访问入口

2、 链接

2.1、校验(Verify)
为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。(文件格式验证,元数据验证,字节码验证,符号引用验证)

2.2、准备(Prepare)
为静态成员变量分配内存并设置默认的初始值(不是等于之后的真实值),这些变量所使用的内存都将在方法区中进行分配。

假设定义类变量:public static int age = 10; 在准备阶段后 age为0,将 age = 10 的指令是存放于构造器方法之中的,在初始化阶段才会执行。如果同时被 final 修饰,则在准备阶段就会value赋值为 10

public class Demo1 
	private static int i;
	public static void main(String[] args) 
		// 正常打印出0,因为静态变量i在准备阶段会有默认值 0 
		System.out.println(i);
	 

public class Demo2 
    public static void main(String[] args) 
    	// 编译通不过,因为局部变量没有赋值不能被使用
    	int i;
    	System.out.println(i);
     

2.3、 解析(Resolve)
将常量池内的符号引用替换为直接引用(也可能发生在初始化之后)。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

符号引用:class文件中的没有加入内存中的一些指代引用的符号

3、初始化(Initialize)

先初始化父类,对 static修饰的静态变量静态代码块进行初始化,并初始化程序员设置的变量值。

4、生命周期结束

  • 执行了System.exit() 方法。
  • 程序正常执行结束。
  • 程序在执行过程中遇到了异常或错误而异常终止。
  • 由于操作系统出现错误而导致java虚拟机进程终止。

Java 对象的创建过程

当一个对象被创建时,虚拟机就会为其分配内存来存放对象自己的 实例变量(非静态变量) 及其 从父类继承过来的实例变量 (即使这些从超类继承过来的实例变量有可能被隐藏也会被分配空间)。在为这些实例变量分配内存的同时,这些实例变量也会被赋予默认值(零值)。

在内存分配完成之后,Java虚拟机就会开始对新创建的对象按照程序猿的意志进行初始化。
在Java对象初始化过程中,主要涉及三种执行对象初始化的结构,分别是 实例变量初始化实例代码块初始化 以及 构造函数初始化

在继承中代码的执行顺序为:

  1. 父类静态对象,父类静态代码块
  2. 子类静态对象,子类静态代码块
  1. 父类非静态对象,父类非静态代码块
  2. 父类构造函数
  3. 子类非静态对象,子类非静态代码块
  4. 子类构造函数

1-2为类的初始化完成,3-6为类的实例化,即一个对象的初始化时完成。

获取对象的方式

A a = (A)Class.forName(pacage.A).newInstance();
A a = new A()

是一样的效果。

newInstance( )是一个方法,而new是一个关键字。创建对象的方式不一样,前者是使用类加载机制,后者是创建一个新类。Class下的newInstance()的使用有局限,因为它生成对象只能调用无参的构造函数,而使用 new关键字生成对象没有这个限制。
newInstance()实际上是把new这个方式分解为两步,即首先调用Class加载方法加载某个类,然后实例化。 这样可以在调用class的静态加载方法forName时获得更好的灵活性,提供了一种降耦的手段。

Class.forName(className)方法,内部实际调用的方法是 Class.forName(className,true,classloader);
第2个boolean参数表示类是否需要初始化, Class.forName(className)默认是需要初始化。一旦初始化,就会触发目标对象的 static块代码执行。

ClassLoader.loadClass(className)方法,内部实际调用的方法是 ClassLoader.loadClass(className,false);
第2个 boolean参数,表示目标对象是否进行链接,false表示不进行链接,可以不进行链接意味着不进行包括初始化等一系列步骤,那么静态块和静态对象就不会得到执行。

为什么在加载数据库驱动包的时候用的是Class.forName( ),却没有调用newInstance( )?
newInstance()方法,会保证这个类加载、连接和(初始化)。
JDBC Driver源码如下,因此使用Class.forName(classname)才能在反射回去类的时候执行static块。

static 
    try 
        java.sql.DriverManager.registerDriver(new Driver());
     catch (SQLException E) 
        throw new RuntimeException("Can't register driver!");
    

所以在静态初始化器的中已经进行了注册,所以我们在使用JDBC时只需Class.forName(XXX.XXX);就可以了。

JVM内存结构

方法区(Method Area)(非堆)

线程共享,在虚拟机启动时创建
存储已被虚拟机加载的类信息常量(static final)静态变量即时编译器编译的代码等数据。

A、类的基本信息:

  1. 每个类的全限定名
  2. 每个类的直接超类的全限定名(可约束类型转换)
  3. 该类是类还是接口
  4. 该类型的访问修饰符
  5. 直接超接口的全限定名的有序列表

B、已装载类的详细信息

  1. 运行时常量池:在方法区中,每个类型都对应一个常量池,存放该类型所用到的所有常量,常量池中存储了诸如文字字符串、final变量值、类名和方法名常量。它们以数组形式通过索引被访问,是外部调用与类联系及类型对象化的桥梁。(存的可能是个普通的字符串,然后经过常量池解析,则变成指向某个类的引用)
  2. 字段信息:字段信息存放类中声明的每一个字段的信息,包括字段的名、类型、修饰符。字段名称指的是类或接口的实例变量或类变量,字段的描述符是一个指示字段的类型的字符串,如 private A a = null; 则 a 为字段名,A为描述符,private 为修饰符。
  3. 方法信息:类中声明的每一个方法的信息,包括方法名、返回值类型、参数类型、修饰符、异常、方法的字节码。(在编译的时候,就已经将方法的局部变量、操作数栈大小等确定并存放在字节码中,在装载的时候,随着类一起装入方法区)
  4. 静态变量:就是类变量,类的所有实例都共享,我们只需知道,在方法区有个静态区,静态区专门存放静态变量和静态快。
  5. 到类classLoader的引用:到该类的类装载器的引用。
  6. 到类class的引用:jvm为每个加载的类型(译者:包括类的接口)都创建一个Java.lang.Class 的实例。而jvm必须为某种方式把class的这个实例和存储在方法区中的类型数据联系起来。

JVM运行时数据区是一种规范,真正的实现
方法区在JDK 8中是Metaspace,在JDK6或7中是Perm Space

堆(heap)

线程共享,在虚拟机启动时创建
java虚拟机管理的内存中最大的一块,Java对象实例以及数组都在堆上分配。

JVM栈(JVM Stacks)

一个Java线程的 运行状态,由一个虚拟机栈来保存,所以虚拟机栈是线程私有的,生命周期与线程相同。

描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个桟帧(stack frame)用于存储局部变量表、操作数桟、动态链接、方法返回地址等信息。每一个方法被调用直至执行完成的过程,就对应着一个桟帧在虚拟机桟中从入栈到出栈的过程。

会抛出两种异常:

  • 当线程请求的桟深度大于虚拟机允许的深度,会抛出StackOverFlow;
  • 如果虚拟机桟允许动态扩容,当扩展时无法申请到足够的内存时会抛出OutOfMemoryError。

栈帧:每个栈帧对应一个被调用的方法,可以理解为一个方法的运行空间。
每个栈帧中包括:
局部变量表:方法中定义的局部变量以及方法的参数存放在这张表中,局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。
操作数栈:以压栈和出栈的方式存储操作数的
动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
方法返回地址:当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。

结合字节码指令理解栈帧:

    public static int calc(int op1, int op2) 
        op1 = 3;
        int result = op1 + op2;
        return result;
    

javap -c Person.class > Person.txt

Compiled from "Person.java"
class Person 
...
 public static int calc(int, int);
  Code:
   0: iconst_3  //将int类型常量3压入[操作数栈]
   1: istore_0  //将int类型值存入[局部变量0]
   2: iload_0   //从[局部变量0]中装载int类型值入栈 
   3: iload_1   //从[局部变量1]中装载int类型值入栈
   4: iadd      //将栈顶元素弹出栈,执行int类型的加法,结果入栈
   5: istore_2  //将栈顶int类型值保存到[局部变量2]中
   6: iload_2   //从[局部变量2]中装载int类型值入栈
   7: ireturn   //从方法中返回int类型的数据
...   

本地方法栈(native method stacks)

如果当前线程执行的方法是Native类型的,这些方法就会在本地方法栈中执行。有的虚拟机将本地方法桟与虚拟机桟合二为一。
也会抛出StackOverFlow或OutOfMemory。

stack: 桟是向低地址扩展的数据结构,是一块连续的内存的区域。栈顶的地址和桟的最大容量是系统预先选定好的。由系统自动分配,速度较快,但程序员是无法控制的。
heap: 堆是向高地址扩展的数据结构,是不连续的内存区域。由new 分配的内存,一般速度较慢,容易产生内存碎片,但用起来方便。

程序计数器(program counter register)

是当前线程所执行的字节码的行号指示器。
如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;
如果正在执行的是Native方法,则这个计数器为空。
唯一一个没有任何 OutOfMemoryError 的区域。

我们都知道一个JVM进程中有多个线程在执行,而线程中的内容是否能够拥有执行权,是根据CPU调度来的。
假如线程A正在执行到某个地方,突然失去了CPU的执行权,切换到线程B了,然后当线程A再获得 CPU执行权的时候,怎么能继续执行呢?这就是需要在线程中维护一个变量,记录线程执行到的位置。

不同区域指向示例

栈指向堆

main方法,调用 calc方法,clac里创建引用类型对象 Object obj=new Object(),这时候就是典型的栈中元 素指向堆中的对象。

方法区指向堆

因为方法区中存放常量、静态变量、虚拟机加载后的类信息等,所有obj是存放在方法区中,而new出来的一切都存放在堆中。

private static Object obj=new Object();

堆指向方法区

1、如实例对象调用静态变量。
2、对象要指向自己的类元数据,及对象要知道自己是哪个类创建的。

Java对象内存模型

一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充。

保证对象大小是8字节的整数倍:为了让内容读取效率更高。防止不必要的多次读取。
64位操作系统:每次读取的单位是 64bit = 8Byte;

java中的对象都是放到堆内存里面的吗

可能不是,这里涉及一个技术点 对象逃逸分析

对象逃逸分析就是分析对象的动态作用域,当一个对象在一个方法中被定义之后,它很有可能被外部方法所引用,例如作为调用参数传递到其他地方中。

逃逸分析的原理?
Java本身的限制(对象只能分配到堆中),为了减少临时对象在堆内分配的数量,我会在一个方法体内定义一个局部变量,并且该变量在方法执行过程中未发生逃逸,按照JVM调优机制,首先会在堆内存创建类的实例,然后将此对象的引用压入调用栈,继续执行,这是JVM优化前的方式。然后,我采用逃逸分析对JVM进行优化。即针对栈的重新分配方式,首先找出未逃逸的变量,将该变量直接存到栈里,无需进入堆,分配完成后,继续调用栈内执行,最后线程执行结束,栈空间被回收,局部变量也被回收了。如此操作,是优化前在堆中,优化后在栈中,从而减少了堆中对象的分配和销毁,从而优化性能。

如何开启
JVM可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上,JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

逃逸的方式?

  • 方法逃逸:在一个方法体内,定义一个局部变量,而它可能被外部方法引用,比如作为调用参数传递给方法,或作为对象直接返回。或者,可以理解成对象跳出了方法。
  • 线程逃逸:这个对象被其他线程访问到,比如赋值给了实例变量,并被其他线程访问到了。对象逃出了当前线程。

逃逸分析的好处?
如果一个对象不会在方法体内,或线程内发生逃逸(或者说是通过逃逸分析后,使其未能发生逃逸)

  1. 栈上分配:
    一般情况下,不会逃逸的对象所占空间比较大,如果能使用栈上的空间,那么大量的对象将随方法的结束而销毁,减轻了GC压力
  2. 同步消除:
    如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。
  3. 标量替换
    Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象。如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了。

内存溢出和内存泄漏的区别

内存泄漏:程序在申请内存后,无法释放已申请的内存空间 (程序实际不用了,但是引用还在,JVM不能回收)。内存泄漏堆积后的后果就是内存溢出。

内存溢出:指程序申请内存时,没有足够的内存供申请者使用。

Java内存模型及各个区域的OOM,如何重现OOM

方法区:如果是动态生成类信息(使用CGlib)或字符串常量过多,会出现 java.lang.OutOfMemoryError: PermGen space (java.lang.OutOfMemoryError: Metaspace)。

堆:代码中存在死循环或循环产生过多重复的对象实体;java.lang.OutOfMemoryError:java heap space。

桟: 递归(未设置递归退出条件)栈溢出:java.lang.StackOverflowError。

本地方法区:与虚拟机桟类似。

程序计数器:唯一一个没有任何OutOfMemoryError的区域。

发现程序内存溢出如何处理

程序运行要用到的内存大于虚拟机能提供的最大内存就发生内存溢出了。

原因:
1、内存中加载的数据量过于庞大,如一次性从数据库取出过多数据;对数据库进行查询尽量采用分页查询。

2、虚拟机不回收内存(内存泄漏):

  • 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
  • 代码中存在死循环或循环产生过多重复的对象实体;java.lang.OutOfMemoryError:java heap space 如果是动态生成类信息或字符串常量过多,会出现 java.lang.OutOfMemoryError: PermGen space (java.lang.OutOfMemoryError: Metaspace)
  • 使用的第三方软件中的bug;
  • 递归(未设置递归退出条件);栈溢出:java.lang.StackOverflowError

3、启动参数内存值设定的过小;

解决方法:
1、结合报错类型(StackOverflowError还是OutOfMemoryError(heap space还是PermGen space)),看是哪块内存溢出,对代码进行走查和分析,找出可能发生内存溢出的位置。
优化程序代码:

  • 尽量减少全局变量和静态变量的引用,让程序使用完变量的时候释放该引用能够让垃圾回收器回收,释放资源。
  • 注意集合数据类型,包括数组,树,图,链表等数据结构,对于这类对象,GC 回收它们一般效率较低。如果程序允许,尽早将不用的引用对象赋为null。
  • 字符串累加的时候一定要用StringBuffer的append方法,不要使用+操作符连接两个字符串。
  • 如果需要使用经常使用的图片,可以使用soft引用类型。它可以尽可能将图片保存在内存中,供程序调用,而不引起OutOfMemory.

2、使用工具(Eclipse Memory Analyzer)分析堆转存快照,确定内存中的对象是否是必须的,分析是否是内存泄漏导致的内存溢出。如果是内存泄漏,可进一步通过工具查看泄漏对象到GCRoot的引用链,从而可以知道泄漏对象是这样与GCRoot相关联,导致GC无法自动回收,有了这些信息就可以比较准确的定位到泄漏代码的位置。

3、修改JVM启动参数,直接增加内存:-Xms256m -Xmx256m 。

以上是关于同一个 Java 文件用不同的 jdk 编译出的 class 文件是一样的吗?的主要内容,如果未能解决你的问题,请参考以下文章

class编译默认用jdk11

在使用jdk编译时,如何如何将一个JAVA源文件编译到一个指定的文件夹里面?

eclipse编译项目用maven编译问题

Intellij IDEA 编译等级和JDK的作用

如何在Java 6中使用为Java 7编译的库?

JDK/bin目录下的不同exe文件的用途(转)