lxttiger 2020-08-18
他是一个高材生, 算法比我溜多了, 昨天吃饭, 深受打击, 如果以后有机会去大公司面试, 一定必考的就是算法题, 还记得去年自己面试的时候, 大公司的算法题是真的不会啊。 想想就让自己觉得特别丧。我好菜啊。
其实回头自己静下来想一想, 自己其实也是有自己的优势的, 那就是自己对运维, 开发, 都是熟悉的, 这个方面上, 自己有一定的有事优势, 自动化运维啊, 和服务器打交道啊, 都是自己的长处。
但是半路出家的开发人员, 算法这个东西, 这个关自己一定要过的, 聪明的人只会踏踏实实, 静下来去做事情, 胡突然才是不断的找捷近, 最后永远都做不成。
接下来的额后半年, 自己的算法题, 可能也需要安排一下了。
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。Clojure(Lisp 语言的一种方言)、Groovy、Scala 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编译成.class文件最终运行在 Java 虚拟机之上。.class文件的二进制格式可以使用 WinHex 查看。
可以说.class文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因。
我们都知道,Java程序最终是转换成class文件执行在虚拟机上的,那么class文件是个怎样的结构,虚拟机又是如何处理去执行class文件里面的内容呢,这篇文章带你深入理解Java字节码中的结构。
首先,编写一个简单的Java源码:
package com.april.test; public class Demo { private int num = 1; public int add() { num = num + 2; return num; } }
这段代码很简单,只有一个成员变量num和一个方法add()。
要运行一段Java源码,必须先将源码转换为class文件,class文件就是编译器编译之后供虚拟机解释执行的二进制字节码文件,可以通过IDE工具或者命令行去将源码编译成class文件。这里我们使用命令行去操作,运行下面命令:
javac Demo.java
就会生成一个Demo.class文件。
我们打开这个Demo.class文件看下。这里用到的是Notepad++,需要安装一个HEX-Editor插件。
下载jad.exe软件进行反编译
jad.exe E:\ -sjava Demo.class
-d < dir >- 指定输出文件的文件目录
-s -输出文件扩展名(默认:.jad)
反编译的结果如下
// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov. // Jad home page: http://www.kpdus.com/jad.html // Decompiler options: packimports(3) // Source File Name: Demo.java package com.april.test; public class Demo { public Demo() { num = 1; } public int add() { num = num + 2; return num; } private int num; }
可以看到,回编译的源码比编写的代码多了一个空的构造函数, 先放下这个疑问,看完这篇分析,相信你就知道答案了。
根据 Java 虚拟机规范,类文件由单个 ClassFile 结构组成:
ClassFile { u4 magic; //Class 文件的标志 u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 u2 access_flags;//Class 的访问标记 u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口 u2 interfaces[interfaces_count];//一个类可以实现多个接口 u2 fields_count;//Class 文件的字段属性 field_info fields[fields_count];//一个类会可以有个字段 u2 methods_count;//Class 文件的方法数量 method_info methods[methods_count];//一个类可以有个多个方法 u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 }
下面详细介绍一下 Class 文件结构涉及到的一些组件。
Class文件字节码结构组织示意图 (之前在网上保存的,非常不错,原出处不明):
u4 magic; //Class 文件的标志
每个 Class 文件的头四个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。
所有的由Java编译器编译而成的class文件的前4个字节都是“0xCAFEBABE”。
它的作用在于:当JVM在尝试加载某个文件到内存中来的时候,会首先判断此class文件有没有JVM认为可以接受的“签名”,即JVM会首先读取文件的前4个字节,判断该4个字节是否是“0xCAFEBABE”,如果是,则JVM会认为可以将此文件当作class文件来加载并使用
u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号
紧接着魔数的四个字节存储的是 Class 文件的版本号:第五和第六是次版本号,第七和第八是主版本号。
JDK1.0的主版本号为45,以后的每个新主版本都会在原先版本的基础上加1。若现在使用的是JDK1.7编译出来的class文件,则相应的主版本号应该是51,对应的7,8个字节的十六进制的值应该是 0x33
JDK版本信息对照表
JDK版本 16进制版本号 十进制版本号 JDK8 00 00 00 34 52 JDK7 00 00 00 33 51 JDK6 00 00 00 32 50 JDK5 00 00 00 31 49 JDK1.4 00 00 00 30 48 JDK1.3 00 00 00 2F 47 JDK1.2 00 00 00 2E 46 JDK1.1 00 00 00 2D 45
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。
当然快捷的方式使用JDK自带的javap工具,如当前有Math.class 文件,进入此文件所在的目录,然后执行 ”javap -v Math“,结果会类似如下所示
u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池
常量池是class文件中非常重要的结构,它描述着整个class文件的字面量信息。常量池是由一组constant_pool结构体数组组成的,而数组的大小则由常量池计数器指定。常量池计数器constant_pool_count 的值 =constant_pool表中的成员数+ 1。constant_pool表的索引值只有在大于 0且小于constant_pool_count时才会被认为是有效的。
注意事项:常量池计数器默认从1开始而不是从0开始:
当constant_pool_count = 1时,常量池中的cp_info个数为0;当constant_pool_count为n时,常量池中的cp_info个数为n-1
原因:
常量池计数器是从1开始计数的,将第0项常量空出来是有特殊考虑的,索引值为0代表“不引用任何一个常量池项”
常量池主要存放两大常量:字面量和符号引用。
字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:
常量池中每一项常量都是一个表,这14种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型.
.class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名-> temp.txt :将结果输出到 temp.txt 文件)。
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。
类访问和属性修饰符:
我们定义了一个 Employee 类
package top.snailclimb.bean; public class Employee { ... }
通过javap -v class类名 指令来看一下类的访问标志。
u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口 u2 interfaces[interfaces_count];//一个类可以实现多个接口
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。
接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按implents(如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。
u2 fields_count;//Class 文件的字段的个数 field_info fields[fields_count];//一个类会可以有个字段
字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
field info(字段表) 的结构:
上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。
字段的 access_flags 的取值:
u2 methods_count;//Class 文件的方法的数量 method_info methods[methods_count];//一个类可以有个多个方法
methods_count 表示方法的数量,而 method_info 表示的方法表。
Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
method_info(方法表的) 结构:
方法表的 access_flag 取值:
注意:因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志。
u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合
在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。
在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
特点:
符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。 符号引用指向了类的名称或者方法的名称或者字段的名称等,不是内存中的表示方式。
例子:
public class People{ private Language language; }
比如 org.simple.People类 引用了 org.simple.Language类 ,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号 org.simple.Language (假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。
直接引用可以是:
总结:
直接引用就是指向了内存中的数据
符号引用替换为直接引用的操作发生在【类加载过程】(加载 -> 连接(验证、准备、解析) -> 初始化)中的 解析阶段,会将【符号引用】转换(替换)为对应的【直接引用】,放入运行时常量池中。
特殊字符串包括三种:类的全限定名, 字段和方法的描述符, 特殊方法的方法名。下面我们就分别介绍这三种特殊字符串。
Object类,在源文件中的全限定名是 java.lang.Object 。而class文件中的全限定名是将点号替换成“/” 。也就是 java/lang/Object 。源文件中一个类的名字, 在class文件中是用全限定名表述的。
各类型的描述符 对于字段的数据类型,其描述符主要有以下几种
数据类 型 描述符 byte B char C double D float F int I long J short S boolean Z 特殊类 型void V 对象类型 “L” + 类型的全限定名 + “;”。如 Ljava/lang/String; 表示 String 类型 数组类型 若干个 “[” + 数组中元素类型的对应字符串,如一维数组 int[] 的描述符为 [I , 二维数组 String[][] 的描述符为 [[java/lang/String;
字段描述符
字段的描述符就是字段的类型所对应的字符或字符串。
如:
int i 中, 字段i的描述符就是 I Object o中, 字段o的描述符就是 Ljava/lang/Object; double[][] d中, 字段d的描述符就是 [[D
方法描述符
方法的描述符比较复杂, 包括所有参数的类型列表和方法返回值。它的格式是这样的
(参数1类型 参数2类型 参数3类型 ...)返回值类型
注意事项
不管是参数的类型还是返回值类型, 都是使用对应字符和对应字符串来表示的, 并且参数列表使 用小括号括起来, 并且各个参数类型之间没有空格, 参数列表和返回值类型之间也没有空格。
方法描述符举例说明如下:
方法描述符 方法声明 ()I int getSize() ()Ljava/lang/String; String toString() ([Ljava/lang/String;)V void main(String[] args) ()V void wait() (JI)V void wait(long timeout, int nanos) (ZILjava/lang/String;II)Z boolean regionMatches(boolean ignoreCase, int toOffset, String other, int ooffset, int len) ([BII)I int read(byte[] b, int off, int len ) ()[[Ljava/lang/Object; Object[][] getObjectArray()
首先要明确一下, 这里的特殊方法是指的类的构造方法和类型初始化方法。构造方法就不用多说了, 至于类型的初始化方法, 对应到源码中就是静态初始化块。也就是说, 静态初始化块, 在class文件中是以一个方法表述的, 这个方法同样有方法描述符和方法名,具体如下:
总结
class文件内容
主要分为三大阶段:加载阶段、链接阶段、初始化阶段 。
其中链接阶段又分为:验证、准备、解析
类的卸载,当一个类对应的对象都已经回收的时候,会触发卸载
“加载”是“类加载”(Class Loading)过程的第一步。这个加载过程主要就是靠类加载器实现的,主要目标就是将不同来源的class文件,都加载到JVM内存(方法区)中。到了方法区,需要将加载的信息,封装到java.lang.Class对象中。
类和数组加载的区别
数组也有类型,称为“数组类型”.如
String[] str = new String[10];
这个数组的数组类型是 [Ljava.lang.String ,而String只是这个数组的元素类型。数组类和非数组类的类加载是不同的,具体情况如下:
保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。那么可以使用 -Xverify:none 参数关闭,以缩短类加载时间 。
仅仅为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值即零值 。
这里不包含用final修饰的static,因为final在编译的时候就会分配了(编译器的优化) 同时这里也[不会为实例变量分配初始化]
比如:
public static int x = 1000;
实际上变量x在准备阶段过后的额初始值是0, 而不是1000
将x赋值为1000的putstatic指令时程序被编译后, 存放于构造器<clint>方法中
但是如果声明为:
public static final int x = 1000;
在编译尖端会为x生成ConstantValue属性, 在准备阶段虚拟机会根据ConstantValue属性将X赋值为1000
解析是虚拟机将常量池的符号引用替换为直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info, CONSTANT_Fieldref_info, CONSTANT_Methodref_info, CONSTANT_InterfaceMethodref_info,四种常量类型
初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码(初始化成为代 码设定的默认值)。
其实初始化过程就是调用类初始化方法的过程,完成对static修饰的类变量的手动赋值还有主动调用静 态代码块。
初始化过程的注意点
方法是编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集 的顺序是由语句在源文件中出现的顺序所决定的. 静态代码块只能访问到出现在静态代码块之前的变量,定义在它之后的变量,在前面的静态语句块 可以赋值,但是不能访问. 虚拟机会保证在多线程环境中一个类的方法被正确地加锁,同步.当多条线程同时去初始化一个类 时,只会有一个线程去执行该类的方法,其它线程都被阻塞等待,直到活动线程执行方法完毕.其他线程虽会被阻塞,只要有一个方法执行完,其它线程唤醒后不会再进入方法.同一个类加载器 下,一个类型只会初始化一次.
使用静态内部类的单例实现:
public class Student { private Student() {} /* * 此处使用一个内部类来维护单例 JVM在类加载的时候,是互斥的,所以可以由此保证线程安全 问题 */ private static class SingletonFactory { private static Student student = new Student(); } /* 获取实例 */ public static Student getSingletonInstance() { return SingletonFactory.student; } }
不同类加载器对象,如果对同一个类进行加载,会形成不同的Class对象。
JVM的类加载是通过ClassLoader及其子类来完成的, 类的层次关系和加载顺序可以由下图来描述
加载过程中会先检查类是否被已加载,检查顺序是自底向上,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader 加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。
自定义类加载器步骤
(1)继承ClassLoader
(2)重写findClass()方法
(3)调用defineClass()方法
实践
下面写一个自定义类加载器:指定类加载路径在D盘下的lib文件夹下。
(1)在本地磁盘新建一个 Test.java 类,代码如下:
package jvm.classloader; public class Test { public void say(){ System.out.println("Hello MyClassLoader"); } }
(2) 使用 javac -d . Test.java 命令,将生成的 Test.class文件放到 D:/lib/jvm/classloader文件夹下。
(3) 在Eclipse中自定义类加载器,代码如下:
package jvm.classloader; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; public class MyClassLoader extends ClassLoader{ private String classpath; public MyClassLoader(String classpath) { this.classpath = classpath; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException{ try { byte [] classDate=getData(name); if(classDate==null){} else{ //defineClass方法将字节码转化为类 return defineClass(name,classDate,0,classDate.length); } } catch (IOException e) { e.printStackTrace(); } return super.findClass(name); } //返回类的字节码 private byte[] getData(String className) throws IOException{ InputStream in = null; ByteArrayOutputStream out = null; String path=classpath + File.separatorChar + className.replace(‘.‘,File.separatorChar)+".class"; try { in=new FileInputStream(path); out=new ByteArrayOutputStream(); byte[] buffer=new byte[2048]; int len=0; while((len=in.read(buffer))!=-1){ out.write(buffer,0,len); } return out.toByteArray(); } catch (FileNotFoundException e) { e.printStackTrace(); } finally{ in.close(); out.close(); } return null; } }
测试代码如下:
package jvm.classloader; import java.lang.reflect.Method; public class TestMyClassLoader { public static void main(String []args) throws Exception{ //自定义类加载器的加载路径 MyClassLoader myClassLoader=new MyClassLoader("D:\\lib"); //包名+类名 Class c=myClassLoader.loadClass("jvm.classloader.Test"); if(c!=null){ Object obj=c.newInstance(); Method method=c.getMethod("say", null); method.invoke(obj, null); System.out.println(c.getClassLoader().toString()); } } }
输出结果:
Hello MyClassLoader
JVM自带的三个加载器只能加载指定路径下的类字节码。
如果某个情况下,我们需要加载应用程序之外的类文件呢?比如本地D盘下的,或者去加载网络上的某个 类文件,这种情况就可以使用自定义加载器了
小手扫一扫
创作不宜, 请助力